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))}