feat(admin): manual account blocking (suspensions)
CI / changes (pull_request) Successful in 2s
CI / unit (pull_request) Successful in 9s
CI / integration (pull_request) Successful in 12s
CI / ui (pull_request) Successful in 45s
CI / gate (pull_request) Successful in 0s
CI / deploy (pull_request) Successful in 1m10s
CI / changes (pull_request) Successful in 2s
CI / unit (pull_request) Successful in 9s
CI / integration (pull_request) Successful in 12s
CI / ui (pull_request) Successful in 45s
CI / gate (pull_request) Successful in 0s
CI / deploy (pull_request) Successful in 1m10s
Operator-driven hard block, the counterpart to the soft high-rate flag: permanent or until a date, with an optional reason chosen from an editable en+ru picklist (snapshotted onto the block). A block forfeits the player's active games (opponent wins, as a resignation) and cancels their open matchmaking games. A backend gate refuses a blocked account on every /api/v1/user/* route except the block-status probe with 403 account_blocked, which threads through the gateway as the Execute result_code; the UI surfaces it as a terminal blocked screen and stops all push/poll. Temporary blocks self-expire; the operator can unblock at any time (lost games stay lost). Sessions are not revoked, so the blocked client can still reach the exempt block-status endpoint. Backend: migration 00003 (account_suspensions + suspension_reasons) + jet regen; account suspension store; game.ForfeitAllForAccount; requireNotSuspended gate + block-status endpoint; admin console block/unblock + Reasons CRUD. Wire: fbs BlockStatus + account.block_status gateway op. UI: blocked screen, app state, transport/codec, i18n. Docs: ARCHITECTURE, FUNCTIONAL(+ru), PRERELEASE (AB).
This commit is contained in:
@@ -378,6 +378,68 @@ func (svc *Service) Resign(ctx context.Context, gameID, accountID uuid.UUID) (Mo
|
||||
return MoveResult{Move: rec, Game: post}, nil
|
||||
}
|
||||
|
||||
// ForfeitAllForAccount resigns every game the account is currently playing and cancels every game
|
||||
// it has opened but no opponent has joined, returning how many it acted on. It is the game-side
|
||||
// effect of an admin block: the blocked player instantly loses each live game (the opponent
|
||||
// winning, exactly as a manual resignation) and leaves matchmaking so nobody joins a doomed game.
|
||||
// It reuses the per-game resignation path (its lock, commit and live events), so it is safe to
|
||||
// call while play continues and is idempotent — an already-resigned or finished game is skipped.
|
||||
// A per-game failure is logged and skipped so one bad game does not strand the rest; the
|
||||
// turn-timeout sweeper remains the backstop for anything missed in a race.
|
||||
func (svc *Service) ForfeitAllForAccount(ctx context.Context, accountID uuid.UUID) (int, error) {
|
||||
games, err := svc.store.ListGamesForAccount(ctx, accountID)
|
||||
if err != nil {
|
||||
return 0, err
|
||||
}
|
||||
count := 0
|
||||
for _, g := range games {
|
||||
if g.Status != StatusActive && g.Status != StatusOpen {
|
||||
continue // a finished game holds no turn to forfeit
|
||||
}
|
||||
acted, err := svc.forfeitOne(ctx, g.ID, accountID)
|
||||
if err != nil {
|
||||
svc.log.Warn("forfeit on block", zap.String("game", g.ID.String()), zap.Error(err))
|
||||
continue
|
||||
}
|
||||
if acted {
|
||||
count++
|
||||
}
|
||||
}
|
||||
return count, nil
|
||||
}
|
||||
|
||||
// forfeitOne resigns one game on behalf of accountID, or cancels it when it is an open auto-match
|
||||
// game still awaiting an opponent. It reports whether it changed the game. A game that has already
|
||||
// finished, or in which the account no longer holds a seat, is a benign no-op.
|
||||
func (svc *Service) forfeitOne(ctx context.Context, gameID, accountID uuid.UUID) (bool, error) {
|
||||
_, err := svc.Resign(ctx, gameID, accountID)
|
||||
switch {
|
||||
case err == nil:
|
||||
return true, nil
|
||||
case errors.Is(err, ErrNoOpponentYet):
|
||||
// An open game with no opponent cannot be resigned (there is no one to award the win), so
|
||||
// delete it to clear it from the matchmaking pool. If it filled in the race window it is
|
||||
// now active, so resign the seat instead.
|
||||
deleted, derr := svc.store.DeleteOpenGame(ctx, gameID)
|
||||
if derr != nil {
|
||||
return false, derr
|
||||
}
|
||||
if deleted {
|
||||
svc.cache.remove(gameID)
|
||||
return true, nil
|
||||
}
|
||||
if _, err := svc.Resign(ctx, gameID, accountID); err != nil &&
|
||||
!errors.Is(err, ErrFinished) && !errors.Is(err, ErrNoOpponentYet) {
|
||||
return false, err
|
||||
}
|
||||
return true, nil
|
||||
case errors.Is(err, ErrFinished), errors.Is(err, ErrNotAPlayer), errors.Is(err, ErrNotFound):
|
||||
return false, nil
|
||||
default:
|
||||
return false, err
|
||||
}
|
||||
}
|
||||
|
||||
// GameVariant returns just a game's variant. The edge layer uses it to map wire alphabet
|
||||
// indices to concrete letters before delegating to the letter-based play, exchange and
|
||||
// word-check methods, keeping a single domain path shared with the robot.
|
||||
|
||||
Reference in New Issue
Block a user