v0.13.0 web and terminal client enhance, replay api, linux server deploy, doc and dependency update
test / test (push) Canceled after 0s
test / test (push) Canceled after 0s
This commit is contained in:
1 parent
52ac0fe31a
commit
f1ac4cbbe7
71 files changed
+7677
-537
No files matched your search
@@ -6,6 +6,7 @@ import (
|
||||
"fmt"
|
||||
"io"
|
||||
"net/http"
|
||||
"net/url"
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
@@ -140,7 +141,11 @@ func (c *Client) doRequest(method, path string, body any, result any) error {
|
||||
return fmt.Errorf("request failed with status %d", resp.StatusCode)
|
||||
}
|
||||
|
||||
// Parse success response
|
||||
// Parse success response; a *string receives a non-JSON body as-is
|
||||
if text, ok := result.(*string); ok {
|
||||
*text = string(respBody)
|
||||
return nil
|
||||
}
|
||||
if result != nil && len(respBody) > 0 {
|
||||
if err := json.Unmarshal(respBody, result); err != nil {
|
||||
// For debug, show raw response if parsing fails
|
||||
@@ -198,6 +203,22 @@ func (c *Client) UndoMoves(gameID string, count int) (*GameResponse, error) {
|
||||
return &resp, err
|
||||
}
|
||||
|
||||
// Resign ends the game in the opponent's favor; color may be "" when the
|
||||
// caller plays exactly one human side.
|
||||
func (c *Client) Resign(gameID, color string) (*GameResponse, error) {
|
||||
var resp GameResponse
|
||||
err := c.doRequest("POST", "/api/games/"+gameID+"/resign", &ResignRequest{Color: color}, &resp)
|
||||
return &resp, err
|
||||
}
|
||||
|
||||
// Draw offers, accepts, or declines a draw; the response's DrawOutcome says
|
||||
// what happened.
|
||||
func (c *Client) Draw(gameID, action, color string) (*GameResponse, error) {
|
||||
var resp GameResponse
|
||||
err := c.doRequest("POST", "/api/games/"+gameID+"/draw", &DrawRequest{Action: action, Color: color}, &resp)
|
||||
return &resp, err
|
||||
}
|
||||
|
||||
func (c *Client) GetBoard(gameID string) (*BoardResponse, error) {
|
||||
var resp BoardResponse
|
||||
err := c.doRequest("GET", "/api/games/"+gameID+"/board", nil, &resp)
|
||||
@@ -210,9 +231,25 @@ func (c *Client) GetGameHistory(gameID string) (*GameHistoryResponse, error) {
|
||||
return &resp, err
|
||||
}
|
||||
|
||||
func (c *Client) GetMyGames(limit, offset int) (*GameListResponse, error) {
|
||||
// GetGamePGN returns the stored game in PGN; ply < 0 exports every move.
|
||||
func (c *Client) GetGamePGN(gameID string, ply int) (string, error) {
|
||||
path := "/api/games/" + gameID + "/pgn"
|
||||
if ply >= 0 {
|
||||
path += fmt.Sprintf("?ply=%d", ply)
|
||||
}
|
||||
var text string
|
||||
err := c.doRequest("GET", path, nil, &text)
|
||||
return text, err
|
||||
}
|
||||
|
||||
// GetMyGames lists the caller's games, newest first; cursor is "" for the
|
||||
// first page, then the previous page's NextCursor.
|
||||
func (c *Client) GetMyGames(limit int, cursor string) (*GameListResponse, error) {
|
||||
var resp GameListResponse
|
||||
path := fmt.Sprintf("/api/users/me/games?limit=%d&offset=%d", limit, offset)
|
||||
path := fmt.Sprintf("/api/users/me/games?limit=%d", limit)
|
||||
if cursor != "" {
|
||||
path += "&cursor=" + url.QueryEscape(cursor)
|
||||
}
|
||||
err := c.doRequest("GET", path, nil, &resp)
|
||||
return &resp, err
|
||||
}
|
||||
|
||||
@@ -36,13 +36,25 @@ type LoginRequest struct {
|
||||
|
||||
// Response types
|
||||
type GameResponse struct {
|
||||
GameID string `json:"gameId"`
|
||||
FEN string `json:"fen"`
|
||||
Turn string `json:"turn"`
|
||||
State string `json:"state"`
|
||||
Moves []string `json:"moves"`
|
||||
Players PlayersResponse `json:"players"`
|
||||
LastMove *MoveInfo `json:"lastMove,omitempty"`
|
||||
GameID string `json:"gameId"`
|
||||
FEN string `json:"fen"`
|
||||
Turn string `json:"turn"`
|
||||
State string `json:"state"`
|
||||
Termination string `json:"termination,omitempty"`
|
||||
DrawOffer string `json:"drawOffer,omitempty"`
|
||||
DrawOutcome string `json:"drawOutcome,omitempty"`
|
||||
Moves []string `json:"moves"`
|
||||
Players PlayersResponse `json:"players"`
|
||||
LastMove *MoveInfo `json:"lastMove,omitempty"`
|
||||
}
|
||||
|
||||
type ResignRequest struct {
|
||||
Color string `json:"color,omitempty"`
|
||||
}
|
||||
|
||||
type DrawRequest struct {
|
||||
Action string `json:"action"`
|
||||
Color string `json:"color,omitempty"`
|
||||
}
|
||||
|
||||
type PlayersResponse struct {
|
||||
@@ -104,6 +116,8 @@ type GameHistoryResponse struct {
|
||||
GameID string `json:"gameId"`
|
||||
InitialFEN string `json:"initialFen"`
|
||||
Result string `json:"result,omitempty"`
|
||||
PGNResult string `json:"pgnResult"`
|
||||
Termination string `json:"termination,omitempty"`
|
||||
StartTimeUTC time.Time `json:"startTimeUtc"`
|
||||
EndTimeUTC *time.Time `json:"endTimeUtc,omitempty"`
|
||||
Players PlayersResponse `json:"players"`
|
||||
@@ -113,6 +127,7 @@ type GameHistoryResponse struct {
|
||||
type HistoryMove struct {
|
||||
MoveNumber int `json:"moveNumber"`
|
||||
MoveUCI string `json:"moveUci"`
|
||||
SAN string `json:"san,omitempty"`
|
||||
FENAfterMove string `json:"fenAfterMove"`
|
||||
PlayerColor string `json:"playerColor"`
|
||||
MoveTimeUTC time.Time `json:"moveTimeUtc"`
|
||||
@@ -123,6 +138,7 @@ type GameSummary struct {
|
||||
InitialFEN string `json:"initialFen"`
|
||||
FinalFEN string `json:"finalFen"`
|
||||
Result string `json:"result,omitempty"`
|
||||
PGNResult string `json:"pgnResult"`
|
||||
StartTimeUTC time.Time `json:"startTimeUtc"`
|
||||
EndTimeUTC *time.Time `json:"endTimeUtc,omitempty"`
|
||||
MoveCount int `json:"moveCount"`
|
||||
@@ -134,4 +150,5 @@ type GameListResponse struct {
|
||||
Limit int `json:"limit"`
|
||||
Offset int `json:"offset"`
|
||||
NextOffset *int `json:"nextOffset,omitempty"`
|
||||
NextCursor string `json:"nextCursor,omitempty"`
|
||||
}
|
||||
@@ -33,7 +33,7 @@ func (r *Registry) registerGameCommands() {
|
||||
Name: "move",
|
||||
ShortName: "m",
|
||||
Description: "Make a move",
|
||||
Usage: "move <uci-move>",
|
||||
Usage: "move <uci-move> (e2e4; castling e1g1; promotion e7e8q, e7e8n)",
|
||||
Handler: moveHandler,
|
||||
})
|
||||
|
||||
@@ -77,6 +77,38 @@ func (r *Registry) registerGameCommands() {
|
||||
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,
|
||||
})
|
||||
|
||||
r.Register(&Command{
|
||||
Name: "pgn",
|
||||
ShortName: "",
|
||||
Description: "Print a stored game as PGN",
|
||||
Usage: "pgn [gameId] [ply] (default: current game, all plies)",
|
||||
Handler: pgnHandler,
|
||||
})
|
||||
|
||||
r.Register(&Command{
|
||||
Name: "poll",
|
||||
ShortName: "p",
|
||||
@@ -238,7 +270,7 @@ func joinGameHandler(s *session.Session, args []string) error {
|
||||
// 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 <uci-move>")
|
||||
return fmt.Errorf("usage: move <uci-move> (promotion appends q, r, b, or n: e7e8q)")
|
||||
}
|
||||
|
||||
gameID := s.CurrentGame
|
||||
@@ -345,21 +377,101 @@ func computerMoveHandler(s *session.Session, args []string) error {
|
||||
return nil
|
||||
}
|
||||
|
||||
// printOutcome reports terminal or error states using the server's actual
|
||||
// State.String() values ("white wins"/"black wins", not "checkmate").
|
||||
// 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":
|
||||
display.Println(display.Green, "\nCHECKMATE! White wins!")
|
||||
case "black wins":
|
||||
display.Println(display.Green, "\nCHECKMATE! Black wins!")
|
||||
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! Game drawn.")
|
||||
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)
|
||||
}
|
||||
}
|
||||
|
||||
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 <gameId>'")
|
||||
}
|
||||
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 <gameId>'")
|
||||
}
|
||||
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 {
|
||||
@@ -530,3 +642,72 @@ func pollHandler(s *session.Session, args []string) error {
|
||||
|
||||
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
|
||||
if len(resp.Games) == 0 {
|
||||
display.Println(display.Yellow, "No stored games")
|
||||
return nil
|
||||
}
|
||||
|
||||
fmt.Printf("\n%-36s %-20s %-20s %-7s %5s %s\n", "Game ID", "White", "Black", "Result", "Plies", "Started (UTC)")
|
||||
for _, g := range resp.Games {
|
||||
fmt.Printf("%-36s %-20s %-20s %-7s %5d %s\n", g.GameID,
|
||||
playerLabel(g.Players.White), playerLabel(g.Players.Black), g.PGNResult, g.MoveCount,
|
||||
g.StartTimeUTC.UTC().Format("2006-01-02 15:04"))
|
||||
}
|
||||
if resp.NextCursor != "" {
|
||||
display.Println(display.Cyan, "\nMore 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.
|
||||
func pgnHandler(s *session.Session, args []string) error {
|
||||
gameID, ply := s.CurrentGame, -1
|
||||
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 <gameId>' or 'pgn <gameId>'")
|
||||
}
|
||||
text, err := s.Client.GetGamePGN(gameID, ply)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
fmt.Println()
|
||||
fmt.Print(text)
|
||||
return nil
|
||||
}
|
||||
@@ -126,6 +126,10 @@ func (r *Registry) helpHandler(s *session.Session, args []string) error {
|
||||
{"state", "s", ""},
|
||||
{"delete", "d", ""},
|
||||
{"poll", "p", ""},
|
||||
{"resign", "", ""},
|
||||
{"draw", "", ""},
|
||||
{"games", "g", ""},
|
||||
{"pgn", "", ""},
|
||||
}
|
||||
|
||||
authCommands := []cmdInfo{
|
||||
|
||||
@@ -12,6 +12,7 @@ type Session struct {
|
||||
AuthToken string
|
||||
Username string
|
||||
LastMoveCount int
|
||||
GamesCursor string // next page of the "games" listing
|
||||
Client *api.Client
|
||||
Verbose bool
|
||||
// Game state for prompt
|
||||
|
||||
Reference in new issue
Block a user