v0.7.0 cli client with readline added, directory structure updated

This commit is contained in:
2025-11-13 08:55:06 -05:00
parent 52868af4ea
commit 6bdc061508
52 changed files with 2260 additions and 157 deletions

View File

@ -0,0 +1,120 @@
// FILE: lixenwraith/chess/internal/server/board/board.go
package board
import (
"fmt"
"strings"
"chess/internal/server/core"
)
const (
StartingFEN = "rnbqkbnr/pppppppp/8/8/8/8/PPPPPPPP/RNBQKBNR w KQkq - 0 1"
)
type Board struct {
squares [8][8]byte
turn core.Color
castling string
enPassant string
halfmove int
fullmove int
}
// FromFEN creates a Board from a FEN string with validation
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))
}
b := &Board{}
// Parse board
ranks := strings.Split(parts[0], "/")
if len(ranks) != 8 {
return nil, fmt.Errorf("invalid FEN: expected 8 ranks")
}
for r := 0; r < 8; r++ {
file := 0
for _, ch := range ranks[r] {
if ch >= '1' && ch <= '8' {
file += int(ch - '0')
} else {
if file >= 8 {
return nil, fmt.Errorf("invalid FEN: too many pieces in rank %d", r+1)
}
b.squares[r][file] = byte(ch)
file++
}
}
if file != 8 {
return nil, fmt.Errorf("invalid FEN: rank %d has %d files", r+1, file)
}
}
// Parse game state with validation
if len(parts[1]) != 1 {
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.castling = parts[2]
b.enPassant = parts[3]
if _, err := fmt.Sscanf(parts[4], "%d", &b.halfmove); err != nil {
return nil, fmt.Errorf("invalid FEN: halfmove counter")
}
if _, err := fmt.Sscanf(parts[5], "%d", &b.fullmove); err != nil {
return nil, fmt.Errorf("invalid FEN: fullmove counter")
}
return b, nil
}
// 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
}
func (b *Board) GetPieceAt(square string) byte {
if len(square) != 2 {
return 0
}
if square[0] < 'a' || square[0] > 'h' || square[1] < '1' || square[1] > '8' {
return 0
}
file := square[0] - 'a'
rank := '8' - square[1]
return b.squares[rank][file]
}

View File

@ -0,0 +1,53 @@
// FILE: lixenwraith/chess/internal/server/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

@ -0,0 +1,15 @@
// FILE: lixenwraith/chess/internal/server/core/error.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"
)

View File

@ -0,0 +1,75 @@
// FILE: lixenwraith/chess/internal/server/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
}

View File

@ -0,0 +1,35 @@
// FILE: lixenwraith/chess/internal/server/core/state.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

@ -0,0 +1,253 @@
// FILE: lixenwraith/chess/internal/server/engine/engine.go
package engine
import (
"bufio"
"context"
"fmt"
"io"
"os/exec"
"strings"
"sync"
"time"
)
const enginePath = "stockfish"
type UCI struct {
cmd *exec.Cmd
stdin io.WriteCloser
stdout *bufio.Scanner
mu sync.Mutex
}
type SearchResult struct {
BestMove string
Score int
Depth int
IsMate bool
MateIn int
}
func New() (*UCI, error) {
cmd := exec.Command(enginePath)
stdin, err := cmd.StdinPipe()
if err != nil {
return nil, err
}
stdout, err := cmd.StdoutPipe()
if err != nil {
return nil, err
}
if err = cmd.Start(); err != nil {
return nil, fmt.Errorf("failed to start engine: %v", err)
}
uci := &UCI{
cmd: cmd,
stdin: stdin,
stdout: bufio.NewScanner(stdout),
}
if err := uci.initialize(); err != nil {
uci.Close()
return nil, err
}
return uci, nil
}
// 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")
ctx, cancel := context.WithTimeout(context.Background(), 2*time.Second)
defer cancel()
done := make(chan string, 1)
go func() {
for u.stdout.Scan() {
line := u.stdout.Text()
if strings.HasPrefix(line, "Fen: ") {
done <- strings.TrimPrefix(line, "Fen: ")
return
}
}
done <- ""
}()
select {
case fen := <-done:
if fen == "" {
return "", fmt.Errorf("failed to get FEN from engine")
}
return fen, nil
case <-ctx.Done():
return "", fmt.Errorf("timeout getting FEN")
}
}
func (u *UCI) initialize() error {
u.sendCommand("uci")
// Wait for uciok with timeout
ctx, cancel := context.WithTimeout(context.Background(), 5*time.Second)
defer cancel()
done := make(chan bool)
go func() {
for u.stdout.Scan() {
if u.stdout.Text() == "uciok" {
done <- true
return
}
}
done <- false
}()
select {
case success := <-done:
if !success {
return fmt.Errorf("engine closed unexpectedly")
}
case <-ctx.Done():
return fmt.Errorf("timeout waiting for uciok")
}
u.sendCommand("isready")
return u.waitReady()
}
func (u *UCI) waitReady() error {
ctx, cancel := context.WithTimeout(context.Background(), 5*time.Second)
defer cancel()
done := make(chan error)
go func() {
for u.stdout.Scan() {
if u.stdout.Text() == "readyok" {
done <- nil
return
}
}
done <- fmt.Errorf("engine closed unexpectedly")
}()
select {
case err := <-done:
return err
case <-ctx.Done():
return fmt.Errorf("timeout waiting for readyok")
}
}
func (u *UCI) sendCommand(cmd string) {
u.mu.Lock()
defer u.mu.Unlock()
fmt.Fprintln(u.stdin, cmd)
}
func (u *UCI) NewGame() {
u.sendCommand("ucinewgame")
u.sendCommand("isready")
u.waitReady()
}
func (u *UCI) SetPosition(fen string, moves []string) {
cmd := fmt.Sprintf("position fen %s", fen)
if len(moves) > 0 {
cmd += " moves " + strings.Join(moves, " ")
}
u.sendCommand(cmd)
}
func (u *UCI) Search(timeMs int) (*SearchResult, error) {
u.sendCommand(fmt.Sprintf("go movetime %d", timeMs))
result := &SearchResult{}
// Add timeout protection (2x the search time + buffer)
ctx, cancel := context.WithTimeout(context.Background(), time.Duration(timeMs*2+1000)*time.Millisecond)
defer cancel()
done := make(chan error)
go func() {
for u.stdout.Scan() {
line := u.stdout.Text()
if strings.HasPrefix(line, "info ") {
fields := strings.Fields(line)
for i := 0; i < len(fields)-1; i++ {
switch fields[i] {
case "depth":
fmt.Sscanf(fields[i+1], "%d", &result.Depth)
case "cp":
fmt.Sscanf(fields[i+1], "%d", &result.Score)
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
}
}
}
}
if strings.HasPrefix(line, "bestmove ") {
parts := strings.Fields(line)
if len(parts) >= 2 {
result.BestMove = parts[1]
}
done <- nil
return
}
}
done <- fmt.Errorf("engine closed unexpectedly")
}()
select {
case err := <-done:
if err != nil {
return nil, err
}
return result, nil
case <-ctx.Done():
return nil, fmt.Errorf("timeout waiting for bestmove")
}
}
func (u *UCI) Close() error {
u.sendCommand("quit")
time.Sleep(100 * time.Millisecond)
// Try graceful shutdown first
done := make(chan error, 1)
go func() {
done <- u.cmd.Wait()
}()
select {
case <-done:
return nil
case <-time.After(1 * time.Second):
// Force kill if doesn't exit gracefully
return u.cmd.Process.Kill()
}
}

View File

@ -0,0 +1,152 @@
// FILE: lixenwraith/chess/internal/server/game/game.go
package game
import (
"fmt"
"chess/internal/server/board"
"chess/internal/server/core"
)
type Snapshot struct {
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 `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 `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, 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: "",
NextTurnColor: startingTurnColor,
PlayerID: initialPlayerID,
},
},
players: map[core.Color]*core.Player{
core.ColorWhite: whitePlayer,
core.ColorBlack: blackPlayer,
},
state: core.StateOngoing,
}
}
func (g *Game) SetLastResult(result *MoveResult) {
g.lastResult = result
}
func (g *Game) LastResult() *MoveResult {
return g.lastResult
}
// CurrentSnapshot returns the latest game snapshot
func (g *Game) CurrentSnapshot() Snapshot {
return g.snapshots[len(g.snapshots)-1]
}
// CurrentFEN returns the current position in FEN notation
func (g *Game) CurrentFEN() string {
return g.CurrentSnapshot().FEN
}
func (g *Game) NextTurnColor() core.Color {
return g.CurrentSnapshot().NextTurnColor
}
func (g *Game) NextPlayer() *core.Player {
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, 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,
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)
}
availableMoves := len(g.snapshots) - 1
if availableMoves < count {
return fmt.Errorf("cannot undo %d moves: only %d moves available", count, availableMoves)
}
g.snapshots = g.snapshots[:len(g.snapshots)-count]
g.state = core.StateOngoing // Reset game state when undoing
g.lastResult = nil // Clear last result
return nil
}
func (g *Game) Moves() []string {
moves := []string{}
for i := 1; i < len(g.snapshots); i++ {
if g.snapshots[i].PreviousMove != "" {
moves = append(moves, g.snapshots[i].PreviousMove)
}
}
return moves
}
func (g *Game) State() core.State {
return g.state
}
func (g *Game) SetState(s core.State) {
g.state = s
}
func (g *Game) InitialFEN() string {
if len(g.snapshots) > 0 {
return g.snapshots[0].FEN
}
return board.StartingFEN
}

View File

