feat(lobby): enter the game immediately and wait for the opponent inside it
CI / changes (pull_request) Successful in 1s
CI / unit (pull_request) Successful in 8s
CI / integration (pull_request) Successful in 14s
CI / ui (pull_request) Successful in 45s
CI / gate (pull_request) Successful in 1s
CI / deploy (pull_request) Successful in 1m4s
CI / changes (pull_request) Successful in 1s
CI / unit (pull_request) Successful in 8s
CI / integration (pull_request) Successful in 14s
CI / ui (pull_request) Successful in 45s
CI / gate (pull_request) Successful in 1s
CI / deploy (pull_request) Successful in 1m4s
Quick auto-match no longer waits on a separate screen: Enqueue opens a real game seating the caller with an empty opponent seat (new game status 'open') and the player enters it at once. A second human searching the same variant+rule joins that open game; otherwise a background reaper seats a robot after a 90s + random 0-90s wait, pushing a new in-app opponent_joined event that fills the opponent card and re-enables resign and chat in place. Matchmaking state is now the open games in the database (the in-memory pool, lobby.poll and lobby.cancel are gone), serialised by a per-bucket advisory lock. While a game is open the starter may move on their turn, but resign, chat and nudge are refused; the lobby and opponent card show "searching for opponent". Schema edited in the baseline (no prod data): 'open' status, nullable game_players.account_id for the empty seat, and a games.open_deadline_at stamp; jet code regenerated.
This commit is contained in:
@@ -4,6 +4,7 @@ import * as fb from '../gen/fbs/scrabblefb';
|
||||
import { BLANK_INDEX, setAlphabet } from './alphabet';
|
||||
import {
|
||||
decodeDraftView,
|
||||
decodeEvent,
|
||||
decodeFriendList,
|
||||
decodeGameList,
|
||||
decodeInvitation,
|
||||
@@ -264,6 +265,19 @@ describe('codec', () => {
|
||||
expect(inv.invitees[0]).toEqual({ accountId: 'inv-1', displayName: 'Friend', seat: 1, response: 'pending' });
|
||||
expect(inv.variant).toBe('scrabble_en');
|
||||
});
|
||||
|
||||
it('decodes an opponent_joined event (reusing the match_found payload layout)', () => {
|
||||
const b = new Builder(64);
|
||||
const gid = b.createString('g-open');
|
||||
fb.MatchFoundEvent.startMatchFoundEvent(b);
|
||||
fb.MatchFoundEvent.addGameId(b, gid);
|
||||
b.finish(fb.MatchFoundEvent.endMatchFoundEvent(b));
|
||||
expect(decodeEvent('opponent_joined', b.asUint8Array())).toEqual({
|
||||
kind: 'opponent_joined',
|
||||
gameId: 'g-open',
|
||||
state: undefined,
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
// The live play loop exchanges alphabet indices, mapped through the per-variant
|
||||
|
||||
@@ -465,6 +465,12 @@ export function decodeEvent(kind: string, payload: Uint8Array): PushEvent | null
|
||||
const st = e.state();
|
||||
return { kind: 'match_found', gameId: s(e.gameId()), state: st ? decodeStateViewTable(st) : undefined };
|
||||
}
|
||||
case 'opponent_joined': {
|
||||
// opponent_joined reuses the match_found payload layout (game id + the recipient's state).
|
||||
const e = fb.MatchFoundEvent.getRootAsMatchFoundEvent(bb);
|
||||
const st = e.state();
|
||||
return { kind: 'opponent_joined', gameId: s(e.gameId()), state: st ? decodeStateViewTable(st) : undefined };
|
||||
}
|
||||
case 'notify': {
|
||||
const e = fb.NotificationEvent.getRootAsNotificationEvent(bb);
|
||||
const acc = e.account();
|
||||
|
||||
@@ -20,4 +20,9 @@ if (isMock && typeof window !== 'undefined') {
|
||||
offline: reportOffline,
|
||||
online: reportOnline,
|
||||
};
|
||||
// Drive the auto-match opponent join deterministically from the e2e (the mock otherwise
|
||||
// attaches a robot on a timer).
|
||||
(window as unknown as { __mock?: { joinOpponent(): void } }).__mock = {
|
||||
joinOpponent: () => (gateway as MockGateway).joinPendingOpponent(),
|
||||
};
|
||||
}
|
||||
|
||||
@@ -51,11 +51,14 @@ export const en = {
|
||||
'new.rulesRussian': '104 tiles · ё is a letter · bingo +50',
|
||||
'new.rulesErudit': '131 tiles · ё = е · no centre ×2 · bonus +15',
|
||||
'new.moveLimit': 'Move time: {n} h 00 min',
|
||||
'new.searchHint':
|
||||
'Finding an opponent can sometimes take a while. If you do not want to wait, close the app after starting the game and come back in a couple of minutes.',
|
||||
|
||||
'game.bag': '{n} in the bag',
|
||||
'game.bagEmpty': 'Bag is empty',
|
||||
'game.hints': 'Hints {n}',
|
||||
'game.yourTurn': 'Your turn',
|
||||
'game.searchingForOpponent': 'Searching for opponent…',
|
||||
'game.waiting': "Waiting for {name}",
|
||||
'game.makeMove': 'Make move',
|
||||
'game.reset': 'Reset',
|
||||
|
||||
@@ -52,11 +52,14 @@ export const ru: Record<MessageKey, string> = {
|
||||
'new.rulesRussian': '104 фишки · ё — отдельная буква · бинго +50',
|
||||
'new.rulesErudit': '131 фишка · ё = е · центр не удваивает · бонус +15',
|
||||
'new.moveLimit': 'Время на ход: {n} ч. 00 мин.',
|
||||
'new.searchHint':
|
||||
'Иногда поиск соперника может занимать некоторое время. Если не хотите ждать, после начала игры закройте приложение и возвращайтесь через пару минут.',
|
||||
|
||||
'game.bag': '{n} в мешке',
|
||||
'game.bagEmpty': 'Мешок пуст',
|
||||
'game.hints': 'Подсказки {n}',
|
||||
'game.yourTurn': 'Ваш ход',
|
||||
'game.searchingForOpponent': 'Поиск соперника...',
|
||||
'game.waiting': 'Ожидаем {name}',
|
||||
'game.makeMove': 'Сделать ход',
|
||||
'game.reset': 'Сброс',
|
||||
|
||||
@@ -88,6 +88,8 @@ export class MockGateway implements GatewayClient {
|
||||
private readonly profile: Profile = { ...PROFILE };
|
||||
private readonly subs = new Set<(e: PushEvent) => void>();
|
||||
private pendingMatch: string | null = null;
|
||||
// The most recently opened auto-match game still awaiting an opponent, for the e2e join hook.
|
||||
private openGameId: string | null = null;
|
||||
private friends: AccountRef[] = MOCK_FRIENDS.map((f) => ({ ...f }));
|
||||
private incoming: AccountRef[] = MOCK_INCOMING.map((f) => ({ ...f }));
|
||||
private outgoing: AccountRef[] = [];
|
||||
@@ -143,14 +145,16 @@ export class MockGateway implements GatewayClient {
|
||||
|
||||
// --- lobby ---
|
||||
async lobbyEnqueue(variant: Variant, multipleWords: boolean): Promise<MatchResult> {
|
||||
// Simulate a 10s-style robot substitution, sped up: match found shortly.
|
||||
// The player enters an open game immediately and waits inside it; a robot opponent takes
|
||||
// the empty seat shortly (a sped-up version of the backend's 90–180 s wait), pushing
|
||||
// opponent_joined so the game UI restores from the "searching for opponent" state.
|
||||
const id = crypto.randomUUID();
|
||||
const g: MockGame = {
|
||||
view: {
|
||||
id,
|
||||
variant,
|
||||
dictVersion: 'v1',
|
||||
status: 'active',
|
||||
status: 'open',
|
||||
players: 2,
|
||||
toMove: 0,
|
||||
turnTimeoutSecs: 86400,
|
||||
@@ -160,7 +164,7 @@ export class MockGateway implements GatewayClient {
|
||||
lastActivityUnix: Math.floor(Date.now() / 1000),
|
||||
seats: [
|
||||
{ seat: 0, accountId: ME, displayName: 'You', score: 0, hintsUsed: 0, isWinner: false },
|
||||
{ seat: 1, accountId: 'robot', displayName: 'Robo', score: 0, hintsUsed: 0, isWinner: false },
|
||||
{ seat: 1, accountId: '', displayName: '', score: 0, hintsUsed: 0, isWinner: false },
|
||||
],
|
||||
},
|
||||
moves: [],
|
||||
@@ -170,9 +174,28 @@ export class MockGateway implements GatewayClient {
|
||||
chat: [],
|
||||
};
|
||||
this.games.set(id, g);
|
||||
this.pendingMatch = id;
|
||||
setTimeout(() => this.emit({ kind: 'match_found', gameId: id }), 1400);
|
||||
return { matched: false };
|
||||
this.openGameId = id;
|
||||
// The opponent joins on a timer for manual mock play; the e2e triggers it deterministically
|
||||
// through the __mock hook (see lib/gateway.ts).
|
||||
setTimeout(() => this.fillOpponent(id), 3000);
|
||||
return { matched: false, game: structuredClone(g.view) };
|
||||
}
|
||||
|
||||
// fillOpponent seats a robot in an open game's empty seat and pushes opponent_joined — the
|
||||
// mock of a human or robot taking the seat. A no-op once the game is no longer open.
|
||||
private fillOpponent(id: string): void {
|
||||
const game = this.games.get(id);
|
||||
if (!game || game.view.status !== 'open') return;
|
||||
game.view.status = 'active';
|
||||
game.view.seats[1] = { seat: 1, accountId: 'robot', displayName: 'Robo', score: 0, hintsUsed: 0, isWinner: false };
|
||||
this.emit({ kind: 'opponent_joined', gameId: id, state: this.stateOf(game) });
|
||||
}
|
||||
|
||||
// joinPendingOpponent is the e2e hook (exposed as window.__mock.joinOpponent) to attach the
|
||||
// opponent to the most recently opened game on demand, making the waiting → joined transition
|
||||
// deterministic.
|
||||
joinPendingOpponent(): void {
|
||||
if (this.openGameId) this.fillOpponent(this.openGameId);
|
||||
}
|
||||
|
||||
async lobbyPoll(): Promise<MatchResult> {
|
||||
@@ -190,8 +213,8 @@ export class MockGateway implements GatewayClient {
|
||||
}
|
||||
|
||||
// --- game ---
|
||||
async gameState(gameId: string, _includeAlphabet: boolean): Promise<StateView> {
|
||||
const g = this.game(gameId);
|
||||
// stateOf builds a player's StateView from a mock game (the viewer is always ME).
|
||||
private stateOf(g: MockGame): StateView {
|
||||
return {
|
||||
game: structuredClone(g.view),
|
||||
seat: this.mySeat(g),
|
||||
@@ -201,6 +224,10 @@ export class MockGateway implements GatewayClient {
|
||||
};
|
||||
}
|
||||
|
||||
async gameState(gameId: string, _includeAlphabet: boolean): Promise<StateView> {
|
||||
return this.stateOf(this.game(gameId));
|
||||
}
|
||||
|
||||
async gameHistory(gameId: string): Promise<History> {
|
||||
const g = this.game(gameId);
|
||||
return { gameId, moves: structuredClone(g.moves) };
|
||||
|
||||
+6
-4
@@ -5,8 +5,9 @@
|
||||
|
||||
export type Variant = 'scrabble_en' | 'scrabble_ru' | 'erudit_ru';
|
||||
|
||||
/** Backend game status strings. */
|
||||
export type GameStatus = 'active' | 'finished' | string;
|
||||
/** Backend game status strings. 'open' is an auto-match game the player has entered
|
||||
* but which is still waiting for an opponent (the opponent seat has no account). */
|
||||
export type GameStatus = 'active' | 'finished' | 'open' | string;
|
||||
|
||||
/** Decoded move action kinds (history-independent, see ARCHITECTURE §9.1). */
|
||||
export type MoveAction = 'play' | 'pass' | 'exchange' | 'resign' | 'timeout' | string;
|
||||
@@ -237,8 +238,8 @@ export interface GameList {
|
||||
/**
|
||||
* A live event delivered over the Subscribe stream. The game events carry the move as a
|
||||
* delta — move plus the post-move summary (and the bag size) — the client applies to its
|
||||
* cached game without a refetch; match_found / game_started carry the recipient's initial
|
||||
* StateView; notify carries the changed lobby payload. The enriched fields are optional
|
||||
* cached game without a refetch; match_found / game_started / opponent_joined carry the
|
||||
* recipient's StateView; notify carries the changed lobby payload. The enriched fields are optional
|
||||
* so a client falls back to a refetch when a payload is absent (a gap, or an older peer).
|
||||
*/
|
||||
export type PushEvent =
|
||||
@@ -248,5 +249,6 @@ export type PushEvent =
|
||||
| { kind: 'chat_message'; message: ChatMessage }
|
||||
| { kind: 'nudge'; gameId: string; fromUserId: string }
|
||||
| { kind: 'match_found'; gameId: string; state?: StateView }
|
||||
| { kind: 'opponent_joined'; gameId: string; state?: StateView }
|
||||
| { kind: 'notify'; sub: string; account?: AccountRef; invitation?: Invitation; state?: StateView }
|
||||
| { kind: 'heartbeat' };
|
||||
|
||||
Reference in New Issue
Block a user