package command import ( "bufio" "fmt" "os" "strconv" "strings" "github.com/lixenwraith/chess/internal/client/api" "github.com/lixenwraith/chess/internal/client/display" "github.com/lixenwraith/chess/internal/client/session" ) func (r *Registry) registerGameCommands() { r.Register(&Command{ Name: "new", ShortName: "n", Description: "Create a new game", Usage: "new", Handler: newGameHandler, }) r.Register(&Command{ Name: "join", ShortName: "j", Description: "Join/set current game ID", Usage: "join ", Handler: joinGameHandler, }) r.Register(&Command{ Name: "move", ShortName: "m", Description: "Make a move", Usage: "move (e2e4; castling e1g1; promotion e7e8q, e7e8n)", Handler: moveHandler, }) r.Register(&Command{ Name: "computer", ShortName: "c", Description: "Trigger computer move", Usage: "computer", Handler: computerMoveHandler, }) r.Register(&Command{ Name: "undo", ShortName: "u", Description: "Undo moves", Usage: "undo [count]", Handler: undoHandler, }) r.Register(&Command{ Name: "show", ShortName: "h", Description: "Show board and game state", Usage: "show", Handler: showBoardHandler, Paged: true, }) r.Register(&Command{ Name: "state", ShortName: "s", Description: "Show raw game JSON", Usage: "state", Handler: gameStateHandler, Paged: true, }) r.Register(&Command{ Name: "delete", ShortName: "d", Description: "Unload a live game (history retained)", Usage: "delete [gameId]", Handler: deleteGameHandler, }) r.Register(&Command{ Name: "resign", ShortName: "", Description: "Resign the current game", Usage: "resign [w|b] (side optional when you play one human side)", Handler: resignHandler, }) r.Register(&Command{ Name: "draw", ShortName: "", Description: "Offer, accept, or decline a draw", Usage: "draw [offer|accept|decline] [w|b] (default: offer)", Handler: drawHandler, }) r.Register(&Command{ Name: "games", ShortName: "g", Description: "List your stored games, newest first (login required)", Usage: "games [more]", Handler: gamesHandler, Paged: true, }) r.Register(&Command{ Name: "pgn", ShortName: "", Description: "Print or save a stored game as PGN", Usage: "pgn [gameId] [ply] [save ] (default: current or replayed game)", Handler: pgnHandler, Paged: true, }) r.Register(&Command{ Name: "poll", ShortName: "p", Description: "Long-poll for game updates", Usage: "poll", Handler: pollHandler, }) } func newGameHandler(s *session.Session, args []string) error { scanner := bufio.NewScanner(os.Stdin) c := s.Client display.Println(display.Cyan, "\nCreating new game...") // White player display.Print(display.Yellow, "White player type (h/c) [h]: ") scanner.Scan() whiteType := strings.ToLower(strings.TrimSpace(scanner.Text())) if whiteType == "" { whiteType = "h" } white := api.PlayerConfig{Type: 1} if whiteType == "c" { white.Type = 2 display.Print(display.Yellow, "Computer level (0-20) [10]: ") scanner.Scan() levelStr := strings.TrimSpace(scanner.Text()) if levelStr == "" { white.Level = 10 } else { level, _ := strconv.Atoi(levelStr) white.Level = level } display.Print(display.Yellow, "Search time (100-10000ms) [1000]: ") scanner.Scan() timeStr := strings.TrimSpace(scanner.Text()) if timeStr == "" { white.SearchTime = 1000 } else { searchTime, _ := strconv.Atoi(timeStr) white.SearchTime = searchTime } } // Black player (same pattern) display.Print(display.Yellow, "Black player type (h/c) [h]: ") scanner.Scan() blackType := strings.ToLower(strings.TrimSpace(scanner.Text())) if blackType == "" { blackType = "h" } black := api.PlayerConfig{Type: 1} if blackType == "c" { black.Type = 2 display.Print(display.Yellow, "Computer level (0-20) [10]: ") scanner.Scan() levelStr := strings.TrimSpace(scanner.Text()) if levelStr == "" { black.Level = 10 } else { level, _ := strconv.Atoi(levelStr) black.Level = level } display.Print(display.Yellow, "Search time (100-10000ms) [1000]: ") scanner.Scan() timeStr := strings.TrimSpace(scanner.Text()) if timeStr == "" { black.SearchTime = 1000 } else { searchTime, _ := strconv.Atoi(timeStr) black.SearchTime = searchTime } } // Starting position display.Print(display.Yellow, "Starting position (FEN) [default]: ") scanner.Scan() fen := strings.TrimSpace(scanner.Text()) req := &api.CreateGameRequest{ White: white, Black: black, FEN: fen, } resp, err := c.CreateGame(req) if err != nil { return err } s.CurrentGame = resp.GameID s.LastMoveCount = len(resp.Moves) s.CurrentGameState = resp // Determine player color if authenticated if s.CurrentUser != "" { if resp.Players.White.ID == s.CurrentUser { s.PlayerColor = "w" } else if resp.Players.Black.ID == s.CurrentUser { s.PlayerColor = "b" } } display.Println(display.Green, "Game created: %s", resp.GameID) display.Println(display.Cyan, "Current game set to: %s", resp.GameID) // If white is computer, inform user to trigger move if white.Type == 2 { display.Println(display.Magenta, "\nWhite is computer. Use 'computer' or 'c' to trigger first move.") } return nil } func joinGameHandler(s *session.Session, args []string) error { if len(args) < 1 { return fmt.Errorf("usage: join ") } gameID := args[0] c := s.GetClient().(*api.Client) // Verify game exists resp, err := c.GetGame(gameID) if err != nil { return err } s.SetCurrentGame(gameID) s.SetLastMoveCount(len(resp.Moves)) s.SetGameState(resp) // Determine player color if authenticated if s.GetCurrentUser() != "" { if resp.Players.White.ID == s.GetCurrentUser() { s.SetPlayerColor("w") } else if resp.Players.Black.ID == s.GetCurrentUser() { s.SetPlayerColor("b") } else { s.SetPlayerColor("") } } display.Printf("%sJoined game: %s%s\n", display.Green, gameID, display.Reset) display.Printf("Turn: %s | State: %s | Moves: %d\n", resp.Turn, resp.State, len(resp.Moves)) return nil } // moveHandler submits a human move. Terminal/error outcomes are reported via // the shared printOutcome (no-op on "ongoing"/"pending"); the computer-turn // hint stays local since it only applies after a successful human move. func moveHandler(s *session.Session, args []string) error { if len(args) < 1 { return fmt.Errorf("usage: move (promotion appends q, r, b, or n: e7e8q)") } gameID := s.CurrentGame if gameID == "" { return fmt.Errorf("no current game, use 'new' or 'join '") } move := args[0] c := s.Client resp, err := c.MakeMove(gameID, move) if err != nil { return err } s.LastMoveCount = len(resp.Moves) s.CurrentGameState = resp display.Println(display.Green, "Move accepted") printOutcome(resp) // Hint to trigger the computer if the game continues on a computer's turn if resp.State == "ongoing" { isComputerTurn := (resp.Turn == "w" && resp.Players.White.Type == 2) || (resp.Turn == "b" && resp.Players.Black.Type == 2) if isComputerTurn { display.Println(display.Magenta, "\nComputer's turn. Use 'computer' or 'c' to trigger move.") } } return nil } // computerMoveHandler triggers a computer move and waits for the result via // the server's long-poll. With the state-aware waiter, a single poll wakes on // either the applied move (move count delta) or a state-only settle // (mate-without-move, stuck) — no fixed-interval GET hammering, no hard cap // below the server's max searchTime. Polls loop only if the wake races the // pending window (e.g. queue wait), each round costing at most WaitTimeout. func computerMoveHandler(s *session.Session, args []string) error { gameID := s.CurrentGame if gameID == "" { return fmt.Errorf("no current game, use 'new' or 'join '") } c := s.Client // Baseline BEFORE triggering: the long-poll returns immediately if the // move count already differs from this value. baselineMoves := s.LastMoveCount if s.CurrentGameState != nil { baselineMoves = len(s.CurrentGameState.Moves) } resp, err := c.MakeMove(gameID, "cccc") if err != nil { return err } if resp.State != "pending" { // Server resolved synchronously (shouldn't normally happen) s.LastMoveCount = len(resp.Moves) s.CurrentGameState = resp display.Println(display.Green, "Move triggered") printOutcome(resp) return nil } display.Println(display.Magenta, "Computer is thinking...") // Up to 3 long-poll rounds (~90s ceiling) covers max searchTime (10s) // plus pathological queue wait, without hanging indefinitely. const maxPolls = 3 var final *api.GameResponse for i := 0; i < maxPolls; i++ { polled, err := c.GetGameWithPoll(gameID, baselineMoves) if err != nil { return err } if polled.State != "pending" { final = polled break } // Woke on timeout while still pending; poll again. } if final == nil { return fmt.Errorf("computer move still pending after %d poll rounds", maxPolls) } s.LastMoveCount = len(final.Moves) s.CurrentGameState = final // A move may legitimately be absent: mate-without-move detection or a // stuck transition settle the state without applying anything. if final.LastMove != nil && len(final.Moves) > baselineMoves { display.Print(display.Magenta, "Computer played: %s", final.LastMove.Move) if final.LastMove.Depth > 0 { display.Printf(" (depth %d, score %d)", final.LastMove.Depth, final.LastMove.Score) } display.Line() } printOutcome(final) return nil } // printOutcome reports terminal or error states using the server's State // ("white wins", "draw", ...) and Termination ("checkmate", "resignation", // "threefold_repetition", ...) values, and a pending draw offer. func printOutcome(resp *api.GameResponse) { switch resp.State { case "white wins", "black wins": winner := "White" if resp.State == "black wins" { winner = "Black" } if resp.Termination == "resignation" { display.Println(display.Green, "\n%s wins by resignation.", winner) } else { display.Println(display.Green, "\nCHECKMATE! %s wins!", winner) } case "stalemate": display.Println(display.Yellow, "\nSTALEMATE! Game drawn.") case "draw": display.Println(display.Yellow, "\nDRAW %s.", drawReason(resp.Termination)) case "stuck": display.Println(display.Yellow, "\nEngine error — 'undo' to recover, or 'new'/'delete'.") } if resp.DrawOffer != "" { offerer := "White" if resp.DrawOffer == "b" { offerer = "Black" } display.Println(display.Magenta, "%s offers a draw: 'draw accept' or 'draw decline'.", offerer) } } // printConcession notes a resignation or agreed draw that play continued // past; the server keeps it on record. func printConcession(resp *api.GameResponse) { c := resp.Concession if c == nil || (c.Result == resp.State && c.Termination == resp.Termination) { return } event := "A draw was agreed" switch c.Result { case "white wins": event = "Black resigned" case "black wins": event = "White resigned" } display.Println(display.Yellow, "%s at ply %d; it stays on record while play continues.", event, c.Ply) } func drawReason(termination string) string { switch termination { case "insufficient_material": return "by insufficient material" case "threefold_repetition": return "by threefold repetition" case "fifty_move_rule": return "by the fifty-move rule" case "agreement": return "by agreement" } return "" } // resignHandler resigns the current game for the given side, or the side the // server infers (the one human side, or the one the user claimed). func resignHandler(s *session.Session, args []string) error { gameID := s.CurrentGame if gameID == "" { return fmt.Errorf("no current game, use 'new' or 'join '") } color := "" if len(args) > 0 { color = args[0] } resp, err := s.Client.Resign(gameID, color) if err != nil { return err } s.LastMoveCount = len(resp.Moves) s.CurrentGameState = resp printOutcome(resp) return nil } // drawHandler offers (default), accepts, or declines a draw. func drawHandler(s *session.Session, args []string) error { gameID := s.CurrentGame if gameID == "" { return fmt.Errorf("no current game, use 'new' or 'join '") } action, color := "offer", "" for _, arg := range args { switch arg { case "offer", "accept", "decline": action = arg default: color = arg } } resp, err := s.Client.Draw(gameID, action, color) if err != nil { return err } s.LastMoveCount = len(resp.Moves) s.CurrentGameState = resp switch resp.DrawOutcome { case "offered": display.Println(display.Green, "Draw offered; it stands until answered or the opponent moves.") case "declined": display.Println(display.Yellow, "Draw declined.") } printOutcome(resp) return nil } func undoHandler(s *session.Session, args []string) error { gameID := s.GetCurrentGame() if gameID == "" { return fmt.Errorf("no current game, use 'new' or 'join '") } count := 1 if len(args) > 0 { var err error count, err = strconv.Atoi(args[0]) if err != nil { return fmt.Errorf("invalid count: %s", args[0]) } } c := s.GetClient().(*api.Client) resp, err := c.UndoMoves(gameID, count) if err != nil { return err } s.SetLastMoveCount(len(resp.Moves)) s.SetGameState(resp) display.Println(display.Green, "Undid %d move(s)", count) printConcession(resp) return nil } func showBoardHandler(s *session.Session, args []string) error { if s.Replay != nil { return showReplayPosition(s) } gameID := s.GetCurrentGame() if gameID == "" { return fmt.Errorf("no current game, use 'new' or 'join '") } c := s.GetClient().(*api.Client) // Get full game state game, err := c.GetGame(gameID) if err != nil { return err } // Get ASCII board board, err := c.GetBoard(gameID) if err != nil { return err } s.SetLastMoveCount(len(game.Moves)) s.SetGameState(game) // Display board with colors display.Line() display.RenderBoard(board.Board) // Display game info display.Printf("\nFEN: %s\n", game.FEN) display.Printf("Turn: %s | State: %s | Moves: %d\n", display.ColorForTurn(game.Turn), game.State, len(game.Moves)) // Display move history if len(game.Moves) > 0 { display.Printf("\nHistory: ") for i, move := range game.Moves { if i > 0 { display.Write(" ") } if i%2 == 0 { display.Printf("%d.%s", (i/2)+1, move) } else { display.Printf(" %s", move) } } display.Line() } // Display last move info if game.LastMove != nil { color := "White" if game.LastMove.PlayerColor == "b" { color = "Black" } display.Printf("Last move: %s by %s", game.LastMove.Move, color) if game.LastMove.Depth > 0 { display.Printf(" (depth %d, score %d)", game.LastMove.Depth, game.LastMove.Score) } display.Line() } printOutcome(game) printConcession(game) return nil } func gameStateHandler(s *session.Session, args []string) error { gameID := s.GetCurrentGame() if gameID == "" { return fmt.Errorf("no current game, use 'new' or 'join '") } c := s.GetClient().(*api.Client) resp, err := c.GetGame(gameID) if err != nil { return err } s.SetLastMoveCount(len(resp.Moves)) // Pretty print JSON display.Println(display.Cyan, "Game State:") display.PrettyPrintJSON(resp) return nil } func deleteGameHandler(s *session.Session, args []string) error { gameID := s.GetCurrentGame() if len(args) > 0 { gameID = args[0] } if gameID == "" { return fmt.Errorf("specify game ID or set current game") } c := s.GetClient().(*api.Client) err := c.DeleteGame(gameID) if err != nil { return err } if gameID == s.GetCurrentGame() { s.SetCurrentGame("") s.SetLastMoveCount(0) } display.Printf("%sLive game unloaded (history retained): %s%s\n", display.Green, gameID, display.Reset) return nil } func pollHandler(s *session.Session, args []string) error { gameID := s.GetCurrentGame() if gameID == "" { return fmt.Errorf("no current game, use 'new' or 'join '") } c := s.GetClient().(*api.Client) moveCount := s.GetLastMoveCount() previous := s.CurrentGameState display.Println(display.Cyan, "Long-polling for updates (move count: %d)...", moveCount) display.Println(display.Cyan, "This may take up to 30 seconds") // The server answers at once for a finished game, and wakes a poll for a // move, an undo, a result, or a draw offer. resp, err := c.GetGameWithPoll(gameID, moveCount) if err != nil { return err } s.SetLastMoveCount(len(resp.Moves)) s.SetGameState(resp) switch { case len(resp.Moves) > moveCount: display.Println(display.Green, "Game updated! New moves detected") if resp.LastMove != nil { display.Printf("Last move: %s\n", resp.LastMove.Move) } case len(resp.Moves) < moveCount: display.Println(display.Green, "Game updated: %d move(s) taken back", moveCount-len(resp.Moves)) case previous == nil || previous.GameID != resp.GameID || previous.State != resp.State || previous.DrawOffer != resp.DrawOffer: display.Println(display.Green, "Game updated") default: display.Println(display.Yellow, "No updates (timeout)") } printOutcome(resp) return nil } // gamesHandler lists the stored games of the signed-in user, 20 at a time; // "games more" continues from the previous page. func gamesHandler(s *session.Session, args []string) error { if s.AuthToken == "" { return fmt.Errorf("login required") } cursor := "" if len(args) > 0 && args[0] == "more" { if s.GamesCursor == "" { return fmt.Errorf("no further games; run 'games' to list from the newest") } cursor = s.GamesCursor } resp, err := s.Client.GetMyGames(20, cursor) if err != nil { return err } s.GamesCursor = resp.NextCursor s.GamesPage = resp.Games if len(resp.Games) == 0 { display.Println(display.Yellow, "No stored games") return nil } display.Printf("\n%3s %-36s %-20s %-20s %-7s %5s %s\n", "#", "Game ID", "White", "Black", "Result", "Plies", "Started (UTC)") for i, g := range resp.Games { display.Printf("%3d %-36s %-20s %-20s %-7s %5d %s\n", i+1, g.GameID, playerLabel(g.Players.White), playerLabel(g.Players.Black), g.PGNResult, g.MoveCount, g.StartTimeUTC.UTC().Format("2006-01-02 15:04")) } display.Println(display.Cyan, "\nReplay one: replay <#> or replay ") if resp.NextCursor != "" { display.Println(display.Cyan, "More games: games more") } return nil } func playerLabel(p api.PlayerInfo) string { switch { case p.Type == 2: return fmt.Sprintf("Stockfish L%d", p.Level) case p.Name != "": return p.Name } return "anonymous" } // pgnHandler prints the stored game as PGN: the current game unless an ID is // given, and every ply unless a count is given. During a replay the default // is the replayed game up to the current ply. "save " writes the // export to a file instead of printing it. func pgnHandler(s *session.Session, args []string) error { path, args, err := pgnSaveArgs(args) if err != nil { return err } gameID, ply := s.CurrentGame, -1 if s.Replay != nil { gameID = s.Replay.History.GameID if !s.Replay.AtEnd() { ply = s.Replay.Ply } } for _, arg := range args { if n, err := strconv.Atoi(arg); err == nil && n >= 0 { ply = n } else { gameID = arg } } if gameID == "" { return fmt.Errorf("no current game, use 'join ' or 'pgn '") } text, err := s.Client.GetGamePGN(gameID, ply) if err != nil { return err } if path != "" { if err := savePGN(path, trimPGN(text)); err != nil { return err } display.Println(display.Green, "Saved PGN of %s to %s", describePGNTarget(gameID, ply), path) return nil } display.Line() display.Write(text) return nil }