From a3cb917ec7b6de1761242ef42432edc76a687546 Mon Sep 17 00:00:00 2001 From: Ilia Denisov Date: Sat, 13 Jun 2026 10:29:56 +0200 Subject: [PATCH] fix(lobby): land in the opened game on enqueue + keep open games active in the lobby Review fixes for open-game auto-match: decodeMatchResult dropped the game when matched=false (an open game awaiting an opponent), so the client never navigated into it - decode the game whenever present. The lobby grouped open games (status != 'active') into 'finished'; treat 'open' as in progress in groupGames/isMyTurn and resultBadge. The under-board status bar now reads "Opponent's turn" while the empty opponent seat is to move (instead of the searching placeholder). The New Game rule toggle is shown from the start when a Russian variant is available, so selecting a variant no longer shifts the layout. Regression tests: codec (game decoded with matched=false), lobbysort + result (open is in progress), and the new-game e2e updated. UI-only; no backend or schema change. --- ui/e2e/game.spec.ts | 12 +++++++----- ui/src/game/Game.svelte | 10 +++++++++- ui/src/lib/codec.test.ts | 30 ++++++++++++++++++++++++++++++ ui/src/lib/codec.ts | 4 +++- ui/src/lib/i18n/en.ts | 1 + ui/src/lib/i18n/ru.ts | 1 + ui/src/lib/lobbysort.test.ts | 14 ++++++++++++++ ui/src/lib/lobbysort.ts | 5 +++-- ui/src/lib/result.test.ts | 6 ++++++ ui/src/lib/result.ts | 2 +- ui/src/screens/NewGame.svelte | 2 +- 11 files changed, 76 insertions(+), 11 deletions(-) diff --git a/ui/e2e/game.spec.ts b/ui/e2e/game.spec.ts index f4ce823..7f464e3 100644 --- a/ui/e2e/game.spec.ts +++ b/ui/e2e/game.spec.ts @@ -70,19 +70,21 @@ test('new game: variant buttons show a rules summary and the move-limit', async await expect(page.locator('.movelimit')).toBeVisible(); // turn-time under the buttons }); -test('new game: auto-match selects a variant, then a Russian pick reveals the off-by-default toggle', async ({ page }) => { +test('new game: auto-match shows the off-by-default rule toggle from the start (no layout jump on selection)', async ({ page }) => { await page.goto('/'); await page.getByRole('button', { name: /guest/i }).click(); await page.getByRole('button', { name: /New/ }).click(); // auto-match - // Several variants are offered, so nothing is selected: Start is disabled and there is no toggle yet. + // Several variants are offered, so nothing is selected: Start is disabled. The rule toggle is shown + // from the start (a Russian variant is available), so selecting one does not shift the layout. const start = page.getByRole('button', { name: /Start game/i }); await expect(start).toBeDisabled(); - await expect(page.getByLabel('Multiple words per turn')).toHaveCount(0); - // Selecting the Russian Scrabble variant highlights it, enables Start, and reveals the rule toggle (off). + const toggle = page.getByLabel('Multiple words per turn'); + await expect(toggle).toBeVisible(); + await expect(toggle).not.toBeChecked(); + // Selecting the Russian Scrabble variant highlights it and enables Start; the toggle stays put. await page.locator('.variant', { hasText: 'Скрэббл' }).click(); await expect(page.locator('.variant.selected')).toHaveCount(1); await expect(start).toBeEnabled(); - const toggle = page.getByLabel('Multiple words per turn'); await expect(toggle).toBeVisible(); await expect(toggle).not.toBeChecked(); }); diff --git a/ui/src/game/Game.svelte b/ui/src/game/Game.svelte index 645572e..943a906 100644 --- a/ui/src/game/Game.svelte +++ b/ui/src/game/Game.svelte @@ -769,6 +769,14 @@ return s.displayName; } + // turnLabel is the under-board status when it is not the viewer's turn: the opponent's name + // once they have joined, or a generic "opponent's turn" while the seat is still empty (waiting). + function turnLabel(): string { + const s = view?.game.seats[view?.game.toMove ?? -1]; + if (s && s.accountId && s.accountId !== app.session?.userId) return s.displayName; + return t('game.opponentsTurn'); + } + // canAddFriend reports whether a seat shows the 🤝: a non-guest viewing a seated opponent // (not the still-empty seat of an open game) who is not yet a friend (an already-requested // opponent still shows it, but disabled). @@ -877,7 +885,7 @@ {#if gameOver} {t('game.over')} — {resultText()} {:else if placement.pending.length === 0} - {isMyTurn ? t('game.yourTurn') : seatName(view.game.seats[view.game.toMove])} + {isMyTurn ? t('game.yourTurn') : turnLabel()} {/if} {#if preview}{preview.legal ? t('game.previewWords', { words: preview.words.join(', '), n: preview.score }) : t('game.previewIllegal')}{:else if !view.game.multipleWordsPerTurn}1️⃣{/if} diff --git a/ui/src/lib/codec.test.ts b/ui/src/lib/codec.test.ts index 60ed7c5..121df13 100644 --- a/ui/src/lib/codec.test.ts +++ b/ui/src/lib/codec.test.ts @@ -9,6 +9,7 @@ import { decodeGameList, decodeInvitation, decodeLinkResult, + decodeMatchResult, decodeOutgoingList, decodeSession, decodeStateView, @@ -278,6 +279,35 @@ describe('codec', () => { state: undefined, }); }); + + it('decodes the match game even when matched is false (an open game awaiting an opponent)', () => { + const b = new Builder(256); + const id = b.createString('g-open'); + const variant = b.createString('scrabble_en'); + const dv = b.createString('v1'); + const status = b.createString('open'); + const er = b.createString(''); + fb.GameView.startGameView(b); + fb.GameView.addId(b, id); + fb.GameView.addVariant(b, variant); + fb.GameView.addDictVersion(b, dv); + fb.GameView.addStatus(b, status); + fb.GameView.addPlayers(b, 2); + fb.GameView.addToMove(b, 0); + fb.GameView.addTurnTimeoutSecs(b, 86400); + fb.GameView.addMoveCount(b, 0); + fb.GameView.addEndReason(b, er); + fb.GameView.addLastActivityUnix(b, BigInt(0)); + const game = fb.GameView.endGameView(b); + fb.MatchResult.startMatchResult(b); + fb.MatchResult.addMatched(b, false); + fb.MatchResult.addGame(b, game); + b.finish(fb.MatchResult.endMatchResult(b)); + const r = decodeMatchResult(b.asUint8Array()); + expect(r.matched).toBe(false); + expect(r.game?.id).toBe('g-open'); + expect(r.game?.status).toBe('open'); + }); }); // The live play loop exchanges alphabet indices, mapped through the per-variant diff --git a/ui/src/lib/codec.ts b/ui/src/lib/codec.ts index bdc7232..7882a74 100644 --- a/ui/src/lib/codec.ts +++ b/ui/src/lib/codec.ts @@ -407,7 +407,9 @@ export function decodeGameList(buf: Uint8Array): GameList { export function decodeMatchResult(buf: Uint8Array): MatchResult { const m = fb.MatchResult.getRootAsMatchResult(new ByteBuffer(buf)); const g = m.game(); - return { matched: m.matched(), game: m.matched() && g ? decodeGameView(g) : undefined }; + // Enqueue always lands the caller in a game — an open game awaiting an opponent has + // matched=false but still carries it — so decode the game whenever it is present. + return { matched: m.matched(), game: g ? decodeGameView(g) : undefined }; } export function decodeChatMessage(buf: Uint8Array): ChatMessage { diff --git a/ui/src/lib/i18n/en.ts b/ui/src/lib/i18n/en.ts index b61f6ef..c62bf5a 100644 --- a/ui/src/lib/i18n/en.ts +++ b/ui/src/lib/i18n/en.ts @@ -58,6 +58,7 @@ export const en = { 'game.bagEmpty': 'Bag is empty', 'game.hints': 'Hints {n}', 'game.yourTurn': 'Your turn', + 'game.opponentsTurn': "Opponent's turn", 'game.searchingForOpponent': 'Searching for opponent…', 'game.waiting': "Waiting for {name}", 'game.makeMove': 'Make move', diff --git a/ui/src/lib/i18n/ru.ts b/ui/src/lib/i18n/ru.ts index e5ed9e4..6e01f54 100644 --- a/ui/src/lib/i18n/ru.ts +++ b/ui/src/lib/i18n/ru.ts @@ -59,6 +59,7 @@ export const ru: Record = { 'game.bagEmpty': 'Мешок пуст', 'game.hints': 'Подсказки {n}', 'game.yourTurn': 'Ваш ход', + 'game.opponentsTurn': 'Ход соперника', 'game.searchingForOpponent': 'Поиск соперника...', 'game.waiting': 'Ожидаем {name}', 'game.makeMove': 'Сделать ход', diff --git a/ui/src/lib/lobbysort.test.ts b/ui/src/lib/lobbysort.test.ts index 962ecbb..dd3fea4 100644 --- a/ui/src/lib/lobbysort.test.ts +++ b/ui/src/lib/lobbysort.test.ts @@ -66,4 +66,18 @@ describe('groupGames', () => { expect(isMyTurn(game('x', 'active', 0, 0), ME)).toBe(true); expect(isMyTurn(game('x', 'active', 1, 0), ME)).toBe(false); }); + + it('treats an open game (awaiting an opponent) as in progress, not finished', () => { + const g = groupGames( + [ + game('open_mine', 'open', 0, 100), // my turn while waiting + game('open_wait', 'open', 1, 100), // the empty seat's turn + ], + ME, + ); + expect(g.yourTurn.map((x) => x.id)).toEqual(['open_mine']); + expect(g.theirTurn.map((x) => x.id)).toEqual(['open_wait']); + expect(g.finished).toEqual([]); + expect(isMyTurn(game('x', 'open', 0, 0), ME)).toBe(true); + }); }); diff --git a/ui/src/lib/lobbysort.ts b/ui/src/lib/lobbysort.ts index 8cad94e..b01631b 100644 --- a/ui/src/lib/lobbysort.ts +++ b/ui/src/lib/lobbysort.ts @@ -8,7 +8,8 @@ import type { GameView } from './model'; /** isMyTurn reports whether an active game's seat-to-move belongs to the caller. */ export function isMyTurn(game: GameView, myId: string): boolean { const me = game.seats.find((s) => s.accountId === myId); - return game.status === 'active' && !!me && game.toMove === me.seat; + // 'open' (an auto-match game still awaiting an opponent) is in progress like 'active'. + return (game.status === 'active' || game.status === 'open') && !!me && game.toMove === me.seat; } /** LobbyGroups holds the three ordered lobby sections. */ @@ -28,7 +29,7 @@ export function groupGames(games: GameView[], myId: string): LobbyGroups { const theirTurn: GameView[] = []; const finished: GameView[] = []; for (const g of games) { - if (g.status !== 'active') finished.push(g); + if (g.status !== 'active' && g.status !== 'open') finished.push(g); else if (isMyTurn(g, myId)) yourTurn.push(g); else theirTurn.push(g); } diff --git a/ui/src/lib/result.test.ts b/ui/src/lib/result.test.ts index 87d12f2..be4ebc4 100644 --- a/ui/src/lib/result.test.ts +++ b/ui/src/lib/result.test.ts @@ -35,6 +35,12 @@ describe('resultBadge', () => { expect(resultBadge({ ...g, toMove: 1 }, 'me').key).toBe('result.oppMove'); }); + it('open (awaiting an opponent) reads as in-progress, not a finished result', () => { + const g = game([seat(0, 'me', 0), seat(1, '', 0)], 'open', 0); + expect(resultBadge(g, 'me')).toEqual({ key: 'result.yourMove', emoji: '🟢' }); + expect(resultBadge({ ...g, toMove: 1 }, 'me').key).toBe('result.oppMove'); + }); + it('finished two-player: victory / defeat / draw', () => { expect(resultBadge(game([seat(0, 'me', 300, true), seat(1, 'a', 200)]), 'me')).toEqual({ key: 'result.victory', diff --git a/ui/src/lib/result.ts b/ui/src/lib/result.ts index d177b94..19360e2 100644 --- a/ui/src/lib/result.ts +++ b/ui/src/lib/result.ts @@ -12,7 +12,7 @@ export interface ResultBadge { export function resultBadge(game: GameView, myId: string): ResultBadge { const me = game.seats.find((s) => s.accountId === myId); - if (game.status === 'active') { + if (game.status === 'active' || game.status === 'open') { return game.toMove === me?.seat ? { key: 'result.yourMove', emoji: '🟢' } : { key: 'result.oppMove', emoji: '⏳' }; diff --git a/ui/src/screens/NewGame.svelte b/ui/src/screens/NewGame.svelte index 06d1871..7a048a2 100644 --- a/ui/src/screens/NewGame.svelte +++ b/ui/src/screens/NewGame.svelte @@ -140,7 +140,7 @@ {/each} - {#if selectedAuto && supportsMultipleWordsToggle(selectedAuto)} + {#if variants.some((v) => supportsMultipleWordsToggle(v.id))}