@ -0,0 +1,232 @@
// FILE: lixenwraith/chess/internal/server/http/auth.go
package http
import (
"fmt"
"regexp"
"strings"
"time"
"unicode"
"chess/internal/server/core"
"github.com/gofiber/fiber/v2"
)
var emailRegex = regexp.MustCompile(`^[a-zA-Z0-9._%+-]+@[a-zA-Z0-9.-]+\.[a-zA-Z]{2,}$`)
var usernameRegex = regexp.MustCompile(`^[a-zA-Z0-9_]{1,40}$`)
// RegisterRequest defines the user registration payload
type RegisterRequest struct {
Username string `json:"username" validate:"required,min=1,max=40"`
Email string `json:"email" validate:"omitempty,max=255"`
Password string `json:"password" validate:"required,min=8,max=128"`
}
// LoginRequest defines the authentication payload
type LoginRequest struct {
Identifier string `json:"identifier" validate:"required"` // username or email
Password string `json:"password" validate:"required"`
}
// AuthResponse contains JWT token and user information
type AuthResponse struct {
Token string `json:"token"`
UserID string `json:"userId"`
Username string `json:"username"`
Email string `json:"email,omitempty"`
ExpiresAt time.Time `json:"expiresAt"`
}
// UserResponse contains current user information
type UserResponse struct {
UserID string `json:"userId"`
Username string `json:"username"`
Email string `json:"email,omitempty"`
CreatedAt time.Time `json:"createdAt"`
}
// RegisterHandler creates a new user account
func (h *HTTPHandler) RegisterHandler(c *fiber.Ctx) error {
var req RegisterRequest
if err := c.BodyParser(&req); err != nil {
return c.Status(fiber.StatusBadRequest).JSON(core.ErrorResponse{
Error: "invalid request body",
Code: core.ErrInvalidRequest,
Details: err.Error(),
})
}
// Validate username format
if !usernameRegex.MatchString(req.Username) {
return c.Status(fiber.StatusBadRequest).JSON(core.ErrorResponse{
Error: "invalid username format",
Code: core.ErrInvalidRequest,
Details: "username must be 1-40 characters, alphanumeric and underscore only",
})
}
// Validate email format if provided
if req.Email != "" && !emailRegex.MatchString(req.Email) {
return c.Status(fiber.StatusBadRequest).JSON(core.ErrorResponse{
Error: "invalid email format",
Code: core.ErrInvalidRequest,
Details: "email must be a valid email address",
})
}
// Validate password strength
if err := validatePassword(req.Password); err != nil {
return c.Status(fiber.StatusBadRequest).JSON(core.ErrorResponse{
Error: "weak password",
Code: core.ErrInvalidRequest,
Details: err.Error(),
})
}
// Normalize for case-insensitive storage
req.Username = strings.ToLower(req.Username)
if req.Email != "" {
req.Email = strings.ToLower(req.Email)
}
// Create user
user, err := h.svc.CreateUser(req.Username, req.Email, req.Password)
if err != nil {
if strings.Contains(err.Error(), "already exists") {
return c.Status(fiber.StatusConflict).JSON(core.ErrorResponse{
Error: "user already exists",
Code: core.ErrInvalidRequest,
Details: "username or email already taken",
})
}
return c.Status(fiber.StatusInternalServerError).JSON(core.ErrorResponse{
Error: "failed to create user",
Code: core.ErrInternalError,
})
}
// Generate JWT token
token, err := h.svc.GenerateUserToken(user.UserID)
if err != nil {
return c.Status(fiber.StatusInternalServerError).JSON(core.ErrorResponse{
Error: "failed to generate token",
Code: core.ErrInternalError,
})
}
return c.Status(fiber.StatusCreated).JSON(AuthResponse{
Token: token,
UserID: user.UserID,
Username: user.Username,
Email: user.Email,
ExpiresAt: time.Now().Add(7 * 24 * time.Hour),
})
}
// validatePassword checks password strength requirements
func validatePassword(password string) error {
const (
minPasswordLength = 8
maxPasswordLength = 128
)
if len(password) < minPasswordLength {
return fmt.Errorf("password must be at least 8 characters")
}
if len(password) > maxPasswordLength {
return fmt.Errorf("password must not exceed 128 characters")
}
// Check for at least one letter and one number
hasLetter := false
hasNumber := false
for _, r := range password {
switch {
case unicode.IsLetter(r):
hasLetter = true
case unicode.IsNumber(r):
hasNumber = true
}
if hasLetter && hasNumber {
break
}
}
if !hasLetter || !hasNumber {
return fmt.Errorf("password must contain at least one letter and one number")
}
return nil
}
// LoginHandler authenticates user and returns JWT token
func (h *HTTPHandler) LoginHandler(c *fiber.Ctx) error {
var req LoginRequest
if err := c.BodyParser(&req); err != nil {
return c.Status(fiber.StatusBadRequest).JSON(core.ErrorResponse{
Error: "invalid request body",
Code: core.ErrInvalidRequest,
Details: err.Error(),
})
}
// Normalize identifier for case-insensitive lookup
req.Identifier = strings.ToLower(req.Identifier)
// Authenticate user
user, err := h.svc.AuthenticateUser(req.Identifier, req.Password)
if err != nil {
// Always return same error to prevent user enumeration
return c.Status(fiber.StatusUnauthorized).JSON(core.ErrorResponse{
Error: "invalid credentials",
Code: core.ErrInvalidRequest,
})
}
// Generate JWT token
token, err := h.svc.GenerateUserToken(user.UserID)
if err != nil {
return c.Status(fiber.StatusInternalServerError).JSON(core.ErrorResponse{
Error: "failed to generate token",
Code: core.ErrInternalError,
})
}
// Update last login
// TODO: for now, non-blocking if login time update fails, log/block in the future
_ = h.svc.UpdateLastLogin(user.UserID)
return c.JSON(AuthResponse{
Token: token,
UserID: user.UserID,
Username: user.Username,
Email: user.Email,
ExpiresAt: time.Now().Add(7 * 24 * time.Hour),
})
}
// GetCurrentUserHandler returns authenticated user information
func (h *HTTPHandler) GetCurrentUserHandler(c *fiber.Ctx) error {
userID, ok := c.Locals("userID").(string)
if !ok || userID == "" {
return c.Status(fiber.StatusUnauthorized).JSON(core.ErrorResponse{
Error: "unauthorized",
Code: core.ErrInvalidRequest,
})
}
user, err := h.svc.GetUserByID(userID)
if err != nil {
return c.Status(fiber.StatusNotFound).JSON(core.ErrorResponse{
Error: "user not found",
Code: core.ErrInvalidRequest,
})
}
return c.JSON(UserResponse{
UserID: user.UserID,
Username: user.Username,
Email: user.Email,
CreatedAt: user.CreatedAt,
})
}

View File

@ -0,0 +1,515 @@
// FILE: lixenwraith/chess/internal/server/http/handler.go
package http
import (
"fmt"
"strconv"
"strings"
"time"
"chess/internal/server/core"
"chess/internal/server/processor"
"chess/internal/server/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"
)
const rateLimitRate = 10 // req/sec
// HTTPHandler handles HTTP requests and routes them to the processor
type HTTPHandler struct {
proc *processor.Processor
svc *service.Service
}
func NewHTTPHandler(proc *processor.Processor, svc *service.Service) *HTTPHandler {
return &HTTPHandler{proc: proc, svc: svc}
}
func NewFiberApp(proc *processor.Processor, svc *service.Service, devMode bool) *fiber.App {
// Create handler
h := NewHTTPHandler(proc, 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,PUT,DELETE,OPTIONS",
AllowHeaders: "Origin,Content-Type,Accept,Authorization",
}))
// Health check (no rate limit)
app.Get("/health", h.Health)
// API v1 routes
api := app.Group("/api/v1")
// Auth routes with specific rate limiting
auth := api.Group("/auth")
// Register: 5 req/min per IP
auth.Post("/register", limiter.New(limiter.Config{
Max: 5,
Expiration: 1 * time.Minute,
KeyGenerator: func(c *fiber.Ctx) string {
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: "5 registrations per minute allowed",
})
},
}), h.RegisterHandler)
// Login: 10 req/min per IP
auth.Post("/login", limiter.New(limiter.Config{
Max: 10,
Expiration: 1 * time.Minute,
KeyGenerator: func(c *fiber.Ctx) string {
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: "10 login attempts per minute allowed",
})
},
}), h.LoginHandler)
// Create token validator closure
validateToken := svc.ValidateToken
// Current user (requires auth)
auth.Get("/me", AuthRequired(validateToken), h.GetCurrentUserHandler)
// Game routes with standard rate limiting
maxReq := rateLimitRate
if devMode {
maxReq = rateLimitRate * 2
}
api.Use(limiter.New(limiter.Config{
Max: maxReq,
Expiration: 1 * time.Second,
KeyGenerator: func(c *fiber.Ctx) string {
if xff := c.Get("X-Forwarded-For"); xff != "" {
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),
})
},
}))
// Content-Type validation for POST and PUT requests
api.Use(contentTypeValidator)
// Middleware validation for sanitization
api.Use(validationMiddleware)
// Register game routes with auth middleware
api.Post("/games", OptionalAuth(validateToken), h.CreateGame) // Optional auth for player ID association
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 with storage status
func (h *HTTPHandler) Health(c *fiber.Ctx) error {
return c.JSON(fiber.Map{
"status": "healthy",
"time": time.Now().Unix(),
"storage": h.svc.GetStorageHealth(),
})
}
// 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))
// Retrieve authenticated user ID if available
userID, _ := c.Locals("userID").(string)
// Generate game ID via service with optional user context
cmd := processor.NewCreateGameCommand(req)
cmd.UserID = userID // Add user ID to command if authenticated
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",
})
}
// Check for long-polling parameters
waitStr := c.Query("wait", "false")
moveCountStr := c.Query("moveCount", "-1")
// Non-wait path - existing behavior
if waitStr != "true" {
// 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)
}
// Long-polling path
moveCount, err := strconv.Atoi(moveCountStr)
if err != nil {
moveCount = -1
}
// First check if game exists and get current state
g, err := h.svc.GetGame(gameID)
if err != nil {
return c.Status(fiber.StatusNotFound).JSON(core.ErrorResponse{
Error: "game not found",
Code: core.ErrGameNotFound,
})
}
currentMoveCount := len(g.Moves())
// If move count already different, return immediately
if moveCount != currentMoveCount {
cmd := processor.NewGetGameCommand(gameID)
resp := h.proc.Execute(cmd)
if !resp.Success {
return c.Status(fiber.StatusNotFound).JSON(resp.Error)
}
return c.JSON(resp.Data)
}
// Register wait with service
ctx := c.Context()
notify := h.svc.RegisterWait(gameID, moveCount, ctx)
// Wait for notification, timeout, or client disconnect
select {
case <-notify:
// State changed or timeout, get fresh game state
cmd := processor.NewGetGameCommand(gameID)
resp := h.proc.Execute(cmd)
// Game might have been deleted
if !resp.Success {
return c.Status(fiber.StatusNotFound).JSON(resp.Error)
}
return c.JSON(resp.Data)
case <-ctx.Done():
// Client disconnected
return nil
}
}
// 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)
}

View File

@ -0,0 +1,62 @@
// FILE: lixenwraith/chess/internal/server/http/middleware.go
package http
import (
"chess/internal/server/core"
"strings"
"github.com/gofiber/fiber/v2"
)
// TokenValidator validates JWT tokens
type TokenValidator func(token string) (userID string, claims map[string]any, err error)
// AuthRequired enforces JWT authentication for protected endpoints
func AuthRequired(validateToken TokenValidator) fiber.Handler {
return func(c *fiber.Ctx) error {
token := extractBearerToken(c.Get("Authorization"))
if token == "" {
return c.Status(fiber.StatusUnauthorized).JSON(core.ErrorResponse{
Error: "missing authorization token",
Code: core.ErrInvalidRequest,
})
}
userID, _, err := validateToken(token)
if err != nil {
return c.Status(fiber.StatusUnauthorized).JSON(core.ErrorResponse{
Error: "invalid or expired token",
Code: core.ErrInvalidRequest,
})
}
c.Locals("userID", userID)
return c.Next()
}
}
// OptionalAuth validates JWT if present but allows anonymous access
func OptionalAuth(validateToken TokenValidator) fiber.Handler {
return func(c *fiber.Ctx) error {
token := extractBearerToken(c.Get("Authorization"))
if token == "" {
return c.Next()
}
userID, _, err := validateToken(token)
if err == nil {
c.Locals("userID", userID)
}
// Continue regardless of token validity
return c.Next()
}
}
// extractBearerToken extracts JWT token from Authorization header
func extractBearerToken(header string) string {
const prefix = "Bearer "
if !strings.HasPrefix(header, prefix) {
return ""
}
return strings.TrimPrefix(header, prefix)
}

View File

@ -0,0 +1,102 @@
// FILE: lixenwraith/chess/internal/server/http/handler.go
package http
import (
"chess/internal/server/core"
"fmt"
"reflect"
"strings"
"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,87 @@
// FILE: lixenwraith/chess/internal/server/processor/command.go
package processor
import (
"chess/internal/server/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
UserID string
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,580 @@
// FILE: lixenwraith/chess/internal/server/processor/processor.go
package processor
import (
"fmt"
"log"
"regexp"
"strings"
"sync"
"time"
"unicode"
"chess/internal/server/board"
"chess/internal/server/core"
"chess/internal/server/engine"
"chess/internal/server/game"
"chess/internal/server/service"
)
const (
minSearchTime = 100
)
// FEN validation regex
var fenPattern = regexp.MustCompile(`^[rnbqkpRNBQKP1-8/]+ [wb] [KQkq-]+ [a-h1-8-]+ \d+ \d+$`)
// Processor handles command execution and coordinates between service and engine layers
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 players with appropriate IDs
whitePlayer := core.NewPlayer(args.White, core.ColorWhite)
blackPlayer := core.NewPlayer(args.Black, core.ColorBlack)
// Override player IDs for authenticated human players
if args.White.Type == core.PlayerHuman && cmd.UserID != "" {
whitePlayer.ID = cmd.UserID
}
if args.Black.Type == core.PlayerHuman && cmd.UserID != "" {
blackPlayer.ID = cmd.UserID
}
// Create game in service with fully-formed players
if err = p.svc.CreateGame(gameID, whitePlayer, blackPlayer, 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)
}
// Create new player instances
whitePlayer := core.NewPlayer(args.White, core.ColorWhite)
blackPlayer := core.NewPlayer(args.Black, core.ColorBlack)
// Update players in service
if err = p.svc.UpdatePlayers(cmd.GameID, whitePlayer, blackPlayer); 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)
}
// 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()
}

View File

@ -0,0 +1,209 @@
// FILE: lixenwraith/chess/internal/server/processor/queue.go
package processor
import (
"context"
"fmt"
"sync"
"time"
"chess/internal/server/core"
"chess/internal/server/engine"
)
// EngineTask contains computer move calculation request and response channel
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

