feat(lobby): drop left honest-AI games from the finished list
CI / changes (pull_request) Successful in 2s
CI / unit (pull_request) Successful in 9s
CI / integration (pull_request) Successful in 13s
CI / ui (pull_request) Has been skipped
CI / gate (pull_request) Successful in 0s
CI / deploy (pull_request) Successful in 1m4s
CI / changes (pull_request) Successful in 2s
CI / unit (pull_request) Successful in 9s
CI / integration (pull_request) Successful in 13s
CI / ui (pull_request) Has been skipped
CI / gate (pull_request) Successful in 0s
CI / deploy (pull_request) Successful in 1m4s
A finished honest-AI (vs_ai) game the player left — by resigning or by abandoning it to the 7-day inactivity timeout (end_reason 'resign'/'timeout') — no longer appears in that player's own lobby finished list. The new game.Service.ListForLobby filters ListForAccount for the lobby endpoint only; the admin console and the account-merge count keep the full set. The filter keys on the game's end reason, not on which seat left, so it extends to any player should the robot ever resign.
This commit is contained in:
@@ -1190,13 +1190,37 @@ func (svc *Service) SharedGame(ctx context.Context, a, b uuid.UUID) (bool, error
|
||||
return svc.store.SharedGameExists(ctx, a, b)
|
||||
}
|
||||
|
||||
// ListForAccount returns every game the account is seated in, newest first, for the
|
||||
// lobby's active/finished lists. The live position is not loaded — the summaries come
|
||||
// straight from the durable rows.
|
||||
// ListForAccount returns every game the account is seated in, newest first — the full
|
||||
// set behind the admin console, the account-merge count and the block-time forfeit
|
||||
// sweep. The player's own lobby uses ListForLobby, which drops the honest-AI games the
|
||||
// player left. The live position is not loaded — the summaries come straight from the
|
||||
// durable rows.
|
||||
func (svc *Service) ListForAccount(ctx context.Context, accountID uuid.UUID) ([]Game, error) {
|
||||
return svc.store.ListGamesForAccount(ctx, accountID)
|
||||
}
|
||||
|
||||
// ListForLobby returns the games shown in the account's own lobby: ListForAccount minus
|
||||
// the honest-AI games the player left — by resigning or by abandoning to the inactivity
|
||||
// timeout (games.end_reason 'resign'/'timeout') — which drop out of the finished list
|
||||
// automatically. The robot never leaves (it moves at once, never sleeps, never resigns),
|
||||
// so only a human's departure matches; the predicate is a game property, not a seat
|
||||
// check, so it extends to any player should the robot ever resign. Admin and
|
||||
// account-merge views keep using ListForAccount and see the full set.
|
||||
func (svc *Service) ListForLobby(ctx context.Context, accountID uuid.UUID) ([]Game, error) {
|
||||
games, err := svc.ListForAccount(ctx, accountID)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
kept := games[:0]
|
||||
for _, g := range games {
|
||||
if g.VsAI && (g.EndReason == "resign" || g.EndReason == "timeout") {
|
||||
continue
|
||||
}
|
||||
kept = append(kept, g)
|
||||
}
|
||||
return kept, nil
|
||||
}
|
||||
|
||||
// CountActiveQuickGames reports how many in-progress quick games the account holds —
|
||||
// the count the simultaneous-game limit (MaxActiveQuickGames) is checked against. It
|
||||
// counts active and still-open quick games (including honest-AI ones) and excludes
|
||||
|
||||
@@ -0,0 +1,134 @@
|
||||
//go:build integration
|
||||
|
||||
package inttest
|
||||
|
||||
import (
|
||||
"context"
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
"github.com/google/uuid"
|
||||
|
||||
"scrabble/backend/internal/engine"
|
||||
"scrabble/backend/internal/game"
|
||||
)
|
||||
|
||||
// TestListForLobbyDropsLeftAIGames covers the lobby filter (game.Service.ListForLobby): a
|
||||
// finished honest-AI game the player left — by resigning or by abandoning to the inactivity
|
||||
// timeout — drops out of the lobby list, while it stays in ListForAccount so the admin console
|
||||
// and the account-merge count keep the full set. A normally finished AI game, an active AI game
|
||||
// and a left human game all stay in the lobby. The drop is a game property, not a per-resigner
|
||||
// one, so it leaves every seat's lobby.
|
||||
func TestListForLobbyDropsLeftAIGames(t *testing.T) {
|
||||
ctx := context.Background()
|
||||
svc := newGameService()
|
||||
|
||||
createAI := func(t *testing.T) (uuid.UUID, []uuid.UUID) {
|
||||
t.Helper()
|
||||
seats := []uuid.UUID{provisionAccount(t), provisionAccount(t)}
|
||||
g, err := svc.Create(ctx, game.CreateParams{
|
||||
Variant: engine.VariantEnglish, Seats: seats, TurnTimeout: time.Hour, Seed: 1, VsAI: true,
|
||||
})
|
||||
if err != nil {
|
||||
t.Fatalf("create AI game: %v", err)
|
||||
}
|
||||
return g.ID, seats
|
||||
}
|
||||
|
||||
t.Run("resign dropped from lobby, kept in account list", func(t *testing.T) {
|
||||
gid, seats := createAI(t)
|
||||
me := seats[0]
|
||||
res, err := svc.Resign(ctx, gid, me)
|
||||
if err != nil {
|
||||
t.Fatalf("resign: %v", err)
|
||||
}
|
||||
if res.Game.EndReason != "resign" {
|
||||
t.Fatalf("end reason = %q, want resign", res.Game.EndReason)
|
||||
}
|
||||
if lobbyHas(t, svc, me, gid) {
|
||||
t.Error("resigned AI game still in the lobby list")
|
||||
}
|
||||
if !containsGame(t, svc, me, gid) {
|
||||
t.Error("resigned AI game missing from the full account list (the row must stay)")
|
||||
}
|
||||
if lobbyHas(t, svc, seats[1], gid) {
|
||||
t.Error("resigned AI game still in the opponent's lobby list (drop is per-game)")
|
||||
}
|
||||
})
|
||||
|
||||
t.Run("timeout dropped from lobby", func(t *testing.T) {
|
||||
gid, seats := createAI(t)
|
||||
me := seats[0] // seat 0 is to move and will be timed out
|
||||
// Disable the away window so the sweep is deterministic regardless of wall clock.
|
||||
setAway(t, me, "UTC", "00:00", "00:00")
|
||||
backdate(t, gid, time.Now().UTC().Add(-(game.AIInactivityTimeout + 2*time.Hour)))
|
||||
if n, err := svc.SweepTimeouts(ctx, time.Now().UTC()); err != nil || n < 1 {
|
||||
t.Fatalf("sweep swept %d (err %v), want >= 1", n, err)
|
||||
}
|
||||
if status, reason := gameStatus(t, svc, gid); status != game.StatusFinished || reason != "timeout" {
|
||||
t.Fatalf("game not timed out: status %q reason %q", status, reason)
|
||||
}
|
||||
if lobbyHas(t, svc, me, gid) {
|
||||
t.Error("abandoned (timed-out) AI game still in the lobby list")
|
||||
}
|
||||
})
|
||||
|
||||
t.Run("active AI game stays in lobby", func(t *testing.T) {
|
||||
gid, seats := createAI(t)
|
||||
if !lobbyHas(t, svc, seats[0], gid) {
|
||||
t.Error("active AI game missing from the lobby list")
|
||||
}
|
||||
})
|
||||
|
||||
t.Run("normally finished AI game stays in lobby", func(t *testing.T) {
|
||||
gid, seats := createAI(t)
|
||||
var fin game.MoveResult
|
||||
for i := 0; i < 8; i++ { // six consecutive passes end the game scoreless
|
||||
r, err := svc.Pass(ctx, gid, seats[i%2])
|
||||
if err != nil {
|
||||
t.Fatalf("pass %d: %v", i, err)
|
||||
}
|
||||
fin = r
|
||||
if r.Game.Status == game.StatusFinished {
|
||||
break
|
||||
}
|
||||
}
|
||||
if fin.Game.Status != game.StatusFinished || fin.Game.EndReason != "scoreless" {
|
||||
t.Fatalf("AI game not scoreless-finished: status %q reason %q", fin.Game.Status, fin.Game.EndReason)
|
||||
}
|
||||
if !lobbyHas(t, svc, seats[0], gid) {
|
||||
t.Error("normally finished AI game must stay in the lobby list")
|
||||
}
|
||||
})
|
||||
|
||||
t.Run("left human game stays in lobby", func(t *testing.T) {
|
||||
seats := []uuid.UUID{provisionAccount(t), provisionAccount(t)}
|
||||
g, err := svc.Create(ctx, game.CreateParams{
|
||||
Variant: engine.VariantEnglish, Seats: seats, TurnTimeout: 24 * time.Hour, Seed: 1,
|
||||
})
|
||||
if err != nil {
|
||||
t.Fatalf("create human game: %v", err)
|
||||
}
|
||||
if _, err := svc.Resign(ctx, g.ID, seats[0]); err != nil {
|
||||
t.Fatalf("resign: %v", err)
|
||||
}
|
||||
if !lobbyHas(t, svc, seats[0], g.ID) {
|
||||
t.Error("resigned human game must stay in the lobby list (only AI games are dropped)")
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
// lobbyHas reports whether the account's own lobby list (ListForLobby) includes gameID.
|
||||
func lobbyHas(t *testing.T, svc *game.Service, accountID, gameID uuid.UUID) bool {
|
||||
t.Helper()
|
||||
games, err := svc.ListForLobby(context.Background(), accountID)
|
||||
if err != nil {
|
||||
t.Fatalf("list for lobby: %v", err)
|
||||
}
|
||||
for _, g := range games {
|
||||
if g.ID == gameID {
|
||||
return true
|
||||
}
|
||||
}
|
||||
return false
|
||||
}
|
||||
@@ -421,14 +421,16 @@ func (s *Server) ensureUnderGameLimit(c *gin.Context, uid uuid.UUID) bool {
|
||||
return true
|
||||
}
|
||||
|
||||
// handleListGames returns the caller's active and finished games for the lobby.
|
||||
// handleListGames returns the caller's active and finished games for the lobby. It omits
|
||||
// the honest-AI games the caller left (resigned or abandoned to timeout); see
|
||||
// game.Service.ListForLobby.
|
||||
func (s *Server) handleListGames(c *gin.Context) {
|
||||
uid, ok := userID(c)
|
||||
if !ok {
|
||||
abortBadRequest(c, "missing identity")
|
||||
return
|
||||
}
|
||||
games, err := s.games.ListForAccount(c.Request.Context(), uid)
|
||||
games, err := s.games.ListForLobby(c.Request.Context(), uid)
|
||||
if err != nil {
|
||||
s.abortErr(c, err)
|
||||
return
|
||||
|
||||
@@ -382,7 +382,12 @@ at once, only the human is ever on the clock, so the per-turn timeout doubles as
|
||||
game emits **no `your_turn`** (the instant reply makes it redundant; `opponent_moved`
|
||||
still advances the UI), its **GCG export labels the robot seat "AI"**, and the
|
||||
games-started / -abandoned metrics carry a **`vs_ai`** attribute so AI and human games
|
||||
chart separately (the admin `/games` list and game card also show the AI flag).
|
||||
chart separately (the admin `/games` list and game card also show the AI flag). A finished
|
||||
honest-AI game the player **left** — `end_reason` `resign` or `timeout` — is also dropped from
|
||||
that player's own *finished* lobby list by `game.Service.ListForLobby` (a lobby-only filter over
|
||||
`ListForAccount`); the admin console and the account-merge count keep the full set, and a
|
||||
normally finished AI game stays. The filter keys on the game's end reason, not on which seat
|
||||
left, so it extends to any player should the robot ever resign.
|
||||
|
||||
The robot keeps **no per-game state**: every choice is derived deterministically
|
||||
from the game's bag `seed` (a restart-stable FNV-1a mix), so a background driver
|
||||
|
||||
+4
-1
@@ -80,7 +80,10 @@ finishes**, its status icon **blinks twice** to draw the eye (an opponent's-turn
|
||||
silent, applied in place). You can **remove a finished game from your own list**:
|
||||
swipe a finished row left (or, on desktop, tap its **⋮**) to reveal a **❌**, then tap it.
|
||||
The removal is per-account and permanent — the game disappears only from your list and stays
|
||||
in the other players' lists, and there is no undo. The game types offered on **New Game** are
|
||||
in the other players' lists, and there is no undo. A finished **AI game (🤖) you left** — by
|
||||
resigning or by letting it lapse to the 7-day timeout — drops from your *finished* list
|
||||
automatically, with no swipe needed; a normally finished AI game stays until you remove it, and
|
||||
no other game type is auto-removed. The game types offered on **New Game** are
|
||||
limited to the languages the player's sign-in service supports (English → Scrabble;
|
||||
Russian → Scrabble + Erudite; a bilingual service shows all three, and the web client is
|
||||
unrestricted). Variants are shown by their **display name** — both Scrabble variants read
|
||||
|
||||
@@ -81,7 +81,10 @@ nudge) приходят от бота **этой партии** — по язы
|
||||
соперника — без моргания, применяется на месте). Завершённую партию можно **убрать из своего списка**:
|
||||
проведи по строке завершённой партии влево (или, на десктопе, нажми её **⋮**), чтобы открыть
|
||||
**❌**, и нажми её. Удаление действует только для твоего аккаунта и необратимо — партия
|
||||
исчезает лишь из твоего списка и остаётся в списках других игроков, отмены нет. Типы партий
|
||||
исчезает лишь из твоего списка и остаётся в списках других игроков, отмены нет. Завершённая
|
||||
**игра с ИИ (🤖), покинутая тобой** — через сдачу или истечение 7-дневного таймаута — убирается
|
||||
из *завершённых* автоматически, без свайпа; нормально доигранная игра с ИИ остаётся, пока ты не
|
||||
уберёшь её, и никакие другие типы партий автоматически не убираются. Типы партий
|
||||
на экране **Новая игра**
|
||||
ограничены языками, которые поддерживает сервис входа игрока (английский → Scrabble;
|
||||
русский → Scrabble + Erudite; двуязычный сервис показывает все три, а веб-клиент не
|
||||
|
||||
Reference in New Issue
Block a user