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