@ -0,0 +1,203 @@
// FILE: lixenwraith/chess/internal/server/service/game.go
package service
import (
"fmt"
"time"
"chess/internal/server/core"
"chess/internal/server/game"
"chess/internal/server/storage"
"github.com/google/uuid"
)
// CreateGame registers a new game with pre-constructed players
func (s *Service) CreateGame(id string, whitePlayer, blackPlayer *core.Player, 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)
}
// Store game with provided players
s.games[id] = game.New(initialFEN, whitePlayer, blackPlayer, startingTurn)
// Persist if storage enabled
if s.store != nil {
record := storage.GameRecord{
GameID: id,
InitialFEN: initialFEN,
WhitePlayerID: whitePlayer.ID,
WhiteType: int(whitePlayer.Type),
WhiteLevel: whitePlayer.Level,
WhiteSearchTime: whitePlayer.SearchTime,
BlackPlayerID: blackPlayer.ID,
BlackType: int(blackPlayer.Type),
BlackLevel: blackPlayer.Level,
BlackSearchTime: blackPlayer.SearchTime,
StartTimeUTC: time.Now().UTC(),
}
s.store.RecordNewGame(record)
}
return nil
}
// UpdatePlayers replaces players in an existing game
func (s *Service) UpdatePlayers(gameID string, whitePlayer, blackPlayer *core.Player) error {
s.mu.Lock()
defer s.mu.Unlock()
g, ok := s.games[gameID]
if !ok {
return fmt.Errorf("game not found: %s", gameID)
}
// Update the game's players
g.UpdatePlayers(whitePlayer, blackPlayer)
return nil
}
// 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)
}
return g, nil
}
// GenerateGameID creates a new unique game ID
func (s *Service) GenerateGameID() string {
s.mu.RLock()
defer s.mu.RUnlock()
// Ensure UUID uniqueness (handle potential conflicts)
for {
id := uuid.New().String()
if _, exists := s.games[id]; !exists {
return id
}
}
}
// ApplyMove adds a validated move to the game history
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)
// Notify waiting clients about the state change
s.waiter.NotifyGame(gameID, len(g.Moves()))
// Persist if storage enabled
if s.store != nil {
moveNumber := len(g.Moves())
record := storage.MoveRecord{
GameID: gameID,
MoveNumber: moveNumber,
MoveUCI: moveUCI,
FENAfterMove: newFEN,
PlayerColor: currentTurn.String(),
MoveTimeUTC: time.Now().UTC(),
}
s.store.RecordMove(record)
}
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)
// Notify if game ended
if state != core.StateOngoing && state != core.StatePending {
s.waiter.NotifyGame(gameID, len(g.Moves()))
}
return nil
}
// SetLastMoveResult stores metadata about the last move
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)
}
originalMoveCount := len(g.Moves())
if err := g.UndoMoves(count); err != nil {
return err
}
// Notify waiting clients about the undo
s.waiter.NotifyGame(gameID, len(g.Moves()))
// Delete undone moves from storage if enabled
if s.store != nil {
remainingMoves := originalMoveCount - count
s.store.DeleteUndoneMoves(gameID, remainingMoves)
}
return 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)
}
// Notify and remove all waiters before deletion
s.waiter.RemoveGame(gameID)
delete(s.games, gameID)
return nil
}

View File

@ -0,0 +1,74 @@
// FILE: lixenwraith/chess/internal/server/service/service.go
package service
import (
"context"
"errors"
"fmt"
"sync"
"time"
"chess/internal/server/game"
"chess/internal/server/storage"
)
// Service is a pure state manager for chess games with optional persistence
type Service struct {
games map[string]*game.Game
mu sync.RWMutex
store *storage.Store // nil if persistence disabled
jwtSecret []byte
waiter *WaitRegistry // Long-polling notification registry
}
// New creates a new service instance with optional storage
func New(store *storage.Store, jwtSecret []byte) *Service {
return &Service{
games: make(map[string]*game.Game),
store: store,
jwtSecret: jwtSecret,
waiter: NewWaitRegistry(),
}
}
// GetStorageHealth returns the storage component status
func (s *Service) GetStorageHealth() string {
if s.store == nil {
return "disabled"
}
if s.store.IsHealthy() {
return "ok"
}
return "degraded"
}
// RegisterWait registers a client to wait for game state changes
func (s *Service) RegisterWait(gameID string, moveCount int, ctx context.Context) <-chan struct{} {
return s.waiter.RegisterWait(gameID, moveCount, ctx)
}
// Shutdown gracefully shuts down the service
func (s *Service) Shutdown(timeout time.Duration) error {
// Collect all errors
var errs []error
// Shutdown wait registry
if err := s.waiter.Shutdown(timeout); err != nil {
errs = append(errs, fmt.Errorf("wait registry: %w", err))
}
s.mu.Lock()
defer s.mu.Unlock()
// Clear all games
s.games = make(map[string]*game.Game)
// Close storage if enabled
if s.store != nil {
if err := s.store.Close(); err != nil {
errs = append(errs, fmt.Errorf("storage: %w", err))
}
}
return errors.Join(errs...)
}

View File

@ -0,0 +1,175 @@
// FILE: lixenwraith/chess/internal/server/service/user.go
package service
import (
"fmt"
"strings"
"time"
"chess/internal/server/storage"
"github.com/google/uuid"
"github.com/lixenwraith/auth"
)
// User represents a registered user account
type User struct {
UserID string
Username string
Email string
CreatedAt time.Time
}
// CreateUser creates new user with transactional consistency
func (s *Service) CreateUser(username, email, password string) (*User, error) {
if s.store == nil {
return nil, fmt.Errorf("storage disabled")
}
// Hash password
passwordHash, err := auth.HashPassword(password)
if err != nil {
return nil, fmt.Errorf("failed to hash password: %w", err)
}
// Generate guaranteed unique user ID with proper collision handling
userID, err := s.generateUniqueUserID()
if err != nil {
return nil, fmt.Errorf("failed to generate unique ID: %w", err)
}
// Create user record
user := &User{
UserID: userID,
Username: username,
Email: email,
CreatedAt: time.Now().UTC(),
}
// Use transactional storage method
record := storage.UserRecord{
UserID: userID,
Username: username,
Email: email,
PasswordHash: passwordHash,
CreatedAt: user.CreatedAt,
}
if err = s.store.CreateUser(record); err != nil {
return nil, err
}
return user, nil
}
// AuthenticateUser verifies user credentials and returns user information
// AuthenticateUser verifies user credentials and returns user information
func (s *Service) AuthenticateUser(identifier, password string) (*User, error) {
if s.store == nil {
return nil, fmt.Errorf("storage disabled")
}
var userRecord *storage.UserRecord
var err error
// Check if identifier looks like email
if strings.Contains(identifier, "@") {
userRecord, err = s.store.GetUserByEmail(identifier)
} else {
userRecord, err = s.store.GetUserByUsername(identifier)
}
if err != nil {
// Always hash to prevent timing attacks
auth.HashPassword(password)
return nil, fmt.Errorf("invalid credentials")
}
// Verify password
if err := auth.VerifyPassword(password, userRecord.PasswordHash); err != nil {
return nil, fmt.Errorf("invalid credentials")
}
return &User{
UserID: userRecord.UserID,
Username: userRecord.Username,
Email: userRecord.Email,
CreatedAt: userRecord.CreatedAt,
}, nil
}
// UpdateLastLogin updates the last login timestamp for a user
func (s *Service) UpdateLastLogin(userID string) error {
if s.store == nil {
return fmt.Errorf("storage disabled")
}
err := s.store.UpdateUserLastLoginSync(userID, time.Now().UTC())
if err != nil {
return fmt.Errorf("failed to update last login time for user %s: %w\n", userID, err)
}
return nil
}
// GetUserByID retrieves user information by user ID
func (s *Service) GetUserByID(userID string) (*User, error) {
if s.store == nil {
return nil, fmt.Errorf("storage disabled")
}
userRecord, err := s.store.GetUserByID(userID)
if err != nil {
return nil, fmt.Errorf("user not found")
}
return &User{
UserID: userRecord.UserID,
Username: userRecord.Username,
Email: userRecord.Email,
CreatedAt: userRecord.CreatedAt,
}, nil
}
// GenerateUserToken creates a JWT token for the specified user
func (s *Service) GenerateUserToken(userID string) (string, error) {
user, err := s.GetUserByID(userID)
if err != nil {
return "", err
}
claims := map[string]any{
"username": user.Username,
"email": user.Email,
}
return auth.GenerateHS256Token(s.jwtSecret, userID, claims, 7*24*time.Hour)
}
// ValidateToken verifies JWT token and returns user ID with claims
func (s *Service) ValidateToken(token string) (string, map[string]any, error) {
return auth.ValidateHS256Token(s.jwtSecret, token)
}
// generateUniqueUserID creates a unique user ID with collision detection
func (s *Service) generateUniqueUserID() (string, error) {
const maxAttempts = 10
for i := 0; i < maxAttempts; i++ {
id := uuid.New().String()
// Check for collision
if _, err := s.store.GetUserByID(id); err != nil {
// Error means not found, ID is unique
return id, nil
}
// Collision detected, try again
if i == maxAttempts-1 {
// After max attempts, fail and don't risk collision
return "", fmt.Errorf("failed to generate unique ID after %d attempts", maxAttempts)
}
}
return "", fmt.Errorf("failed to generate unique user ID")
}

View File

@ -0,0 +1,178 @@
// FILE: lixenwraith/chess/internal/server/service/waiter.go
package service
import (
"context"
"fmt"
"sync"
"time"
)
const (
// WaitTimeout is the maximum time a client can wait for notifications
WaitTimeout = 25 * time.Second
// WaitChannelBuffer size for notification channels
WaitChannelBuffer = 1
)
// WaitRegistry manages clients waiting for game state changes via long-polling
type WaitRegistry struct {
mu sync.RWMutex
waiters map[string][]*WaitRequest // gameID → waiting clients
shutdown chan struct{}
wg sync.WaitGroup
}
// WaitRequest represents a single client waiting for game updates
type WaitRequest struct {
MoveCount int // Last known move count
Notify chan struct{} // Buffered channel for notifications
Timer *time.Timer // Timeout timer
Context context.Context // Client connection context
GameID string // Game being watched
}
// NewWaitRegistry creates a new wait registry
func NewWaitRegistry() *WaitRegistry {
return &WaitRegistry{
waiters: make(map[string][]*WaitRequest),
shutdown: make(chan struct{}),
}
}
// RegisterWait registers a client to wait for game state changes
func (w *WaitRegistry) RegisterWait(gameID string, moveCount int, ctx context.Context) <-chan struct{} {
w.mu.Lock()
defer w.mu.Unlock()
// Create wait request
req := &WaitRequest{
MoveCount: moveCount,
Notify: make(chan struct{}, WaitChannelBuffer),
Context: ctx,
GameID: gameID,
}
// Setup timeout timer
req.Timer = time.AfterFunc(WaitTimeout, func() {
w.handleTimeout(req)
})
// Add to waiters map
w.waiters[gameID] = append(w.waiters[gameID], req)
// Setup cleanup on context cancellation
w.wg.Add(1)
go func() {
defer w.wg.Done()
select {
case <-ctx.Done():
// Client disconnected
w.removeWaiter(gameID, req)
case <-req.Notify:
// Notification received
req.Timer.Stop()
w.removeWaiter(gameID, req)
case <-w.shutdown:
// Server shutting down
req.Timer.Stop()
close(req.Notify)
}
}()
return req.Notify
}
// NotifyGame notifies all clients waiting on a game about state change
func (w *WaitRegistry) NotifyGame(gameID string, currentMoveCount int) {
w.mu.RLock()
waitList := w.waiters[gameID]
w.mu.RUnlock()
if len(waitList) == 0 {
return
}
// Non-blocking notification to all waiters
for _, req := range waitList {
// Only notify if move count changed
if req.MoveCount != currentMoveCount {
select {
case req.Notify <- struct{}{}:
// Notification sent
default:
// Channel full or closed, skip slow client
}
}
}
}
// RemoveGame removes all waiters for a game (called before game deletion)
func (w *WaitRegistry) RemoveGame(gameID string) {
w.mu.Lock()
waitList := w.waiters[gameID]
delete(w.waiters, gameID)
w.mu.Unlock()
// Notify all waiters that game is gone
for _, req := range waitList {
select {
case req.Notify <- struct{}{}:
default:
}
}
}
// Shutdown gracefully shuts down the wait registry
func (w *WaitRegistry) Shutdown(timeout time.Duration) error {
close(w.shutdown)
// Wait for all goroutines with timeout
done := make(chan struct{})
go func() {
w.wg.Wait()
close(done)
}()
select {
case <-done:
return nil
case <-time.After(timeout):
return fmt.Errorf("http wait registry shutdown failed")
}
}
// handleTimeout handles wait request timeout
func (w *WaitRegistry) handleTimeout(req *WaitRequest) {
// Send timeout notification
select {
case req.Notify <- struct{}{}:
// Timeout notification sent
default:
// Channel full or closed
}
}
// removeWaiter removes a specific waiter from the registry
func (w *WaitRegistry) removeWaiter(gameID string, req *WaitRequest) {
w.mu.Lock()
defer w.mu.Unlock()
waitList := w.waiters[gameID]
for i, waiter := range waitList {
if waiter == req {
// Remove from slice
w.waiters[gameID] = append(waitList[:i], waitList[i+1:]...)
break
}
}
// Clean up empty entries
if len(w.waiters[gameID]) == 0 {
delete(w.waiters, gameID)
}
// Stop timer if still running
req.Timer.Stop()
}

