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

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

View File

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

View File

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

View File

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

View File

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