feat: AI-game & resign UX cleanup (lobby drop, board reveal, GCG share fix, hide AI export) #80

Merged
developer merged 4 commits from feature/lobby-drop-left-ai-games into development 2026-06-17 12:11:24 +00:00
5 changed files with 42 additions and 9 deletions
Showing only changes of commit fb0ddab0f1 - Show all commits
+4 -3
View File
@@ -224,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
+4 -3
View File
@@ -228,9 +228,10 @@ UTC), суточного окна отсутствия (away; сетка по 10
### История и статистика ### История и статистика
Завершённые партии архивируются в независимом от словаря виде и экспортируются Завершённые партии архивируются в независимом от словаря виде и экспортируются
в GCG; экспорт доступен **только после завершения партии** (экспорт идущей партии в GCG; экспорт доступен **только после завершения партии** и никогда — для
раскрыл бы журнал ходов), и клиент делится файлом `.gcg` там, где платформа это тренировочной партии с ИИ (экспорт идущей партии раскрыл бы журнал ходов, а партия
поддерживает, иначе скачивает его. Статистика (только у постоянных аккаунтов): с ИИ одноразовая). Клиент делится файлом `.gcg` там, где платформа это поддерживает,
иначе скачивает его. Статистика (только у постоянных аккаунтов):
победы, поражения, ничьи, макс. очков за партию и макс. очков за один ход (лучший победы, поражения, ничьи, макс. очков за партию и макс. очков за один ход (лучший
ход, уже включающий все образованные им слова и бонус за все фишки). ход, уже включающий все образованные им слова и бонус за все фишки).
Партия, которую больше нельзя продолжить — из-за изменения правил более ранний ход Партия, которую больше нельзя продолжить — из-за изменения правил более ранний ход
+4 -2
View File
@@ -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
+23
View File
@@ -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 /
+7 -1
View File
@@ -986,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}