v0.2.0 transitioned to api-only, extended and improved features, docs and tests added

This commit is contained in:
2025-10-29 23:28:19 -04:00
parent b98ea83012
commit 0ad608293e
32 changed files with 3683 additions and 1670 deletions

View File

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

View File

@ -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
View 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"`
}

View File

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

View File

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

View File

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

View 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,
}
}

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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