v0.2.0 transitioned to api-only, extended and improved features, docs and tests added
This commit is contained in:
@ -21,7 +21,7 @@ type Board struct {
|
||||
fullmove int
|
||||
}
|
||||
|
||||
func FEN(fen string) (*Board, error) {
|
||||
func ParseFEN(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))
|
||||
@ -54,10 +54,17 @@ func FEN(fen string) (*Board, error) {
|
||||
}
|
||||
|
||||
// Parse game state with validation
|
||||
if len(parts[1]) != 1 || (parts[1][0] != 'w' && parts[1][0] != 'b') {
|
||||
if len(parts[1]) != 1 {
|
||||
return nil, fmt.Errorf("invalid FEN: turn must be 'w' or 'b'")
|
||||
}
|
||||
switch parts[1] {
|
||||
case "w":
|
||||
b.turn = core.ColorWhite
|
||||
case "b":
|
||||
b.turn = core.ColorBlack
|
||||
default:
|
||||
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]
|
||||
|
||||
@ -71,6 +78,30 @@ func FEN(fen string) (*Board, error) {
|
||||
return b, nil
|
||||
}
|
||||
|
||||
// ToASCII creates an ASCII representation of the board
|
||||
func (b *Board) ToASCII() string {
|
||||
var sb strings.Builder
|
||||
sb.WriteString(" 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++ {
|
||||
square := fmt.Sprintf("%c%c", 'a'+f, '8'-r)
|
||||
piece := b.GetPieceAt(square)
|
||||
|
||||
if piece == 0 {
|
||||
sb.WriteString(". ")
|
||||
} else {
|
||||
sb.WriteString(fmt.Sprintf("%c ", piece))
|
||||
}
|
||||
}
|
||||
sb.WriteString(fmt.Sprintf(" %d\n", 8-r))
|
||||
}
|
||||
sb.WriteString(" a b c d e f g h")
|
||||
|
||||
return sb.String()
|
||||
}
|
||||
|
||||
func (b *Board) Turn() core.Color {
|
||||
return b.turn
|
||||
}
|
||||
|
||||
@ -1,293 +0,0 @@
|
||||
// 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/api.go
Normal file
53
internal/core/api.go
Normal file
@ -0,0 +1,53 @@
|
||||
// FILE: internal/core/api.go
|
||||
package core
|
||||
|
||||
// Request types
|
||||
|
||||
type CreateGameRequest struct {
|
||||
White PlayerConfig `json:"white" validate:"required"`
|
||||
Black PlayerConfig `json:"black" validate:"required"`
|
||||
FEN string `json:"fen,omitempty" validate:"omitempty,max=100"`
|
||||
}
|
||||
|
||||
type ConfigurePlayersRequest struct {
|
||||
White PlayerConfig `json:"white" validate:"required"`
|
||||
Black PlayerConfig `json:"black" validate:"required"`
|
||||
}
|
||||
|
||||
type MoveRequest struct {
|
||||
Move string `json:"move" validate:"required,min=4,max=5"` // "cccc" for computer move, 4-5 chars for UCI moves
|
||||
}
|
||||
|
||||
type UndoRequest struct {
|
||||
Count int `json:"count" validate:"required,min=1,max=300"` // Max based on longest games in history (272), theoretical max 5949
|
||||
}
|
||||
|
||||
// Response types
|
||||
|
||||
type GameResponse struct {
|
||||
GameID string `json:"gameId"`
|
||||
FEN string `json:"fen"`
|
||||
Turn string `json:"turn"` // "w" or "b"
|
||||
State string `json:"state"` // "ongoing", "white_wins", etc
|
||||
Moves []string `json:"moves"`
|
||||
Players PlayersResponse `json:"players"`
|
||||
LastMove *MoveInfo `json:"lastMove,omitempty"`
|
||||
}
|
||||
|
||||
type MoveInfo struct {
|
||||
Move string `json:"move"`
|
||||
PlayerColor string `json:"playerColor"` // "w" or "b"
|
||||
Score int `json:"score,omitempty"`
|
||||
Depth int `json:"depth,omitempty"`
|
||||
}
|
||||
|
||||
type BoardResponse struct {
|
||||
FEN string `json:"fen"`
|
||||
Board string `json:"board"` // ASCII representation
|
||||
}
|
||||
|
||||
type ErrorResponse struct {
|
||||
Error string `json:"error"`
|
||||
Code string `json:"code"`
|
||||
Details string `json:"details,omitempty"`
|
||||
}
|
||||
@ -1,53 +0,0 @@
|
||||
// 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
|
||||
}
|
||||
15
internal/core/error.go
Normal file
15
internal/core/error.go
Normal file
@ -0,0 +1,15 @@
|
||||
// FILE: internal/core/core.go
|
||||
package core
|
||||
|
||||
// Error codes
|
||||
const (
|
||||
ErrGameNotFound = "GAME_NOT_FOUND"
|
||||
ErrInvalidMove = "INVALID_MOVE"
|
||||
ErrNotHumanTurn = "NOT_HUMAN_TURN"
|
||||
ErrGameOver = "GAME_OVER"
|
||||
ErrRateLimitExceeded = "RATE_LIMIT_EXCEEDED"
|
||||
ErrInvalidContent = "INVALID_CONTENT_TYPE"
|
||||
ErrInvalidRequest = "INVALID_REQUEST"
|
||||
ErrInvalidFEN = "INVALID_FEN"
|
||||
ErrInternalError = "INTERNAL_ERROR"
|
||||
)
|
||||
75
internal/core/player.go
Normal file
75
internal/core/player.go
Normal file
@ -0,0 +1,75 @@
|
||||
// FILE: internal/core/player.go
|
||||
package core
|
||||
|
||||
import (
|
||||
"github.com/google/uuid"
|
||||
)
|
||||
|
||||
type PlayerType int
|
||||
|
||||
const (
|
||||
PlayerHuman PlayerType = iota + 1
|
||||
PlayerComputer
|
||||
)
|
||||
|
||||
// Player is the complete game entity with all state
|
||||
type Player struct {
|
||||
ID string `json:"id"`
|
||||
Color Color `json:"color"`
|
||||
Type PlayerType `json:"type"`
|
||||
Level int `json:"level,omitempty"` // Only for computer
|
||||
SearchTime int `json:"searchTime,omitempty"` // Only for computer
|
||||
}
|
||||
|
||||
// PlayerConfig for API requests and configuration
|
||||
type PlayerConfig struct {
|
||||
Type PlayerType `json:"type" validate:"required,oneof=1 2"`
|
||||
Level int `json:"level,omitempty" validate:"omitempty,min=0,max=20"`
|
||||
SearchTime int `json:"searchTime,omitempty" validate:"omitempty,min=100,max=10000"` // Processor sets the min value
|
||||
}
|
||||
|
||||
// PlayersResponse for API responses - now contains full Player structs
|
||||
type PlayersResponse struct {
|
||||
White *Player `json:"white"`
|
||||
Black *Player `json:"black"`
|
||||
}
|
||||
|
||||
// NewPlayer creates a Player from PlayerConfig
|
||||
func NewPlayer(config PlayerConfig, color Color) *Player {
|
||||
player := &Player{
|
||||
ID: uuid.New().String(),
|
||||
Color: color,
|
||||
Type: config.Type,
|
||||
}
|
||||
|
||||
if config.Type == PlayerComputer {
|
||||
player.Level = config.Level
|
||||
player.SearchTime = config.SearchTime
|
||||
}
|
||||
|
||||
return player
|
||||
}
|
||||
|
||||
type Color byte
|
||||
|
||||
const (
|
||||
ColorWhite = iota + 1
|
||||
ColorBlack
|
||||
)
|
||||
|
||||
func (c Color) String() string {
|
||||
if c == ColorWhite {
|
||||
return "w"
|
||||
} else if c == ColorBlack {
|
||||
return "b"
|
||||
} else {
|
||||
return "-"
|
||||
}
|
||||
}
|
||||
|
||||
func OppositeColor(c Color) Color {
|
||||
if c == ColorWhite {
|
||||
return ColorBlack
|
||||
}
|
||||
return ColorWhite
|
||||
}
|
||||
35
internal/core/state.go
Normal file
35
internal/core/state.go
Normal file
@ -0,0 +1,35 @@
|
||||
// FILE: internal/core/core.go
|
||||
package core
|
||||
|
||||
type State int
|
||||
|
||||
const (
|
||||
StateOngoing State = iota
|
||||
StatePending // Computer is calculating a move
|
||||
StateStuck // Computer is calculating a move
|
||||
StateWhiteWins
|
||||
StateBlackWins
|
||||
StateDraw
|
||||
StateStalemate
|
||||
)
|
||||
|
||||
func (s State) String() string {
|
||||
switch s {
|
||||
case StatePending:
|
||||
return "pending"
|
||||
case StateStuck:
|
||||
return "stuck"
|
||||
case StateWhiteWins:
|
||||
return "white wins"
|
||||
case StateBlackWins:
|
||||
return "black wins"
|
||||
case StateDraw:
|
||||
return "draw"
|
||||
case StateStalemate:
|
||||
return "stalemate"
|
||||
case StateOngoing:
|
||||
return "ongoing"
|
||||
default:
|
||||
return "unknown"
|
||||
}
|
||||
}
|
||||
@ -25,6 +25,8 @@ type SearchResult struct {
|
||||
BestMove string
|
||||
Score int
|
||||
Depth int
|
||||
IsMate bool
|
||||
MateIn int
|
||||
}
|
||||
|
||||
func New() (*UCI, error) {
|
||||
@ -40,7 +42,7 @@ func New() (*UCI, error) {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
if err := cmd.Start(); err != nil {
|
||||
if err = cmd.Start(); err != nil {
|
||||
return nil, fmt.Errorf("failed to start engine: %v", err)
|
||||
}
|
||||
|
||||
@ -58,6 +60,16 @@ func New() (*UCI, error) {
|
||||
return uci, nil
|
||||
}
|
||||
|
||||
// SetSkillLevel sets the Stockfish skill level (0-20)
|
||||
func (u *UCI) SetSkillLevel(level int) {
|
||||
if level < 0 {
|
||||
level = 0
|
||||
} else if level > 20 {
|
||||
level = 20
|
||||
}
|
||||
u.sendCommand(fmt.Sprintf("setoption name Skill Level value %d", level))
|
||||
}
|
||||
|
||||
// Get FEN from Stockfish's debug ('d') command
|
||||
func (u *UCI) GetFEN() (string, error) {
|
||||
u.sendCommand("d")
|
||||
@ -184,6 +196,16 @@ func (u *UCI) Search(timeMs int) (*SearchResult, error) {
|
||||
fmt.Sscanf(fields[i+1], "%d", &result.Depth)
|
||||
case "cp":
|
||||
fmt.Sscanf(fields[i+1], "%d", &result.Score)
|
||||
result.IsMate = false
|
||||
case "mate":
|
||||
fmt.Sscanf(fields[i+1], "%d", &result.MateIn)
|
||||
result.IsMate = true
|
||||
// Convert mate score to centipawn equivalent for backwards compatibility
|
||||
if result.MateIn > 0 {
|
||||
result.Score = 100000 - result.MateIn
|
||||
} else {
|
||||
result.Score = -100000 - result.MateIn
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@ -228,4 +250,4 @@ func (u *UCI) Close() error {
|
||||
// Force kill if doesn't exit gracefully
|
||||
return u.cmd.Process.Kill()
|
||||
}
|
||||
}
|
||||
}
|
||||
@ -9,34 +9,45 @@ import (
|
||||
)
|
||||
|
||||
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
|
||||
FEN string `json:"fen"`
|
||||
PreviousMove string `json:"previousMove"`
|
||||
NextTurnColor core.Color `json:"nextTurnColor"`
|
||||
PlayerType core.PlayerType `json:"playerType"`
|
||||
PlayerID string `json:"playerId"` // ID of the player whose turn it is
|
||||
}
|
||||
|
||||
// MoveResult tracks the outcome of a move
|
||||
type MoveResult struct {
|
||||
Move string
|
||||
Player core.Color
|
||||
GameState core.State
|
||||
Score int
|
||||
Depth int
|
||||
Move string `json:"move"`
|
||||
PlayerColor core.Color `json:"playerColor"`
|
||||
GameState core.State `json:"gameState"`
|
||||
Score int `json:"score"`
|
||||
Depth int `json:"depth"`
|
||||
}
|
||||
|
||||
type Game struct {
|
||||
snapshots []Snapshot
|
||||
players map[core.Color]*core.Player
|
||||
state core.State
|
||||
lastResult *MoveResult
|
||||
snapshots []Snapshot `json:"snapshots"`
|
||||
players map[core.Color]*core.Player `json:"players"`
|
||||
state core.State `json:"state"`
|
||||
lastResult *MoveResult `json:"lastResult,omitempty"`
|
||||
}
|
||||
|
||||
func New(initialFEN string, whitePlayer, blackPlayer *core.Player, startingTurn core.Color) *Game {
|
||||
func New(initialFEN string, whitePlayer, blackPlayer *core.Player, startingTurnColor core.Color) *Game {
|
||||
// Determine which player's turn it is initially
|
||||
var initialPlayerID string
|
||||
if startingTurnColor == core.ColorWhite {
|
||||
initialPlayerID = whitePlayer.ID
|
||||
} else {
|
||||
initialPlayerID = blackPlayer.ID
|
||||
}
|
||||
|
||||
return &Game{
|
||||
snapshots: []Snapshot{
|
||||
{
|
||||
FEN: initialFEN,
|
||||
PreviousMove: "", // No move led to initial position
|
||||
NextTurn: startingTurn,
|
||||
FEN: initialFEN,
|
||||
PreviousMove: "",
|
||||
NextTurnColor: startingTurnColor,
|
||||
PlayerID: initialPlayerID,
|
||||
},
|
||||
},
|
||||
players: map[core.Color]*core.Player{
|
||||
@ -63,26 +74,40 @@ func (g *Game) CurrentFEN() string {
|
||||
return g.CurrentSnapshot().FEN
|
||||
}
|
||||
|
||||
func (g *Game) NextTurn() core.Color {
|
||||
return g.CurrentSnapshot().NextTurn
|
||||
func (g *Game) NextTurnColor() core.Color {
|
||||
return g.CurrentSnapshot().NextTurnColor
|
||||
}
|
||||
|
||||
func (g *Game) NextPlayer() *core.Player {
|
||||
return g.players[g.NextTurn()]
|
||||
return g.players[g.NextTurnColor()]
|
||||
}
|
||||
|
||||
func (g *Game) GetPlayer(color core.Color) *core.Player {
|
||||
return g.players[color]
|
||||
}
|
||||
|
||||
func (g *Game) AddSnapshot(fen string, move string, nextTurn core.Color) {
|
||||
func (g *Game) AddSnapshot(fen string, move string, nextTurnColor core.Color) {
|
||||
// Get the player ID for the next turn
|
||||
nextPlayer := g.players[nextTurnColor]
|
||||
g.snapshots = append(g.snapshots, Snapshot{
|
||||
FEN: fen,
|
||||
PreviousMove: move,
|
||||
NextTurn: nextTurn,
|
||||
FEN: fen,
|
||||
PreviousMove: move,
|
||||
NextTurnColor: nextTurnColor,
|
||||
PlayerID: nextPlayer.ID,
|
||||
})
|
||||
}
|
||||
|
||||
func (g *Game) UpdatePlayers(whitePlayer, blackPlayer *core.Player) {
|
||||
g.players[core.ColorWhite] = whitePlayer
|
||||
g.players[core.ColorBlack] = blackPlayer
|
||||
|
||||
// Update current snapshot's PlayerID to reflect new player
|
||||
if len(g.snapshots) > 0 {
|
||||
currentSnap := &g.snapshots[len(g.snapshots)-1]
|
||||
currentSnap.PlayerID = g.players[currentSnap.NextTurnColor].ID
|
||||
}
|
||||
}
|
||||
|
||||
func (g *Game) UndoMoves(count int) error {
|
||||
if count < 1 {
|
||||
return fmt.Errorf("invalid undo count: %d", count)
|
||||
|
||||
412
internal/http/handler.go
Normal file
412
internal/http/handler.go
Normal file
@ -0,0 +1,412 @@
|
||||
// FILE: internal/http/handler.go
|
||||
package http
|
||||
|
||||
import (
|
||||
"chess/internal/core"
|
||||
"chess/internal/processor"
|
||||
"fmt"
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
"github.com/gofiber/fiber/v2"
|
||||
"github.com/gofiber/fiber/v2/middleware/cors"
|
||||
"github.com/gofiber/fiber/v2/middleware/limiter"
|
||||
"github.com/gofiber/fiber/v2/middleware/logger"
|
||||
"github.com/gofiber/fiber/v2/middleware/recover"
|
||||
)
|
||||
|
||||
const rateLimitRate = 10 // req/sec
|
||||
|
||||
type HTTPHandler struct {
|
||||
proc *processor.Processor
|
||||
}
|
||||
|
||||
func NewHTTPHandler(proc *processor.Processor) *HTTPHandler {
|
||||
return &HTTPHandler{proc: proc}
|
||||
}
|
||||
|
||||
func NewFiberApp(proc *processor.Processor, devMode bool) *fiber.App {
|
||||
// Create handler
|
||||
h := NewHTTPHandler(proc)
|
||||
|
||||
// Initialize Fiber app
|
||||
app := fiber.New(fiber.Config{
|
||||
ErrorHandler: customErrorHandler,
|
||||
ReadTimeout: 10 * time.Second,
|
||||
WriteTimeout: 10 * time.Second,
|
||||
IdleTimeout: 30 * time.Second,
|
||||
})
|
||||
|
||||
// Global middleware (order matters)
|
||||
app.Use(recover.New())
|
||||
app.Use(logger.New(logger.Config{
|
||||
Format: "${time} ${status} ${method} ${path} ${latency}\n",
|
||||
}))
|
||||
app.Use(cors.New(cors.Config{
|
||||
AllowOrigins: "*",
|
||||
AllowMethods: "GET,POST,PUT,DELETE,OPTIONS",
|
||||
AllowHeaders: "Origin,Content-Type,Accept",
|
||||
}))
|
||||
|
||||
// Health check (no rate limit)
|
||||
app.Get("/health", h.Health)
|
||||
|
||||
// API v1 routes with rate limiting
|
||||
api := app.Group("/api/v1")
|
||||
|
||||
// Rate limiter: 10/20 req/sec per IP with expiry
|
||||
maxReq := rateLimitRate
|
||||
if devMode {
|
||||
maxReq = rateLimitRate * 2 // Loosen rate limiter for testing
|
||||
}
|
||||
api.Use(limiter.New(limiter.Config{
|
||||
Max: maxReq, // Allow requests per second
|
||||
Expiration: 1 * time.Second, // Per second
|
||||
KeyGenerator: func(c *fiber.Ctx) string {
|
||||
// Check X-Forwarded-For first, then X-Real-IP, then RemoteIP
|
||||
if xff := c.Get("X-Forwarded-For"); xff != "" {
|
||||
// Take the first IP from X-Forwarded-For chain
|
||||
if idx := strings.Index(xff, ","); idx != -1 {
|
||||
return strings.TrimSpace(xff[:idx])
|
||||
}
|
||||
return xff
|
||||
}
|
||||
return c.IP()
|
||||
},
|
||||
LimitReached: func(c *fiber.Ctx) error {
|
||||
return c.Status(fiber.StatusTooManyRequests).JSON(core.ErrorResponse{
|
||||
Error: "rate limit exceeded",
|
||||
Code: core.ErrRateLimitExceeded,
|
||||
Details: fmt.Sprintf("%d requests per second allowed", maxReq),
|
||||
})
|
||||
},
|
||||
Storage: nil, // Use in-memory storage (default)
|
||||
SkipFailedRequests: false,
|
||||
SkipSuccessfulRequests: false,
|
||||
}))
|
||||
|
||||
// Content-Type validation for POST and PUT requests
|
||||
api.Use(contentTypeValidator)
|
||||
|
||||
// Middleware validation for sanitization
|
||||
api.Use(validationMiddleware)
|
||||
|
||||
// Register game routes
|
||||
api.Post("/games", h.CreateGame)
|
||||
api.Put("/games/:gameId/players", h.ConfigurePlayers)
|
||||
api.Get("/games/:gameId", h.GetGame)
|
||||
api.Delete("/games/:gameId", h.DeleteGame)
|
||||
api.Post("/games/:gameId/moves", h.MakeMove)
|
||||
api.Post("/games/:gameId/undo", h.UndoMove)
|
||||
api.Get("/games/:gameId/board", h.GetBoard)
|
||||
|
||||
return app
|
||||
}
|
||||
|
||||
// contentTypeValidator ensures POST and PUT requests have application/json
|
||||
func contentTypeValidator(c *fiber.Ctx) error {
|
||||
method := c.Method()
|
||||
if method == fiber.MethodPost || method == fiber.MethodPut {
|
||||
contentType := c.Get("Content-Type")
|
||||
if contentType != "application/json" && contentType != "" {
|
||||
return c.Status(fiber.StatusUnsupportedMediaType).JSON(core.ErrorResponse{
|
||||
Error: "unsupported media type",
|
||||
Code: core.ErrInvalidContent,
|
||||
Details: "Content-Type must be application/json",
|
||||
})
|
||||
}
|
||||
}
|
||||
return c.Next()
|
||||
}
|
||||
|
||||
// customErrorHandler provides consistent error responses
|
||||
func customErrorHandler(c *fiber.Ctx, err error) error {
|
||||
code := fiber.StatusInternalServerError
|
||||
response := core.ErrorResponse{
|
||||
Error: "internal server error",
|
||||
Code: core.ErrInternalError,
|
||||
}
|
||||
|
||||
// Check if it's a Fiber error
|
||||
if e, ok := err.(*fiber.Error); ok {
|
||||
code = e.Code
|
||||
response.Error = e.Message
|
||||
|
||||
// Map HTTP status to error codes
|
||||
switch code {
|
||||
case fiber.StatusNotFound:
|
||||
response.Code = core.ErrGameNotFound
|
||||
case fiber.StatusBadRequest:
|
||||
response.Code = core.ErrInvalidRequest
|
||||
case fiber.StatusTooManyRequests:
|
||||
response.Code = core.ErrRateLimitExceeded
|
||||
}
|
||||
}
|
||||
|
||||
return c.Status(code).JSON(response)
|
||||
}
|
||||
|
||||
// Health check endpoint
|
||||
func (h *HTTPHandler) Health(c *fiber.Ctx) error {
|
||||
return c.JSON(fiber.Map{
|
||||
"status": "healthy",
|
||||
"time": time.Now().Unix(),
|
||||
})
|
||||
}
|
||||
|
||||
// CreateGame creates a new game with specified player types
|
||||
func (h *HTTPHandler) CreateGame(c *fiber.Ctx) error {
|
||||
// Ensure middleware validation ran
|
||||
validated, ok := c.Locals("validated").(bool)
|
||||
if !ok || !validated {
|
||||
return c.Status(fiber.StatusInternalServerError).JSON(core.ErrorResponse{
|
||||
Error: "validation bypass detected",
|
||||
Code: core.ErrInternalError,
|
||||
})
|
||||
}
|
||||
|
||||
// Retrieve validated parsed body
|
||||
validatedBody := c.Locals("validatedBody")
|
||||
if validatedBody == nil {
|
||||
return c.Status(fiber.StatusInternalServerError).JSON(core.ErrorResponse{
|
||||
Error: "validation data missing",
|
||||
Code: core.ErrInternalError,
|
||||
})
|
||||
}
|
||||
var req core.CreateGameRequest
|
||||
req = *(validatedBody.(*core.CreateGameRequest))
|
||||
|
||||
// Let processor generate game ID via service
|
||||
cmd := processor.NewCreateGameCommand(req)
|
||||
|
||||
resp := h.proc.Execute(cmd)
|
||||
|
||||
// Return appropriate HTTP response
|
||||
if !resp.Success {
|
||||
return c.Status(fiber.StatusBadRequest).JSON(resp.Error)
|
||||
}
|
||||
|
||||
return c.Status(fiber.StatusCreated).JSON(resp.Data)
|
||||
}
|
||||
|
||||
// ConfigurePlayers updates player configuration mid-game
|
||||
func (h *HTTPHandler) ConfigurePlayers(c *fiber.Ctx) error {
|
||||
gameID := c.Params("gameId")
|
||||
|
||||
// Validate UUID format
|
||||
if !isValidUUID(gameID) {
|
||||
return c.Status(fiber.StatusBadRequest).JSON(core.ErrorResponse{
|
||||
Error: "invalid game ID format",
|
||||
Code: core.ErrInvalidRequest,
|
||||
Details: "game ID must be a valid UUID",
|
||||
})
|
||||
}
|
||||
|
||||
// Ensure middleware validation ran
|
||||
validated, ok := c.Locals("validated").(bool)
|
||||
if !ok || !validated {
|
||||
return c.Status(fiber.StatusInternalServerError).JSON(core.ErrorResponse{
|
||||
Error: "validation bypass detected",
|
||||
Code: core.ErrInternalError,
|
||||
})
|
||||
}
|
||||
|
||||
// Retrieve validated parsed body
|
||||
validatedBody := c.Locals("validatedBody")
|
||||
if validatedBody == nil {
|
||||
return c.Status(fiber.StatusInternalServerError).JSON(core.ErrorResponse{
|
||||
Error: "validation data missing",
|
||||
Code: core.ErrInternalError,
|
||||
})
|
||||
}
|
||||
var req core.ConfigurePlayersRequest
|
||||
req = *(validatedBody.(*core.ConfigurePlayersRequest))
|
||||
|
||||
// Create command and execute
|
||||
cmd := processor.NewConfigurePlayersCommand(gameID, req)
|
||||
resp := h.proc.Execute(cmd)
|
||||
|
||||
// Return appropriate HTTP response
|
||||
if !resp.Success {
|
||||
statusCode := fiber.StatusBadRequest
|
||||
if resp.Error.Code == core.ErrGameNotFound {
|
||||
statusCode = fiber.StatusNotFound
|
||||
}
|
||||
return c.Status(statusCode).JSON(resp.Error)
|
||||
}
|
||||
|
||||
return c.JSON(resp.Data)
|
||||
}
|
||||
|
||||
// GetGame retrieves current game state
|
||||
func (h *HTTPHandler) GetGame(c *fiber.Ctx) error {
|
||||
gameID := c.Params("gameId")
|
||||
|
||||
// Validate UUID format
|
||||
if !isValidUUID(gameID) {
|
||||
return c.Status(fiber.StatusBadRequest).JSON(core.ErrorResponse{
|
||||
Error: "invalid game ID format",
|
||||
Code: core.ErrInvalidRequest,
|
||||
Details: "game ID must be a valid UUID",
|
||||
})
|
||||
}
|
||||
|
||||
// Create command and execute
|
||||
cmd := processor.NewGetGameCommand(gameID)
|
||||
resp := h.proc.Execute(cmd)
|
||||
|
||||
// Return appropriate HTTP response
|
||||
if !resp.Success {
|
||||
return c.Status(fiber.StatusNotFound).JSON(resp.Error)
|
||||
}
|
||||
|
||||
return c.JSON(resp.Data)
|
||||
}
|
||||
|
||||
// MakeMove submits a move
|
||||
func (h *HTTPHandler) MakeMove(c *fiber.Ctx) error {
|
||||
gameID := c.Params("gameId")
|
||||
|
||||
// Validate UUID format
|
||||
if !isValidUUID(gameID) {
|
||||
return c.Status(fiber.StatusBadRequest).JSON(core.ErrorResponse{
|
||||
Error: "invalid game ID format",
|
||||
Code: core.ErrInvalidRequest,
|
||||
Details: "game ID must be a valid UUID",
|
||||
})
|
||||
}
|
||||
|
||||
// Ensure middleware validation ran
|
||||
validated, ok := c.Locals("validated").(bool)
|
||||
if !ok || !validated {
|
||||
return c.Status(fiber.StatusInternalServerError).JSON(core.ErrorResponse{
|
||||
Error: "validation bypass detected",
|
||||
Code: core.ErrInternalError,
|
||||
})
|
||||
}
|
||||
|
||||
// Retrieve validated parsed body
|
||||
validatedBody := c.Locals("validatedBody")
|
||||
if validatedBody == nil {
|
||||
return c.Status(fiber.StatusInternalServerError).JSON(core.ErrorResponse{
|
||||
Error: "validation data missing",
|
||||
Code: core.ErrInternalError,
|
||||
})
|
||||
}
|
||||
var req core.MoveRequest
|
||||
req = *(validatedBody.(*core.MoveRequest))
|
||||
|
||||
// Create command and execute
|
||||
cmd := processor.NewMakeMoveCommand(gameID, req)
|
||||
resp := h.proc.Execute(cmd)
|
||||
|
||||
// Return appropriate HTTP response with correct status code
|
||||
if !resp.Success {
|
||||
statusCode := fiber.StatusBadRequest
|
||||
if resp.Error.Code == core.ErrGameNotFound {
|
||||
statusCode = fiber.StatusNotFound
|
||||
}
|
||||
return c.Status(statusCode).JSON(resp.Error)
|
||||
}
|
||||
|
||||
return c.JSON(resp.Data)
|
||||
}
|
||||
|
||||
// UndoMove undoes one or more moves
|
||||
func (h *HTTPHandler) UndoMove(c *fiber.Ctx) error {
|
||||
gameID := c.Params("gameId")
|
||||
|
||||
// Validate UUID format
|
||||
if !isValidUUID(gameID) {
|
||||
return c.Status(fiber.StatusBadRequest).JSON(core.ErrorResponse{
|
||||
Error: "invalid game ID format",
|
||||
Code: core.ErrInvalidRequest,
|
||||
Details: "game ID must be a valid UUID",
|
||||
})
|
||||
}
|
||||
|
||||
// Ensure middleware validation ran
|
||||
validated, ok := c.Locals("validated").(bool)
|
||||
if !ok || !validated {
|
||||
return c.Status(fiber.StatusInternalServerError).JSON(core.ErrorResponse{
|
||||
Error: "validation bypass detected",
|
||||
Code: core.ErrInternalError,
|
||||
})
|
||||
}
|
||||
|
||||
// Retrieve validated parsed body
|
||||
validatedBody := c.Locals("validatedBody")
|
||||
if validatedBody == nil {
|
||||
return c.Status(fiber.StatusInternalServerError).JSON(core.ErrorResponse{
|
||||
Error: "validation data missing",
|
||||
Code: core.ErrInternalError,
|
||||
})
|
||||
}
|
||||
var req core.UndoRequest
|
||||
req = *(validatedBody.(*core.UndoRequest))
|
||||
|
||||
// Create command and execute
|
||||
cmd := processor.NewUndoMoveCommand(gameID, req)
|
||||
resp := h.proc.Execute(cmd)
|
||||
|
||||
// Return appropriate HTTP response
|
||||
if !resp.Success {
|
||||
statusCode := fiber.StatusBadRequest
|
||||
if resp.Error.Code == core.ErrGameNotFound {
|
||||
statusCode = fiber.StatusNotFound
|
||||
}
|
||||
return c.Status(statusCode).JSON(resp.Error)
|
||||
}
|
||||
|
||||
return c.JSON(resp.Data)
|
||||
}
|
||||
|
||||
// DeleteGame ends and cleans up a game
|
||||
func (h *HTTPHandler) DeleteGame(c *fiber.Ctx) error {
|
||||
gameID := c.Params("gameId")
|
||||
|
||||
// Validate UUID format
|
||||
if !isValidUUID(gameID) {
|
||||
return c.Status(fiber.StatusBadRequest).JSON(core.ErrorResponse{
|
||||
Error: "invalid game ID format",
|
||||
Code: core.ErrInvalidRequest,
|
||||
Details: "game ID must be a valid UUID",
|
||||
})
|
||||
}
|
||||
|
||||
// Create command and execute
|
||||
cmd := processor.NewDeleteGameCommand(gameID)
|
||||
resp := h.proc.Execute(cmd)
|
||||
|
||||
// Return appropriate HTTP response
|
||||
if !resp.Success {
|
||||
return c.Status(fiber.StatusNotFound).JSON(resp.Error)
|
||||
}
|
||||
|
||||
return c.SendStatus(fiber.StatusNoContent)
|
||||
}
|
||||
|
||||
// GetBoard returns ASCII representation of the board
|
||||
func (h *HTTPHandler) GetBoard(c *fiber.Ctx) error {
|
||||
gameID := c.Params("gameId")
|
||||
|
||||
// Validate UUID format
|
||||
if !isValidUUID(gameID) {
|
||||
return c.Status(fiber.StatusBadRequest).JSON(core.ErrorResponse{
|
||||
Error: "invalid game ID format",
|
||||
Code: core.ErrInvalidRequest,
|
||||
Details: "game ID must be a valid UUID",
|
||||
})
|
||||
}
|
||||
|
||||
// Create command and execute
|
||||
cmd := processor.NewGetBoardCommand(gameID)
|
||||
resp := h.proc.Execute(cmd)
|
||||
|
||||
// Return appropriate HTTP response
|
||||
if !resp.Success {
|
||||
return c.Status(fiber.StatusNotFound).JSON(resp.Error)
|
||||
}
|
||||
|
||||
return c.JSON(resp.Data)
|
||||
}
|
||||
103
internal/http/validator.go
Normal file
103
internal/http/validator.go
Normal file
@ -0,0 +1,103 @@
|
||||
// FILE: internal/http/handler.go
|
||||
package http
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"reflect"
|
||||
"strings"
|
||||
|
||||
"chess/internal/core"
|
||||
|
||||
"github.com/go-playground/validator/v10"
|
||||
"github.com/gofiber/fiber/v2"
|
||||
"github.com/google/uuid"
|
||||
)
|
||||
|
||||
// Add validator instance near top of file
|
||||
var validate = validator.New()
|
||||
|
||||
// Add custom validation middleware function
|
||||
func validationMiddleware(c *fiber.Ctx) error {
|
||||
// Skip validation for GET, DELETE, OPTIONS
|
||||
method := c.Method()
|
||||
if method == fiber.MethodGet || method == fiber.MethodDelete || method == fiber.MethodOptions {
|
||||
return c.Next()
|
||||
}
|
||||
|
||||
// Determine request type based on path
|
||||
path := c.Path()
|
||||
var requestType interface{}
|
||||
|
||||
switch {
|
||||
case strings.HasSuffix(path, "/games") && method == fiber.MethodPost:
|
||||
requestType = &core.CreateGameRequest{}
|
||||
case strings.HasSuffix(path, "/players") && method == fiber.MethodPut:
|
||||
requestType = &core.ConfigurePlayersRequest{}
|
||||
case strings.HasSuffix(path, "/moves") && method == fiber.MethodPost:
|
||||
requestType = &core.MoveRequest{}
|
||||
case strings.HasSuffix(path, "/undo") && method == fiber.MethodPost:
|
||||
requestType = &core.UndoRequest{}
|
||||
default:
|
||||
return c.Next() // No validation for unknown endpoints
|
||||
}
|
||||
|
||||
// Parse body
|
||||
if err := c.BodyParser(requestType); err != nil {
|
||||
return c.Status(fiber.StatusBadRequest).JSON(core.ErrorResponse{
|
||||
Error: "invalid request body",
|
||||
Code: core.ErrInvalidRequest,
|
||||
Details: err.Error(),
|
||||
})
|
||||
}
|
||||
|
||||
// Validate
|
||||
if errs := validate.Struct(requestType); errs != nil {
|
||||
var details strings.Builder
|
||||
for _, err := range errs.(validator.ValidationErrors) {
|
||||
if details.Len() > 0 {
|
||||
details.WriteString("; ")
|
||||
}
|
||||
switch err.Tag() {
|
||||
case "required":
|
||||
details.WriteString(fmt.Sprintf("%s is required", err.Field()))
|
||||
case "oneof":
|
||||
details.WriteString(fmt.Sprintf("%s must be one of [%s]", err.Field(), err.Param()))
|
||||
case "min":
|
||||
if err.Type().Kind() == reflect.String {
|
||||
details.WriteString(fmt.Sprintf("%s must be at least %s characters", err.Field(), err.Param()))
|
||||
} else {
|
||||
details.WriteString(fmt.Sprintf("%s must be at least %s", err.Field(), err.Param()))
|
||||
}
|
||||
case "max":
|
||||
if err.Type().Kind() == reflect.String {
|
||||
details.WriteString(fmt.Sprintf("%s must be at most %s characters", err.Field(), err.Param()))
|
||||
} else {
|
||||
details.WriteString(fmt.Sprintf("%s must be at most %s", err.Field(), err.Param()))
|
||||
}
|
||||
case "omitempty": // Skip, a control tag that doesn't error
|
||||
continue
|
||||
case "dive": // Skip, panics on wrong type, no error handling since current code does not call validator on slice or map
|
||||
continue
|
||||
default:
|
||||
details.WriteString(fmt.Sprintf("%s failed %s validation", err.Field(), err.Tag()))
|
||||
}
|
||||
}
|
||||
|
||||
return c.Status(fiber.StatusBadRequest).JSON(core.ErrorResponse{
|
||||
Error: "validation failed",
|
||||
Code: core.ErrInvalidRequest,
|
||||
Details: details.String(),
|
||||
})
|
||||
}
|
||||
|
||||
// Store validated body for handler use
|
||||
c.Locals("validatedBody", requestType)
|
||||
c.Locals("validated", true)
|
||||
|
||||
return c.Next()
|
||||
}
|
||||
|
||||
func isValidUUID(s string) bool {
|
||||
_, err := uuid.Parse(s)
|
||||
return err == nil
|
||||
}
|
||||
86
internal/processor/command.go
Normal file
86
internal/processor/command.go
Normal file
@ -0,0 +1,86 @@
|
||||
// FILE: internal/processor/command.go
|
||||
package processor
|
||||
|
||||
import (
|
||||
"chess/internal/core"
|
||||
)
|
||||
|
||||
// CommandType defines the type of command being executed
|
||||
type CommandType int
|
||||
|
||||
const (
|
||||
CmdCreateGame CommandType = iota
|
||||
CmdConfigurePlayers
|
||||
CmdGetGame
|
||||
CmdDeleteGame
|
||||
CmdMakeMove
|
||||
CmdUndoMove
|
||||
CmdGetBoard
|
||||
)
|
||||
|
||||
// Command is a unified structure for all processor operations
|
||||
type Command struct {
|
||||
Type CommandType
|
||||
GameID string // For game-specific commands
|
||||
Args interface{} // Command-specific arguments
|
||||
}
|
||||
|
||||
// ProcessorResponse wraps the response with metadata
|
||||
type ProcessorResponse struct {
|
||||
Success bool `json:"success"`
|
||||
Pending bool `json:"pending,omitempty"` // For async operations
|
||||
Data interface{} `json:"data,omitempty"`
|
||||
Error *core.ErrorResponse `json:"error,omitempty"`
|
||||
}
|
||||
|
||||
func NewCreateGameCommand(req core.CreateGameRequest) Command {
|
||||
return Command{
|
||||
Type: CmdCreateGame,
|
||||
Args: req,
|
||||
}
|
||||
}
|
||||
|
||||
func NewConfigurePlayersCommand(gameID string, req core.ConfigurePlayersRequest) Command {
|
||||
return Command{
|
||||
Type: CmdConfigurePlayers,
|
||||
GameID: gameID,
|
||||
Args: req,
|
||||
}
|
||||
}
|
||||
|
||||
func NewGetGameCommand(gameID string) Command {
|
||||
return Command{
|
||||
Type: CmdGetGame,
|
||||
GameID: gameID,
|
||||
}
|
||||
}
|
||||
|
||||
func NewMakeMoveCommand(gameID string, req core.MoveRequest) Command {
|
||||
return Command{
|
||||
Type: CmdMakeMove,
|
||||
GameID: gameID,
|
||||
Args: req,
|
||||
}
|
||||
}
|
||||
|
||||
func NewUndoMoveCommand(gameID string, req core.UndoRequest) Command {
|
||||
return Command{
|
||||
Type: CmdUndoMove,
|
||||
GameID: gameID,
|
||||
Args: req,
|
||||
}
|
||||
}
|
||||
|
||||
func NewDeleteGameCommand(gameID string) Command {
|
||||
return Command{
|
||||
Type: CmdDeleteGame,
|
||||
GameID: gameID,
|
||||
}
|
||||
}
|
||||
|
||||
func NewGetBoardCommand(gameID string) Command {
|
||||
return Command{
|
||||
Type: CmdGetBoard,
|
||||
GameID: gameID,
|
||||
}
|
||||
}
|
||||
565
internal/processor/processor.go
Normal file
565
internal/processor/processor.go
Normal file
@ -0,0 +1,565 @@
|
||||
// FILE: internal/processor/processor.go
|
||||
|
||||
package processor
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"log"
|
||||
"regexp"
|
||||
"strings"
|
||||
"sync"
|
||||
"time"
|
||||
"unicode"
|
||||
|
||||
"chess/internal/board"
|
||||
"chess/internal/core"
|
||||
"chess/internal/engine"
|
||||
"chess/internal/game"
|
||||
"chess/internal/service"
|
||||
)
|
||||
|
||||
const (
|
||||
minSearchTime = 100
|
||||
)
|
||||
|
||||
// FEN validation regex
|
||||
var fenPattern = regexp.MustCompile(`^[rnbqkpRNBQKP1-8/]+ [wb] [KQkq-]+ [a-h1-8-]+ \d+ \d+$`)
|
||||
|
||||
// Processor orchestrates all game logic and engine interactions
|
||||
type Processor struct {
|
||||
svc *service.Service
|
||||
queue *EngineQueue
|
||||
validationEng *engine.UCI // For synchronous move validation
|
||||
mu sync.RWMutex
|
||||
}
|
||||
|
||||
// New creates a processor with its own engine instances
|
||||
func New(svc *service.Service) (*Processor, error) {
|
||||
// Create validation engine
|
||||
validationEng, err := engine.New()
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("failed to create validation engine: %v", err)
|
||||
}
|
||||
|
||||
return &Processor{
|
||||
svc: svc,
|
||||
queue: NewEngineQueue(2), // 2 workers for computer moves
|
||||
validationEng: validationEng,
|
||||
}, nil
|
||||
}
|
||||
|
||||
func (p *Processor) Execute(cmd Command) ProcessorResponse {
|
||||
switch cmd.Type {
|
||||
case CmdCreateGame:
|
||||
return p.handleCreateGame(cmd)
|
||||
case CmdConfigurePlayers:
|
||||
return p.handleConfigurePlayers(cmd)
|
||||
case CmdGetGame:
|
||||
return p.handleGetGame(cmd)
|
||||
case CmdMakeMove:
|
||||
return p.handleMakeMove(cmd)
|
||||
case CmdUndoMove:
|
||||
return p.handleUndoMove(cmd)
|
||||
case CmdDeleteGame:
|
||||
return p.handleDeleteGame(cmd)
|
||||
case CmdGetBoard:
|
||||
return p.handleGetBoard(cmd)
|
||||
default:
|
||||
return p.errorResponse("unknown command", core.ErrInvalidRequest)
|
||||
}
|
||||
}
|
||||
|
||||
// isFENSafe check for control characters that could inject UCI commands and FEN pattern match
|
||||
func (p *Processor) isFENSafe(fen string) bool {
|
||||
// Check for control characters
|
||||
for _, r := range fen {
|
||||
if unicode.IsControl(r) && r != ' ' {
|
||||
return false
|
||||
}
|
||||
}
|
||||
|
||||
// Validate FEN format
|
||||
return fenPattern.MatchString(fen)
|
||||
}
|
||||
|
||||
func (p *Processor) isMoveSafe(move string) bool {
|
||||
// Check for control characters
|
||||
for _, r := range move {
|
||||
if unicode.IsControl(r) {
|
||||
return false
|
||||
}
|
||||
}
|
||||
|
||||
// UCI valid moves are 4-5 characters only
|
||||
// Examples: e2e4 / e1g1 (castle) / a7a8q (promotion)
|
||||
// UCI moves: [a-h][1-8][a-h][1-8][qrbn]?
|
||||
if len(move) < 4 || len(move) > 5 {
|
||||
return false
|
||||
}
|
||||
|
||||
// Check each character
|
||||
if move[0] < 'a' || move[0] > 'h' ||
|
||||
move[1] < '1' || move[1] > '8' ||
|
||||
move[2] < 'a' || move[2] > 'h' ||
|
||||
move[3] < '1' || move[3] > '8' {
|
||||
return false
|
||||
}
|
||||
|
||||
// Promotion piece if present
|
||||
if len(move) == 5 {
|
||||
promotion := move[4]
|
||||
if promotion != 'q' && promotion != 'r' && promotion != 'b' && promotion != 'n' {
|
||||
return false
|
||||
}
|
||||
}
|
||||
|
||||
return true
|
||||
}
|
||||
|
||||
// handleCreateGame creates a new game and triggers computer move if needed
|
||||
func (p *Processor) handleCreateGame(cmd Command) ProcessorResponse {
|
||||
args, ok := cmd.Args.(core.CreateGameRequest)
|
||||
if !ok {
|
||||
return p.errorResponse("invalid arguments", core.ErrInvalidRequest)
|
||||
}
|
||||
|
||||
// Enforce minimum searchTime for computer players
|
||||
if args.White.Type == core.PlayerComputer && args.White.SearchTime < 100 {
|
||||
args.White.SearchTime = minSearchTime
|
||||
}
|
||||
if args.Black.Type == core.PlayerComputer && args.Black.SearchTime < 100 {
|
||||
args.Black.SearchTime = minSearchTime
|
||||
}
|
||||
|
||||
// Generate game ID
|
||||
gameID := p.svc.GenerateGameID()
|
||||
|
||||
// Validate and canonicalize FEN if provided
|
||||
initialFEN := board.StartingFEN
|
||||
if args.FEN != "" {
|
||||
if !p.isFENSafe(args.FEN) {
|
||||
return p.errorResponse("invalid FEN format or characters", core.ErrInvalidFEN)
|
||||
}
|
||||
initialFEN = args.FEN
|
||||
}
|
||||
|
||||
p.mu.Lock()
|
||||
p.validationEng.NewGame()
|
||||
p.validationEng.SetPosition(initialFEN, []string{})
|
||||
validatedFEN, err := p.validationEng.GetFEN()
|
||||
p.mu.Unlock()
|
||||
|
||||
if err != nil {
|
||||
return p.errorResponse(fmt.Sprintf("invalid FEN: %v", err), core.ErrInvalidRequest)
|
||||
}
|
||||
|
||||
// Parse to get starting turn
|
||||
b, err := board.ParseFEN(validatedFEN)
|
||||
if err != nil {
|
||||
return p.errorResponse(fmt.Sprintf("FEN parse error: %v", err), core.ErrInvalidRequest)
|
||||
}
|
||||
|
||||
// Create game in service with validated FEN and turn
|
||||
if err = p.svc.CreateGame(gameID, args.White, args.Black, validatedFEN, b.Turn()); err != nil {
|
||||
return p.errorResponse(fmt.Sprintf("failed to create game: %v", err), core.ErrInternalError)
|
||||
}
|
||||
|
||||
// Check if the initial FEN represents a completed game
|
||||
p.checkGameEnd(gameID, validatedFEN, core.OppositeColor(b.Turn()))
|
||||
|
||||
// Get created game
|
||||
g, err := p.svc.GetGame(gameID)
|
||||
if err != nil {
|
||||
return p.errorResponse("game creation failed", core.ErrInternalError)
|
||||
}
|
||||
|
||||
// Build response
|
||||
response := p.buildGameResponse(gameID, g)
|
||||
|
||||
return ProcessorResponse{
|
||||
Success: true,
|
||||
Data: response,
|
||||
}
|
||||
}
|
||||
|
||||
// handleConfigurePlayers updates player configuration mid-game
|
||||
func (p *Processor) handleConfigurePlayers(cmd Command) ProcessorResponse {
|
||||
args, ok := cmd.Args.(core.ConfigurePlayersRequest)
|
||||
if !ok {
|
||||
return p.errorResponse("invalid arguments", core.ErrInvalidRequest)
|
||||
}
|
||||
|
||||
if args.White.Type == core.PlayerComputer && args.White.SearchTime < 100 {
|
||||
args.White.SearchTime = minSearchTime
|
||||
}
|
||||
if args.Black.Type == core.PlayerComputer && args.Black.SearchTime < 100 {
|
||||
args.Black.SearchTime = minSearchTime
|
||||
}
|
||||
|
||||
g, err := p.svc.GetGame(cmd.GameID)
|
||||
if err != nil {
|
||||
return p.errorResponse("game not found", core.ErrGameNotFound)
|
||||
}
|
||||
|
||||
// Block configuration changes during computer move
|
||||
if g.State() == core.StatePending {
|
||||
return p.errorResponse("cannot change players while computer is calculating", core.ErrInvalidRequest)
|
||||
}
|
||||
|
||||
// Update players in service
|
||||
if err = p.svc.UpdatePlayers(cmd.GameID, args.White, args.Black); err != nil {
|
||||
return p.errorResponse(fmt.Sprintf("failed to update players: %v", err), core.ErrInternalError)
|
||||
}
|
||||
|
||||
// Get updated game
|
||||
g, _ = p.svc.GetGame(cmd.GameID)
|
||||
response := p.buildGameResponse(cmd.GameID, g)
|
||||
|
||||
return ProcessorResponse{
|
||||
Success: true,
|
||||
Data: response,
|
||||
}
|
||||
}
|
||||
|
||||
// handleGetGame retrieves game state and triggers computer move if needed
|
||||
func (p *Processor) handleGetGame(cmd Command) ProcessorResponse {
|
||||
g, err := p.svc.GetGame(cmd.GameID)
|
||||
if err != nil {
|
||||
return p.errorResponse("game not found", core.ErrGameNotFound)
|
||||
}
|
||||
|
||||
response := p.buildGameResponse(cmd.GameID, g)
|
||||
|
||||
return ProcessorResponse{
|
||||
Success: true,
|
||||
Data: response,
|
||||
}
|
||||
}
|
||||
|
||||
// handleMakeMove processes human moves
|
||||
func (p *Processor) handleMakeMove(cmd Command) ProcessorResponse {
|
||||
args, ok := cmd.Args.(core.MoveRequest)
|
||||
if !ok {
|
||||
return p.errorResponse("invalid arguments", core.ErrInvalidRequest)
|
||||
}
|
||||
|
||||
g, err := p.svc.GetGame(cmd.GameID)
|
||||
if err != nil {
|
||||
return p.errorResponse("game not found", core.ErrGameNotFound)
|
||||
}
|
||||
|
||||
// Validate game state
|
||||
switch g.State() {
|
||||
case core.StatePending:
|
||||
return p.errorResponse("computer move in progress", core.ErrInvalidRequest)
|
||||
case core.StateStuck:
|
||||
return p.errorResponse("game is stuck due to engine error", core.ErrGameOver)
|
||||
case core.StateWhiteWins, core.StateBlackWins, core.StateDraw, core.StateStalemate:
|
||||
return p.errorResponse(fmt.Sprintf("game is over: %s", g.State()), core.ErrGameOver)
|
||||
case core.StateOngoing:
|
||||
break
|
||||
default:
|
||||
return p.errorResponse("game is in invalid state", core.ErrInvalidRequest)
|
||||
}
|
||||
|
||||
// Handle empty move string - trigger computer move
|
||||
if strings.TrimSpace(args.Move) == "cccc" {
|
||||
if g.NextPlayer().Type != core.PlayerComputer {
|
||||
return p.errorResponse("not computer player's turn", core.ErrNotHumanTurn)
|
||||
}
|
||||
|
||||
// Set state to pending and trigger computer move
|
||||
p.svc.UpdateGameState(cmd.GameID, core.StatePending)
|
||||
p.triggerComputerMove(cmd.GameID, g)
|
||||
|
||||
// Re-fetch for updated state
|
||||
g, _ = p.svc.GetGame(cmd.GameID)
|
||||
response := p.buildGameResponse(cmd.GameID, g)
|
||||
response.LastMove = &core.MoveInfo{
|
||||
PlayerColor: g.NextTurnColor().String(),
|
||||
}
|
||||
|
||||
return ProcessorResponse{
|
||||
Success: true,
|
||||
Pending: true,
|
||||
Data: response,
|
||||
}
|
||||
}
|
||||
|
||||
// Handle human move
|
||||
if g.NextPlayer().Type != core.PlayerHuman {
|
||||
return p.errorResponse("not human player's turn", core.ErrNotHumanTurn)
|
||||
}
|
||||
|
||||
// Normalize and validate move format
|
||||
move := strings.ToLower(strings.TrimSpace(args.Move))
|
||||
if !p.isMoveSafe(move) {
|
||||
return p.errorResponse("invalid move format", core.ErrInvalidMove)
|
||||
}
|
||||
|
||||
currentFEN := g.CurrentFEN()
|
||||
currentColor := g.NextTurnColor()
|
||||
|
||||
// Validate move with engine
|
||||
p.mu.Lock()
|
||||
p.validationEng.SetPosition(currentFEN, []string{move})
|
||||
newFEN, err := p.validationEng.GetFEN()
|
||||
p.mu.Unlock()
|
||||
|
||||
if err != nil || newFEN == currentFEN {
|
||||
return p.errorResponse("illegal move", core.ErrInvalidMove)
|
||||
}
|
||||
|
||||
// Apply move to game state via service
|
||||
if err = p.svc.ApplyMove(cmd.GameID, move, newFEN); err != nil {
|
||||
return p.errorResponse(fmt.Sprintf("failed to apply move: %v", err), core.ErrInternalError)
|
||||
}
|
||||
|
||||
// Store move result metadata
|
||||
p.svc.SetLastMoveResult(cmd.GameID, &game.MoveResult{
|
||||
Move: move,
|
||||
PlayerColor: currentColor,
|
||||
GameState: core.StateOngoing,
|
||||
})
|
||||
|
||||
// Check for checkmate/stalemate
|
||||
p.checkGameEnd(cmd.GameID, newFEN, currentColor)
|
||||
|
||||
// Get updated game
|
||||
g, _ = p.svc.GetGame(cmd.GameID)
|
||||
response := p.buildGameResponse(cmd.GameID, g)
|
||||
|
||||
// Add human move info
|
||||
response.LastMove = &core.MoveInfo{
|
||||
Move: move,
|
||||
PlayerColor: currentColor.String(),
|
||||
}
|
||||
|
||||
return ProcessorResponse{
|
||||
Success: true,
|
||||
Data: response,
|
||||
}
|
||||
}
|
||||
|
||||
// handleUndoMove reverts game state
|
||||
func (p *Processor) handleUndoMove(cmd Command) ProcessorResponse {
|
||||
g, err := p.svc.GetGame(cmd.GameID)
|
||||
if err != nil {
|
||||
return p.errorResponse("game not found", core.ErrGameNotFound)
|
||||
}
|
||||
|
||||
// Check game state
|
||||
switch g.State() {
|
||||
case core.StatePending:
|
||||
return p.errorResponse("cannot undo while computer move is in progress", core.ErrInvalidRequest)
|
||||
case core.StateStuck:
|
||||
return p.errorResponse("cannot undo in stuck game", core.ErrInvalidRequest)
|
||||
}
|
||||
|
||||
args := core.UndoRequest{Count: 1}
|
||||
if cmd.Args != nil {
|
||||
if req, ok := cmd.Args.(core.UndoRequest); ok {
|
||||
args = req
|
||||
}
|
||||
}
|
||||
|
||||
if err = p.svc.UndoMoves(cmd.GameID, args.Count); err != nil {
|
||||
if strings.Contains(err.Error(), "not found") {
|
||||
return p.errorResponse("game not found", core.ErrGameNotFound)
|
||||
}
|
||||
return p.errorResponse(err.Error(), core.ErrInvalidRequest)
|
||||
}
|
||||
|
||||
// Reset game state to ongoing after undo
|
||||
p.svc.UpdateGameState(cmd.GameID, core.StateOngoing)
|
||||
|
||||
g, _ = p.svc.GetGame(cmd.GameID)
|
||||
response := p.buildGameResponse(cmd.GameID, g)
|
||||
|
||||
return ProcessorResponse{
|
||||
Success: true,
|
||||
Data: response,
|
||||
}
|
||||
}
|
||||
|
||||
// handleDeleteGame removes a game
|
||||
func (p *Processor) handleDeleteGame(cmd Command) ProcessorResponse {
|
||||
g, err := p.svc.GetGame(cmd.GameID)
|
||||
if err != nil {
|
||||
return p.errorResponse("game not found", core.ErrGameNotFound)
|
||||
}
|
||||
|
||||
// TODO: gracefully handle deleting game even if pending, discard engine response
|
||||
// Only block deletion if actively computing
|
||||
if g.State() == core.StatePending {
|
||||
return p.errorResponse("cannot delete game while computer move is in progress", core.ErrInvalidRequest)
|
||||
}
|
||||
|
||||
if err = p.svc.DeleteGame(cmd.GameID); err != nil {
|
||||
return p.errorResponse("game not found", core.ErrGameNotFound)
|
||||
}
|
||||
|
||||
return ProcessorResponse{
|
||||
Success: true,
|
||||
}
|
||||
}
|
||||
|
||||
// handleGetBoard returns board visualization
|
||||
func (p *Processor) handleGetBoard(cmd Command) ProcessorResponse {
|
||||
g, err := p.svc.GetGame(cmd.GameID)
|
||||
if err != nil {
|
||||
return p.errorResponse("game not found", core.ErrGameNotFound)
|
||||
}
|
||||
|
||||
b, err := board.ParseFEN(g.CurrentFEN())
|
||||
if err != nil {
|
||||
return p.errorResponse("error parsing FEN", core.ErrInvalidFEN)
|
||||
}
|
||||
ascii := b.ToASCII()
|
||||
|
||||
return ProcessorResponse{
|
||||
Success: true,
|
||||
Data: core.BoardResponse{
|
||||
FEN: g.CurrentFEN(),
|
||||
Board: ascii,
|
||||
},
|
||||
}
|
||||
}
|
||||
|
||||
// triggerComputerMove initiates async engine calculation
|
||||
func (p *Processor) triggerComputerMove(gameID string, g *game.Game) {
|
||||
fen := g.CurrentFEN()
|
||||
color := g.NextTurnColor()
|
||||
player := g.NextPlayer()
|
||||
|
||||
// Submit to queue with callback and computer config
|
||||
p.queue.SubmitAsync(gameID, fen, color, player, func(result EngineResult) {
|
||||
// Check if game still exists
|
||||
currentGame, err := p.svc.GetGame(gameID)
|
||||
if err != nil {
|
||||
return // Game was deleted
|
||||
}
|
||||
|
||||
// Only process if still in pending state
|
||||
if currentGame.State() != core.StatePending {
|
||||
return
|
||||
}
|
||||
|
||||
if result.Error != nil {
|
||||
log.Printf("Engine error for game %s: %v", gameID, result.Error)
|
||||
p.svc.UpdateGameState(gameID, core.StateStuck)
|
||||
return
|
||||
}
|
||||
|
||||
// Use centralized state determination
|
||||
state := p.determineGameEndState(core.OppositeColor(color), &engine.SearchResult{
|
||||
BestMove: result.Move,
|
||||
Score: result.Score,
|
||||
Depth: result.Depth,
|
||||
IsMate: result.IsMate,
|
||||
MateIn: result.MateIn,
|
||||
})
|
||||
|
||||
if state != core.StateOngoing {
|
||||
p.svc.UpdateGameState(gameID, state)
|
||||
return
|
||||
}
|
||||
|
||||
// Apply computer move
|
||||
p.mu.Lock()
|
||||
p.validationEng.SetPosition(fen, []string{result.Move})
|
||||
newFEN, _ := p.validationEng.GetFEN()
|
||||
p.mu.Unlock()
|
||||
|
||||
p.svc.ApplyMove(gameID, result.Move, newFEN)
|
||||
p.svc.SetLastMoveResult(gameID, &game.MoveResult{
|
||||
Move: result.Move,
|
||||
PlayerColor: color,
|
||||
Score: result.Score,
|
||||
Depth: result.Depth,
|
||||
})
|
||||
|
||||
// Reset to ongoing first
|
||||
p.svc.UpdateGameState(gameID, core.StateOngoing)
|
||||
|
||||
// Check if opponent is checkmated
|
||||
p.checkGameEnd(gameID, newFEN, color)
|
||||
})
|
||||
}
|
||||
|
||||
// determineGameEndState centralized function to determine game end state based on engine evaluation
|
||||
func (p *Processor) determineGameEndState(lastMoveBy core.Color, searchResult *engine.SearchResult) core.State {
|
||||
// No legal moves detected
|
||||
if searchResult.BestMove == "" || searchResult.BestMove == "(none)" {
|
||||
if searchResult.IsMate {
|
||||
// It's a checkmate - the side that just moved wins
|
||||
if lastMoveBy == core.ColorWhite {
|
||||
return core.StateWhiteWins
|
||||
}
|
||||
return core.StateBlackWins
|
||||
}
|
||||
// Stalemate - no legal moves but not in check
|
||||
return core.StateStalemate
|
||||
}
|
||||
|
||||
// Game continues
|
||||
return core.StateOngoing
|
||||
}
|
||||
|
||||
// checkGameEnd determines if game has ended
|
||||
func (p *Processor) checkGameEnd(gameID, fen string, lastMoveBy core.Color) {
|
||||
p.mu.Lock()
|
||||
p.validationEng.SetPosition(fen, []string{})
|
||||
search, _ := p.validationEng.Search(100)
|
||||
p.mu.Unlock()
|
||||
|
||||
// Use centralized state determination
|
||||
state := p.determineGameEndState(lastMoveBy, search)
|
||||
if state != core.StateOngoing {
|
||||
p.svc.UpdateGameState(gameID, state)
|
||||
}
|
||||
}
|
||||
|
||||
// buildGameResponse constructs standard game response
|
||||
func (p *Processor) buildGameResponse(gameID string, g *game.Game) core.GameResponse {
|
||||
resp := core.GameResponse{
|
||||
GameID: gameID,
|
||||
FEN: g.CurrentFEN(),
|
||||
Turn: g.NextTurnColor().String(),
|
||||
State: g.State().String(),
|
||||
Moves: g.Moves(),
|
||||
Players: core.PlayersResponse{
|
||||
White: g.GetPlayer(core.ColorWhite),
|
||||
Black: g.GetPlayer(core.ColorBlack),
|
||||
},
|
||||
}
|
||||
|
||||
// Include last move if available
|
||||
if result := g.LastResult(); result != nil {
|
||||
resp.LastMove = &core.MoveInfo{
|
||||
Move: result.Move,
|
||||
PlayerColor: result.PlayerColor.String(),
|
||||
Score: result.Score,
|
||||
Depth: result.Depth,
|
||||
}
|
||||
}
|
||||
|
||||
return resp
|
||||
}
|
||||
|
||||
// errorResponse creates error response
|
||||
func (p *Processor) errorResponse(message, code string) ProcessorResponse {
|
||||
return ProcessorResponse{
|
||||
Success: false,
|
||||
Error: &core.ErrorResponse{
|
||||
Error: message,
|
||||
Code: code,
|
||||
},
|
||||
}
|
||||
}
|
||||
|
||||
// Close cleans up resources
|
||||
func (p *Processor) Close() error {
|
||||
p.queue.Shutdown(5 * time.Second)
|
||||
return p.validationEng.Close()
|
||||
}
|
||||
209
internal/processor/queue.go
Normal file
209
internal/processor/queue.go
Normal file
@ -0,0 +1,209 @@
|
||||
// FILE: internal/processor/queue.go
|
||||
package processor
|
||||
|
||||
import (
|
||||
"context"
|
||||
"fmt"
|
||||
"sync"
|
||||
"time"
|
||||
|
||||
"chess/internal/core"
|
||||
"chess/internal/engine"
|
||||
)
|
||||
|
||||
// EngineTask represents a computer move calculation request
|
||||
type EngineTask struct {
|
||||
GameID string
|
||||
FEN string
|
||||
Color core.Color
|
||||
Player *core.Player // Full player config including engine configuration
|
||||
Response chan<- EngineResult
|
||||
}
|
||||
|
||||
// EngineResult contains the outcome of an engine calculation
|
||||
type EngineResult struct {
|
||||
GameID string
|
||||
Move string
|
||||
Score int
|
||||
Depth int
|
||||
IsMate bool
|
||||
MateIn int
|
||||
Error error
|
||||
}
|
||||
|
||||
// EngineQueue manages async engine computations
|
||||
type EngineQueue struct {
|
||||
tasks chan EngineTask
|
||||
workers int
|
||||
wg sync.WaitGroup
|
||||
ctx context.Context
|
||||
cancel context.CancelFunc
|
||||
}
|
||||
|
||||
// NewEngineQueue creates a queue with specified worker count
|
||||
func NewEngineQueue(workerCount int) *EngineQueue {
|
||||
if workerCount < 1 {
|
||||
workerCount = 2 // Default
|
||||
}
|
||||
|
||||
ctx, cancel := context.WithCancel(context.Background())
|
||||
|
||||
q := &EngineQueue{
|
||||
tasks: make(chan EngineTask, 100), // Buffered for queueing
|
||||
workers: workerCount,
|
||||
ctx: ctx,
|
||||
cancel: cancel,
|
||||
}
|
||||
|
||||
q.start()
|
||||
return q
|
||||
}
|
||||
|
||||
// start initializes the worker pool
|
||||
func (q *EngineQueue) start() {
|
||||
for i := 0; i < q.workers; i++ {
|
||||
q.wg.Add(1)
|
||||
go q.worker(i)
|
||||
}
|
||||
}
|
||||
|
||||
// worker processes engine tasks
|
||||
func (q *EngineQueue) worker(id int) {
|
||||
defer q.wg.Done()
|
||||
|
||||
// Each worker gets its own engine instance
|
||||
eng, err := engine.New()
|
||||
if err != nil {
|
||||
fmt.Printf("Worker %d failed to initialize engine: %v\n", id, err)
|
||||
return
|
||||
}
|
||||
defer eng.Close()
|
||||
|
||||
for {
|
||||
select {
|
||||
case task, ok := <-q.tasks:
|
||||
if !ok {
|
||||
return // Channel closed
|
||||
}
|
||||
|
||||
result := q.processTask(eng, task)
|
||||
|
||||
// Send result if receiver still listening
|
||||
select {
|
||||
case task.Response <- result:
|
||||
case <-time.After(100 * time.Millisecond):
|
||||
// Receiver abandoned, discard result
|
||||
}
|
||||
|
||||
case <-q.ctx.Done():
|
||||
return
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// processTask executes a single engine calculation
|
||||
func (q *EngineQueue) processTask(eng *engine.UCI, task EngineTask) EngineResult {
|
||||
result := EngineResult{
|
||||
GameID: task.GameID,
|
||||
}
|
||||
|
||||
// Apply computer configuration if provided
|
||||
if task.Player.Type == core.PlayerComputer {
|
||||
eng.SetSkillLevel(task.Player.Level)
|
||||
}
|
||||
|
||||
// Setup position
|
||||
eng.SetPosition(task.FEN, []string{})
|
||||
|
||||
// Determine search time
|
||||
searchTime := 1000 // Default 1 second
|
||||
if task.Player.Type == core.PlayerComputer && task.Player.SearchTime > 0 {
|
||||
searchTime = task.Player.SearchTime
|
||||
}
|
||||
|
||||
// Search for best move
|
||||
search, err := eng.Search(searchTime)
|
||||
if err != nil {
|
||||
result.Error = fmt.Errorf("engine search failed: %v", err)
|
||||
return result
|
||||
}
|
||||
|
||||
// Check for no legal moves
|
||||
if search.BestMove == "" || search.BestMove == "(none)" {
|
||||
result.Move = ""
|
||||
result.IsMate = search.IsMate
|
||||
result.MateIn = search.MateIn
|
||||
return result
|
||||
}
|
||||
|
||||
result.Move = search.BestMove
|
||||
result.Score = search.Score
|
||||
result.Depth = search.Depth
|
||||
result.IsMate = search.IsMate
|
||||
result.MateIn = search.MateIn
|
||||
|
||||
return result
|
||||
}
|
||||
|
||||
// Submit adds a task to the queue
|
||||
func (q *EngineQueue) Submit(task EngineTask) error {
|
||||
select {
|
||||
case q.tasks <- task:
|
||||
return nil
|
||||
case <-q.ctx.Done():
|
||||
return fmt.Errorf("queue is shutting down")
|
||||
default:
|
||||
return fmt.Errorf("queue is full")
|
||||
}
|
||||
}
|
||||
|
||||
// SubmitAsync submits a task without blocking for result
|
||||
func (q *EngineQueue) SubmitAsync(gameID, fen string, color core.Color, player *core.Player, callback func(EngineResult)) error {
|
||||
respChan := make(chan EngineResult, 1)
|
||||
|
||||
task := EngineTask{
|
||||
GameID: gameID,
|
||||
FEN: fen,
|
||||
Color: color,
|
||||
Player: player,
|
||||
Response: respChan,
|
||||
}
|
||||
|
||||
if err := q.Submit(task); err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
// Handle result in background
|
||||
go func() {
|
||||
select {
|
||||
case result := <-respChan:
|
||||
callback(result)
|
||||
case <-time.After(5 * time.Second):
|
||||
callback(EngineResult{
|
||||
GameID: gameID,
|
||||
Error: fmt.Errorf("engine timeout"),
|
||||
})
|
||||
}
|
||||
}()
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
// Shutdown gracefully stops the queue
|
||||
func (q *EngineQueue) Shutdown(timeout time.Duration) error {
|
||||
q.cancel()
|
||||
close(q.tasks)
|
||||
|
||||
done := make(chan struct{})
|
||||
go func() {
|
||||
q.wg.Wait()
|
||||
close(done)
|
||||
}()
|
||||
|
||||
select {
|
||||
case <-done:
|
||||
return nil
|
||||
case <-time.After(timeout):
|
||||
return fmt.Errorf("shutdown timeout exceeded")
|
||||
}
|
||||
}
|
||||
@ -3,202 +3,137 @@ package service
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"strings"
|
||||
"sync"
|
||||
|
||||
"chess/internal/board"
|
||||
"chess/internal/core"
|
||||
"chess/internal/engine"
|
||||
"chess/internal/game"
|
||||
|
||||
"github.com/google/uuid"
|
||||
)
|
||||
|
||||
// Service is a pure state manager for chess games
|
||||
// It has NO knowledge of chess rules or engine interactions
|
||||
type Service struct {
|
||||
games map[string]*game.Game
|
||||
engine *engine.UCI
|
||||
games map[string]*game.Game
|
||||
mu sync.RWMutex
|
||||
}
|
||||
|
||||
// New creates a new service instance
|
||||
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,
|
||||
games: make(map[string]*game.Game),
|
||||
}, 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]
|
||||
// CreateGame creates game with player configuration
|
||||
func (s *Service) CreateGame(id string, whiteConfig, blackConfig core.PlayerConfig, initialFEN string, startingTurn core.Color) error {
|
||||
s.mu.Lock()
|
||||
defer s.mu.Unlock()
|
||||
|
||||
if _, exists := s.games[id]; exists {
|
||||
return fmt.Errorf("game %s already exists", id)
|
||||
}
|
||||
|
||||
// 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)
|
||||
// Create players with UUIDs and config
|
||||
whitePlayer := core.NewPlayer(whiteConfig, core.ColorWhite)
|
||||
blackPlayer := core.NewPlayer(blackConfig, core.ColorBlack)
|
||||
|
||||
s.games[id] = game.New(initialFEN, 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")
|
||||
}
|
||||
// UpdatePlayers replaces players in an existing game
|
||||
func (s *Service) UpdatePlayers(gameID string, whiteConfig, blackConfig core.PlayerConfig) error {
|
||||
s.mu.Lock()
|
||||
defer s.mu.Unlock()
|
||||
|
||||
g, ok := s.games[gameID]
|
||||
if !ok {
|
||||
return fmt.Errorf("game not found")
|
||||
return fmt.Errorf("game not found: %s", gameID)
|
||||
}
|
||||
|
||||
// Check if it's human's turn
|
||||
if g.NextPlayer().Type != core.PlayerHuman {
|
||||
return fmt.Errorf("not a human player's turn")
|
||||
}
|
||||
// Create new player instances with new UUIDs
|
||||
whitePlayer := core.NewPlayer(whiteConfig, core.ColorWhite)
|
||||
blackPlayer := core.NewPlayer(blackConfig, core.ColorBlack)
|
||||
|
||||
currentFEN := g.CurrentFEN()
|
||||
humanColor := g.NextTurn()
|
||||
// Update the game's players
|
||||
g.UpdatePlayers(whitePlayer, blackPlayer)
|
||||
|
||||
// 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) {
|
||||
// GetGame retrieves a game by ID
|
||||
func (s *Service) GetGame(gameID string) (*game.Game, error) {
|
||||
s.mu.RLock()
|
||||
defer s.mu.RUnlock()
|
||||
|
||||
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
|
||||
return g, nil
|
||||
}
|
||||
|
||||
func (s *Service) Undo(gameID string, count int) error {
|
||||
// GenerateGameID creates a new unique game ID
|
||||
func (s *Service) GenerateGameID() string {
|
||||
return uuid.New().String()
|
||||
}
|
||||
|
||||
// ApplyMove adds a validated move to the game history
|
||||
// The processor has already validated this move and calculated the new FEN
|
||||
func (s *Service) ApplyMove(gameID, moveUCI, newFEN string) error {
|
||||
s.mu.Lock()
|
||||
defer s.mu.Unlock()
|
||||
|
||||
g, ok := s.games[gameID]
|
||||
if !ok {
|
||||
return fmt.Errorf("game not found: %s", gameID)
|
||||
}
|
||||
|
||||
// Determine whose turn it was before this move
|
||||
currentTurn := g.NextTurnColor()
|
||||
nextTurn := core.OppositeColor(currentTurn)
|
||||
|
||||
// Add the new position to game history
|
||||
g.AddSnapshot(newFEN, moveUCI, nextTurn)
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
// UpdateGameState sets the game's end state (checkmate, stalemate, etc)
|
||||
func (s *Service) UpdateGameState(gameID string, state core.State) error {
|
||||
s.mu.Lock()
|
||||
defer s.mu.Unlock()
|
||||
|
||||
g, ok := s.games[gameID]
|
||||
if !ok {
|
||||
return fmt.Errorf("game not found: %s", gameID)
|
||||
}
|
||||
|
||||
g.SetState(state)
|
||||
return nil
|
||||
}
|
||||
|
||||
// SetLastMoveResult stores metadata about the last move (score, depth, etc)
|
||||
// Used by processor to track computer move evaluations
|
||||
func (s *Service) SetLastMoveResult(gameID string, result *game.MoveResult) error {
|
||||
s.mu.Lock()
|
||||
defer s.mu.Unlock()
|
||||
|
||||
g, ok := s.games[gameID]
|
||||
if !ok {
|
||||
return fmt.Errorf("game not found: %s", gameID)
|
||||
}
|
||||
|
||||
g.SetLastResult(result)
|
||||
return nil
|
||||
}
|
||||
|
||||
// UndoMoves removes the specified number of moves from game history
|
||||
func (s *Service) UndoMoves(gameID string, count int) error {
|
||||
s.mu.Lock()
|
||||
defer s.mu.Unlock()
|
||||
|
||||
g, ok := s.games[gameID]
|
||||
if !ok {
|
||||
return fmt.Errorf("game not found: %s", gameID)
|
||||
@ -207,34 +142,25 @@ func (s *Service) Undo(gameID string, count int) error {
|
||||
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
|
||||
}
|
||||
|
||||
// DeleteGame removes a game from memory
|
||||
func (s *Service) DeleteGame(gameID string) error {
|
||||
s.mu.Lock()
|
||||
defer s.mu.Unlock()
|
||||
|
||||
if _, ok := s.games[gameID]; !ok {
|
||||
return fmt.Errorf("game not found: %s", gameID)
|
||||
}
|
||||
|
||||
delete(s.games, gameID)
|
||||
return nil
|
||||
}
|
||||
|
||||
// Close cleans up resources (currently a no-op as no engine to close)
|
||||
func (s *Service) Close() error {
|
||||
if s.engine != nil {
|
||||
return s.engine.Close()
|
||||
}
|
||||
s.mu.Lock()
|
||||
defer s.mu.Unlock()
|
||||
|
||||
// Clear all games
|
||||
s.games = make(map[string]*game.Game)
|
||||
return nil
|
||||
}
|
||||
@ -1,248 +0,0 @@
|
||||
// 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
|
||||
}
|
||||
@ -1,316 +0,0 @@
|
||||
// FILE: internal/transport/http/game_handler.go
|
||||
package http
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"strings"
|
||||
|
||||
"chess/internal/board"
|
||||
"chess/internal/core"
|
||||
"chess/internal/game"
|
||||
|
||||
"github.com/gofiber/fiber/v2"
|
||||
"github.com/google/uuid"
|
||||
)
|
||||
|
||||
// CreateGame creates a new game with specified player types
|
||||
func (h *HTTPHandler) CreateGame(c *fiber.Ctx) error {
|
||||
var req CreateGameRequest
|
||||
if err := c.BodyParser(&req); err != nil {
|
||||
return c.Status(fiber.StatusBadRequest).JSON(ErrorResponse{
|
||||
Error: "invalid request body",
|
||||
Code: ErrInvalidRequest,
|
||||
Details: err.Error(),
|
||||
})
|
||||
}
|
||||
|
||||
gameID := uuid.New().String()
|
||||
|
||||
// Create game with proper type conversion
|
||||
var fenArray []string
|
||||
if req.FEN != "" {
|
||||
fenArray = []string{req.FEN}
|
||||
}
|
||||
|
||||
err := h.svc.NewGame(
|
||||
gameID,
|
||||
core.PlayerType(req.White),
|
||||
core.PlayerType(req.Black),
|
||||
fenArray...,
|
||||
)
|
||||
if err != nil {
|
||||
return c.Status(fiber.StatusBadRequest).JSON(ErrorResponse{
|
||||
Error: "failed to create game",
|
||||
Code: ErrInvalidRequest,
|
||||
Details: err.Error(),
|
||||
})
|
||||
}
|
||||
|
||||
// Build response - cache game instance
|
||||
g, _ := h.svc.GetGame(gameID)
|
||||
response := h.buildGameResponse(gameID, g)
|
||||
|
||||
// Execute computer move if computer starts
|
||||
if g.NextPlayer().Type == core.PlayerComputer && g.State() == core.StateOngoing {
|
||||
if err := h.executeComputerMove(gameID, g, &response); err != nil {
|
||||
// Log error but return game created successfully
|
||||
fmt.Printf("Warning: failed to execute initial computer move: %v\n", err)
|
||||
}
|
||||
}
|
||||
|
||||
return c.Status(fiber.StatusCreated).JSON(response)
|
||||
}
|
||||
|
||||
// GetGame retrieves current game state, executing computer move if needed
|
||||
func (h *HTTPHandler) GetGame(c *fiber.Ctx) error {
|
||||
gameID := c.Params("gameId")
|
||||
|
||||
g, err := h.svc.GetGame(gameID)
|
||||
if err != nil {
|
||||
return c.Status(fiber.StatusNotFound).JSON(ErrorResponse{
|
||||
Error: "game not found",
|
||||
Code: ErrGameNotFound,
|
||||
})
|
||||
}
|
||||
|
||||
response := h.buildGameResponse(gameID, g)
|
||||
|
||||
// Auto-execute computer move if it's computer's turn
|
||||
if g.NextPlayer().Type == core.PlayerComputer && g.State() == core.StateOngoing {
|
||||
if err := h.executeComputerMove(gameID, g, &response); err != nil {
|
||||
return c.Status(fiber.StatusInternalServerError).JSON(ErrorResponse{
|
||||
Error: "failed to execute computer move",
|
||||
Code: ErrInternalError,
|
||||
Details: err.Error(),
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
return c.JSON(response)
|
||||
}
|
||||
|
||||
// MakeMove submits a human player move
|
||||
func (h *HTTPHandler) MakeMove(c *fiber.Ctx) error {
|
||||
gameID := c.Params("gameId")
|
||||
|
||||
var req MoveRequest
|
||||
if err := c.BodyParser(&req); err != nil {
|
||||
return c.Status(fiber.StatusBadRequest).JSON(ErrorResponse{
|
||||
Error: "invalid request body",
|
||||
Code: ErrInvalidRequest,
|
||||
Details: err.Error(),
|
||||
})
|
||||
}
|
||||
|
||||
g, err := h.svc.GetGame(gameID)
|
||||
if err != nil {
|
||||
return c.Status(fiber.StatusNotFound).JSON(ErrorResponse{
|
||||
Error: "game not found",
|
||||
Code: ErrGameNotFound,
|
||||
})
|
||||
}
|
||||
|
||||
// Check game state BEFORE making move
|
||||
if g.State() != core.StateOngoing {
|
||||
fmt.Printf("DEBUG: Move rejected - game over (state: %s)\n", g.State())
|
||||
return c.Status(fiber.StatusBadRequest).JSON(ErrorResponse{
|
||||
Error: "game is over",
|
||||
Code: ErrGameOver,
|
||||
Details: fmt.Sprintf("game state: %s", g.State()),
|
||||
})
|
||||
}
|
||||
|
||||
// Verify it's human's turn
|
||||
currentPlayer := g.NextPlayer()
|
||||
if currentPlayer.Type != core.PlayerHuman {
|
||||
fmt.Printf("DEBUG: Move rejected - not human turn (current: %v, turn: %c)\n",
|
||||
currentPlayer.Type, g.NextTurn())
|
||||
return c.Status(fiber.StatusBadRequest).JSON(ErrorResponse{
|
||||
Error: "not human player's turn",
|
||||
Code: ErrNotHumanTurn,
|
||||
Details: fmt.Sprintf("current turn: %c", g.NextTurn()),
|
||||
})
|
||||
}
|
||||
|
||||
fmt.Printf("DEBUG: Attempting human move %s for game %s\n", req.Move, gameID)
|
||||
|
||||
// Make human move
|
||||
if err := h.svc.MakeHumanMove(gameID, req.Move); err != nil {
|
||||
fmt.Printf("DEBUG: Move failed: %v\n", err)
|
||||
return c.Status(fiber.StatusBadRequest).JSON(ErrorResponse{
|
||||
Error: "invalid move",
|
||||
Code: ErrInvalidMove,
|
||||
Details: err.Error(),
|
||||
})
|
||||
}
|
||||
|
||||
// Get updated game state - refresh g
|
||||
g, _ = h.svc.GetGame(gameID)
|
||||
response := h.buildGameResponse(gameID, g)
|
||||
|
||||
// Include human move info from LastResult
|
||||
if result := g.LastResult(); result != nil {
|
||||
response.LastMove = &MoveInfo{
|
||||
Move: result.Move,
|
||||
Player: colorToString(result.Player),
|
||||
}
|
||||
}
|
||||
|
||||
fmt.Printf("DEBUG: Human move successful, new state: %s, next turn: %c\n",
|
||||
g.State(), g.NextTurn())
|
||||
|
||||
// Execute computer response if needed
|
||||
if g.NextPlayer().Type == core.PlayerComputer && g.State() == core.StateOngoing {
|
||||
fmt.Printf("DEBUG: Executing computer response\n")
|
||||
if err := h.executeComputerMove(gameID, g, &response); err != nil {
|
||||
// Computer move failed, but human move succeeded
|
||||
fmt.Printf("Warning: computer move failed: %v\n", err)
|
||||
}
|
||||
}
|
||||
|
||||
return c.JSON(response)
|
||||
}
|
||||
|
||||
// UndoMove undoes one or more moves
|
||||
func (h *HTTPHandler) UndoMove(c *fiber.Ctx) error {
|
||||
gameID := c.Params("gameId")
|
||||
|
||||
var req UndoRequest
|
||||
if err := c.BodyParser(&req); err != nil {
|
||||
// Body parsing failed, use default
|
||||
req.Count = 1
|
||||
}
|
||||
|
||||
if req.Count < 1 {
|
||||
req.Count = 1
|
||||
}
|
||||
|
||||
if err := h.svc.Undo(gameID, req.Count); err != nil {
|
||||
// Determine if game not found or invalid undo
|
||||
if strings.Contains(err.Error(), "not found") {
|
||||
return c.Status(fiber.StatusNotFound).JSON(ErrorResponse{
|
||||
Error: "game not found",
|
||||
Code: ErrGameNotFound,
|
||||
})
|
||||
}
|
||||
return c.Status(fiber.StatusBadRequest).JSON(ErrorResponse{
|
||||
Error: "cannot undo moves",
|
||||
Code: ErrInvalidRequest,
|
||||
Details: err.Error(),
|
||||
})
|
||||
}
|
||||
|
||||
// Return updated game state
|
||||
g, _ := h.svc.GetGame(gameID)
|
||||
response := h.buildGameResponse(gameID, g)
|
||||
|
||||
return c.JSON(response)
|
||||
}
|
||||
|
||||
// DeleteGame ends and cleans up a game
|
||||
func (h *HTTPHandler) DeleteGame(c *fiber.Ctx) error {
|
||||
gameID := c.Params("gameId")
|
||||
|
||||
if err := h.svc.DeleteGame(gameID); err != nil {
|
||||
return c.Status(fiber.StatusNotFound).JSON(ErrorResponse{
|
||||
Error: "game not found",
|
||||
Code: ErrGameNotFound,
|
||||
})
|
||||
}
|
||||
|
||||
return c.SendStatus(fiber.StatusNoContent)
|
||||
}
|
||||
|
||||
// GetBoard returns ASCII representation of the board
|
||||
func (h *HTTPHandler) GetBoard(c *fiber.Ctx) error {
|
||||
gameID := c.Params("gameId")
|
||||
|
||||
g, err := h.svc.GetGame(gameID)
|
||||
if err != nil {
|
||||
return c.Status(fiber.StatusNotFound).JSON(ErrorResponse{
|
||||
Error: "game not found",
|
||||
Code: ErrGameNotFound,
|
||||
})
|
||||
}
|
||||
|
||||
b, _ := h.svc.GetCurrentBoard(gameID)
|
||||
|
||||
// Generate ASCII board
|
||||
ascii := h.generateASCIIBoard(b)
|
||||
|
||||
return c.JSON(BoardResponse{
|
||||
FEN: g.CurrentFEN(),
|
||||
Board: ascii,
|
||||
})
|
||||
}
|
||||
|
||||
// Helper: Build standard game response - FIXED to use GetPlayer()
|
||||
func (h *HTTPHandler) buildGameResponse(gameID string, g *game.Game) GameResponse {
|
||||
whitePlayer := g.GetPlayer(core.ColorWhite)
|
||||
blackPlayer := g.GetPlayer(core.ColorBlack)
|
||||
|
||||
return GameResponse{
|
||||
GameID: gameID,
|
||||
FEN: g.CurrentFEN(),
|
||||
Turn: colorToString(g.NextTurn()),
|
||||
State: stateToString(g.State()),
|
||||
Moves: g.Moves(),
|
||||
Players: PlayersInfo{
|
||||
White: PlayerType(whitePlayer.Type),
|
||||
Black: PlayerType(blackPlayer.Type),
|
||||
},
|
||||
}
|
||||
}
|
||||
|
||||
// Helper: Execute computer move and update response - FIXED to accept game instance
|
||||
func (h *HTTPHandler) executeComputerMove(gameID string, g *game.Game, response *GameResponse) error {
|
||||
result, err := h.svc.MakeComputerMove(gameID)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
// Refresh game state after computer move
|
||||
g, _ = h.svc.GetGame(gameID)
|
||||
|
||||
// Update response fields
|
||||
response.FEN = g.CurrentFEN()
|
||||
response.Turn = colorToString(g.NextTurn())
|
||||
response.State = stateToString(g.State())
|
||||
response.Moves = g.Moves()
|
||||
|
||||
// Add computer move info
|
||||
if result != nil {
|
||||
response.LastMove = &MoveInfo{
|
||||
Move: result.Move,
|
||||
Player: colorToString(result.Player),
|
||||
Score: result.Score,
|
||||
Depth: result.Depth,
|
||||
}
|
||||
}
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
// Helper: Generate ASCII board representation
|
||||
func (h *HTTPHandler) generateASCIIBoard(b *board.Board) string {
|
||||
var sb strings.Builder
|
||||
sb.WriteString(" 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++ {
|
||||
square := fmt.Sprintf("%c%c", 'a'+f, '8'-r)
|
||||
piece := b.GetPieceAt(square)
|
||||
|
||||
if piece == 0 {
|
||||
sb.WriteString(". ")
|
||||
} else {
|
||||
sb.WriteString(fmt.Sprintf("%c ", piece))
|
||||
}
|
||||
}
|
||||
sb.WriteString(fmt.Sprintf(" %d\n", 8-r))
|
||||
}
|
||||
sb.WriteString(" a b c d e f g h")
|
||||
|
||||
return sb.String()
|
||||
}
|
||||
@ -1,148 +0,0 @@
|
||||
// FILE: internal/transport/http/handler.go
|
||||
package http
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
"chess/internal/service"
|
||||
|
||||
"github.com/gofiber/fiber/v2"
|
||||
"github.com/gofiber/fiber/v2/middleware/cors"
|
||||
"github.com/gofiber/fiber/v2/middleware/limiter"
|
||||
"github.com/gofiber/fiber/v2/middleware/logger"
|
||||
"github.com/gofiber/fiber/v2/middleware/recover"
|
||||
)
|
||||
|
||||
type HTTPHandler struct {
|
||||
svc *service.Service
|
||||
}
|
||||
|
||||
func NewHTTPHandler(svc *service.Service) *HTTPHandler {
|
||||
return &HTTPHandler{svc: svc}
|
||||
}
|
||||
|
||||
func NewFiberApp(svc *service.Service, devMode bool) *fiber.App {
|
||||
// Create handler
|
||||
h := NewHTTPHandler(svc)
|
||||
|
||||
// Initialize Fiber app
|
||||
app := fiber.New(fiber.Config{
|
||||
ErrorHandler: customErrorHandler,
|
||||
ReadTimeout: 10 * time.Second,
|
||||
WriteTimeout: 10 * time.Second,
|
||||
IdleTimeout: 30 * time.Second,
|
||||
})
|
||||
|
||||
// Global middleware (order matters)
|
||||
app.Use(recover.New())
|
||||
app.Use(logger.New(logger.Config{
|
||||
Format: "${time} ${status} ${method} ${path} ${latency}\n",
|
||||
}))
|
||||
app.Use(cors.New(cors.Config{
|
||||
AllowOrigins: "*",
|
||||
AllowMethods: "GET,POST,DELETE,OPTIONS",
|
||||
AllowHeaders: "Origin,Content-Type,Accept",
|
||||
}))
|
||||
|
||||
// Health check (no rate limit)
|
||||
app.Get("/health", h.Health)
|
||||
|
||||
// API v1 routes with rate limiting
|
||||
api := app.Group("/api/v1")
|
||||
|
||||
// Rate limiter: 1/10 req/sec per IP with expiry
|
||||
maxReq := 1
|
||||
if devMode {
|
||||
maxReq = 10
|
||||
}
|
||||
api.Use(limiter.New(limiter.Config{
|
||||
Max: maxReq, // Allow requests per second
|
||||
Expiration: 1 * time.Second, // Per second
|
||||
KeyGenerator: func(c *fiber.Ctx) string {
|
||||
// Check X-Forwarded-For first, then X-Real-IP, then RemoteIP
|
||||
if xff := c.Get("X-Forwarded-For"); xff != "" {
|
||||
// Take the first IP from X-Forwarded-For chain
|
||||
if idx := strings.Index(xff, ","); idx != -1 {
|
||||
return strings.TrimSpace(xff[:idx])
|
||||
}
|
||||
return xff
|
||||
}
|
||||
return c.IP()
|
||||
},
|
||||
LimitReached: func(c *fiber.Ctx) error {
|
||||
return c.Status(fiber.StatusTooManyRequests).JSON(ErrorResponse{
|
||||
Error: "rate limit exceeded",
|
||||
Code: ErrRateLimitExceeded,
|
||||
Details: fmt.Sprintf("%d requests per second allowed", maxReq),
|
||||
})
|
||||
},
|
||||
Storage: nil, // Use in-memory storage (default)
|
||||
SkipFailedRequests: false,
|
||||
SkipSuccessfulRequests: false,
|
||||
}))
|
||||
|
||||
// Content-Type validation for POST requests
|
||||
api.Use(contentTypeValidator)
|
||||
|
||||
// Register game routes
|
||||
api.Post("/games", h.CreateGame)
|
||||
api.Get("/games/:gameId", h.GetGame)
|
||||
api.Delete("/games/:gameId", h.DeleteGame)
|
||||
api.Post("/games/:gameId/moves", h.MakeMove)
|
||||
api.Post("/games/:gameId/undo", h.UndoMove)
|
||||
api.Get("/games/:gameId/board", h.GetBoard)
|
||||
|
||||
return app
|
||||
}
|
||||
|
||||
// contentTypeValidator ensures POST requests have application/json
|
||||
func contentTypeValidator(c *fiber.Ctx) error {
|
||||
if c.Method() == "POST" {
|
||||
contentType := c.Get("Content-Type")
|
||||
if contentType != "application/json" && contentType != "" {
|
||||
return c.Status(fiber.StatusUnsupportedMediaType).JSON(ErrorResponse{
|
||||
Error: "unsupported media type",
|
||||
Code: ErrInvalidContent,
|
||||
Details: "Content-Type must be application/json",
|
||||
})
|
||||
}
|
||||
}
|
||||
return c.Next()
|
||||
}
|
||||
|
||||
// customErrorHandler provides consistent error responses
|
||||
func customErrorHandler(c *fiber.Ctx, err error) error {
|
||||
code := fiber.StatusInternalServerError
|
||||
response := ErrorResponse{
|
||||
Error: "internal server error",
|
||||
Code: ErrInternalError,
|
||||
}
|
||||
|
||||
// Check if it's a Fiber error
|
||||
if e, ok := err.(*fiber.Error); ok {
|
||||
code = e.Code
|
||||
response.Error = e.Message
|
||||
|
||||
// Map HTTP status to error codes
|
||||
switch code {
|
||||
case fiber.StatusNotFound:
|
||||
response.Code = ErrGameNotFound
|
||||
case fiber.StatusBadRequest:
|
||||
response.Code = ErrInvalidRequest
|
||||
case fiber.StatusTooManyRequests:
|
||||
response.Code = ErrRateLimitExceeded
|
||||
}
|
||||
}
|
||||
|
||||
return c.Status(code).JSON(response)
|
||||
}
|
||||
|
||||
// Health check endpoint
|
||||
func (h *HTTPHandler) Health(c *fiber.Ctx) error {
|
||||
return c.JSON(fiber.Map{
|
||||
"status": "healthy",
|
||||
"time": time.Now().Unix(),
|
||||
})
|
||||
}
|
||||
@ -1,119 +0,0 @@
|
||||
// FILE: internal/transport/http/types.go
|
||||
package http
|
||||
|
||||
import (
|
||||
"chess/internal/core"
|
||||
)
|
||||
|
||||
// Request types
|
||||
|
||||
type CreateGameRequest struct {
|
||||
White PlayerType `json:"white"` // 0=human, 1=computer
|
||||
Black PlayerType `json:"black"` // 0=human, 1=computer
|
||||
FEN string `json:"fen,omitempty"`
|
||||
}
|
||||
|
||||
type MoveRequest struct {
|
||||
Move string `json:"move"` // UCI format: "e2e4"
|
||||
}
|
||||
|
||||
type UndoRequest struct {
|
||||
Count int `json:"count,omitempty"` // default: 1
|
||||
}
|
||||
|
||||
// Response types
|
||||
|
||||
type GameResponse struct {
|
||||
GameID string `json:"gameId"`
|
||||
FEN string `json:"fen"`
|
||||
Turn string `json:"turn"` // "w" or "b"
|
||||
State string `json:"state"` // "ongoing", "white_wins", etc
|
||||
Moves []string `json:"moves"`
|
||||
Players PlayersInfo `json:"players"`
|
||||
LastMove *MoveInfo `json:"lastMove,omitempty"`
|
||||
}
|
||||
|
||||
type PlayersInfo struct {
|
||||
White PlayerType `json:"white"`
|
||||
Black PlayerType `json:"black"`
|
||||
}
|
||||
|
||||
type MoveInfo struct {
|
||||
Move string `json:"move"`
|
||||
Player string `json:"player"` // "w" or "b"
|
||||
Score int `json:"score,omitempty"`
|
||||
Depth int `json:"depth,omitempty"`
|
||||
}
|
||||
|
||||
type BoardResponse struct {
|
||||
FEN string `json:"fen"`
|
||||
Board string `json:"board"` // ASCII representation
|
||||
}
|
||||
|
||||
type ErrorResponse struct {
|
||||
Error string `json:"error"`
|
||||
Code string `json:"code"`
|
||||
Details string `json:"details,omitempty"`
|
||||
}
|
||||
|
||||
// Custom type for JSON marshaling of PlayerType
|
||||
type PlayerType core.PlayerType
|
||||
|
||||
func (p PlayerType) MarshalJSON() ([]byte, error) {
|
||||
// Map to int for JSON: 0=human, 1=computer
|
||||
return []byte(string('0' + p)), nil
|
||||
}
|
||||
|
||||
func (p *PlayerType) UnmarshalJSON(data []byte) error {
|
||||
if len(data) == 1 && data[0] >= '0' && data[0] <= '1' {
|
||||
*p = PlayerType(data[0] - '0')
|
||||
return nil
|
||||
}
|
||||
// Also accept string format for compatibility
|
||||
str := string(data)
|
||||
if str == `"human"` || str == "human" {
|
||||
*p = PlayerType(core.PlayerHuman)
|
||||
} else if str == `"computer"` || str == "computer" {
|
||||
*p = PlayerType(core.PlayerComputer)
|
||||
} else if str == "0" {
|
||||
*p = PlayerType(core.PlayerHuman)
|
||||
} else if str == "1" {
|
||||
*p = PlayerType(core.PlayerComputer)
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
// Helper functions
|
||||
|
||||
func colorToString(c core.Color) string {
|
||||
return string(c)
|
||||
}
|
||||
|
||||
func stateToString(s core.State) string {
|
||||
switch s {
|
||||
case core.StateOngoing:
|
||||
return "ongoing"
|
||||
case core.StateWhiteWins:
|
||||
return "white_wins"
|
||||
case core.StateBlackWins:
|
||||
return "black_wins"
|
||||
case core.StateDraw:
|
||||
return "draw"
|
||||
case core.StateStalemate:
|
||||
return "stalemate"
|
||||
default:
|
||||
return "unknown"
|
||||
}
|
||||
}
|
||||
|
||||
// Error codes
|
||||
const (
|
||||
ErrGameNotFound = "GAME_NOT_FOUND"
|
||||
ErrInvalidMove = "INVALID_MOVE"
|
||||
ErrNotHumanTurn = "NOT_HUMAN_TURN"
|
||||
ErrGameOver = "GAME_OVER"
|
||||
ErrRateLimitExceeded = "RATE_LIMIT_EXCEEDED"
|
||||
ErrInvalidContent = "INVALID_CONTENT_TYPE"
|
||||
ErrInvalidRequest = "INVALID_REQUEST"
|
||||
ErrInternalError = "INTERNAL_ERROR"
|
||||
)
|
||||
@ -1,29 +0,0 @@
|
||||
// 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