View File

@ -0,0 +1,138 @@
// FILE: lixenwraith/chess/internal/server/storage/game.go
package storage
import (
"database/sql"
"fmt"
"log"
)
// RecordNewGame asynchronously records a new game
func (s *Store) RecordNewGame(record GameRecord) error {
if !s.healthStatus.Load() {
return nil // Silently drop if degraded
}
select {
case s.writeChan <- func(tx *sql.Tx) error {
query := `INSERT INTO games (
game_id, initial_fen,
white_player_id, white_type, white_level, white_search_time,
black_player_id, black_type, black_level, black_search_time,
start_time_utc
) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)`
_, err := tx.Exec(query,
record.GameID, record.InitialFEN,
record.WhitePlayerID, record.WhiteType, record.WhiteLevel, record.WhiteSearchTime,
record.BlackPlayerID, record.BlackType, record.BlackLevel, record.BlackSearchTime,
record.StartTimeUTC,
)
return err
}:
return nil
default:
// Channel full, drop write
log.Printf("Storage write queue full, dropping game record")
return nil
}
}
// RecordMove asynchronously records a move
func (s *Store) RecordMove(record MoveRecord) error {
if !s.healthStatus.Load() {
return nil // Silently drop if degraded
}
select {
case s.writeChan <- func(tx *sql.Tx) error {
query := `INSERT INTO moves (
game_id, move_number, move_uci, fen_after_move, player_color, move_time_utc
) VALUES (?, ?, ?, ?, ?, ?)`
_, err := tx.Exec(query,
record.GameID, record.MoveNumber, record.MoveUCI,
record.FENAfterMove, record.PlayerColor, record.MoveTimeUTC,
)
return err
}:
return nil
default:
// Channel full, drop write
log.Printf("Storage write queue full, dropping move record")
return nil
}
}
// DeleteUndoneMoves asynchronously deletes moves after undo
func (s *Store) DeleteUndoneMoves(gameID string, afterMoveNumber int) error {
if !s.healthStatus.Load() {
return nil // Silently drop if degraded
}
select {
case s.writeChan <- func(tx *sql.Tx) error {
query := `DELETE FROM moves WHERE game_id = ? AND move_number > ?`
_, err := tx.Exec(query, gameID, afterMoveNumber)
return err
}:
return nil
default:
// Channel full, drop write
log.Printf("Storage write queue full, dropping undo operation")
return nil
}
}
// QueryGames retrieves games with optional filtering
func (s *Store) QueryGames(gameID, playerID string) ([]GameRecord, error) {
query := `SELECT
game_id, initial_fen,
white_player_id, white_type, white_level, white_search_time,
black_player_id, black_type, black_level, black_search_time,
start_time_utc
FROM games WHERE 1=1`
var args []interface{}
// Handle gameID filtering
if gameID != "" && gameID != "*" {
query += " AND game_id = ?"
args = append(args, gameID)
}
// Handle playerID filtering
if playerID != "" && playerID != "*" {
query += " AND (white_player_id = ? OR black_player_id = ?)"
args = append(args, playerID, playerID)
}
query += " ORDER BY start_time_utc DESC"
rows, err := s.db.Query(query, args...)
if err != nil {
return nil, fmt.Errorf("query failed: %w", err)
}
defer rows.Close()
var games []GameRecord
for rows.Next() {
var g GameRecord
err := rows.Scan(
&g.GameID, &g.InitialFEN,
&g.WhitePlayerID, &g.WhiteType, &g.WhiteLevel, &g.WhiteSearchTime,
&g.BlackPlayerID, &g.BlackType, &g.BlackLevel, &g.BlackSearchTime,
&g.StartTimeUTC,
)
if err != nil {
return nil, fmt.Errorf("scan failed: %w", err)
}
games = append(games, g)
}
if err := rows.Err(); err != nil {
return nil, fmt.Errorf("rows iteration failed: %w", err)
}
return games, nil
}

View File

@ -0,0 +1,86 @@
// FILE: lixenwraith/chess/internal/server/storage/schema.go
package storage
import "time"
// UserRecord represents a user account in the database
type UserRecord struct {
UserID string `db:"user_id"`
Username string `db:"username"`
Email string `db:"email"`
PasswordHash string `db:"password_hash"`
CreatedAt time.Time `db:"created_at"`
LastLoginAt *time.Time `db:"last_login_at"`
}
// GameRecord represents a row in the games table
type GameRecord struct {
GameID string `db:"game_id"`
InitialFEN string `db:"initial_fen"`
WhitePlayerID string `db:"white_player_id"`
WhiteType int `db:"white_type"`
WhiteLevel int `db:"white_level"`
WhiteSearchTime int `db:"white_search_time"`
BlackPlayerID string `db:"black_player_id"`
BlackType int `db:"black_type"`
BlackLevel int `db:"black_level"`
BlackSearchTime int `db:"black_search_time"`
StartTimeUTC time.Time `db:"start_time_utc"`
}
// MoveRecord represents a row in the moves table
type MoveRecord struct {
MoveID int64 `db:"move_id"`
GameID string `db:"game_id"`
MoveNumber int `db:"move_number"`
MoveUCI string `db:"move_uci"`
FENAfterMove string `db:"fen_after_move"`
PlayerColor string `db:"player_color"` // "w" or "b"
MoveTimeUTC time.Time `db:"move_time_utc"`
}
// Schema defines the SQLite database structure
const Schema = `
CREATE TABLE IF NOT EXISTS users (
user_id TEXT PRIMARY KEY,
username TEXT UNIQUE NOT NULL COLLATE NOCASE,
email TEXT COLLATE NOCASE,
password_hash TEXT NOT NULL,
created_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP,
last_login_at DATETIME
);
CREATE INDEX IF NOT EXISTS idx_users_username ON users(username);
CREATE INDEX IF NOT EXISTS idx_users_email ON users(email);
CREATE UNIQUE INDEX IF NOT EXISTS idx_users_email_unique ON users(email) WHERE email IS NOT NULL AND email != '';
CREATE TABLE IF NOT EXISTS games (
game_id TEXT PRIMARY KEY,
initial_fen TEXT NOT NULL,
white_player_id TEXT NOT NULL,
white_type INTEGER NOT NULL,
white_level INTEGER NOT NULL DEFAULT 0,
white_search_time INTEGER NOT NULL DEFAULT 1000,
black_player_id TEXT NOT NULL,
black_type INTEGER NOT NULL,
black_level INTEGER NOT NULL DEFAULT 0,
black_search_time INTEGER NOT NULL DEFAULT 1000,
start_time_utc DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP
);
CREATE TABLE IF NOT EXISTS moves (
move_id INTEGER PRIMARY KEY AUTOINCREMENT,
game_id TEXT NOT NULL,
move_number INTEGER NOT NULL,
move_uci TEXT NOT NULL,
fen_after_move TEXT NOT NULL,
player_color TEXT NOT NULL CHECK(player_color IN ('w', 'b')),
move_time_utc DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP,
FOREIGN KEY (game_id) REFERENCES games(game_id) ON DELETE CASCADE,
UNIQUE(game_id, move_number)
);
CREATE INDEX IF NOT EXISTS idx_moves_game_id ON moves(game_id);
CREATE INDEX IF NOT EXISTS idx_games_white_player ON games(white_player_id);
CREATE INDEX IF NOT EXISTS idx_games_black_player ON games(black_player_id);
`

View File

@ -0,0 +1,186 @@
// FILE: lixenwraith/chess/internal/server/storage/storage.go
package storage
import (
"context"
"database/sql"
"fmt"
"log"
"os"
"sync"
"sync/atomic"
"time"
_ "github.com/mattn/go-sqlite3"
)
// Store handles SQLite database operations with async writes for games and sync writes for auth
type Store struct {
db *sql.DB
path string
writeChan chan func(*sql.Tx) error
healthStatus atomic.Bool
ctx context.Context
cancel context.CancelFunc
wg sync.WaitGroup
}
// NewStore creates a new storage instance with async writer
func NewStore(dataSourceName string, devMode bool) (*Store, error) {
db, err := sql.Open("sqlite3", dataSourceName)
if err != nil {
return nil, fmt.Errorf("failed to open database: %w", err)
}
// Enable WAL mode in development for better concurrency
if devMode {
if _, err := db.Exec("PRAGMA journal_mode=WAL"); err != nil {
db.Close()
return nil, fmt.Errorf("failed to enable WAL mode: %w", err)
}
}
// Enable foreign keys
if _, err := db.Exec("PRAGMA foreign_keys = ON"); err != nil {
db.Close()
return nil, fmt.Errorf("failed to enable foreign keys: %w", err)
}
// Configure connection pool
db.SetMaxOpenConns(25)
db.SetMaxIdleConns(5)
ctx, cancel := context.WithCancel(context.Background())
s := &Store{
db: db,
path: dataSourceName,
writeChan: make(chan func(*sql.Tx) error, 1000), // Buffered for async writes
ctx: ctx,
cancel: cancel,
}
// Initialize health as true
s.healthStatus.Store(true)
// Start async writer
s.wg.Add(1)
go s.writerLoop()
return s, nil
}
// IsHealthy returns true if the storage is operational
func (s *Store) IsHealthy() bool {
return s.healthStatus.Load()
}
// writerLoop processes async write operations
func (s *Store) writerLoop() {
defer s.wg.Done()
for {
select {
case <-s.ctx.Done():
// Drain remaining writes with timeout
deadline := time.After(2 * time.Second)
for {
select {
case fn := <-s.writeChan:
if s.healthStatus.Load() {
s.executeWrite(fn)
}
case <-deadline:
return
default:
return
}
}
case fn := <-s.writeChan:
// Skip if already degraded
if !s.healthStatus.Load() {
continue
}
s.executeWrite(fn)
}
}
}
// executeWrite runs a transactional write operation
func (s *Store) executeWrite(fn func(*sql.Tx) error) {
tx, err := s.db.Begin()
if err != nil {
log.Printf("Storage degraded: failed to begin transaction: %v", err)
s.healthStatus.Store(false)
return
}
if err := fn(tx); err != nil {
tx.Rollback()
log.Printf("Storage degraded: write operation failed: %v", err)
s.healthStatus.Store(false)
return
}
if err := tx.Commit(); err != nil {
log.Printf("Storage degraded: failed to commit: %v", err)
s.healthStatus.Store(false)
return
}
}
// Close gracefully closes the database connection
func (s *Store) Close() error {
// Signal writer to stop
s.cancel()
// Wait for writer with timeout
done := make(chan struct{})
go func() {
s.wg.Wait()
close(done)
}()
select {
case <-done:
// Writer finished cleanly
case <-time.After(2 * time.Second):
log.Printf("Warning: storage writer shutdown timeout, some writes may be lost")
}
if s.db != nil {
return s.db.Close()
}
return nil
}
// InitDB creates the database schema
func (s *Store) InitDB() error {
tx, err := s.db.Begin()
if err != nil {
return fmt.Errorf("failed to begin transaction: %w", err)
}
defer tx.Rollback()
if _, err := tx.Exec(Schema); err != nil {
return fmt.Errorf("failed to create schema: %w", err)
}
return tx.Commit()
}
// DeleteDB removes the database file
func (s *Store) DeleteDB() error {
// Close connection first
if err := s.Close(); err != nil {
return fmt.Errorf("failed to close database: %w", err)
}
// ☣ DESTRUCTIVE: Removes database file
if err := os.Remove(s.path); err != nil && !os.IsNotExist(err) {
return fmt.Errorf("failed to delete database file: %w", err)
}
return nil
}

