feat(lobby): enter the game immediately and wait for the opponent inside it #51

Merged
developer merged 4 commits from feature/quick-game-open-wait into development 2026-06-13 09:14:51 +00:00
11 changed files with 76 additions and 11 deletions
Showing only changes of commit a3cb917ec7 - Show all commits
+7 -5
View File
@@ -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 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.goto('/');
await page.getByRole('button', { name: /guest/i }).click(); await page.getByRole('button', { name: /guest/i }).click();
await page.getByRole('button', { name: /New/ }).click(); // auto-match 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 }); const start = page.getByRole('button', { name: /Start game/i });
await expect(start).toBeDisabled(); await expect(start).toBeDisabled();
await expect(page.getByLabel('Multiple words per turn')).toHaveCount(0); const toggle = page.getByLabel('Multiple words per turn');
// Selecting the Russian Scrabble variant highlights it, enables Start, and reveals the rule toggle (off). 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 page.locator('.variant', { hasText: 'Скрэббл' }).click();
await expect(page.locator('.variant.selected')).toHaveCount(1); await expect(page.locator('.variant.selected')).toHaveCount(1);
await expect(start).toBeEnabled(); await expect(start).toBeEnabled();
const toggle = page.getByLabel('Multiple words per turn');
await expect(toggle).toBeVisible(); await expect(toggle).toBeVisible();
await expect(toggle).not.toBeChecked(); await expect(toggle).not.toBeChecked();
}); });
+9 -1
View File
@@ -769,6 +769,14 @@
return s.displayName; 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 // 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 // (not the still-empty seat of an open game) who is not yet a friend (an already-requested
// opponent still shows it, but disabled). // opponent still shows it, but disabled).
@@ -877,7 +885,7 @@
{#if gameOver} {#if gameOver}
<strong class="over">{t('game.over')}{resultText()}</strong> <strong class="over">{t('game.over')}{resultText()}</strong>
{:else if placement.pending.length === 0} {:else if placement.pending.length === 0}
<span class="turn-ind">{isMyTurn ? t('game.yourTurn') : seatName(view.game.seats[view.game.toMove])}</span> <span class="turn-ind">{isMyTurn ? t('game.yourTurn') : turnLabel()}</span>
{/if} {/if}
<span class="scores"> <span class="scores">
{#if preview}{preview.legal ? t('game.previewWords', { words: preview.words.join(', '), n: preview.score }) : t('game.previewIllegal')}{:else if !view.game.multipleWordsPerTurn}<span class="oneword" title={t('game.oneWordRule')}>1️⃣</span>{/if} {#if preview}{preview.legal ? t('game.previewWords', { words: preview.words.join(', '), n: preview.score }) : t('game.previewIllegal')}{:else if !view.game.multipleWordsPerTurn}<span class="oneword" title={t('game.oneWordRule')}>1️⃣</span>{/if}
+30
View File
@@ -9,6 +9,7 @@ import {
decodeGameList, decodeGameList,
decodeInvitation, decodeInvitation,
decodeLinkResult, decodeLinkResult,
decodeMatchResult,
decodeOutgoingList, decodeOutgoingList,
decodeSession, decodeSession,
decodeStateView, decodeStateView,
@@ -278,6 +279,35 @@ describe('codec', () => {
state: undefined, 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 // The live play loop exchanges alphabet indices, mapped through the per-variant
+3 -1
View File
@@ -407,7 +407,9 @@ export function decodeGameList(buf: Uint8Array): GameList {
export function decodeMatchResult(buf: Uint8Array): MatchResult { export function decodeMatchResult(buf: Uint8Array): MatchResult {
const m = fb.MatchResult.getRootAsMatchResult(new ByteBuffer(buf)); const m = fb.MatchResult.getRootAsMatchResult(new ByteBuffer(buf));
const g = m.game(); 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 { export function decodeChatMessage(buf: Uint8Array): ChatMessage {
+1
View File
@@ -58,6 +58,7 @@ export const en = {
'game.bagEmpty': 'Bag is empty', 'game.bagEmpty': 'Bag is empty',
'game.hints': 'Hints {n}', 'game.hints': 'Hints {n}',
'game.yourTurn': 'Your turn', 'game.yourTurn': 'Your turn',
'game.opponentsTurn': "Opponent's turn",
'game.searchingForOpponent': 'Searching for opponent…', 'game.searchingForOpponent': 'Searching for opponent…',
'game.waiting': "Waiting for {name}", 'game.waiting': "Waiting for {name}",
'game.makeMove': 'Make move', 'game.makeMove': 'Make move',
+1
View File
@@ -59,6 +59,7 @@ export const ru: Record<MessageKey, string> = {
'game.bagEmpty': 'Мешок пуст', 'game.bagEmpty': 'Мешок пуст',
'game.hints': 'Подсказки {n}', 'game.hints': 'Подсказки {n}',
'game.yourTurn': 'Ваш ход', 'game.yourTurn': 'Ваш ход',
'game.opponentsTurn': 'Ход соперника',
'game.searchingForOpponent': 'Поиск соперника...', 'game.searchingForOpponent': 'Поиск соперника...',
'game.waiting': 'Ожидаем {name}', 'game.waiting': 'Ожидаем {name}',
'game.makeMove': 'Сделать ход', 'game.makeMove': 'Сделать ход',
+14
View File
@@ -66,4 +66,18 @@ describe('groupGames', () => {
expect(isMyTurn(game('x', 'active', 0, 0), ME)).toBe(true); expect(isMyTurn(game('x', 'active', 0, 0), ME)).toBe(true);
expect(isMyTurn(game('x', 'active', 1, 0), ME)).toBe(false); 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);
});
}); });
+3 -2
View File
@@ -8,7 +8,8 @@ import type { GameView } from './model';
/** isMyTurn reports whether an active game's seat-to-move belongs to the caller. */ /** isMyTurn reports whether an active game's seat-to-move belongs to the caller. */
export function isMyTurn(game: GameView, myId: string): boolean { export function isMyTurn(game: GameView, myId: string): boolean {
const me = game.seats.find((s) => s.accountId === myId); 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. */ /** LobbyGroups holds the three ordered lobby sections. */
@@ -28,7 +29,7 @@ export function groupGames(games: GameView[], myId: string): LobbyGroups {
const theirTurn: GameView[] = []; const theirTurn: GameView[] = [];
const finished: GameView[] = []; const finished: GameView[] = [];
for (const g of games) { 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 if (isMyTurn(g, myId)) yourTurn.push(g);
else theirTurn.push(g); else theirTurn.push(g);
} }
+6
View File
@@ -35,6 +35,12 @@ describe('resultBadge', () => {
expect(resultBadge({ ...g, toMove: 1 }, 'me').key).toBe('result.oppMove'); 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', () => { it('finished two-player: victory / defeat / draw', () => {
expect(resultBadge(game([seat(0, 'me', 300, true), seat(1, 'a', 200)]), 'me')).toEqual({ expect(resultBadge(game([seat(0, 'me', 300, true), seat(1, 'a', 200)]), 'me')).toEqual({
key: 'result.victory', key: 'result.victory',
+1 -1
View File
@@ -12,7 +12,7 @@ export interface ResultBadge {
export function resultBadge(game: GameView, myId: string): ResultBadge { export function resultBadge(game: GameView, myId: string): ResultBadge {
const me = game.seats.find((s) => s.accountId === myId); 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 return game.toMove === me?.seat
? { key: 'result.yourMove', emoji: '🟢' } ? { key: 'result.yourMove', emoji: '🟢' }
: { key: 'result.oppMove', emoji: '⏳' }; : { key: 'result.oppMove', emoji: '⏳' };
+1 -1
View File
@@ -140,7 +140,7 @@
</button> </button>
{/each} {/each}
</div> </div>
{#if selectedAuto && supportsMultipleWordsToggle(selectedAuto)} {#if variants.some((v) => supportsMultipleWordsToggle(v.id))}
<label class="toggle"> <label class="toggle">
<span>{t('new.multipleWordsPerTurn')}</span> <span>{t('new.multipleWordsPerTurn')}</span>
<input type="checkbox" bind:checked={multipleWords} /> <input type="checkbox" bind:checked={multipleWords} />