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
|
||||
});
|
||||
|
||||
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();
|
||||
});
|
||||
|
||||
@@ -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}
|
||||
<strong class="over">{t('game.over')} — {resultText()}</strong>
|
||||
{: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}
|
||||
<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}
|
||||
|
||||
@@ -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
|
||||
|
||||
+3
-1
@@ -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 {
|
||||
|
||||
@@ -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',
|
||||
|
||||
@@ -59,6 +59,7 @@ export const ru: Record<MessageKey, string> = {
|
||||
'game.bagEmpty': 'Мешок пуст',
|
||||
'game.hints': 'Подсказки {n}',
|
||||
'game.yourTurn': 'Ваш ход',
|
||||
'game.opponentsTurn': 'Ход соперника',
|
||||
'game.searchingForOpponent': 'Поиск соперника...',
|
||||
'game.waiting': 'Ожидаем {name}',
|
||||
'game.makeMove': 'Сделать ход',
|
||||
|
||||
@@ -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);
|
||||
});
|
||||
});
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
|
||||
@@ -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',
|
||||
|
||||
@@ -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: '⏳' };
|
||||
|
||||
@@ -140,7 +140,7 @@
|
||||
</button>
|
||||
{/each}
|
||||
</div>
|
||||
{#if selectedAuto && supportsMultipleWordsToggle(selectedAuto)}
|
||||
{#if variants.some((v) => supportsMultipleWordsToggle(v.id))}
|
||||
<label class="toggle">
|
||||
<span>{t('new.multipleWordsPerTurn')}</span>
|
||||
<input type="checkbox" bind:checked={multipleWords} />
|
||||
|
||||
Reference in New Issue
Block a user