View File

@ -0,0 +1,187 @@
// FILE: lixenwraith/chess/internal/server/storage/user.go
package storage
import (
"database/sql"
"fmt"
"log"
"time"
)
// CreateUser creates user with transaction isolation to prevent race conditions
func (s *Store) CreateUser(record UserRecord) error {
tx, err := s.db.Begin()
if err != nil {
return fmt.Errorf("failed to begin transaction: %w", err)
}
defer tx.Rollback()
// Check uniqueness within transaction
exists, err := s.userExists(tx, record.Username, record.Email)
if err != nil {
return err
}
if exists {
return fmt.Errorf("username or email already exists")
}
// Insert user
query := `INSERT INTO users (
user_id, username, email, password_hash, created_at
) VALUES (?, ?, ?, ?, ?)`
_, err = tx.Exec(query,
record.UserID, record.Username, record.Email,
record.PasswordHash, record.CreatedAt,
)
if err != nil {
return err
}
return tx.Commit()
}
// userExists verifies username/email uniqueness within a transaction
func (s *Store) userExists(tx *sql.Tx, username, email string) (bool, error) {
var count int
query := `SELECT COUNT(*) FROM users WHERE username = ? COLLATE NOCASE`
args := []interface{}{username}
if email != "" {
query = `SELECT COUNT(*) FROM users WHERE username = ? COLLATE NOCASE OR email = ? COLLATE NOCASE`
args = append(args, email)
}
err := tx.QueryRow(query, args...).Scan(&count)
if err != nil {
return false, err
}
return count > 0, nil
}
// UpdateUserPassword updates user password hash
func (s *Store) UpdateUserPassword(userID string, passwordHash string) error {
query := `UPDATE users SET password_hash = ? WHERE user_id = ?`
_, err := s.db.Exec(query, passwordHash, userID)
return err
}
// UpdateUserEmail updates user email
func (s *Store) UpdateUserEmail(userID string, email string) error {
query := `UPDATE users SET email = ? WHERE user_id = ?`
_, err := s.db.Exec(query, email, userID)
return err
}
// UpdateUserUsername updates username
func (s *Store) UpdateUserUsername(userID string, username string) error {
query := `UPDATE users SET username = ? WHERE user_id = ?`
_, err := s.db.Exec(query, username, userID)
return err
}
// GetAllUsers retrieves all users
func (s *Store) GetAllUsers() ([]UserRecord, error) {
query := `SELECT user_id, username, email, password_hash, created_at, last_login_at
FROM users ORDER BY created_at DESC`
rows, err := s.db.Query(query)
if err != nil {
return nil, err
}
defer rows.Close()
var users []UserRecord
for rows.Next() {
var user UserRecord
err := rows.Scan(
&user.UserID, &user.Username, &user.Email,
&user.PasswordHash, &user.CreatedAt, &user.LastLoginAt,
)
if err != nil {
return nil, err
}
users = append(users, user)
}
return users, rows.Err()
}
// UpdateUserLastLoginSync updates user last login time
func (s *Store) UpdateUserLastLoginSync(userID string, loginTime time.Time) error {
query := `UPDATE users SET last_login_at = ? WHERE user_id = ?`
_, err := s.db.Exec(query, loginTime, userID)
if err != nil {
return fmt.Errorf("failed to update last login for user %s: %w", userID, err)
}
return nil
}
// GetUserByUsername retrieves user by username with case-insensitive matching
func (s *Store) GetUserByUsername(username string) (*UserRecord, error) {
var user UserRecord
query := `SELECT user_id, username, email, password_hash, created_at, last_login_at
FROM users WHERE username = ? COLLATE NOCASE`
err := s.db.QueryRow(query, username).Scan(
&user.UserID, &user.Username, &user.Email,
&user.PasswordHash, &user.CreatedAt, &user.LastLoginAt,
)
if err != nil {
return nil, err
}
return &user, nil
}
// GetUserByEmail retrieves user by email with case-insensitive matching
func (s *Store) GetUserByEmail(email string) (*UserRecord, error) {
var user UserRecord
query := `SELECT user_id, username, email, password_hash, created_at, last_login_at
FROM users WHERE email = ? COLLATE NOCASE`
err := s.db.QueryRow(query, email).Scan(
&user.UserID, &user.Username, &user.Email,
&user.PasswordHash, &user.CreatedAt, &user.LastLoginAt,
)
if err != nil {
return nil, err
}
return &user, nil
}
// GetUserByID retrieves user by unique user ID
func (s *Store) GetUserByID(userID string) (*UserRecord, error) {
var user UserRecord
query := `SELECT user_id, username, email, password_hash, created_at, last_login_at
FROM users WHERE user_id = ?`
err := s.db.QueryRow(query, userID).Scan(
&user.UserID, &user.Username, &user.Email,
&user.PasswordHash, &user.CreatedAt, &user.LastLoginAt,
)
if err != nil {
return nil, err
}
return &user, nil
}
// DeleteUser removes a user from the database
func (s *Store) DeleteUser(userID string) error {
if !s.healthStatus.Load() {
return nil
}
select {
case s.writeChan <- func(tx *sql.Tx) error {
query := `DELETE FROM users WHERE user_id = ?`
_, err := tx.Exec(query, userID)
return err
}:
return nil
default:
log.Printf("Storage write queue full, dropping user deletion")
return nil
}
}

View File

@ -0,0 +1,88 @@
// FILE: lixenwraith/chess/internal/server/webserver/server.go
package webserver
import (
"embed"
"fmt"
"io/fs"
"strings"
"time"
"github.com/gofiber/fiber/v2"
"github.com/gofiber/fiber/v2/middleware/cors"
"github.com/gofiber/fiber/v2/middleware/logger"
)
//go:embed web
var webFS embed.FS
// Start initializes and starts the web UI server
func Start(host string, port int, apiURL string) error {
app := fiber.New(fiber.Config{
ReadTimeout: 10 * time.Second,
WriteTimeout: 10 * time.Second,
IdleTimeout: 30 * time.Second,
})
// Middleware
app.Use(logger.New(logger.Config{
Format: "${time} WEB ${status} ${method} ${path} ${latency}\n",
}))
app.Use(cors.New())
// Create a sub-filesystem that points to the 'web' directory
webContent, err := fs.Sub(webFS, "web")
if err != nil {
return fmt.Errorf("failed to create web sub-filesystem: %w", err)
}
// API config endpoint, served before the static file handler
app.Get("/config", func(c *fiber.Ctx) error {
return c.JSON(fiber.Map{
"apiUrl": apiURL,
})
})
// Serve static files from the embedded 'web' directory
app.Get("*", func(c *fiber.Ctx) error {
path := c.Path()
// Default to index.html for the root path
if path == "/" {
path = "/index.html"
}
// The path for the embedded filesystem must not have a leading slash
fsPath := strings.TrimPrefix(path, "/")
// Try to read the file
data, err := fs.ReadFile(webContent, fsPath)
if err != nil {
// If the file isn't found, serve index.html for SPA-style routing.
// This handles client-side routes that don't correspond to a file.
data, err = fs.ReadFile(webContent, "index.html")
if err != nil {
return c.Status(fiber.StatusInternalServerError).SendString("index.html not found")
}
c.Set("Content-Type", "text/html; charset=utf-8")
return c.Send(data)
}
// Set the correct Content-Type based on file extension
contentType := "application/octet-stream"
switch {
case strings.HasSuffix(fsPath, ".html"):
contentType = "text/html; charset=utf-8"
case strings.HasSuffix(fsPath, ".js"):
contentType = "application/javascript; charset=utf-8"
case strings.HasSuffix(fsPath, ".css"):
contentType = "text/css; charset=utf-8"
}
c.Set("Content-Type", contentType)
return c.Send(data)
})
addr := fmt.Sprintf("%s:%d", host, port)
return app.Listen(addr)
}

View File

