fix(lobby): land in the opened game on enqueue + keep open games active in the lobby
CI / changes (pull_request) Successful in 2s
CI / unit (pull_request) Successful in 10s
CI / integration (pull_request) Successful in 17s
CI / ui (pull_request) Successful in 45s
CI / gate (pull_request) Successful in 0s
CI / deploy (pull_request) Successful in 58s

Review fixes for open-game auto-match: decodeMatchResult dropped the game when matched=false (an open game awaiting an opponent), so the client never navigated into it - decode the game whenever present. The lobby grouped open games (status != 'active') into 'finished'; treat 'open' as in progress in groupGames/isMyTurn and resultBadge. The under-board status bar now reads "Opponent's turn" while the empty opponent seat is to move (instead of the searching placeholder). The New Game rule toggle is shown from the start when a Russian variant is available, so selecting a variant no longer shifts the layout.

Regression tests: codec (game decoded with matched=false), lobbysort + result (open is in progress), and the new-game e2e updated. UI-only; no backend or schema change.
This commit is contained in:
Ilia Denisov
2026-06-13 10:29:56 +02:00
parent c305363ccd
commit a3cb917ec7
11 changed files with 76 additions and 11 deletions
+30
View File
@@ -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
View File
@@ -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 {
+1
View File
@@ -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',
+1
View File
@@ -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': 'Сделать ход',
+14
View File
@@ -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);
});
});
+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. */
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);
}
+6
View File
@@ -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',
+1 -1
View File
@@ -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: '⏳' };