feat: AI-game & resign UX cleanup (lobby drop, board reveal, GCG share fix, hide AI export) #80
@@ -1190,13 +1190,37 @@ func (svc *Service) SharedGame(ctx context.Context, a, b uuid.UUID) (bool, error
|
|||||||
return svc.store.SharedGameExists(ctx, a, b)
|
return svc.store.SharedGameExists(ctx, a, b)
|
||||||
}
|
}
|
||||||
|
|
||||||
// ListForAccount returns every game the account is seated in, newest first, for the
|
// ListForAccount returns every game the account is seated in, newest first — the full
|
||||||
// lobby's active/finished lists. The live position is not loaded — the summaries come
|
// set behind the admin console, the account-merge count and the block-time forfeit
|
||||||
// straight from the durable rows.
|
// 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) {
|
func (svc *Service) ListForAccount(ctx context.Context, accountID uuid.UUID) ([]Game, error) {
|
||||||
return svc.store.ListGamesForAccount(ctx, accountID)
|
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 —
|
// CountActiveQuickGames reports how many in-progress quick games the account holds —
|
||||||
// the count the simultaneous-game limit (MaxActiveQuickGames) is checked against. It
|
// the count the simultaneous-game limit (MaxActiveQuickGames) is checked against. It
|
||||||
// counts active and still-open quick games (including honest-AI ones) and excludes
|
// 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
|
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) {
|
func (s *Server) handleListGames(c *gin.Context) {
|
||||||
uid, ok := userID(c)
|
uid, ok := userID(c)
|
||||||
if !ok {
|
if !ok {
|
||||||
abortBadRequest(c, "missing identity")
|
abortBadRequest(c, "missing identity")
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
games, err := s.games.ListForAccount(c.Request.Context(), uid)
|
games, err := s.games.ListForLobby(c.Request.Context(), uid)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
s.abortErr(c, err)
|
s.abortErr(c, err)
|
||||||
return
|
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`
|
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
|
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
|
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
|
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
|
from the game's bag `seed` (a restart-stable FNV-1a mix), so a background driver
|
||||||
|
|||||||
+8
-4
@@ -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**:
|
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.
|
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
|
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;
|
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
|
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
|
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
|
### History & statistics
|
||||||
Finished games are archived in a dictionary-independent form and exportable to
|
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
|
GCG; the export is offered **only once a game is finished**, and never for an
|
||||||
would leak the move journal), and the client shares the `.gcg` file where the
|
honest-AI practice game (a live game's export would leak the move journal; an AI
|
||||||
platform supports it, otherwise downloads it. Statistics (durable accounts only):
|
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
|
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).
|
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
|
A game that can no longer be continued — because a rule changed and an earlier
|
||||||
|
|||||||
@@ -81,7 +81,10 @@ nudge) приходят от бота **этой партии** — по язы
|
|||||||
соперника — без моргания, применяется на месте). Завершённую партию можно **убрать из своего списка**:
|
соперника — без моргания, применяется на месте). Завершённую партию можно **убрать из своего списка**:
|
||||||
проведи по строке завершённой партии влево (или, на десктопе, нажми её **⋮**), чтобы открыть
|
проведи по строке завершённой партии влево (или, на десктопе, нажми её **⋮**), чтобы открыть
|
||||||
**❌**, и нажми её. Удаление действует только для твоего аккаунта и необратимо — партия
|
**❌**, и нажми её. Удаление действует только для твоего аккаунта и необратимо — партия
|
||||||
исчезает лишь из твоего списка и остаётся в списках других игроков, отмены нет. Типы партий
|
исчезает лишь из твоего списка и остаётся в списках других игроков, отмены нет. Завершённая
|
||||||
|
**игра с ИИ (🤖), покинутая тобой** — через сдачу или истечение 7-дневного таймаута — убирается
|
||||||
|
из *завершённых* автоматически, без свайпа; нормально доигранная игра с ИИ остаётся, пока ты не
|
||||||
|
уберёшь её, и никакие другие типы партий автоматически не убираются. Типы партий
|
||||||
на экране **Новая игра**
|
на экране **Новая игра**
|
||||||
ограничены языками, которые поддерживает сервис входа игрока (английский → Scrabble;
|
ограничены языками, которые поддерживает сервис входа игрока (английский → Scrabble;
|
||||||
русский → Scrabble + Erudite; двуязычный сервис показывает все три, а веб-клиент не
|
русский → Scrabble + Erudite; двуязычный сервис показывает все три, а веб-клиент не
|
||||||
@@ -225,9 +228,10 @@ UTC), суточного окна отсутствия (away; сетка по 10
|
|||||||
|
|
||||||
### История и статистика
|
### История и статистика
|
||||||
Завершённые партии архивируются в независимом от словаря виде и экспортируются
|
Завершённые партии архивируются в независимом от словаря виде и экспортируются
|
||||||
в GCG; экспорт доступен **только после завершения партии** (экспорт идущей партии
|
в GCG; экспорт доступен **только после завершения партии** и никогда — для
|
||||||
раскрыл бы журнал ходов), и клиент делится файлом `.gcg` там, где платформа это
|
тренировочной партии с ИИ (экспорт идущей партии раскрыл бы журнал ходов, а партия
|
||||||
поддерживает, иначе скачивает его. Статистика (только у постоянных аккаунтов):
|
с ИИ одноразовая). Клиент делится файлом `.gcg` там, где платформа это поддерживает,
|
||||||
|
иначе скачивает его. Статистика (только у постоянных аккаунтов):
|
||||||
победы, поражения, ничьи, макс. очков за партию и макс. очков за один ход (лучший
|
победы, поражения, ничьи, макс. очков за партию и макс. очков за один ход (лучший
|
||||||
ход, уже включающий все образованные им слова и бонус за все фишки).
|
ход, уже включающий все образованные им слова и бонус за все фишки).
|
||||||
Партия, которую больше нельзя продолжить — из-за изменения правил более ранний ход
|
Партия, которую больше нельзя продолжить — из-за изменения правил более ранний ход
|
||||||
|
|||||||
+4
-2
@@ -300,9 +300,11 @@ enabled on the first, uncached load) and flip in place when an event refreshes t
|
|||||||
Safari.
|
Safari.
|
||||||
- **History / GCG**: the in-game slide-down history lays each move out in a per-seat grid
|
- **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
|
(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
|
- **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
|
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
|
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
|
row is turn-driven: on your turn the message field shows until your one allowed message is
|
||||||
|
|||||||
@@ -238,6 +238,30 @@ test('dropping the game ends it and shows the result', async ({ page }) => {
|
|||||||
await expect(page.locator('.status .over')).toBeVisible();
|
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 }) => {
|
test('a placed tile drags from one board cell to another (relocation)', async ({ page }) => {
|
||||||
await openGame(page);
|
await openGame(page);
|
||||||
await page.locator('.rack .tile').first().click();
|
await page.locator('.rack .tile').first().click();
|
||||||
|
|||||||
@@ -59,6 +59,29 @@ test('AI game: 🤖 opponent, no wait, chat disabled, dictionary still works', a
|
|||||||
await expect(page.locator('.check input')).toBeEnabled();
|
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
|
// 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 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 /
|
// stream is down, and a refetch on stream reconnect. The __stream hook (lib/app.svelte.ts) drops /
|
||||||
|
|||||||
+11
-1
@@ -667,6 +667,10 @@
|
|||||||
busy = true;
|
busy = true;
|
||||||
try {
|
try {
|
||||||
applyMoveResult(await gateway.resign(id));
|
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) {
|
} catch (e) {
|
||||||
handleError(e);
|
handleError(e);
|
||||||
} finally {
|
} finally {
|
||||||
@@ -982,7 +986,13 @@
|
|||||||
{#if view}
|
{#if view}
|
||||||
<div class="hhead">
|
<div class="hhead">
|
||||||
{#if gameOver}
|
{#if gameOver}
|
||||||
<button class="hicon" onclick={exportGcg} aria-label={t('game.exportGcg')}>📤</button>
|
{#if view.game.vsAi}
|
||||||
|
<!-- GCG export is not offered for a practice AI game; an empty slot keeps the comms
|
||||||
|
icon pinned right (the header is space-between). -->
|
||||||
|
<span aria-hidden="true"></span>
|
||||||
|
{:else}
|
||||||
|
<button class="hicon" onclick={exportGcg} aria-label={t('game.exportGcg')}>📤</button>
|
||||||
|
{/if}
|
||||||
{:else}
|
{:else}
|
||||||
<button class="hicon" onclick={() => (resignOpen = true)} disabled={waitingForOpponent} aria-label={t('game.dropGame')}>🏁</button>
|
<button class="hicon" onclick={() => (resignOpen = true)} disabled={waitingForOpponent} aria-label={t('game.dropGame')}>🏁</button>
|
||||||
{/if}
|
{/if}
|
||||||
|
|||||||
@@ -1,8 +1,11 @@
|
|||||||
import { describe, expect, it } from 'vitest';
|
import { afterEach, describe, expect, it, vi } from 'vitest';
|
||||||
import { pickGcgDelivery } from './share';
|
import { pickGcgDelivery, shareOrDownloadGcg } from './share';
|
||||||
|
import type { GcgExport } from './model';
|
||||||
|
|
||||||
const file = {} as File;
|
const file = {} as File;
|
||||||
|
|
||||||
|
const gcg: GcgExport = { gameId: 'g1', filename: 'game.gcg', content: '#title game' };
|
||||||
|
|
||||||
describe('pickGcgDelivery', () => {
|
describe('pickGcgDelivery', () => {
|
||||||
it('shares when the platform can share files', () => {
|
it('shares when the platform can share files', () => {
|
||||||
expect(pickGcgDelivery({ canShare: () => true, share: async () => {} }, file)).toBe('share');
|
expect(pickGcgDelivery({ canShare: () => true, share: async () => {} }, file)).toBe('share');
|
||||||
@@ -20,3 +23,40 @@ describe('pickGcgDelivery', () => {
|
|||||||
expect(pickGcgDelivery({ canShare: () => true } as never, file)).toBe('download');
|
expect(pickGcgDelivery({ canShare: () => true } as never, file)).toBe('download');
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
|
describe('shareOrDownloadGcg', () => {
|
||||||
|
afterEach(() => vi.unstubAllGlobals());
|
||||||
|
|
||||||
|
function stubDownloadEnv(canShare: boolean, share: () => Promise<void>) {
|
||||||
|
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 <a download> 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();
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|||||||
+7
-2
@@ -25,12 +25,17 @@ export async function shareOrDownloadGcg(gcg: GcgExport): Promise<void> {
|
|||||||
const file = new File([gcg.content], gcg.filename, { type: 'application/x-gcg' });
|
const file = new File([gcg.content], gcg.filename, { type: 'application/x-gcg' });
|
||||||
const nav = typeof navigator !== 'undefined' ? navigator : undefined;
|
const nav = typeof navigator !== 'undefined' ? navigator : undefined;
|
||||||
if (pickGcgDelivery(nav, file) === 'share' && nav) {
|
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
|
||||||
|
// <a download> 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 {
|
try {
|
||||||
await nav.share({ files: [file], title: gcg.filename });
|
await nav.share({ files: [file], title: gcg.filename });
|
||||||
return;
|
|
||||||
} catch {
|
} 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);
|
downloadFile(gcg.content, gcg.filename);
|
||||||
}
|
}
|
||||||
|
|||||||
Reference in New Issue
Block a user