@ -0,0 +1,750 @@
// FILE: lixenwraith/chess/internal/server/webserver/web/app.js
// Game state management
let gameState = {
gameId: null,
fen: null,
turn: 'w',
isPlayerWhite: true,
isLocked: false,
pollInterval: null,
apiUrl: '',
selectedSquare: null,
healthCheckInterval: null,
networkError: false,
moveList: [],
};
// Chess piece Unicode: all black pieces for better fill, white pawn due to inability to override emoji variant display
const pieceMap = {
'p': '♙', 'r': '♜', 'n': '♞', 'b': '♝', 'q': '♛', 'k': '♚',
'P': '♙', 'R': '♜', 'N': '♞', 'B': '♝', 'Q': '♛', 'K': '♚'
};
// Initialize on page load
document.addEventListener('DOMContentLoaded', async () => {
const config = await getConfig();
gameState.apiUrl = config.apiUrl;
document.getElementById('new-game-btn').addEventListener('click', showNewGameModal);
document.getElementById('undo-btn').addEventListener('click', undoMoves);
document.getElementById('start-game-btn').addEventListener('click', startNewGame);
document.getElementById('cancel-btn').addEventListener('click', hideNewGameModal);
document.getElementById('copy-history').addEventListener('click', copyHistory);
const levelSlider = document.getElementById('computer-level');
const levelValue = document.getElementById('level-value');
levelSlider.addEventListener('input', () => { levelValue.textContent = levelSlider.value; });
const timeSlider = document.getElementById('search-time');
const timeValue = document.getElementById('time-value');
timeSlider.addEventListener('input', () => { timeValue.textContent = timeSlider.value; });
startHealthCheck();
// Don't auto-show modal on load
});
async function getConfig() {
try {
const response = await fetch('/config');
return await response.json();
} catch (error) {
console.error('Failed to get config:', error);
return { apiUrl: 'http://localhost:8080' };
}
}
function startHealthCheck() {
const checkHealth = async () => {
try {
const response = await fetch(`${gameState.apiUrl}/health`);
if (response.ok) {
const health = await response.json();
updateServerIndicator(health.status === 'healthy' ? 'healthy' : 'degraded');
updateStorageIndicator(health.storage || 'unknown');
gameState.networkError = false;
} else {
handleApiError('health check', null, response);
updateStorageIndicator('unknown');
}
} catch (error) {
handleApiError('health check', error);
updateStorageIndicator('unknown');
}
};
checkHealth();
gameState.healthCheckInterval = setInterval(checkHealth, 10000);
}
function updateServerIndicator(status, message = null) {
const indicator = document.getElementById('server-indicator');
const light = indicator.querySelector('.light');
light.setAttribute('data-status', status);
// Set custom tooltip if message provided
if (message) {
indicator.setAttribute('data-status', message);
} else {
// Default messages
const defaultMessages = {
'healthy': 'healthy',
'degraded': 'degraded',
'unknown': 'unknown'
};
indicator.setAttribute('data-status', defaultMessages[status] || status);
}
}
function updateStorageIndicator(status) {
const indicator = document.getElementById('storage-indicator');
const light = indicator.querySelector('.light');
light.setAttribute('data-status', status);
indicator.setAttribute('data-status', status);
}
function updateTurnIndicator(state, turn) {
const indicator = document.getElementById('turn-indicator');
const light = indicator.querySelector('.light');
let status = '';
let tooltipText = '';
if (state === 'pending' || gameState.isLocked) {
status = 'thinking';
tooltipText = 'Computer Thinking';
} else if (state && isGameOver(state)) {
switch(state) {
case 'white wins':
status = 'white-wins';
tooltipText = 'White Wins';
break;
case 'black wins':
status = 'black-wins';
tooltipText = 'Black Wins';
break;
case 'stalemate':
status = 'stalemate';
tooltipText = 'Stalemate';
break;
case 'draw':
status = 'draw';
tooltipText = 'Draw';
break;
default:
status = 'unknown';
tooltipText = 'Game Over';
}
} else if (turn === 'w') {
status = 'white';
tooltipText = 'White';
} else if (turn === 'b') {
status = 'black';
tooltipText = 'Black';
} else {
status = 'unknown';
tooltipText = 'Unknown';
}
light.setAttribute('data-status', status);
indicator.setAttribute('data-status', tooltipText);
}
function showNewGameModal() {
const modal = document.getElementById('modal-overlay');
modal.classList.add('show');
setupModalKeyboardNav();
}
function hideNewGameModal() {
const modal = document.getElementById('modal-overlay');
modal.classList.remove('show');
teardownModalKeyboardNav();
}
function setupModalKeyboardNav() {
document.addEventListener('keydown', handleModalKeydown);
}
function teardownModalKeyboardNav() {
document.removeEventListener('keydown', handleModalKeydown);
}
function handleModalKeydown(e) {
const modal = document.getElementById('modal-overlay');
if (!modal.classList.contains('show')) return;
switch(e.key) {
case 'Enter':
e.preventDefault();
startNewGame();
break;
case 'Escape':
e.preventDefault();
hideNewGameModal();
break;
case 'w':
case 'W':
e.preventDefault();
document.querySelector('input[name="player-color"][value="white"]').checked = true;
break;
case 'b':
case 'B':
e.preventDefault();
document.querySelector('input[name="player-color"][value="black"]').checked = true;
break;
case 'l':
case 'L':
e.preventDefault();
document.getElementById('computer-level').focus();
break;
case 's':
case 'S':
e.preventDefault();
document.getElementById('search-time').focus();
break;
case 'ArrowLeft':
handleSliderNav(e, -1);
break;
case 'ArrowRight':
handleSliderNav(e, 1);
break;
}
}
function handleSliderNav(e, direction) {
const activeEl = document.activeElement;
if (activeEl.id === 'computer-level') {
e.preventDefault();
activeEl.value = Math.max(0, Math.min(20, parseInt(activeEl.value) + direction));
activeEl.dispatchEvent(new Event('input'));
} else if (activeEl.id === 'search-time') {
e.preventDefault();
activeEl.value = Math.max(100, Math.min(10000, parseInt(activeEl.value) + direction * 100));
activeEl.dispatchEvent(new Event('input'));
}
}
function copyHistory() {
const moves = gameState.moveList;
let pgn = '';
for (let i = 0; i < moves.length; i++) {
if (i % 2 === 0) {
pgn += `${Math.floor(i / 2) + 1}. `;
}
pgn += moves[i] + ' ';
}
if (gameState.fen) {
pgn += `\n\n[FEN "${gameState.fen}"]`;
}
navigator.clipboard.writeText(pgn.trim()).then(() => {
const btn = document.getElementById('copy-history');
btn.classList.add('copied');
setTimeout(() => {
btn.classList.remove('copied');
}, 2000);
});
}
async function startNewGame() {
const playerColor = document.querySelector('input[name="player-color"]:checked').value;
const computerLevel = parseInt(document.getElementById('computer-level').value);
const searchTime = parseInt(document.getElementById('search-time').value);
const startingFEN = document.getElementById('starting-fen').value.trim();
gameState.isPlayerWhite = (playerColor === 'white');
const whiteConfig = gameState.isPlayerWhite ? { type: 1 } : { type: 2, level: computerLevel, searchTime: searchTime };
const blackConfig = gameState.isPlayerWhite ? { type: 2, level: computerLevel, searchTime: searchTime } : { type: 1 };
const requestBody = {
white: whiteConfig,
black: blackConfig
};
const defaultFEN = 'rnbqkbnr/pppppppp/8/8/8/8/PPPPPPPP/RNBQKBNR w KQkq - 0 1';
if (startingFEN && startingFEN !== defaultFEN) {
requestBody.fen = startingFEN;
}
try {
const response = await fetch(`${gameState.apiUrl}/api/v1/games`, {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify(requestBody)
});
if (!response.ok) throw new Error('Failed to create game');
if (!response.ok) {
const errorInfo = handleApiError('create game', null, response);
throw new Error(errorInfo.statusMessage);
}
const game = await response.json();
gameState.gameId = game.gameId;
gameState.moveList = [];
hideNewGameModal();
initializeBoard();
updateGameDisplay(game);
document.getElementById('undo-btn').disabled = true;
if (!gameState.isPlayerWhite) triggerComputerMove();
} catch (error) {
if (error.message === 'Failed to fetch') {
handleApiError('create game', error);
} else {
flashErrorMessage(error.message);
}
updateTurnIndicator('', '');
}
}
function initializeBoard() {
const boardEl = document.getElementById('board');
boardEl.innerHTML = '';
const isBlackPov = !gameState.isPlayerWhite;
// Update coordinate labels based on perspective
const topCoords = document.querySelector('.coordinates.top');
const leftCoords = document.querySelector('.coordinates.left');
if (isBlackPov) {
topCoords.innerHTML = '<span>h</span><span>g</span><span>f</span><span>e</span><span>d</span><span>c</span><span>b</span><span>a</span>';
leftCoords.innerHTML = '<span>1</span><span>2</span><span>3</span><span>4</span><span>5</span><span>6</span><span>7</span><span>8</span>';
} else {
topCoords.innerHTML = '<span>a</span><span>b</span><span>c</span><span>d</span><span>e</span><span>f</span><span>g</span><span>h</span>';
leftCoords.innerHTML = '<span>8</span><span>7</span><span>6</span><span>5</span><span>4</span><span>3</span><span>2</span><span>1</span>';
}
for (let i = 0; i < 64; i++) {
const square = document.createElement('div');
const rank = 7 - Math.floor(i / 8);
const file = i % 8;
const squareName = `${String.fromCharCode(97 + file)}${rank + 1}`;
const displayRank = isBlackPov ? 7 - rank : rank;
const displayFile = isBlackPov ? 7 - file : file;
square.className = `square ${(displayRank + displayFile) % 2 === 0 ? 'dark' : 'light'}`;
square.dataset.square = squareName;
square.addEventListener('click', handleSquareClick);
boardEl.appendChild(square);
}
}
function renderBoardFromFEN(fen) {
const fenBoard = fen.split(' ')[0];
// Clear board and remove checkmate indicators
document.querySelectorAll('.square').forEach(s => {
s.textContent = '';
s.classList.remove('white-piece', 'black-piece', 'mated-king');
delete s.dataset.pieceColor;
});
let rank = 7, file = 0;
for (const char of fenBoard) {
if (char === '/') {
rank--; file = 0;
} else if (/\d/.test(char)) {
file += parseInt(char, 10);
} else {
const squareName = `${String.fromCharCode(97 + file)}${rank + 1}`;
const squareEl = document.querySelector(`[data-square="${squareName}"]`);
if (squareEl) {
const pieceColor = (char === char.toUpperCase()) ? 'w' : 'b';
squareEl.textContent = pieceMap[char === 'P' ? 'P' : char.toLowerCase()] || '';
squareEl.classList.add(pieceColor === 'w' ? 'white-piece' : 'black-piece');
squareEl.dataset.pieceColor = pieceColor;
squareEl.dataset.pieceType = char.toLowerCase();
}
file++;
}
}
}
function handleSquareClick(e) {
if (gameState.isLocked) return;
// Block moves after game over
if (isGameOver(gameState.state)) return;
const squareEl = e.currentTarget;
const { square, pieceColor } = squareEl.dataset;
const playerTurnColor = gameState.isPlayerWhite ? 'w' : 'b';
if (gameState.turn !== playerTurnColor) return;
if (gameState.selectedSquare) {
const from = gameState.selectedSquare;
const fromEl = document.querySelector(`[data-square="${from}"]`);
fromEl.classList.remove('selected');
gameState.selectedSquare = null;
if (from !== square) {
handleHumanMove(from, square);
}
} else if (pieceColor === playerTurnColor) {
gameState.selectedSquare = square;
squareEl.classList.add('selected');
} else {
flashErrorMessage('Invalid Piece Selection');
// Flash red for invalid piece selection
flashSquare(squareEl, false);
}
}
function flashSquare(element, success = true) {
const className = success ? 'flash-green' : 'flash-red';
element.classList.add(className);
setTimeout(() => element.classList.remove(className), 400);
}
async function handleHumanMove(from, to) {
const move = from + to;
const fromEl = document.querySelector(`[data-square="${from}"]`);
const toEl = document.querySelector(`[data-square="${to}"]`);
try {
const response = await fetch(`${gameState.apiUrl}/api/v1/games/${gameState.gameId}/moves`, {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ move })
});
const game = await response.json();
if (!response.ok) {
// Handle client errors differently - these aren't network issues
if (response.status === 400) {
// Invalid move - flash message and squares
// Handled early, not shown as server error, and bypasses handleApiError
flashErrorMessage('Invalid Move');
flashSquare(fromEl, false);
flashSquare(toEl, false);
renderBoardFromFEN(gameState.fen);
return;
}
// Other errors use error handler
handleApiError('move', null, response);
return;
}
flashSquare(fromEl, true);
flashSquare(toEl, true);
updateGameDisplay(game);
if (!isGameOver(game.state)) {
triggerComputerMove();
}
} catch (error) {
handleApiError('move', error);
}
}
async function triggerComputerMove() {
lockBoard();
try {
const response = await fetch(`${gameState.apiUrl}/api/v1/games/${gameState.gameId}/moves`, {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ move: 'cccc' })
});
if (!response.ok) {
handleApiError('trigger computer move', null, response);
unlockBoard();
return;
}
gameState.networkError = false;
startPolling();
} catch (error) {
handleApiError('trigger computer move', error);
unlockBoard();
}
}
function startPolling() {
gameState.pollInterval = setInterval(async () => {
try {
const response = await fetch(`${gameState.apiUrl}/api/v1/games/${gameState.gameId}`);
if (!response.ok) {
// Use error handler but continue polling for 404 (game might be deleted)
const errorInfo = handleApiError('poll game state', null, response);
if (response.status === 404) {
stopPolling();
unlockBoard();
flashErrorMessage('Game no longer exists');
gameState.gameId = null;
return;
}
// For other errors, display but keep polling
handleApiError('poll game state', null, response);
return;
}
const game = await response.json();
if (game.state !== 'pending') {
stopPolling();
updateGameDisplay(game);
unlockBoard();
}
gameState.networkError = false;
updateServerIndicator('healthy');
} catch (error) {
handleApiError('poll game state', error);
stopPolling();
unlockBoard();
}
}, 1500);
}
function stopPolling() {
clearInterval(gameState.pollInterval);
gameState.pollInterval = null;
}
function lockBoard() {
gameState.isLocked = true;
updateTurnIndicator('pending', gameState.turn);
}
function unlockBoard() {
gameState.isLocked = false;
updateTurnIndicator('', gameState.turn);
}
async function undoMoves() {
if (gameState.isLocked) return;
if (!gameState.moveList || gameState.moveList.length < 2) {
console.log('No moves to undo');
return;
}
try {
const response = await fetch(`${gameState.apiUrl}/api/v1/games/${gameState.gameId}/undo`, {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ count: 2 })
});
if (!response.ok) {
const errorInfo = handleApiError('undo', null, response);
// For client errors like "no moves to undo", don't throw
if (errorInfo.isClientError) {
console.log('Undo failed:', errorInfo.statusMessage);
return;
}
throw new Error(errorInfo.statusMessage);
}
const game = await response.json();
gameState.state = game.state;
updateGameDisplay(game);
} catch (error) {
if (error.message === 'Failed to fetch') {
handleApiError('undo', error);
}
}
}
function renderMoveHistory(moves) {
const grid = document.getElementById('move-grid');
grid.innerHTML = '';
let startsWithBlack = false;
if (gameState.fen) {
const fenParts = gameState.fen.split(' ');
const moveNum = parseInt(fenParts[5]) || 1;
const activeColor = fenParts[1];
startsWithBlack = (moveNum === 1 && activeColor === 'b' && moves.length === 0);
}
for (let i = 0; i < moves.length; i++) {
const isWhiteMove = (i % 2 === 0);
const moveNumber = Math.floor(i / 2) + 1;
if (i === 0 || i % 2 === 0) {
const numEl = document.createElement('div');
numEl.className = 'move-number';
numEl.textContent = moveNumber + '.';
grid.appendChild(numEl);
const whiteEl = document.createElement('div');
if (isWhiteMove && !startsWithBlack) {
whiteEl.className = 'move-white';
whiteEl.textContent = moves[i];
} else if (!isWhiteMove && startsWithBlack) {
whiteEl.className = 'move-empty';
whiteEl.textContent = '...';
} else {
whiteEl.className = 'move-empty';
whiteEl.textContent = '';
}
grid.appendChild(whiteEl);
const blackEl = document.createElement('div');
if (i + 1 < moves.length && !startsWithBlack) {
blackEl.className = 'move-black';
blackEl.textContent = moves[i + 1];
i++;
} else if (isWhiteMove && startsWithBlack) {
blackEl.className = 'move-black';
blackEl.textContent = moves[i];
} else {
blackEl.className = 'move-empty';
blackEl.textContent = '';
}
grid.appendChild(blackEl);
}
}
const historyContainer = document.getElementById('move-history');
historyContainer.scrollTop = historyContainer.scrollHeight;
}
function updateGameDisplay(game) {
gameState.fen = game.fen;
gameState.turn = game.turn;
gameState.state = game.state;
gameState.moveList = game.moves || [];
renderBoardFromFEN(game.fen);
updateTurnIndicator(game.state, game.turn);
// Clear previous checkmate indicators
document.querySelectorAll('.mated-king').forEach(el => {
el.classList.remove('mated-king');
});
// Highlight last move
document.querySelectorAll('.last-move-from, .last-move-to').forEach(el => {
el.classList.remove('last-move-from', 'last-move-to');
});
if (game.lastMove && game.lastMove.move) {
const from = game.lastMove.move.substring(0, 2);
const to = game.lastMove.move.substring(2, 4);
const fromEl = document.querySelector(`[data-square="${from}"]`);
const toEl = document.querySelector(`[data-square="${to}"]`);
if (fromEl) fromEl.classList.add('last-move-from');
if (toEl) toEl.classList.add('last-move-to');
}
// Update move history
renderMoveHistory(game.moves || []);
// Update undo button
document.getElementById('undo-btn').disabled = !game.moves || game.moves.length < 2;
// Handle checkmate visually
if (game.state === 'white wins' || game.state === 'black wins') {
markMatedKing(game);
}
}
function markMatedKing(game) {
// Find and mark the mated king
const matedColor = game.state === 'white wins' ? 'b' : 'w';
document.querySelectorAll('.square').forEach(square => {
if (square.dataset.pieceType === 'k' && square.dataset.pieceColor === matedColor) {
square.classList.add('mated-king');
}
});
}
function isGameOver(state) {
return ['white wins', 'black wins', 'stalemate', 'draw'].includes(state);
}
function handleApiError(action, error, response = null) {
let serverStatus = 'degraded';
let statusMessage = 'Server Error';
let isNetworkError = !response;
if (isNetworkError) {
// Network/connection error
statusMessage = 'Connection Failed';
console.error(`Network error during ${action}:`, error);
} else if (response) {
const status = response.status;
// Map status codes to user-friendly messages
switch (status) {
case 400:
// Bad request - not a server issue, game logic error
serverStatus = 'healthy'; // Server is fine, request was invalid
if (action === 'undo') {
statusMessage = 'No Moves to Undo';
} else if (action === 'move') {
statusMessage = 'Invalid Move';
} else {
statusMessage = 'Invalid Request';
}
break;
case 404:
serverStatus = 'healthy'; // Server is fine, game doesn't exist
statusMessage = 'Game Not Found';
break;
case 429:
serverStatus = 'degraded';
statusMessage = 'Rate Limited';
break;
case 415:
serverStatus = 'healthy';
statusMessage = 'Invalid Content Type';
break;
case 500:
case 502:
case 503:
serverStatus = 'degraded';
statusMessage = status === 503 ? 'Service Unavailable' : 'Server Error';
break;
default:
if (status >= 500) {
serverStatus = 'degraded';
statusMessage = `Server Error (${status})`;
} else if (status >= 400) {
serverStatus = 'healthy';
statusMessage = `Request Failed (${status})`;
}
}
console.error(`API error during ${action}: ${status} - ${statusMessage}`);
}
flashErrorMessage(statusMessage);
// Update indicators based on error type
if (isNetworkError || (response && response.status >= 500)) {
updateServerIndicator(serverStatus, statusMessage);
gameState.networkError = true;
} else {
// For client errors (4xx), server is healthy but request failed
updateServerIndicator('healthy');
gameState.networkError = false;
}
return {
serverStatus,
statusMessage,
isNetworkError,
isClientError: response && response.status >= 400 && response.status < 500,
isServerError: response && response.status >= 500
};
}
function flashErrorMessage(message) {
const overlay = document.getElementById('error-flash-overlay');
const messageEl = document.getElementById('error-flash-message');
// Set message text
messageEl.textContent = message;
// Show overlay
overlay.classList.add('show');
// Auto-hide after animation completes
setTimeout(() => {
overlay.classList.remove('show');
}, 500);
}

