v0.13.1 concession management and record, clients update, version report
test / test (push) Canceled after 0s
test / test (push) Canceled after 0s
This commit is contained in:
1 parent
f1ac4cbbe7
commit
6e8d80c788
30 files changed
+704
-81
No files matched your search
@@ -41,7 +41,8 @@ type UndoRequest struct {
|
||||
// GameResponse is the live game. Termination names how a finished game ended;
|
||||
// DrawOffer is the color whose draw offer awaits an answer; DrawOutcome
|
||||
// reports what a draw request did ("offered", "accepted", "declined") and is
|
||||
// set only on responses to that request.
|
||||
// set only on responses to that request. Concession is the game's first
|
||||
// resignation or agreed draw, kept after an undo continues play.
|
||||
type GameResponse struct {
|
||||
GameID string `json:"gameId"`
|
||||
FEN string `json:"fen"`
|
||||
@@ -50,11 +51,22 @@ type GameResponse struct {
|
||||
Termination string `json:"termination,omitempty"`
|
||||
DrawOffer string `json:"drawOffer,omitempty"`
|
||||
DrawOutcome string `json:"drawOutcome,omitempty"`
|
||||
Concession *Concession `json:"concession,omitempty"`
|
||||
Moves []string `json:"moves"`
|
||||
Players PlayersResponse `json:"players"`
|
||||
LastMove *MoveInfo `json:"lastMove,omitempty"`
|
||||
}
|
||||
|
||||
// Concession records a resignation or agreed draw. Result uses the enclosing
|
||||
// response's vocabulary: a state ("black wins") in GameResponse, a stored
|
||||
// result ("black_wins") in GameHistoryResponse. Ply is the number of moves
|
||||
// played when it was made.
|
||||
type Concession struct {
|
||||
Result string `json:"result"`
|
||||
Termination string `json:"termination"`
|
||||
Ply int `json:"ply"`
|
||||
}
|
||||
|
||||
type MoveInfo struct {
|
||||
Move string `json:"move"`
|
||||
PlayerColor string `json:"playerColor"` // "w" or "b"
|
||||
@@ -73,7 +85,8 @@ type BoardResponse struct {
|
||||
//
|
||||
// PGNResult is the PGN result token ("1-0", "0-1", "1/2-1/2", or "*") and
|
||||
// Termination names how a result was reached (see core.Termination; omitted
|
||||
// while the game is unfinished).
|
||||
// while the game is unfinished). Concession is the game's first resignation
|
||||
// or agreed draw, kept when an undo continued play past it.
|
||||
type GameHistoryResponse struct {
|
||||
GameID string `json:"gameId"`
|
||||
InitialFEN string `json:"initialFen"`
|
||||
@@ -82,6 +95,7 @@ type GameHistoryResponse struct {
|
||||
Termination string `json:"termination,omitempty"`
|
||||
StartTimeUTC time.Time `json:"startTimeUtc"`
|
||||
EndTimeUTC *time.Time `json:"endTimeUtc,omitempty"`
|
||||
Concession *Concession `json:"concession,omitempty"`
|
||||
Players PlayersResponse `json:"players"`
|
||||
Moves []HistoryMove `json:"moves"`
|
||||
}
|
||||
|
||||
@@ -15,4 +15,5 @@ const (
|
||||
ErrUnauthorized = "UNAUTHORIZED"
|
||||
ErrConflict = "GAME_CONFLICT"
|
||||
ErrStorageUnavailable = "STORAGE_UNAVAILABLE"
|
||||
ErrRouteNotFound = "NOT_FOUND"
|
||||
)
|
||||
@@ -91,6 +91,13 @@ func (t Termination) ValidFor(s State) bool {
|
||||
}
|
||||
}
|
||||
|
||||
// IsConcession reports whether the players chose the result: a resignation or
|
||||
// a draw by agreement. A concession is final when both sides are human; against
|
||||
// the computer play may continue, and the first concession stays on record.
|
||||
func (t Termination) IsConcession() bool {
|
||||
return t == TermResignation || t == TermAgreement
|
||||
}
|
||||
|
||||
// ParseColor accepts "w", "b", "white", or "black"; anything else is zero.
|
||||
func ParseColor(s string) Color {
|
||||
switch s {
|
||||
|
||||
@@ -36,6 +36,15 @@ type Game struct {
|
||||
drawOffer core.Color
|
||||
// offerPly[c] is the ply count when color c last offered a draw, or -1.
|
||||
offerPly map[core.Color]int
|
||||
// concession is the first resignation or agreed draw; an undo keeps it.
|
||||
concession *Concession
|
||||
}
|
||||
|
||||
// Concession is a result the players chose, and the ply it was chosen at.
|
||||
type Concession struct {
|
||||
State core.State
|
||||
Termination core.Termination
|
||||
Ply int
|
||||
}
|
||||
|
||||
// View is an immutable copy of the state needed by processors and transports.
|
||||
@@ -54,6 +63,7 @@ type View struct {
|
||||
Termination core.Termination
|
||||
DrawOffer core.Color // color whose offer awaits an answer; 0 when none
|
||||
OfferPly map[core.Color]int
|
||||
Concession *Concession
|
||||
LastResult *MoveResult
|
||||
EndTimeUTC *time.Time
|
||||
}
|
||||
@@ -125,9 +135,28 @@ func (g *Game) View() View {
|
||||
copy := *g.endTimeUTC
|
||||
view.EndTimeUTC = ©
|
||||
}
|
||||
if g.concession != nil {
|
||||
copy := *g.concession
|
||||
view.Concession = ©
|
||||
}
|
||||
return view
|
||||
}
|
||||
|
||||
// BothHuman reports whether neither side is played by the computer.
|
||||
func (v View) BothHuman() bool {
|
||||
return bothHuman(v.WhitePlayer, v.BlackPlayer)
|
||||
}
|
||||
|
||||
// BothHuman reports whether neither side is played by the computer.
|
||||
func (g *Game) BothHuman() bool {
|
||||
return bothHuman(g.players[core.ColorWhite], g.players[core.ColorBlack])
|
||||
}
|
||||
|
||||
func bothHuman(white, black *core.Player) bool {
|
||||
return white != nil && white.Type == core.PlayerHuman &&
|
||||
black != nil && black.Type == core.PlayerHuman
|
||||
}
|
||||
|
||||
func (v View) NextPlayer() *core.Player {
|
||||
if v.NextTurnColor == core.ColorWhite {
|
||||
return v.WhitePlayer
|
||||
@@ -268,6 +297,11 @@ func (g *Game) ClearDrawOffers() {
|
||||
g.offerPly[core.ColorBlack] = -1
|
||||
}
|
||||
|
||||
// Concession returns the game's first resignation or agreed draw, or nil.
|
||||
func (g *Game) Concession() *Concession {
|
||||
return g.concession
|
||||
}
|
||||
|
||||
// Termination returns how a finished game ended.
|
||||
func (g *Game) Termination() core.Termination {
|
||||
return g.termination
|
||||
@@ -288,12 +322,15 @@ func (g *Game) State() core.State {
|
||||
}
|
||||
|
||||
// SetOutcome sets the state and how it was reached; a non-terminal state
|
||||
// clears the termination.
|
||||
// clears the termination. The first concession is remembered.
|
||||
func (g *Game) SetOutcome(s core.State, termination core.Termination, at time.Time) {
|
||||
g.SetStateAt(s, at)
|
||||
if s.IsTerminal() {
|
||||
g.termination = termination
|
||||
g.drawOffer = 0
|
||||
if termination.IsConcession() && g.concession == nil {
|
||||
g.concession = &Concession{State: s, Termination: termination, Ply: g.Plies()}
|
||||
}
|
||||
} else {
|
||||
g.termination = core.TermNone
|
||||
}
|
||||
|
||||
@@ -24,12 +24,13 @@ const rateLimitRate = 10 // req/sec
|
||||
|
||||
// HTTPHandler handles HTTP requests and routes them to the processor
|
||||
type HTTPHandler struct {
|
||||
proc *processor.Processor
|
||||
svc *service.Service
|
||||
proc *processor.Processor
|
||||
svc *service.Service
|
||||
version string
|
||||
}
|
||||
|
||||
func NewHTTPHandler(proc *processor.Processor, svc *service.Service) *HTTPHandler {
|
||||
return &HTTPHandler{proc: proc, svc: svc}
|
||||
func NewHTTPHandler(proc *processor.Processor, svc *service.Service, version string) *HTTPHandler {
|
||||
return &HTTPHandler{proc: proc, svc: svc, version: version}
|
||||
}
|
||||
|
||||
// Options configures the API application.
|
||||
@@ -43,6 +44,8 @@ type Options struct {
|
||||
// X-Real-IP (nginx: proxy_set_header X-Real-IP $remote_addr). Avoid
|
||||
// X-Forwarded-For: its first entry is supplied by the client.
|
||||
ProxyHeader string
|
||||
// Version is the build identifier /health reports.
|
||||
Version string
|
||||
}
|
||||
|
||||
// appConfig returns the Fiber configuration. Client-IP resolution only honors
|
||||
@@ -75,7 +78,7 @@ func clientIP(c *fiber.Ctx) string {
|
||||
|
||||
func NewFiberApp(proc *processor.Processor, svc *service.Service, opts Options) *fiber.App {
|
||||
// Create handler
|
||||
h := NewHTTPHandler(proc, svc)
|
||||
h := NewHTTPHandler(proc, svc, opts.Version)
|
||||
devMode := opts.DevMode
|
||||
|
||||
// Initialize Fiber app
|
||||
@@ -170,11 +173,11 @@ func NewFiberApp(proc *processor.Processor, svc *service.Service, opts Options)
|
||||
api.Post("/games", OptionalAuth(validateToken), h.CreateGame) // Optional auth for player ID association
|
||||
api.Get("/games/:gameId/history", h.GetGameHistory)
|
||||
api.Get("/games/:gameId/pgn", h.GetGamePGN)
|
||||
api.Put("/games/:gameId/players", h.ConfigurePlayers)
|
||||
api.Put("/games/:gameId/players", OptionalAuth(validateToken), h.ConfigurePlayers)
|
||||
api.Get("/games/:gameId", h.GetGame)
|
||||
api.Delete("/games/:gameId", h.DeleteGame)
|
||||
api.Delete("/games/:gameId", OptionalAuth(validateToken), h.DeleteGame)
|
||||
api.Post("/games/:gameId/moves", OptionalAuth(validateToken), h.MakeMove)
|
||||
api.Post("/games/:gameId/undo", h.UndoMove)
|
||||
api.Post("/games/:gameId/undo", OptionalAuth(validateToken), h.UndoMove)
|
||||
api.Post("/games/:gameId/resign", OptionalAuth(validateToken), h.Resign)
|
||||
api.Post("/games/:gameId/draw", OptionalAuth(validateToken), h.Draw)
|
||||
api.Get("/games/:gameId/board", h.GetBoard)
|
||||
@@ -213,9 +216,12 @@ func customErrorHandler(c *fiber.Ctx, err error) error {
|
||||
response.Error = e.Message
|
||||
|
||||
// Map HTTP status to error codes
|
||||
// An unknown route is not a missing game: a client newer than the
|
||||
// server must not report "game not found" for an endpoint the server
|
||||
// predates.
|
||||
switch code {
|
||||
case fiber.StatusNotFound:
|
||||
response.Code = core.ErrGameNotFound
|
||||
response.Code = core.ErrRouteNotFound
|
||||
case fiber.StatusBadRequest:
|
||||
response.Code = core.ErrInvalidRequest
|
||||
case fiber.StatusTooManyRequests:
|
||||
@@ -237,6 +243,7 @@ func (h *HTTPHandler) Health(c *fiber.Ctx) error {
|
||||
"status": status,
|
||||
"time": time.Now().Unix(),
|
||||
"storage": storageHealth,
|
||||
"version": h.version,
|
||||
})
|
||||
}
|
||||
|
||||
@@ -314,15 +321,12 @@ func (h *HTTPHandler) ConfigurePlayers(c *fiber.Ctx) error {
|
||||
|
||||
// Create command and execute
|
||||
cmd := processor.NewConfigurePlayersCommand(gameID, req)
|
||||
cmd.UserID, _ = c.Locals("userID").(string)
|
||||
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.Status(statusForCode(resp.Error.Code)).JSON(resp.Error)
|
||||
}
|
||||
|
||||
return c.JSON(resp.Data)
|
||||
@@ -500,15 +504,12 @@ func (h *HTTPHandler) UndoMove(c *fiber.Ctx) error {
|
||||
|
||||
// Create command and execute
|
||||
cmd := processor.NewUndoMoveCommand(gameID, req)
|
||||
cmd.UserID, _ = c.Locals("userID").(string)
|
||||
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.Status(statusForCode(resp.Error.Code)).JSON(resp.Error)
|
||||
}
|
||||
|
||||
return c.JSON(resp.Data)
|
||||
@@ -581,11 +582,12 @@ func (h *HTTPHandler) DeleteGame(c *fiber.Ctx) error {
|
||||
|
||||
// Create command and execute
|
||||
cmd := processor.NewDeleteGameCommand(gameID)
|
||||
cmd.UserID, _ = c.Locals("userID").(string)
|
||||
resp := h.proc.Execute(cmd)
|
||||
|
||||
// Return appropriate HTTP response
|
||||
if !resp.Success {
|
||||
return c.Status(fiber.StatusNotFound).JSON(resp.Error)
|
||||
return c.Status(statusForCode(resp.Error.Code)).JSON(resp.Error)
|
||||
}
|
||||
|
||||
return c.SendStatus(fiber.StatusNoContent)
|
||||
|
||||
@@ -224,7 +224,8 @@ func (p *Processor) handleCreateGame(cmd Command) ProcessorResponse {
|
||||
}
|
||||
}
|
||||
|
||||
// handleConfigurePlayers updates player configuration mid-game
|
||||
// handleConfigurePlayers updates player configuration mid-game; another
|
||||
// user's claim refuses it.
|
||||
func (p *Processor) handleConfigurePlayers(cmd Command) ProcessorResponse {
|
||||
args, ok := cmd.Args.(core.ConfigurePlayersRequest)
|
||||
if !ok {
|
||||
@@ -253,7 +254,13 @@ func (p *Processor) handleConfigurePlayers(cmd Command) ProcessorResponse {
|
||||
blackPlayer := core.NewPlayer(args.Black, core.ColorBlack)
|
||||
|
||||
// Update players in service
|
||||
if err = p.svc.UpdatePlayers(cmd.GameID, whitePlayer, blackPlayer); err != nil {
|
||||
if err = p.svc.UpdatePlayers(cmd.GameID, whitePlayer, blackPlayer, cmd.UserID); err != nil {
|
||||
switch {
|
||||
case errors.Is(err, service.ErrGameNotFound):
|
||||
return p.endGameError(err)
|
||||
case errors.Is(err, service.ErrSlotOwner):
|
||||
return p.errorResponse("another player has claimed a side of this game; players cannot be changed", core.ErrUnauthorized)
|
||||
}
|
||||
return p.errorResponse(fmt.Sprintf("failed to update players: %v", err), core.ErrInternalError)
|
||||
}
|
||||
|
||||
@@ -427,8 +434,10 @@ func (p *Processor) handleMakeMove(cmd Command) ProcessorResponse {
|
||||
|
||||
// handleUndoMove reverts game state. StateStuck is deliberately permitted:
|
||||
// undo -> StateOngoing is the recovery path for engine failures. Terminal
|
||||
// states are also permitted so a finished game can be rewound. Any reverted-to
|
||||
// snapshot had legal moves made from it, so resetting to Ongoing is sound
|
||||
// states are also permitted so a finished game can be rewound, except a
|
||||
// resignation or agreed draw between two humans; another user's claim refuses
|
||||
// an undo. Any reverted-to snapshot had legal moves made from it, so resetting
|
||||
// to Ongoing is sound
|
||||
// without re-classification.
|
||||
func (p *Processor) handleUndoMove(cmd Command) ProcessorResponse {
|
||||
g, err := p.svc.GetGameView(cmd.GameID)
|
||||
@@ -447,9 +456,12 @@ func (p *Processor) handleUndoMove(cmd Command) ProcessorResponse {
|
||||
}
|
||||
}
|
||||
|
||||
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)
|
||||
if err = p.svc.UndoMoves(cmd.GameID, args.Count, cmd.UserID); err != nil {
|
||||
switch {
|
||||
case errors.Is(err, service.ErrGameNotFound), errors.Is(err, service.ErrConcessionFinal):
|
||||
return p.endGameError(err)
|
||||
case errors.Is(err, service.ErrSlotOwner):
|
||||
return p.errorResponse("another player has claimed a side of this game; moves cannot be taken back", core.ErrUnauthorized)
|
||||
}
|
||||
return p.errorResponse(err.Error(), core.ErrInvalidRequest)
|
||||
}
|
||||
@@ -461,7 +473,8 @@ func (p *Processor) handleUndoMove(cmd Command) ProcessorResponse {
|
||||
}
|
||||
}
|
||||
|
||||
// handleDeleteGame unloads a game from live memory.
|
||||
// handleDeleteGame unloads a game from live memory for one of its claimants,
|
||||
// or anyone when it is unclaimed.
|
||||
func (p *Processor) handleDeleteGame(cmd Command) ProcessorResponse {
|
||||
g, err := p.svc.GetGameView(cmd.GameID)
|
||||
if err != nil {
|
||||
@@ -473,8 +486,14 @@ func (p *Processor) handleDeleteGame(cmd Command) ProcessorResponse {
|
||||
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)
|
||||
if err = p.svc.UnloadGame(cmd.GameID, cmd.UserID); err != nil {
|
||||
switch {
|
||||
case errors.Is(err, service.ErrGameNotFound):
|
||||
return p.endGameError(err)
|
||||
case errors.Is(err, service.ErrSlotOwner):
|
||||
return p.errorResponse("only a player who claimed a side can unload this game", core.ErrUnauthorized)
|
||||
}
|
||||
return p.errorResponse(err.Error(), core.ErrInvalidRequest)
|
||||
}
|
||||
|
||||
return ProcessorResponse{
|
||||
@@ -663,6 +682,11 @@ func (p *Processor) buildGameResponse(gameID string, g game.View) core.GameRespo
|
||||
if g.DrawOffer != 0 {
|
||||
resp.DrawOffer = g.DrawOffer.String()
|
||||
}
|
||||
if c := g.Concession; c != nil {
|
||||
resp.Concession = &core.Concession{
|
||||
Result: c.State.String(), Termination: string(c.Termination), Ply: c.Ply,
|
||||
}
|
||||
}
|
||||
|
||||
// Include last move if available
|
||||
if result := g.LastResult; result != nil {
|
||||
@@ -957,6 +981,8 @@ func (p *Processor) endGameError(err error) ProcessorResponse {
|
||||
return p.errorResponse(err.Error(), core.ErrConflict)
|
||||
case errors.Is(err, service.ErrGameChanged):
|
||||
return p.errorResponse("game changed; refresh and retry", core.ErrConflict)
|
||||
case errors.Is(err, service.ErrConcessionFinal):
|
||||
return p.errorResponse(err.Error(), core.ErrGameOver)
|
||||
}
|
||||
return p.errorResponse(fmt.Sprintf("failed to update game: %v", err), core.ErrInternalError)
|
||||
}
|
||||
@@ -7,6 +7,7 @@ import (
|
||||
"errors"
|
||||
"fmt"
|
||||
"strconv"
|
||||
"strings"
|
||||
|
||||
"chess/internal/server/chess"
|
||||
"chess/internal/server/core"
|
||||
@@ -69,6 +70,9 @@ func BuildPGN(record *storage.GameRecord, moves []storage.MoveRecord, ply int) (
|
||||
if complete {
|
||||
result = chess.ResultToken(record.Result)
|
||||
comment = Describe(record.Result, record.Termination)
|
||||
if note := continuedAfter(record, len(moves)); note != "" {
|
||||
comment = strings.TrimSpace(note + " " + comment)
|
||||
}
|
||||
}
|
||||
start := record.StartTimeUTC.UTC()
|
||||
tags := []chess.Tag{
|
||||
@@ -105,6 +109,29 @@ func BuildPGN(record *storage.GameRecord, moves []storage.MoveRecord, ply int) (
|
||||
}, nil
|
||||
}
|
||||
|
||||
// continuedAfter notes a resignation or agreed draw that an undo took back,
|
||||
// such as "White resigned at ply 24; play continued."; empty when there was
|
||||
// none or it is still the game's result.
|
||||
func continuedAfter(record *storage.GameRecord, plies int) string {
|
||||
if record.ConcessionResult == "" {
|
||||
return ""
|
||||
}
|
||||
if record.ConcessionResult == record.Result &&
|
||||
record.ConcessionTermination == record.Termination && record.ConcessionPly == plies {
|
||||
return ""
|
||||
}
|
||||
var event string
|
||||
switch record.ConcessionResult {
|
||||
case "white_wins":
|
||||
event = "Black resigned"
|
||||
case "black_wins":
|
||||
event = "White resigned"
|
||||
default:
|
||||
event = "A draw was agreed"
|
||||
}
|
||||
return fmt.Sprintf("%s at ply %d; play continued.", event, record.ConcessionPly)
|
||||
}
|
||||
|
||||
// Describe returns a sentence for a stored result and termination, such as
|
||||
// "Black wins by resignation." or "Draw by threefold repetition."; empty for
|
||||
// an unfinished game.
|
||||
|
||||
@@ -154,3 +154,24 @@ func TestBuildPGN(t *testing.T) {
|
||||
t.Error("ply beyond the line accepted")
|
||||
}
|
||||
}
|
||||
|
||||
// A resignation taken back by an undo is noted before the live result.
|
||||
func TestBuildPGNNotesContinuedConcession(t *testing.T) {
|
||||
moves := stored(t, chess.StartFEN, "e2e4", "e7e5", "d2d4")
|
||||
rec := record(chess.StartFEN, "", "")
|
||||
rec.ConcessionResult, rec.ConcessionTermination, rec.ConcessionPly = "black_wins", "resignation", 2
|
||||
|
||||
pgn, err := BuildPGN(rec, moves, -1)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if !strings.HasSuffix(pgn.Text, "\n\n1. e4 e5 2. d4 { White resigned at ply 2; play continued. } *\n") {
|
||||
t.Errorf("continued game:\n%s", pgn.Text)
|
||||
}
|
||||
|
||||
// The concession that is still the result needs no note.
|
||||
rec.Result, rec.Termination, rec.ConcessionPly = "black_wins", "resignation", 3
|
||||
if pgn, _ = BuildPGN(rec, moves, -1); strings.Contains(pgn.Text, "continued") {
|
||||
t.Errorf("standing resignation noted as continued:\n%s", pgn.Text)
|
||||
}
|
||||
}
|
||||
@@ -94,12 +94,97 @@ func TestResignationClaimsAndPersists(t *testing.T) {
|
||||
t.Fatalf("history after resignation: %+v", history)
|
||||
}
|
||||
|
||||
// Undo after a result rewinds it (D6), in memory and in storage.
|
||||
if err := svc.UndoMoves(gameID, 1); err != nil {
|
||||
// Between two humans a resignation is final: neither the other claimant
|
||||
// nor an anonymous caller can take it back, and the players cannot be
|
||||
// reconfigured around it.
|
||||
for _, actor := range []string{alice, bob, ""} {
|
||||
if err := svc.UndoMoves(gameID, 1, actor); !errors.Is(err, ErrSlotOwner) {
|
||||
t.Fatalf("undo by %q after a two-player resignation: %v, want ErrSlotOwner", actor, err)
|
||||
}
|
||||
}
|
||||
computer := core.NewPlayer(core.PlayerConfig{Type: core.PlayerComputer, Level: 1, SearchTime: 100}, core.ColorBlack)
|
||||
white := core.NewPlayer(core.PlayerConfig{Type: core.PlayerHuman}, core.ColorWhite)
|
||||
if err := svc.UpdatePlayers(gameID, white, computer, alice); !errors.Is(err, ErrSlotOwner) {
|
||||
t.Fatalf("reconfiguring around a resignation: %v, want ErrSlotOwner", err)
|
||||
}
|
||||
if history.Concession == nil || history.Concession.Result != "white_wins" ||
|
||||
history.Concession.Termination != "resignation" || history.Concession.Ply != 1 {
|
||||
t.Fatalf("concession not recorded: %+v", history.Concession)
|
||||
}
|
||||
}
|
||||
|
||||
// A single controller (hotseat or anonymous) is refused only by finality.
|
||||
func TestConcessionBetweenHumansIsFinal(t *testing.T) {
|
||||
svc := newPersistentTestService(t)
|
||||
gameID := newHumanGame(t, svc)
|
||||
move(t, svc, gameID, "", "e2e4")
|
||||
view, _ := svc.GetGameView(gameID)
|
||||
if err := svc.OfferDraw(gameID, core.ColorBlack, "", view.FEN); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if history, _ = svc.GetGameHistory(gameID); history.Result != "" || history.Termination != "" {
|
||||
t.Fatalf("undo kept the resignation: %+v", history)
|
||||
if err := svc.EndGame(gameID, EndCommit{
|
||||
ExpectedFEN: view.FEN, Color: core.ColorWhite, State: core.StateDraw,
|
||||
Termination: core.TermAgreement, OfferFrom: core.ColorBlack,
|
||||
}); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if err := svc.UndoMoves(gameID, 1, ""); !errors.Is(err, ErrConcessionFinal) {
|
||||
t.Fatalf("undo after an agreed draw between humans: %v, want ErrConcessionFinal", err)
|
||||
}
|
||||
}
|
||||
|
||||
// Against the computer an undo reopens a resigned game; the first
|
||||
// concession stays on record through the rewind, later moves, and a second
|
||||
// resignation, while result and termination follow the live game.
|
||||
func TestConcessionAgainstComputerStaysOnRecord(t *testing.T) {
|
||||
svc := newPersistentTestService(t)
|
||||
gameID, alice := uuid.NewString(), uuid.NewString()
|
||||
white := core.NewPlayer(core.PlayerConfig{Type: core.PlayerHuman}, core.ColorWhite)
|
||||
black := core.NewPlayer(core.PlayerConfig{Type: core.PlayerComputer, Level: 1, SearchTime: 100}, core.ColorBlack)
|
||||
if err := svc.CreateGame(gameID, white, black, chess.StartFEN, core.ColorWhite,
|
||||
core.StateOngoing, core.TermNone); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
move(t, svc, gameID, alice, "e2e4")
|
||||
move(t, svc, gameID, "", "e7e5")
|
||||
resign := func() {
|
||||
t.Helper()
|
||||
view, _ := svc.GetGameView(gameID)
|
||||
if err := svc.EndGame(gameID, EndCommit{
|
||||
ExpectedFEN: view.FEN, Color: core.ColorWhite, ActorUserID: alice,
|
||||
State: core.StateBlackWins, Termination: core.TermResignation,
|
||||
}); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
}
|
||||
resign()
|
||||
|
||||
if err := svc.UndoMoves(gameID, 2, ""); !errors.Is(err, ErrSlotOwner) {
|
||||
t.Fatalf("anonymous undo of a claimed game: %v, want ErrSlotOwner", err)
|
||||
}
|
||||
if err := svc.UndoMoves(gameID, 2, alice); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
view, _ := svc.GetGameView(gameID)
|
||||
if view.State != core.StateOngoing || view.Concession == nil ||
|
||||
view.Concession.State != core.StateBlackWins || view.Concession.Ply != 2 {
|
||||
t.Fatalf("view after undoing a resignation: %+v", view)
|
||||
}
|
||||
history, err := svc.GetGameHistory(gameID)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if history.Result != "" || history.Termination != "" || len(history.Moves) != 0 ||
|
||||
history.Concession == nil || history.Concession.Result != "black_wins" ||
|
||||
history.Concession.Termination != "resignation" || history.Concession.Ply != 2 {
|
||||
t.Fatalf("history after undoing a resignation: %+v", history)
|
||||
}
|
||||
|
||||
move(t, svc, gameID, alice, "d2d4")
|
||||
resign()
|
||||
history, _ = svc.GetGameHistory(gameID)
|
||||
if history.Result != "black_wins" || len(history.Moves) != 1 || history.Concession.Ply != 2 {
|
||||
t.Fatalf("second resignation replaced the first concession: %+v", history)
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -21,8 +21,42 @@ var (
|
||||
ErrNotHuman = errors.New("that side is played by the computer")
|
||||
ErrNoDrawOffer = errors.New("no draw offer from the opponent")
|
||||
ErrOfferLimit = errors.New("one draw offer per move: make a move before offering again")
|
||||
// ErrConcessionFinal refuses to continue a game two human players ended
|
||||
// by resignation or agreement.
|
||||
ErrConcessionFinal = errors.New("a resignation or agreed draw between two human players is final")
|
||||
)
|
||||
|
||||
// requireControl lets actor rewind or reconfigure g only when no side is
|
||||
// claimed by another user. A claim is what protects a player's moves; an
|
||||
// unclaimed side is open to anyone holding the game ID.
|
||||
func requireControl(g *game.Game, actor string) error {
|
||||
for _, color := range []core.Color{core.ColorWhite, core.ColorBlack} {
|
||||
if owner := g.GetSlotOwner(color); owner != "" && owner != actor {
|
||||
return ErrSlotOwner
|
||||
}
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
// requireParticipant lets actor unload g when it holds one of its claims, or
|
||||
// when nothing is claimed.
|
||||
func requireParticipant(g *game.Game, actor string) error {
|
||||
claimed := false
|
||||
for _, color := range []core.Color{core.ColorWhite, core.ColorBlack} {
|
||||
switch g.GetSlotOwner(color) {
|
||||
case "":
|
||||
case actor:
|
||||
return nil
|
||||
default:
|
||||
claimed = true
|
||||
}
|
||||
}
|
||||
if claimed {
|
||||
return ErrSlotOwner
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
type MoveCommit struct {
|
||||
ExpectedFEN string
|
||||
ExpectedState core.State
|
||||
@@ -108,18 +142,22 @@ func (s *Service) CreateGame(
|
||||
return nil
|
||||
}
|
||||
|
||||
// UpdatePlayers replaces players in an existing game
|
||||
func (s *Service) UpdatePlayers(gameID string, whitePlayer, blackPlayer *core.Player) error {
|
||||
// UpdatePlayers replaces players in an existing game on behalf of actor, who
|
||||
// must not be locked out by another user's claim.
|
||||
func (s *Service) UpdatePlayers(gameID string, whitePlayer, blackPlayer *core.Player, actor string) error {
|
||||
s.mu.Lock()
|
||||
defer s.mu.Unlock()
|
||||
|
||||
g, ok := s.games[gameID]
|
||||
if !ok {
|
||||
return fmt.Errorf("game not found: %s", gameID)
|
||||
return fmt.Errorf("%w: %s", ErrGameNotFound, gameID)
|
||||
}
|
||||
if g.State() == core.StatePending {
|
||||
return errors.New("cannot change players while computer is calculating")
|
||||
}
|
||||
if err := requireControl(g, actor); err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
oldWhite := g.GetPlayer(core.ColorWhite)
|
||||
oldBlack := g.GetPlayer(core.ColorBlack)
|
||||
@@ -369,6 +407,7 @@ func (s *Service) EndGame(gameID string, commit EndCommit) error {
|
||||
result, _ := commit.State.Result()
|
||||
end := storage.GameEnd{
|
||||
GameID: gameID, Result: result, Termination: string(commit.Termination), EndTimeUTC: now,
|
||||
Ply: g.Plies(),
|
||||
}
|
||||
if claimUserID != "" {
|
||||
end.ClaimColor, end.ClaimedBy = commit.Color.String(), claimUserID
|
||||
@@ -490,8 +529,11 @@ func offerAllowed(g *game.Game, color core.Color) error {
|
||||
return nil
|
||||
}
|
||||
|
||||
// UndoMoves removes the specified number of moves from game history
|
||||
func (s *Service) UndoMoves(gameID string, count int) error {
|
||||
// UndoMoves takes back count moves on behalf of actor. Another user's claim
|
||||
// refuses it, so two players cannot rewind each other; a resignation or agreed
|
||||
// draw between two humans is final. Against the computer an undo reopens a
|
||||
// finished game and the first concession stays on record.
|
||||
func (s *Service) UndoMoves(gameID string, count int, actor string) error {
|
||||
s.mu.Lock()
|
||||
defer s.mu.Unlock()
|
||||
|
||||
@@ -502,6 +544,12 @@ func (s *Service) UndoMoves(gameID string, count int) error {
|
||||
if g.State() == core.StatePending {
|
||||
return errors.New("cannot undo while computer move is in progress")
|
||||
}
|
||||
if err := requireControl(g, actor); err != nil {
|
||||
return err
|
||||
}
|
||||
if g.State().IsTerminal() && g.Termination().IsConcession() && g.BothHuman() {
|
||||
return ErrConcessionFinal
|
||||
}
|
||||
|
||||
originalMoveCount := len(g.Moves())
|
||||
|
||||
@@ -525,6 +573,21 @@ func (s *Service) UndoMoves(gameID string, count int) error {
|
||||
return nil
|
||||
}
|
||||
|
||||
// UnloadGame removes a live game on behalf of actor, who must hold one of
|
||||
// its claims when it has any. Durable history is kept.
|
||||
func (s *Service) UnloadGame(gameID string, actor string) error {
|
||||
s.mu.Lock()
|
||||
defer s.mu.Unlock()
|
||||
g, ok := s.games[gameID]
|
||||
if !ok {
|
||||
return fmt.Errorf("%w: %s", ErrGameNotFound, gameID)
|
||||
}
|
||||
if err := requireParticipant(g, actor); err != nil {
|
||||
return err
|
||||
}
|
||||
return s.deleteGameLocked(gameID, g)
|
||||
}
|
||||
|
||||
// DeleteGame removes a game from the service
|
||||
func (s *Service) DeleteGame(gameID string) error {
|
||||
s.mu.Lock()
|
||||
@@ -534,6 +597,10 @@ func (s *Service) DeleteGame(gameID string) error {
|
||||
if !ok {
|
||||
return fmt.Errorf("%w: %s", ErrGameNotFound, gameID)
|
||||
}
|
||||
return s.deleteGameLocked(gameID, g)
|
||||
}
|
||||
|
||||
func (s *Service) deleteGameLocked(gameID string, g *game.Game) error {
|
||||
if g.State() == core.StatePending {
|
||||
return errors.New("cannot delete game while computer move is in progress")
|
||||
}
|
||||
|
||||
@@ -70,7 +70,7 @@ func TestUndoClearsDurableTerminalResult(t *testing.T) {
|
||||
}); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if err := svc.UndoMoves(gameID, 1); err != nil {
|
||||
if err := svc.UndoMoves(gameID, 1, ""); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
|
||||
@@ -100,7 +100,7 @@ func TestPlayerReconfigurationPreservesClaimAndPersistsConfiguration(t *testing.
|
||||
replacementBlack := core.NewPlayer(
|
||||
core.PlayerConfig{Type: core.PlayerComputer, Level: 12, SearchTime: 500}, core.ColorBlack,
|
||||
)
|
||||
if err := svc.UpdatePlayers(gameID, replacementWhite, replacementBlack); err != nil {
|
||||
if err := svc.UpdatePlayers(gameID, replacementWhite, replacementBlack, userID); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
|
||||
@@ -118,7 +118,7 @@ func TestPlayerReconfigurationPreservesClaimAndPersistsConfiguration(t *testing.
|
||||
computerWhite := core.NewPlayer(
|
||||
core.PlayerConfig{Type: core.PlayerComputer, Level: 8, SearchTime: 300}, core.ColorWhite,
|
||||
)
|
||||
if err := svc.UpdatePlayers(gameID, computerWhite, replacementBlack); err != nil {
|
||||
if err := svc.UpdatePlayers(gameID, computerWhite, replacementBlack, userID); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
history, err = svc.GetGameHistory(gameID)
|
||||
|
||||
@@ -46,6 +46,12 @@ func (s *Service) GetGameHistory(gameID string) (*core.GameHistoryResponse, erro
|
||||
Players: playersResponse(*record),
|
||||
Moves: make([]core.HistoryMove, 0, len(moves)),
|
||||
}
|
||||
if record.ConcessionResult != "" {
|
||||
history.Concession = &core.Concession{
|
||||
Result: record.ConcessionResult, Termination: record.ConcessionTermination,
|
||||
Ply: record.ConcessionPly,
|
||||
}
|
||||
}
|
||||
san, err := replay.Notate(record.InitialFEN, moves)
|
||||
if err != nil {
|
||||
slog.Warn("stored game has moves without notation", "game_id", record.GameID, "error", err)
|
||||
|
||||
@@ -16,7 +16,8 @@ const gameSelectColumns = `
|
||||
g.game_id, g.initial_fen,
|
||||
g.white_player_id, g.white_type, g.white_level, g.white_search_time, g.white_claimed_by, g.white_name,
|
||||
g.black_player_id, g.black_type, g.black_level, g.black_search_time, g.black_claimed_by, g.black_name,
|
||||
g.result, g.termination, g.start_time_utc, g.end_time_utc`
|
||||
g.result, g.termination, g.start_time_utc, g.end_time_utc,
|
||||
g.concession_result, g.concession_termination, g.concession_ply, g.concession_time_utc`
|
||||
|
||||
// claimantName snapshots a claimant's username inside the writing
|
||||
// transaction; NULL when the slot is unclaimed or the account is gone.
|
||||
@@ -114,6 +115,9 @@ type GameEnd struct {
|
||||
EndTimeUTC time.Time
|
||||
ClaimColor string
|
||||
ClaimedBy string
|
||||
// Ply is the number of moves played; it is recorded with the first
|
||||
// resignation or agreed draw (see GameRecord.ConcessionResult).
|
||||
Ply int
|
||||
}
|
||||
|
||||
// RecordGameEnd persists the result, how it was reached, and any claim in one
|
||||
@@ -138,9 +142,25 @@ func (s *Store) RecordGameEnd(end GameEnd) error {
|
||||
if (end.ClaimColor == "") != (end.ClaimedBy == "") {
|
||||
return errors.New("claim color and claimant must be provided together")
|
||||
}
|
||||
if end.Ply < 0 {
|
||||
return errors.New("ply must not be negative")
|
||||
}
|
||||
return s.enqueue("record_game_end", end.GameID, func(ctx context.Context, tx *sql.Tx) error {
|
||||
return updateGameEnd(ctx, tx, end.GameID, end.ClaimColor, end.ClaimedBy,
|
||||
end.Result, end.Termination, &ended)
|
||||
if err := updateGameEnd(ctx, tx, end.GameID, end.ClaimColor, end.ClaimedBy,
|
||||
end.Result, end.Termination, &ended); err != nil {
|
||||
return err
|
||||
}
|
||||
if !isConcession(end.Termination) {
|
||||
return nil
|
||||
}
|
||||
// The first concession stays; a later one after an undo is only the
|
||||
// current result.
|
||||
_, err := tx.ExecContext(ctx, `UPDATE games SET
|
||||
concession_result = $2, concession_termination = $3,
|
||||
concession_ply = $4, concession_time_utc = $5
|
||||
WHERE game_id = $1 AND concession_result IS NULL`,
|
||||
end.GameID, end.Result, end.Termination, end.Ply, ended)
|
||||
return err
|
||||
})
|
||||
}
|
||||
|
||||
@@ -209,6 +229,7 @@ func (s *Store) RecordPlayers(gameID string, white, black PlayerRecord) error {
|
||||
|
||||
// RewindGame atomically removes undone moves and clears a previously terminal
|
||||
// result so replay readers never observe an ongoing line with a stale outcome.
|
||||
// A recorded concession is kept.
|
||||
func (s *Store) RewindGame(gameID string, afterMoveNumber int) error {
|
||||
if gameID == "" || afterMoveNumber < 0 {
|
||||
return errors.New("game ID and a non-negative move number are required")
|
||||
@@ -519,7 +540,9 @@ type rowScanner interface {
|
||||
// scanGame scans gameSelectColumns followed by any extra destinations.
|
||||
func scanGame(scanner rowScanner, record *GameRecord, extra ...any) error {
|
||||
var whiteClaimed, whiteName, blackClaimed, blackName, result, termination sql.NullString
|
||||
var endTime sql.NullTime
|
||||
var concessionResult, concessionTermination sql.NullString
|
||||
var concessionPly sql.NullInt64
|
||||
var endTime, concessionTime sql.NullTime
|
||||
dest := []any{
|
||||
&record.GameID, &record.InitialFEN,
|
||||
&record.WhitePlayerID, &record.WhiteType, &record.WhiteLevel, &record.WhiteSearchTime,
|
||||
@@ -527,6 +550,7 @@ func scanGame(scanner rowScanner, record *GameRecord, extra ...any) error {
|
||||
&record.BlackPlayerID, &record.BlackType, &record.BlackLevel, &record.BlackSearchTime,
|
||||
&blackClaimed, &blackName,
|
||||
&result, &termination, &record.StartTimeUTC, &endTime,
|
||||
&concessionResult, &concessionTermination, &concessionPly, &concessionTime,
|
||||
}
|
||||
if err := scanner.Scan(append(dest, extra...)...); err != nil {
|
||||
return err
|
||||
@@ -539,6 +563,10 @@ func scanGame(scanner rowScanner, record *GameRecord, extra ...any) error {
|
||||
record.Termination = termination.String
|
||||
record.StartTimeUTC = record.StartTimeUTC.UTC()
|
||||
record.EndTimeUTC = utcPointer(endTime)
|
||||
record.ConcessionResult = concessionResult.String
|
||||
record.ConcessionTermination = concessionTermination.String
|
||||
record.ConcessionPly = int(concessionPly.Int64)
|
||||
record.ConcessionTimeUTC = utcPointer(concessionTime)
|
||||
return nil
|
||||
}
|
||||
|
||||
@@ -589,6 +617,11 @@ func isValidResult(result string) bool {
|
||||
}
|
||||
|
||||
// validTermination mirrors the games_termination_check constraint.
|
||||
// isConcession reports a result the players chose (core.Termination.IsConcession).
|
||||
func isConcession(termination string) bool {
|
||||
return termination == "resignation" || termination == "agreement"
|
||||
}
|
||||
|
||||
func validTermination(result, termination string) bool {
|
||||
switch result {
|
||||
case "white_wins", "black_wins":
|
||||
|
||||
@@ -41,6 +41,12 @@ type GameRecord struct {
|
||||
Termination string // how Result was reached; empty while unfinished
|
||||
StartTimeUTC time.Time
|
||||
EndTimeUTC *time.Time
|
||||
// The first resignation or agreed draw, kept when an undo continues play;
|
||||
// ConcessionResult is empty when there was none.
|
||||
ConcessionResult string
|
||||
ConcessionTermination string
|
||||
ConcessionPly int
|
||||
ConcessionTimeUTC *time.Time
|
||||
}
|
||||
|
||||
// GameSummaryRecord is a game row plus its replay extent. MoveCount and
|
||||
@@ -75,7 +81,7 @@ type MovePersistence struct {
|
||||
// schemaVersion is the newest schema this binary understands. Migrations are
|
||||
// applied in order inside one transaction; PostgreSQL DDL is transactional, so
|
||||
// a failed upgrade leaves the previous version intact.
|
||||
const schemaVersion = 2
|
||||
const schemaVersion = 3
|
||||
|
||||
// migrations[v-1] upgrades the schema from version v-1 to v. Never edit a
|
||||
// released migration; append a new one.
|
||||
@@ -182,6 +188,31 @@ ALTER TABLE games
|
||||
OR (result = 'draw' AND termination IN
|
||||
('insufficient_material', 'threefold_repetition', 'fifty_move_rule', 'agreement')));
|
||||
`,
|
||||
|
||||
// v3: the first resignation or agreed draw. An undo against the computer
|
||||
// may reopen such a game, which clears result and termination; these
|
||||
// columns keep the concession on record. Ply is the number of moves
|
||||
// played when it was made. Existing concessions are backfilled from the
|
||||
// current result and move count.
|
||||
`
|
||||
ALTER TABLE games
|
||||
ADD COLUMN concession_result text,
|
||||
ADD COLUMN concession_termination text,
|
||||
ADD COLUMN concession_ply integer,
|
||||
ADD COLUMN concession_time_utc timestamptz;
|
||||
UPDATE games g SET
|
||||
concession_result = g.result,
|
||||
concession_termination = g.termination,
|
||||
concession_ply = (SELECT count(*) FROM moves m WHERE m.game_id = g.game_id),
|
||||
concession_time_utc = g.end_time_utc
|
||||
WHERE g.termination IN ('resignation', 'agreement');
|
||||
ALTER TABLE games ADD CONSTRAINT games_concession_check CHECK (
|
||||
(concession_result IS NULL AND concession_termination IS NULL
|
||||
AND concession_ply IS NULL AND concession_time_utc IS NULL)
|
||||
OR (concession_ply >= 0 AND concession_time_utc IS NOT NULL AND (
|
||||
(concession_result IN ('white_wins', 'black_wins') AND concession_termination = 'resignation')
|
||||
OR (concession_result = 'draw' AND concession_termination = 'agreement'))));
|
||||
`,
|
||||
}
|
||||
|
||||
// ownedTables lists every table created by migrations, children first.
|
||||
|
||||
@@ -945,6 +945,81 @@ func TestMigrationV2BackfillsTermination(t *testing.T) {
|
||||
}
|
||||
}
|
||||
|
||||
func TestMigrationV3BackfillsConcessions(t *testing.T) {
|
||||
dsn := pgtest.DSN(t)
|
||||
store, err := NewStore(dsn)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
t.Cleanup(func() { _ = store.Close() })
|
||||
|
||||
// A version 2 database: a resignation after two moves, an agreed draw at
|
||||
// the start, and a checkmate.
|
||||
for _, migration := range migrations[:2] {
|
||||
if _, err := store.db.Exec(migration); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
}
|
||||
if _, err := store.db.Exec(`INSERT INTO schema_version (version) VALUES (2)`); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
ids := map[string]string{}
|
||||
for _, row := range [][2]string{
|
||||
{"black_wins", "resignation"}, {"draw", "agreement"}, {"white_wins", "checkmate"},
|
||||
} {
|
||||
id := uuid.NewString()
|
||||
ids[row[1]] = id
|
||||
if _, err := store.db.Exec(`INSERT INTO games (game_id, initial_fen, white_player_id, white_type,
|
||||
black_player_id, black_type, result, termination, end_time_utc)
|
||||
VALUES ($1, 'initial', $2, 1, $3, 2, $4, $5, now())`,
|
||||
id, uuid.NewString(), uuid.NewString(), row[0], row[1]); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
}
|
||||
for i, uci := range []string{"e2e4", "e7e5"} {
|
||||
if _, err := store.db.Exec(`INSERT INTO moves (game_id, move_number, move_uci, fen_after_move,
|
||||
player_color) VALUES ($1, $2, $3, 'fen', $4)`,
|
||||
ids["resignation"], i+1, uci, []string{"w", "b"}[i]); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
}
|
||||
|
||||
if err := store.InitDB(); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
for termination, want := range map[string]GameRecord{
|
||||
"resignation": {ConcessionResult: "black_wins", ConcessionTermination: "resignation", ConcessionPly: 2},
|
||||
"agreement": {ConcessionResult: "draw", ConcessionTermination: "agreement", ConcessionPly: 0},
|
||||
"checkmate": {},
|
||||
} {
|
||||
records, err := store.QueryGames(ids[termination], "")
|
||||
if err != nil || len(records) != 1 {
|
||||
t.Fatalf("%s: %v, %d records", termination, err, len(records))
|
||||
}
|
||||
got := records[0]
|
||||
if got.ConcessionResult != want.ConcessionResult ||
|
||||
got.ConcessionTermination != want.ConcessionTermination ||
|
||||
got.ConcessionPly != want.ConcessionPly ||
|
||||
(want.ConcessionResult != "") != (got.ConcessionTimeUTC != nil) {
|
||||
t.Errorf("%s: concession %q %q %d %v", termination, got.ConcessionResult,
|
||||
got.ConcessionTermination, got.ConcessionPly, got.ConcessionTimeUTC)
|
||||
}
|
||||
}
|
||||
|
||||
// Only a resignation or an agreed draw is a concession, and its columns
|
||||
// are set together.
|
||||
for name, query := range map[string]string{
|
||||
"checkmate concession": `UPDATE games SET concession_result = 'white_wins',
|
||||
concession_termination = 'checkmate', concession_ply = 1, concession_time_utc = now()
|
||||
WHERE game_id = $1`,
|
||||
"partial concession": `UPDATE games SET concession_result = 'draw' WHERE game_id = $1`,
|
||||
} {
|
||||
if _, err := store.db.Exec(query, ids["checkmate"]); err == nil {
|
||||
t.Errorf("games_concession_check accepted a %s", name)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestWriteForDeletedGameIsDroppedWithoutDegrading(t *testing.T) {
|
||||
store := openStore(t, pgtest.DSN(t))
|
||||
missing := make(chan string, 4)
|
||||
|
||||
@@ -971,6 +971,9 @@ async function undoMoves() {
|
||||
console.log('No moves to undo');
|
||||
return;
|
||||
}
|
||||
// A resignation or agreed draw stays on record when play continues.
|
||||
const conceded = isGameOver(gameState.state) &&
|
||||
(gameState.termination === 'resignation' || gameState.termination === 'agreement');
|
||||
|
||||
try {
|
||||
const response = await authFetch(`${gameState.apiUrl}/api/games/${gameState.gameId}/undo`, {
|
||||
@@ -994,6 +997,9 @@ async function undoMoves() {
|
||||
updateGameDisplay(game);
|
||||
if (game.state === 'stuck') {
|
||||
flashErrorMessage('Engine error — Undo to recover or start a new game');
|
||||
} else if (conceded && game.concession) {
|
||||
const what = game.concession.termination === 'agreement' ? 'agreed draw' : 'resignation';
|
||||
flashMessage(`Play continues; the ${what} stays on record`, 'info', 3000);
|
||||
}
|
||||
} catch (error) {
|
||||
if (error.message === 'Failed to fetch') {
|
||||
@@ -1264,7 +1270,7 @@ function handleApiError(action, error, response = null) {
|
||||
break;
|
||||
case 403:
|
||||
serverStatus = 'healthy';
|
||||
if (action === 'move' || action === 'trigger computer move') {
|
||||
if (action === 'move' || action === 'trigger computer move' || action === 'undo') {
|
||||
statusMessage = 'Slot Claimed';
|
||||
} else {
|
||||
statusMessage = 'Not Authorized';
|
||||
|
||||
Reference in new issue
Block a user