feat(lobby): enter the game immediately and wait for the opponent inside it #51
+7
-5
@@ -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();
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -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}
|
||||||
|
|||||||
@@ -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
@@ -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 {
|
||||||
|
|||||||
@@ -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',
|
||||||
|
|||||||
@@ -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': 'Сделать ход',
|
||||||
|
|||||||
@@ -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);
|
||||||
|
});
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -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);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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',
|
||||||
|
|||||||
@@ -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: '⏳' };
|
||||||
|
|||||||
@@ -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} />
|
||||||
|
|||||||
Reference in New Issue
Block a user