View File

@ -0,0 +1,100 @@
<!-- FILE: lixenwraith/chess/internal/server/webserver/web/index.html -->
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>Chess Game</title>
<link rel="stylesheet" href="style.css">
</head>
<body>
<div class="outer-container">
<div class="container">
<main>
<div class="game-area">
<div class="board-container">
<div class="board-wrapper">
<div class="coordinates top">
<span>a</span><span>b</span><span>c</span><span>d</span>
<span>e</span><span>f</span><span>g</span><span>h</span>
</div>
<div class="coordinates left">
<span>8</span><span>7</span><span>6</span><span>5</span>
<span>4</span><span>3</span><span>2</span><span>1</span>
</div>
<div id="board"></div>
</div>
</div>
</div>
<aside class="info-panel">
<div class="status-indicators">
<div class="indicator" id="server-indicator" data-tooltip="Server">
<span class="light" data-status="unknown"></span>
</div>
<div class="indicator" id="storage-indicator" data-tooltip="Storage">
<span class="light" data-status="unknown"></span>
</div>
<div class="indicator" id="turn-indicator" data-tooltip="Turn">
<span class="light turn-light" data-status="white"></span>
</div>
<div class="error-flash-overlay" id="error-flash-overlay">
<div class="error-flash-message" id="error-flash-message"></div>
</div>
</div>
<div class="controls">
<button id="new-game-btn" class="btn btn-primary">New Game</button>
<button id="undo-btn" class="btn btn-secondary" disabled>Undo</button>
</div>
<div class="move-history-container">
<button class="copy-btn" id="copy-history" title="Copy PGN">
<svg viewBox="0 0 24 24" width="16" height="16">
<rect x="9" y="9" width="13" height="13" rx="2" fill="none" stroke="currentColor" stroke-width="2"/>
<path d="M5 15H4a2 2 0 01-2-2V4a2 2 0 012-2h9a2 2 0 012 2v1" fill="none" stroke="currentColor" stroke-width="2"/>
</svg>
</button>
<div id="move-history" class="move-history">
<div class="move-grid" id="move-grid"></div>
</div>
</div>
</aside>
</main>
</div>
</div>
<div id="modal-overlay" class="modal-overlay">
<div class="modal">
<h2>New Game</h2>
<div class="form-group">
<label class="group-label">Your Color</label>
<div class="radio-group">
<label><input type="radio" name="player-color" value="white" checked><span>White</span></label>
<label><input type="radio" name="player-color" value="black"><span>Black</span></label>
</div>
</div>
<div class="form-group">
<label for="computer-level">Computer Level: <span id="level-value">10</span></label>
<input type="range" id="computer-level" min="0" max="20" value="10">
</div>
<div class="form-group">
<label for="search-time">Search Time (ms): <span id="time-value">1000</span></label>
<input type="range" id="search-time" min="100" max="10000" step="100" value="1000">
</div>
<div class="form-group">
<label for="starting-fen">Starting Position (FEN)</label>
<textarea id="starting-fen" class="fen-input" rows="2"
placeholder="Enter FEN notation">rnbqkbnr/pppppppp/8/8/8/8/PPPPPPPP/RNBQKBNR w KQkq - 0 1</textarea>
</div>
<div class="modal-buttons">
<button id="start-game-btn" class="btn btn-primary">Start Game</button>
<button id="cancel-btn" class="btn btn-secondary">Cancel</button>
</div>
</div>
</div>
<script src="app.js"></script>
</body>
</html>

View File

