From 2f4aa1b75bea6c88d828679d57ad4c58b3ae47cf Mon Sep 17 00:00:00 2001 From: Ilia Denisov Date: Wed, 17 Jun 2026 13:03:35 +0200 Subject: [PATCH 1/4] feat(lobby): drop left honest-AI games from the finished list MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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. --- backend/internal/game/service.go | 30 +++- backend/internal/inttest/lobby_filter_test.go | 134 ++++++++++++++++++ backend/internal/server/handlers_game.go | 6 +- docs/ARCHITECTURE.md | 7 +- docs/FUNCTIONAL.md | 5 +- docs/FUNCTIONAL_ru.md | 5 +- 6 files changed, 179 insertions(+), 8 deletions(-) create mode 100644 backend/internal/inttest/lobby_filter_test.go 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..bc69e80 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 diff --git a/docs/FUNCTIONAL_ru.md b/docs/FUNCTIONAL_ru.md index 0f618a7..3d8beed 100644 --- a/docs/FUNCTIONAL_ru.md +++ b/docs/FUNCTIONAL_ru.md @@ -81,7 +81,10 @@ nudge) приходят от бота **этой партии** — по язы соперника — без моргания, применяется на месте). Завершённую партию можно **убрать из своего списка**: проведи по строке завершённой партии влево (или, на десктопе, нажми её **⋮**), чтобы открыть **❌**, и нажми её. Удаление действует только для твоего аккаунта и необратимо — партия -исчезает лишь из твоего списка и остаётся в списках других игроков, отмены нет. Типы партий +исчезает лишь из твоего списка и остаётся в списках других игроков, отмены нет. Завершённая +**игра с ИИ (🤖), покинутая тобой** — через сдачу или истечение 7-дневного таймаута — убирается +из *завершённых* автоматически, без свайпа; нормально доигранная игра с ИИ остаётся, пока ты не +уберёшь её, и никакие другие типы партий автоматически не убираются. Типы партий на экране **Новая игра** ограничены языками, которые поддерживает сервис входа игрока (английский → Scrabble; русский → Scrabble + Erudite; двуязычный сервис показывает все три, а веб-клиент не -- 2.52.0 From 2c24d54047cf9bbfb802b1faa5720e590dabcdc7 Mon Sep 17 00:00:00 2001 From: Ilia Denisov Date: Wed, 17 Jun 2026 13:33:27 +0200 Subject: [PATCH 2/4] feat(ui): reveal the full board on resign (close history, zoom out) After the player confirms a resign, close the move-history drawer (portrait; the landscape dock is unaffected) and zoom the board out if it was magnified, so the resigned game shows its full final board. --- ui/e2e/game.spec.ts | 24 ++++++++++++++++++++++++ ui/src/game/Game.svelte | 4 ++++ 2 files changed, 28 insertions(+) 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/src/game/Game.svelte b/ui/src/game/Game.svelte index a11dd47..8bc1c20 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 { -- 2.52.0 From e850ecd83b4070ba76aaeafd5d47cf5e92d1d0c4 Mon Sep 17 00:00:00 2001 From: Ilia Denisov Date: Wed, 17 Jun 2026 14:01:02 +0200 Subject: [PATCH 3/4] fix(ui): don't strand the Mini App on a cancelled GCG share MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit On iOS WKWebView (the Telegram Mini App), cancelling the Web Share sheet fell through to the Blob fallback. iOS ignores the download attribute, so clicking the anchor navigated the webview to the blob: URL — replacing the SPA with the raw GCG file, with no way back (force-quit only). The share path no longer falls back to a download: Web Share is available on that platform, so a cancelled or failed share is a no-op and the user can retry. The Blob download stays the desktop-only path (no Web Share). --- ui/src/lib/share.test.ts | 44 ++++++++++++++++++++++++++++++++++++++-- ui/src/lib/share.ts | 9 ++++++-- 2 files changed, 49 insertions(+), 4 deletions(-) diff --git a/ui/src/lib/share.test.ts b/ui/src/lib/share.test.ts index fbb98ff..4551802 100644 --- a/ui/src/lib/share.test.ts +++ b/ui/src/lib/share.test.ts @@ -1,8 +1,11 @@ -import { describe, expect, it } from 'vitest'; -import { pickGcgDelivery } from './share'; +import { afterEach, describe, expect, it, vi } from 'vitest'; +import { pickGcgDelivery, shareOrDownloadGcg } from './share'; +import type { GcgExport } from './model'; const file = {} as File; +const gcg: GcgExport = { gameId: 'g1', filename: 'game.gcg', content: '#title game' }; + describe('pickGcgDelivery', () => { it('shares when the platform can share files', () => { expect(pickGcgDelivery({ canShare: () => true, share: async () => {} }, file)).toBe('share'); @@ -20,3 +23,40 @@ describe('pickGcgDelivery', () => { expect(pickGcgDelivery({ canShare: () => true } as never, file)).toBe('download'); }); }); + +describe('shareOrDownloadGcg', () => { + afterEach(() => vi.unstubAllGlobals()); + + function stubDownloadEnv(canShare: boolean, share: () => Promise) { + const anchor = { href: '', download: '', click: vi.fn(), remove: vi.fn() }; + const createElement = vi.fn(() => anchor); + vi.stubGlobal('File', class {}); + vi.stubGlobal('Blob', class {}); + vi.stubGlobal('navigator', { canShare: () => canShare, share }); + vi.stubGlobal('document', { createElement, body: { appendChild: vi.fn() } }); + vi.stubGlobal('URL', { createObjectURL: vi.fn(() => 'blob:x'), revokeObjectURL: vi.fn() }); + return { anchor, createElement }; + } + + it('never falls back to the navigating Blob download when a share is cancelled', async () => { + // Reproduces the iOS Telegram Mini App break: Web Share is available and the user cancels it + // (AbortError). The fallback navigates the WKWebView to the blob: URL and strands + // the app, so a cancelled (or failed) share must do nothing here. + const share = vi.fn().mockRejectedValue(new DOMException('cancelled', 'AbortError')); + const { createElement } = stubDownloadEnv(true, share); + + await shareOrDownloadGcg(gcg); + + expect(share).toHaveBeenCalledOnce(); + expect(createElement).not.toHaveBeenCalled(); // no download anchor → no webview navigation + }); + + it('downloads via an anchor when the platform cannot share files', async () => { + const { anchor, createElement } = stubDownloadEnv(false, vi.fn()); + + await shareOrDownloadGcg(gcg); + + expect(createElement).toHaveBeenCalledWith('a'); + expect(anchor.click).toHaveBeenCalledOnce(); + }); +}); diff --git a/ui/src/lib/share.ts b/ui/src/lib/share.ts index 33587b4..757aef2 100644 --- a/ui/src/lib/share.ts +++ b/ui/src/lib/share.ts @@ -25,12 +25,17 @@ export async function shareOrDownloadGcg(gcg: GcgExport): Promise { const file = new File([gcg.content], gcg.filename, { type: 'application/x-gcg' }); const nav = typeof navigator !== 'undefined' ? navigator : undefined; if (pickGcgDelivery(nav, file) === 'share' && nav) { + // Web Share is available (mobile, including the iOS Telegram Mini App): use it and stop here + // whatever the outcome. Do NOT fall back to the Blob download — on iOS WKWebView an + // navigates the webview to the blob: URL, replacing the SPA with the raw file + // and stranding the app, so a cancelled or failed share must simply do nothing (the user can + // retry). The download path is only for desktop browsers without Web Share. try { await nav.share({ files: [file], title: gcg.filename }); - return; } catch { - // The user cancelled or sharing failed — fall back to a download. + /* cancelled or failed — intentionally a no-op (see above) */ } + return; } downloadFile(gcg.content, gcg.filename); } -- 2.52.0 From fb0ddab0f1ebfab6f3eda181560552184ede830e Mon Sep 17 00:00:00 2001 From: Ilia Denisov Date: Wed, 17 Jun 2026 14:01:08 +0200 Subject: [PATCH 4/4] feat(ui): hide GCG export in honest-AI games MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit A vs_ai game is throwaway practice, so its finished history header no longer offers the 📤 GCG export (an empty slot keeps the comms icon pinned right). Docs note the AI exclusion; UI_DESIGN also records that confirming a resign reveals the full board (closes the history drawer, zooms out). --- docs/FUNCTIONAL.md | 7 ++++--- docs/FUNCTIONAL_ru.md | 7 ++++--- docs/UI_DESIGN.md | 6 ++++-- ui/e2e/quickmatch.spec.ts | 23 +++++++++++++++++++++++ ui/src/game/Game.svelte | 8 +++++++- 5 files changed, 42 insertions(+), 9 deletions(-) diff --git a/docs/FUNCTIONAL.md b/docs/FUNCTIONAL.md index bc69e80..e6bd4de 100644 --- a/docs/FUNCTIONAL.md +++ b/docs/FUNCTIONAL.md @@ -224,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 3d8beed..025ffa9 100644 --- a/docs/FUNCTIONAL_ru.md +++ b/docs/FUNCTIONAL_ru.md @@ -228,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/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 8bc1c20..b098ea3 100644 --- a/ui/src/game/Game.svelte +++ b/ui/src/game/Game.svelte @@ -986,7 +986,13 @@ {#if view}
{#if gameOver} - + {#if view.game.vsAi} + + + {:else} + + {/if} {:else} {/if} -- 2.52.0