Promote development → master (initial production release: pre-release line + Stage 18) #104
@@ -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