@ -0,0 +1,800 @@
/* FILE: lixenwraith/chess/internal/server/webserver/web/style.css */
* {
margin: 0;
padding: 0;
box-sizing: border-box;
}
:root {
/* Host-site dark theme colors */
--host-bg: #11111b;
--host-surface: #1a1b26;
--host-royal: #5f57f5;
--host-royal-secondary: #38348f;
--host-blue-primary: #2563eb;
--host-blue-secondary: #1e40af;
--host-white: #ffffff;
--blue-accent: #dbeafe;
--host-gray-muted: #64748b;
--host-gray-light: #f8fafc;
/* Tokyo Night colors */
--tokyo-cyan: #7dcfff;
--tokyo-green: #9ece6a;
--tokyo-yellow: #e0af68;
--tokyo-red: #f7768e;
--tokyo-border: #3b4261;
--tokyo-fg: #a9b1d6;
/* Board colors */
--square-light: #f0d9b5;
--square-dark: #b58863;
--square-selected: #6a994e;
--move-from: #5090d3;
--move-to: #81b3f0;
--checkmated-king: #8b0000;
}
/* Base Layout */
body {
font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, 'Helvetica Neue', Arial, sans-serif;
background: var(--host-bg);
height: 100vh;
width: 100vw;
margin: 0;
display: flex;
justify-content: center;
align-items: center;
overflow: hidden;
}
.outer-container {
width: 100%;
height: 100vh;
padding: 8px;
display: flex;
justify-content: center;
align-items: center;
overflow: hidden;
}
.container {
background: var(--host-surface);
border-radius: 12px;
box-shadow: 0 20px 40px rgba(0,0,0,0.4);
padding: 1.5rem;
width: calc(100% - 16px);
height: calc(100% - 16px);
color: var(--tokyo-fg);
display: flex;
flex-direction: column;
overflow: hidden;
}
main {
display: grid;
grid-template-columns: 1fr 340px;
gap: 2rem;
flex: 1;
min-height: 0;
overflow: hidden;
height: 100%;
max-width: 980px;
margin: 0 auto;
align-items: center;
}
.game-area {
display: flex;
align-items: center;
justify-content: center;
min-width: 440px;
overflow: visible;
height: 100%;
}
.board-container {
display: flex;
align-items: center;
justify-content: center;
width: 440px;
height: 440px;
padding: 20px;
position: relative;
}
.board-wrapper {
position: relative;
width: 400px;
height: 400px;
margin: 0;
}
.coordinates {
position: absolute;
display: flex;
color: var(--tokyo-border);
font-size: 0.75rem;
font-weight: 500;
user-select: none;
}
.coordinates.top {
top: -18px;
left: 0;
right: 0;
justify-content: space-around;
padding: 0 2px;
}
.coordinates.left {
left: -18px;
top: 0;
bottom: 0;
flex-direction: column;
justify-content: space-around;
padding: 2px 0;
}
#board {
position: absolute;
top: 0;
left: 0;
width: 100%;
height: 100%;
display: grid;
grid-template-columns: repeat(8, 1fr);
grid-template-rows: repeat(8, 1fr);
border: 3px solid var(--tokyo-border);
border-radius: 4px;
}
.square {
display: flex;
justify-content: center;
align-items: center;
font-size: clamp(24px, 5vw, 36px);
cursor: pointer;
user-select: none;
position: relative;
aspect-ratio: 1;
overflow: hidden;
}
.square.light { background-color: var(--square-light); }
.square.dark { background-color: var(--square-dark); }
.square.selected { background-color: var(--square-selected) !important; }
.square.last-move-from { background-color: var(--move-from) !important; }
.square.last-move-to { background-color: var(--move-to) !important; }
.square.white-piece { color: var(--host-white); text-shadow: 1px 1px 2px rgba(0,0,0,0.5); }
.square.black-piece { color: var(--host-bg); text-shadow: 1px 1px 2px rgba(255,255,255,0.2); }
/* Move feedback animations */
@keyframes flashGreen {
0%, 100% { background-color: inherit; }
50% { background-color: rgba(154, 206, 106, 0.8); }
}
@keyframes flashRed {
0%, 100% { background-color: inherit; }
50% { background-color: rgba(247, 118, 142, 0.8); }
}
.square.flash-green {
animation: flashGreen 0.4s ease-out;
}
.square.flash-red {
animation: flashRed 0.4s ease-out;
position: relative;
z-index: 2;
}
.square.flash-red::before {
content: '';
position: absolute;
inset: 0;
background-color: rgba(247, 118, 142, 0.8);
animation: flashFade 0.4s ease-out;
pointer-events: none;
}
@keyframes flashFade {
0% { opacity: 0; }
50% { opacity: 1; }
100% { opacity: 0; }
}
/* Checkmate indicator */
.square.mated-king::after {
content: '';
position: absolute;
inset: 4px;
border: 3px solid var(--tokyo-red);
border-radius: 2px;
pointer-events: none;
z-index: 1;
}
.square.mated-king.white-piece,
.square.mated-king.black-piece {
color: var(--checkmated-king) !important;
}
/* Copy Buttons */
.copy-btn {
position: absolute;
right: 8px;
top: 50%;
transform: translateY(-50%);
width: 24px;
height: 24px;
padding: 4px;
background: var(--tokyo-border);
border: none;
border-radius: 4px;
color: var(--tokyo-fg);
cursor: pointer;
opacity: 0;
transition: opacity 0.2s, background 0.2s;
display: flex;
align-items: center;
justify-content: center;
}
.move-history-container:hover .copy-btn {
opacity: 0.7;
}
.copy-btn:hover {
opacity: 1 !important;
background: var(--host-royal);
}
.copy-btn.copied {
background: var(--tokyo-green);
opacity: 1;
}
.copy-btn.copied svg {
display: none;
}
.copy-btn.copied::after {
content: '✓';
font-size: 14px;
}
/* Info Panel */
.info-panel {
background: var(--host-bg);
border-radius: 8px;
padding: 1rem;
display: flex;
flex-direction: column;
gap: 1rem;
overflow: hidden;
width: 340px;
height: 440px;
align-self: center;
}
/* Status Indicators */
.status-indicators {
display: flex;
padding: 0.75rem;
justify-content: space-evenly;
align-items: center;
background: var(--host-gray-muted);
border: none;
border-radius: 6px;
cursor: pointer;
height: auto;
position: relative;
}
.indicator {
position: relative;
display: flex;
align-items: center;
justify-content: center;
height: 100%;
}
.indicator .light {
font-size: 2rem;
font-weight: 500;
transition: all 0.2s;
line-height: 1;
display: flex;
align-items: center;
justify-content: center;
}
.indicator .light[data-status="white-wins"] { color: var(--tokyo-red); }
.indicator .light[data-status="black-wins"] { color: var(--tokyo-red); }
/* Status colors */
.indicator .light[data-status="healthy"] { color: var(--tokyo-green); }
.indicator .light[data-status="disabled"] { color: var(--tokyo-yellow); }
.indicator .light[data-status="degraded"] { color: var(--tokyo-red); }
.indicator .light[data-status="unknown"] { color: var(--tokyo-border); }
.indicator .light[data-status="white"] { color: var(--host-white); }
.indicator .light[data-status="black"] { color: var(--host-bg); }
.indicator .light[data-status="thinking"] {
color: var(--tokyo-yellow);
animation: pulse 1.5s infinite;
}
.indicator .light[data-status="network-error"] { color: var(--tokyo-red); }
.indicator .light[data-status="draw"],
.indicator .light[data-status="stalemate"] {
color: var(--tokyo-yellow);
}
@keyframes pulse {
0%, 100% { opacity: 1; transform: scale(1); }
50% { opacity: 0.6; transform: scale(0.95); }
}
.indicator:hover::after {
content: attr(data-tooltip) ": " attr(data-status);
position: absolute;
bottom: -30px;
left: 50%;
transform: translateX(-50%);
background: var(--tokyo-border);
color: var(--tokyo-fg);
padding: 0.25rem 0.5rem;
border-radius: 4px;
font-size: 0.75rem;
white-space: nowrap;
z-index: 10;
pointer-events: none;
}
/* Error Flash Overlay */
.error-flash-overlay {
position: absolute;
inset: 0;
background: var(--host-bg);
border-radius: 6px;
display: none;
align-items: center;
justify-content: center;
z-index: 100;
pointer-events: none;
}
.error-flash-overlay.show {
display: flex;
animation: none;
}
.error-flash-message {
color: var(--tokyo-red);
font-size: 0.85rem;
font-weight: 500;
text-align: center;
padding: 0.5rem;
}
/* Move History */
.move-history-container {
flex: 1 1 auto;
min-height: 0;
max-height: 100%;
position: relative;
background: var(--host-surface);
border-radius: 6px;
border: 1px solid var(--tokyo-border);
overflow: hidden;
}
.move-history-container .copy-btn {
right: 8px;
top: 8px;
transform: none;
}
.move-history {
height: 100%;
overflow-y: auto;
overflow-x: auto;
padding: 0.75rem;
}
.move-history::-webkit-scrollbar {
width: 6px;
height: 6px;
}
.move-history::-webkit-scrollbar-track {
background: transparent;
}
.move-history::-webkit-scrollbar-thumb {
background: var(--tokyo-border);
border-radius: 3px;
}
.move-grid {
display: grid;
grid-template-columns: 2.5rem 1fr 1fr;
gap: 0.25rem 0.5rem;
font-family: 'Courier New', monospace;
font-size: 0.9rem;
align-items: center;
}
.move-number {
color: var(--tokyo-yellow);
text-align: right;
font-weight: 600;
}
.move-white,
.move-black {
padding: 0.25rem 0.5rem;
border-radius: 3px;
}
.move-white:hover,
.move-black:hover {
background: var(--host-royal);
}
.move-white { color: var(--host-gray-light); }
.move-black { color: var(--tokyo-cyan); }
/* Controls */
.controls {
display: grid;
grid-template-columns: 1fr 1fr;
gap: 0.5rem;
flex-shrink: 0;
}
.btn {
padding: 0.75rem;
border: none;
border-radius: 6px;
font-size: 0.9rem;
font-weight: 500;
cursor: pointer;
color: var(--host-white);
transition: all 0.2s;
}
.btn-primary {
background: var(--host-blue-primary);
color: var(--host-white);
}
.btn-primary:hover {
background: var(--host-royal);
transform: translateY(-1px);
}
.btn-secondary {
background: var(--host-blue-secondary);
color: var(--host-white);
}
.btn-secondary:hover:not(:disabled) {
background: var(--host-royal);
transform: translateY(-1px);
}
.btn:disabled {
opacity: 0.4;
cursor: not-allowed;
}
/* Modal */
.modal-overlay {
display: none;
position: fixed;
inset: 0;
background: rgba(0, 0, 0, 0.7);
z-index: 2000;
justify-content: center;
align-items: center;
backdrop-filter: blur(4px);
}
.modal-overlay.show {
display: flex;
}
.modal {
background: var(--host-surface);
border: 1px solid var(--tokyo-border);
border-radius: 12px;
padding: 2rem;
width: 90%;
max-width: 400px;
color: var(--tokyo-fg);
}
.modal h2 {
margin-bottom: 1.5rem;
color: var(--host-royal);
text-align: center;
}
.form-group {
margin-bottom: 1.5rem;
}
.form-group label {
display: block;
margin-bottom: 0.5rem;
color: var(--tokyo-cyan);
}
.form-group .group-label {
text-align: center;
}
.radio-group {
display: flex;
gap: 1.5rem;
justify-content: center;
}
.radio-group label {
display: flex;
align-items: center;
gap: 0.5rem;
margin: 0;
color: var(--tokyo-fg);
}
input[type="range"] {
width: 100%;
height: 4px;
background: var(--tokyo-border);
border-radius: 2px;
outline: none;
}
input[type="range"]::-webkit-slider-thumb {
appearance: none;
width: 16px;
height: 16px;
background: var(--host-gray-muted);
border-radius: 50%;
cursor: pointer;
}
/* Modal FEN input */
.fen-input {
width: 100%;
padding: 0.5rem;
background: var(--host-bg);
border: 1px solid var(--tokyo-border);
border-radius: 4px;
color: var(--tokyo-fg);
font-family: 'Courier New', monospace;
font-size: 0.85rem;
resize: none;
white-space: pre-wrap;
word-wrap: break-word;
}
.fen-input:focus {
outline: none;
border-color: var(--host-royal);
}
.modal-buttons {
display: flex;
justify-content: center;
gap: 1rem;
margin-top: 2rem;
}
/* Mobile/Responsiveness */
@media (max-width: 978px) {
body {
height: auto;
min-height: 100vh;
overflow-y: auto;
overflow-x: hidden;
scrollbar-gutter: stable;
}
.outer-container {
padding: 8px;
min-height: 100vh;
height: auto;
display: flex;
justify-content: center;
align-items: flex-start;
overflow: visible;
}
.container {
border-radius: 12px;
width: calc(100% - 16px);
min-height: calc(100vh - 16px);
height: auto;
padding: 1rem;
margin: 0;
display: flex;
flex-direction: column;
justify-content: center;
overflow: visible;
}
main {
grid-template-columns: 1fr;
grid-template-rows: auto auto;
gap: 0.5rem;
height: auto;
max-width: none;
margin: 0 auto;
align-items: center;
justify-items: center;
overflow: visible;
flex-shrink: 0;
}
.game-area {
min-width: auto;
height: auto;
display: flex;
justify-content: center;
align-items: center;
}
.board-container {
width: 440px;
height: 440px;
padding: 20px;
display: flex;
align-items: center;
justify-content: center;
position: relative;
flex-shrink: 0;
}
.board-wrapper {
width: 400px;
height: 400px;
position: relative;
margin: 0;
}
.coordinates.top {
top: -18px;
left: 0;
right: 0;
}
.coordinates.left {
left: -18px;
top: 0;
bottom: 0;
}
#board {
position: absolute;
top: 0;
left: 0;
width: 100%;
height: 100%;
}
.info-panel {
width: 440px;
height: 200px;
display: grid;
grid-template-columns: 180px 1fr;
grid-template-rows: min-content min-content 1fr;
gap: 0.5rem 1rem;
padding: 1rem;
align-self: center;
overflow: hidden;
flex-shrink: 0;
margin-bottom: 1rem;
}
.status-indicators {
grid-column: 1;
grid-row: 1;
display: flex;
padding: 0.25rem;
justify-content: space-evenly;
align-items: center;
background: var(--host-gray-muted);
border: none;
border-radius: 6px;
height: auto;
margin: 0;
}
.controls {
grid-column: 1;
grid-row: 2 / 4;
grid-template-columns: 1fr;
grid-template-rows: 1fr 1fr;
gap: 0.5rem;
align-self: stretch;
height: auto;
min-height: 80px;
}
.btn {
padding: 0.5rem;
font-size: 0.85rem;
min-height: 35px;
height: 100%;
width: 100%;
}
.move-history-container {
grid-column: 2;
grid-row: 1 / 4;
height: 100%;
min-height: 0;
max-height: 100%;
align-self: stretch;
}
.move-grid {
grid-template-columns: 2rem 1fr 1fr;
gap: 0.25rem 0.5rem;
font-size: 0.85rem;
}
.move-history {
padding: 0.5rem;
}
.square {
font-size: clamp(24px, 5vw, 36px);
}
}
@media (max-width: 530px) {
body {
overflow-y: auto;
overflow-x: auto;
min-width: 530px;
}
.outer-container {
width: 530px;
min-width: 530px;
padding: 8px;
min-height: 100vh;
height: auto;
display: flex;
justify-content: center;
align-items: flex-start;
overflow: visible;
}
.container {
width: 514px;
min-width: 514px;
border-radius: 12px;
min-height: calc(100vh - 16px);
height: auto;
padding: 1rem;
margin: 0;
display: flex;
flex-direction: column;
justify-content: center;
overflow: visible;
}
.board-container,
.info-panel {
width: 440px;
min-width: 440px;
}
}