v0.7.0 cli client with readline added, directory structure updated
This commit is contained in:
120
internal/server/board/board.go
Normal file
120
internal/server/board/board.go
Normal 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]
|
||||
}
|
||||
53
internal/server/core/api.go
Normal file
53
internal/server/core/api.go
Normal 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"`
|
||||
}
|
||||
15
internal/server/core/error.go
Normal file
15
internal/server/core/error.go
Normal 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"
|
||||
)
|
||||
75
internal/server/core/player.go
Normal file
75
internal/server/core/player.go
Normal 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
|
||||
}
|
||||
35
internal/server/core/state.go
Normal file
35
internal/server/core/state.go
Normal 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"
|
||||
}
|
||||
}
|
||||
253
internal/server/engine/engine.go
Normal file
253
internal/server/engine/engine.go
Normal 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()
|
||||
}
|
||||
}
|
||||
152
internal/server/game/game.go
Normal file
152
internal/server/game/game.go
Normal 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
|
||||
}
|
||||
232
internal/server/http/auth.go
Normal file
232
internal/server/http/auth.go
Normal 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,
|
||||
})
|
||||
}
|
||||
515
internal/server/http/handler.go
Normal file
515
internal/server/http/handler.go
Normal 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)
|
||||
}
|
||||
62
internal/server/http/middleware.go
Normal file
62
internal/server/http/middleware.go
Normal 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)
|
||||
}
|
||||
102
internal/server/http/validator.go
Normal file
102
internal/server/http/validator.go
Normal 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
|
||||
}
|
||||
87
internal/server/processor/command.go
Normal file
87
internal/server/processor/command.go
Normal 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,
|
||||
}
|
||||
}
|
||||
580
internal/server/processor/processor.go
Normal file
580
internal/server/processor/processor.go
Normal 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()
|
||||
}
|
||||
209
internal/server/processor/queue.go
Normal file
209
internal/server/processor/queue.go
Normal 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")
|
||||
}
|
||||
}
|
||||
203
internal/server/service/game.go
Normal file
203
internal/server/service/game.go
Normal 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
|
||||
}
|
||||
74
internal/server/service/service.go
Normal file
74
internal/server/service/service.go
Normal 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...)
|
||||
}
|
||||
175
internal/server/service/user.go
Normal file
175
internal/server/service/user.go
Normal 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")
|
||||
}
|
||||
178
internal/server/service/waiter.go
Normal file
178
internal/server/service/waiter.go
Normal 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()
|
||||
}
|
||||
138
internal/server/storage/game.go
Normal file
138
internal/server/storage/game.go
Normal 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
|
||||
}
|
||||
86
internal/server/storage/schema.go
Normal file
86
internal/server/storage/schema.go
Normal 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);
|
||||
`
|
||||
186
internal/server/storage/storage.go
Normal file
186
internal/server/storage/storage.go
Normal 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
|
||||
}
|
||||
187
internal/server/storage/user.go
Normal file
187
internal/server/storage/user.go
Normal 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
|
||||
}
|
||||
}
|
||||
88
internal/server/webserver/server.go
Normal file
88
internal/server/webserver/server.go
Normal 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)
|
||||
}
|
||||
750
internal/server/webserver/web/app.js
Normal file
750
internal/server/webserver/web/app.js
Normal 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);
|
||||
}
|
||||
100
internal/server/webserver/web/index.html
Normal file
100
internal/server/webserver/web/index.html
Normal 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>
|
||||
800
internal/server/webserver/web/style.css
Normal file
800
internal/server/webserver/web/style.css
Normal 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;
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user