diff --git a/backend/internal/game/service.go b/backend/internal/game/service.go index 3053a25..b2c0e89 100644 --- a/backend/internal/game/service.go +++ b/backend/internal/game/service.go @@ -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 diff --git a/backend/internal/inttest/lobby_filter_test.go b/backend/internal/inttest/lobby_filter_test.go new file mode 100644 index 0000000..2ff15ce --- /dev/null +++ b/backend/internal/inttest/lobby_filter_test.go @@ -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 +} diff --git a/backend/internal/server/handlers_game.go b/backend/internal/server/handlers_game.go index 7aaec0f..f6440de 100644 --- a/backend/internal/server/handlers_game.go +++ b/backend/internal/server/handlers_game.go @@ -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 diff --git a/docs/ARCHITECTURE.md b/docs/ARCHITECTURE.md index 9120572..001b408 100644 --- a/docs/ARCHITECTURE.md +++ b/docs/ARCHITECTURE.md @@ -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 diff --git a/docs/FUNCTIONAL.md b/docs/FUNCTIONAL.md index be38e7d..e6bd4de 100644 --- a/docs/FUNCTIONAL.md +++ b/docs/FUNCTIONAL.md @@ -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 @@ -221,9 +224,10 @@ barred from feedback (a role, not a full account block) sees the send control di ### History & statistics Finished games are archived in a dictionary-independent form and exportable to -GCG; the export is offered **only once a game is finished** (exporting a live game -would leak the move journal), and the client shares the `.gcg` file where the -platform supports it, otherwise downloads it. Statistics (durable accounts only): +GCG; the export is offered **only once a game is finished**, and never for an +honest-AI practice game (a live game's export would leak the move journal; an AI +game is throwaway). The client shares the `.gcg` file where the platform supports +it, otherwise downloads it. Statistics (durable accounts only): wins, losses, draws, max points in a game, and max points for a single move (the best play, which already includes every word it formed plus the all-tiles bonus). A game that can no longer be continued — because a rule changed and an earlier diff --git a/docs/FUNCTIONAL_ru.md b/docs/FUNCTIONAL_ru.md index 0f618a7..025ffa9 100644 --- a/docs/FUNCTIONAL_ru.md +++ b/docs/FUNCTIONAL_ru.md @@ -81,7 +81,10 @@ nudge) приходят от бота **этой партии** — по язы соперника — без моргания, применяется на месте). Завершённую партию можно **убрать из своего списка**: проведи по строке завершённой партии влево (или, на десктопе, нажми её **⋮**), чтобы открыть **❌**, и нажми её. Удаление действует только для твоего аккаунта и необратимо — партия -исчезает лишь из твоего списка и остаётся в списках других игроков, отмены нет. Типы партий +исчезает лишь из твоего списка и остаётся в списках других игроков, отмены нет. Завершённая +**игра с ИИ (🤖), покинутая тобой** — через сдачу или истечение 7-дневного таймаута — убирается +из *завершённых* автоматически, без свайпа; нормально доигранная игра с ИИ остаётся, пока ты не +уберёшь её, и никакие другие типы партий автоматически не убираются. Типы партий на экране **Новая игра** ограничены языками, которые поддерживает сервис входа игрока (английский → Scrabble; русский → Scrabble + Erudite; двуязычный сервис показывает все три, а веб-клиент не @@ -225,9 +228,10 @@ UTC), суточного окна отсутствия (away; сетка по 10 ### История и статистика Завершённые партии архивируются в независимом от словаря виде и экспортируются -в GCG; экспорт доступен **только после завершения партии** (экспорт идущей партии -раскрыл бы журнал ходов), и клиент делится файлом `.gcg` там, где платформа это -поддерживает, иначе скачивает его. Статистика (только у постоянных аккаунтов): +в GCG; экспорт доступен **только после завершения партии** и никогда — для +тренировочной партии с ИИ (экспорт идущей партии раскрыл бы журнал ходов, а партия +с ИИ одноразовая). Клиент делится файлом `.gcg` там, где платформа это поддерживает, +иначе скачивает его. Статистика (только у постоянных аккаунтов): победы, поражения, ничьи, макс. очков за партию и макс. очков за один ход (лучший ход, уже включающий все образованные им слова и бонус за все фишки). Партия, которую больше нельзя продолжить — из-за изменения правил более ранний ход diff --git a/docs/UI_DESIGN.md b/docs/UI_DESIGN.md index 469bd1a..53b6976 100644 --- a/docs/UI_DESIGN.md +++ b/docs/UI_DESIGN.md @@ -300,9 +300,11 @@ enabled on the first, uncached load) and flip in place when an event refreshes t Safari. - **History / GCG**: the in-game slide-down history lays each move out in a per-seat grid (the word(s) and the move score, no running total); *Export GCG* (the 📤 in the history - header) shares or downloads the `.gcg` file and appears only once the game is finished. + header) shares or downloads the `.gcg` file and appears only once the game is finished — and + never in an honest-AI game (throwaway practice). Confirming a resign reveals the full board: + it closes the history drawer (portrait) and zooms the board out. - **Finished game**: the board keeps no last-word highlight and no zoom; the history header - offers *Export GCG* (not *Drop game*) and the comms hub hides the 🔎 *Dictionary* tab; and + offers *Export GCG* (not *Drop game*; an AI game offers neither) and the comms hub hides the 🔎 *Dictionary* tab; and the footer (rack + tab bar) is **drawn but inert** (greyed, non-interactive) rather than hidden, so the layout does not jump. Chat send / nudge are the ⬆️ / 🛎️ icons. The input row is turn-driven: on your turn the message field shows until your one allowed message is diff --git a/ui/e2e/game.spec.ts b/ui/e2e/game.spec.ts index db5c8a1..750e559 100644 --- a/ui/e2e/game.spec.ts +++ b/ui/e2e/game.spec.ts @@ -238,6 +238,30 @@ test('dropping the game ends it and shows the result', async ({ page }) => { await expect(page.locator('.status .over')).toBeVisible(); }); +test('resigning reveals the full board: closes the history and zooms out', async ({ page }) => { + await openGame(page); + + // Zoom in (double-tap an empty cell), then open the history — the two states the resign + // confirmation must clear so the resigned game shows its full board. + await page + .locator('[data-cell]:not(.filled)') + .nth(20) + .evaluate((el: HTMLElement) => { + el.click(); + el.click(); + }); + await expect(page.locator('.viewport.zoomed')).toBeVisible(); + await page.locator('.scoreboard').click(); // open the history (the 🏁 lives in its header) + await expect(page.locator('.boardwrap.slid')).toBeVisible(); + + await page.getByRole('button', { name: 'Drop game' }).click(); // 🏁 + await page.locator('button.danger').click(); // confirm + + await expect(page.locator('.status .over')).toBeVisible(); // the game ended + await expect(page.locator('.history')).toHaveCount(0); // history auto-closed (portrait) + await expect(page.locator('.viewport.zoomed')).toHaveCount(0); // board zoomed out +}); + test('a placed tile drags from one board cell to another (relocation)', async ({ page }) => { await openGame(page); await page.locator('.rack .tile').first().click(); diff --git a/ui/e2e/quickmatch.spec.ts b/ui/e2e/quickmatch.spec.ts index dc66901..2a2318d 100644 --- a/ui/e2e/quickmatch.spec.ts +++ b/ui/e2e/quickmatch.spec.ts @@ -59,6 +59,29 @@ test('AI game: 🤖 opponent, no wait, chat disabled, dictionary still works', a await expect(page.locator('.check input')).toBeEnabled(); }); +// GCG export is pointless for a practice AI game, so the finished AI game hides the 📤 export +// (the comms hub stays). Start an AI game, resign it, reopen the history and check the header. +test('AI game: no GCG export offered after it ends', async ({ page }) => { + await page.goto('/'); + await page.getByRole('button', { name: /guest/i }).click(); + await page.getByRole('button', { name: /New/ }).click(); + await page.locator('.variant').first().click(); // AI is the default opponent + await page.getByRole('button', { name: /Start game/i }).click(); + await expect(page.locator('.scoreboard').getByText('🤖')).toBeVisible(); + + // Resign the practice game from the history header. + await page.locator('.scoreboard').click(); + await page.getByRole('button', { name: 'Drop game' }).click(); + await page.locator('button.danger').click(); + await expect(page.locator('.status .over')).toBeVisible(); + + // Reopen the history (resigning auto-closed it): the finished AI game offers no GCG export, + // while the comms hub stays reachable. + await page.locator('.scoreboard').click(); + await expect(page.getByRole('button', { name: 'Export GCG' })).toHaveCount(0); + await expect(page.getByRole('button', { name: 'Chat' })).toBeVisible(); +}); + // The opponent_joined push is best-effort and never replayed, so a join that lands while the live // stream is down is lost. The waiting game screen recovers it without a push: a poll while the // stream is down, and a refetch on stream reconnect. The __stream hook (lib/app.svelte.ts) drops / diff --git a/ui/src/game/Game.svelte b/ui/src/game/Game.svelte index a11dd47..b098ea3 100644 --- a/ui/src/game/Game.svelte +++ b/ui/src/game/Game.svelte @@ -667,6 +667,10 @@ busy = true; try { applyMoveResult(await gateway.resign(id)); + // Reveal the final board once the game is resigned: close the move-history drawer + // (portrait — the landscape dock is unaffected) and zoom out if it was magnified. + historyOpen = false; + zoomed = false; } catch (e) { handleError(e); } finally { @@ -982,7 +986,13 @@ {#if view}