feat(lobby): cap simultaneous quick games at 10
CI / changes (pull_request) Successful in 2s
CI / unit (pull_request) Successful in 9s
CI / integration (pull_request) Successful in 15s
CI / ui (pull_request) Successful in 49s
CI / gate (pull_request) Successful in 0s
CI / deploy (pull_request) Successful in 1m8s
CI / changes (pull_request) Successful in 2s
CI / unit (pull_request) Successful in 9s
CI / integration (pull_request) Successful in 15s
CI / ui (pull_request) Successful in 49s
CI / gate (pull_request) Successful in 0s
CI / deploy (pull_request) Successful in 1m8s
Limit a player to 10 active quick games (auto-match + AI); friend games created by invitation are not counted. At the cap the backend refuses both new-game entry points — quick enqueue and invitation creation — with 409 game_limit_reached, while accepting an incoming invitation stays allowed, so friend games are capped from the other end. The lobby disables "New Game" and shows a low-emphasis notice, driven by a new at_game_limit flag on games.list (no per-event payload: a turn change does not move the count, and the lobby already re-fetches games.list on entry and every game event). - game.MaxActiveQuickGames + Store/Service.CountActiveQuickGames (active/open seats, no game_invitations row; hidden games still count -> dedicated count) - Server.ensureUnderGameLimit gating handleEnqueue + handleCreateInvitation; game.ErrGameLimitReached -> 409 game_limit_reached - FB GameList.at_game_limit (regenerated Go + TS) through the gateway transcode and UI codec; gameListDTO + lobbycache snapshot + Lobby.svelte + i18n - tests: integration count rule + HTTP gate + accept bypass; server error map; gateway transcode round-trip; UI codec + lobbycache unit; e2e gamelimit - docs: PRERELEASE (GL), FUNCTIONAL(+ru), ARCHITECTURE 8, UI_DESIGN, backend README
This commit is contained in:
@@ -241,6 +241,7 @@ describe('codec', () => {
|
||||
const games = fb.GameList.createGamesVector(b, [game]);
|
||||
fb.GameList.startGameList(b);
|
||||
fb.GameList.addGames(b, games);
|
||||
fb.GameList.addAtGameLimit(b, true);
|
||||
b.finish(fb.GameList.endGameList(b));
|
||||
|
||||
const gl = decodeGameList(b.asUint8Array());
|
||||
@@ -250,6 +251,7 @@ describe('codec', () => {
|
||||
expect(gl.games[0].seats[0].score).toBe(13);
|
||||
expect(gl.games[0].lastActivityUnix).toBe(1717000000);
|
||||
expect(gl.games[0].vsAi).toBe(true);
|
||||
expect(gl.atGameLimit).toBe(true);
|
||||
});
|
||||
|
||||
it('decodes an OutgoingRequestList of account refs', () => {
|
||||
|
||||
+1
-1
@@ -445,7 +445,7 @@ export function decodeGameList(buf: Uint8Array): GameList {
|
||||
const g = gl.games(i);
|
||||
if (g) games.push(decodeGameView(g));
|
||||
}
|
||||
return { games };
|
||||
return { games, atGameLimit: gl.atGameLimit() };
|
||||
}
|
||||
|
||||
export function decodeMatchResult(buf: Uint8Array): MatchResult {
|
||||
|
||||
@@ -24,11 +24,17 @@ if (isMock && typeof window !== 'undefined') {
|
||||
// attaches a robot on a timer).
|
||||
(
|
||||
window as unknown as {
|
||||
__mock?: { joinOpponent(): void; joinOpponentSilently(): void; adminReply(): void };
|
||||
__mock?: {
|
||||
joinOpponent(): void;
|
||||
joinOpponentSilently(): void;
|
||||
adminReply(): void;
|
||||
setGameLimit(v: boolean): void;
|
||||
};
|
||||
}
|
||||
).__mock = {
|
||||
joinOpponent: () => (gateway as MockGateway).joinPendingOpponent(),
|
||||
joinOpponentSilently: () => (gateway as MockGateway).joinPendingOpponentSilently(),
|
||||
adminReply: () => (gateway as MockGateway).mockAdminReply(),
|
||||
setGameLimit: (v: boolean) => (gateway as MockGateway).setGameLimit(v),
|
||||
};
|
||||
}
|
||||
|
||||
@@ -33,6 +33,7 @@ export const en = {
|
||||
'lobby.finishedGames': 'Finished games',
|
||||
'lobby.noActive': 'No active games yet.',
|
||||
'lobby.noFinished': 'No finished games yet.',
|
||||
'lobby.limitReached': "You've reached the simultaneous games limit.",
|
||||
'lobby.new': 'New',
|
||||
'lobby.stats': 'Stats',
|
||||
'lobby.profile': 'Profile',
|
||||
|
||||
@@ -34,6 +34,7 @@ export const ru: Record<MessageKey, string> = {
|
||||
'lobby.finishedGames': 'Завершённые игры',
|
||||
'lobby.noActive': 'Пока нет активных игр.',
|
||||
'lobby.noFinished': 'Пока нет завершённых игр.',
|
||||
'lobby.limitReached': 'Вы достигли лимита одновременных партий',
|
||||
'lobby.new': 'Новая',
|
||||
'lobby.stats': 'Статы',
|
||||
'lobby.profile': 'Профиль',
|
||||
|
||||
@@ -41,7 +41,7 @@ beforeEach(() => clearLobby());
|
||||
|
||||
describe('patchLobbyGame', () => {
|
||||
it('replaces the matching game by id and leaves the others untouched', () => {
|
||||
setLobby({ games: [gameView('a', 'active', 0), gameView('b', 'active', 0)], invitations: [], incoming: [] });
|
||||
setLobby({ games: [gameView('a', 'active', 0), gameView('b', 'active', 0)], invitations: [], incoming: [], atGameLimit: false });
|
||||
// The player's own move flipped game "a" to the opponent's turn.
|
||||
patchLobbyGame(gameView('a', 'active', 1));
|
||||
const snap = getLobby();
|
||||
@@ -52,14 +52,14 @@ describe('patchLobbyGame', () => {
|
||||
|
||||
it('preserves invitations and incoming when patching a game', () => {
|
||||
const incoming: AccountRef[] = [{ accountId: 'u9', displayName: 'Nine' }];
|
||||
setLobby({ games: [gameView('a')], invitations: [], incoming });
|
||||
setLobby({ games: [gameView('a')], invitations: [], incoming, atGameLimit: false });
|
||||
patchLobbyGame(gameView('a', 'finished'));
|
||||
expect(getLobby()?.incoming).toEqual(incoming);
|
||||
expect(getLobby()?.games[0].status).toBe('finished');
|
||||
});
|
||||
|
||||
it('adds the game when it is not yet in the cached lobby (a game started elsewhere)', () => {
|
||||
setLobby({ games: [gameView('a')], invitations: [], incoming: [] });
|
||||
setLobby({ games: [gameView('a')], invitations: [], incoming: [], atGameLimit: false });
|
||||
patchLobbyGame(gameView('z', 'active'));
|
||||
// Order is irrelevant — the lobby re-groups and re-sorts on render — so assert membership.
|
||||
expect(getLobby()?.games.map((g) => g.id).sort()).toEqual(['a', 'z']);
|
||||
@@ -72,21 +72,29 @@ describe('patchLobbyGame', () => {
|
||||
|
||||
it('does not mutate the previous snapshot array', () => {
|
||||
const games = [gameView('a', 'active', 0)];
|
||||
setLobby({ games, invitations: [], incoming: [] });
|
||||
setLobby({ games, invitations: [], incoming: [], atGameLimit: false });
|
||||
patchLobbyGame(gameView('a', 'active', 1));
|
||||
expect(games[0].toMove).toBe(0); // the original array/object is left intact
|
||||
});
|
||||
|
||||
it('preserves the at-game-limit flag across a game patch', () => {
|
||||
// A finished-game patch arriving while the lobby is unmounted must not drop the flag —
|
||||
// the lobby renders the "New Game" button from the cached snapshot before the refresh.
|
||||
setLobby({ games: [gameView('a')], invitations: [], incoming: [], atGameLimit: true });
|
||||
patchLobbyGame(gameView('a', 'finished'));
|
||||
expect(getLobby()?.atGameLimit).toBe(true);
|
||||
});
|
||||
});
|
||||
|
||||
describe('patchLobbyInvitation', () => {
|
||||
it('adds a new pending invitation to the cached lobby', () => {
|
||||
setLobby({ games: [], invitations: [], incoming: [] });
|
||||
setLobby({ games: [], invitations: [], incoming: [], atGameLimit: false });
|
||||
patchLobbyInvitation(invitation('i1', 'pending'));
|
||||
expect(getLobby()?.invitations.map((x) => x.id)).toEqual(['i1']);
|
||||
});
|
||||
|
||||
it('replaces a pending invitation already in the list (e.g. an updated response)', () => {
|
||||
setLobby({ games: [], invitations: [invitation('i1', 'pending'), invitation('i2', 'pending')], incoming: [] });
|
||||
setLobby({ games: [], invitations: [invitation('i1', 'pending'), invitation('i2', 'pending')], incoming: [], atGameLimit: false });
|
||||
const updated = { ...invitation('i1', 'pending'), turnTimeoutSecs: 999 };
|
||||
patchLobbyInvitation(updated);
|
||||
expect(getLobby()?.invitations.map((x) => x.id)).toEqual(['i1', 'i2']);
|
||||
@@ -95,14 +103,14 @@ describe('patchLobbyInvitation', () => {
|
||||
|
||||
it('removes an invitation that reached a terminal status', () => {
|
||||
for (const terminal of ['declined', 'cancelled', 'started', 'expired']) {
|
||||
setLobby({ games: [], invitations: [invitation('i1', 'pending'), invitation('i2', 'pending')], incoming: [] });
|
||||
setLobby({ games: [], invitations: [invitation('i1', 'pending'), invitation('i2', 'pending')], incoming: [], atGameLimit: false });
|
||||
patchLobbyInvitation(invitation('i1', terminal));
|
||||
expect(getLobby()?.invitations.map((x) => x.id)).toEqual(['i2']);
|
||||
}
|
||||
});
|
||||
|
||||
it('is a no-op for a terminal invitation that is not in the list', () => {
|
||||
setLobby({ games: [], invitations: [invitation('i2', 'pending')], incoming: [] });
|
||||
setLobby({ games: [], invitations: [invitation('i2', 'pending')], incoming: [], atGameLimit: false });
|
||||
patchLobbyInvitation(invitation('i1', 'declined'));
|
||||
expect(getLobby()?.invitations.map((x) => x.id)).toEqual(['i2']);
|
||||
});
|
||||
@@ -114,7 +122,7 @@ describe('patchLobbyInvitation', () => {
|
||||
|
||||
it('preserves games and incoming when patching an invitation', () => {
|
||||
const incoming: AccountRef[] = [{ accountId: 'u9', displayName: 'Nine' }];
|
||||
setLobby({ games: [gameView('g1')], invitations: [], incoming });
|
||||
setLobby({ games: [gameView('g1')], invitations: [], incoming, atGameLimit: false });
|
||||
patchLobbyInvitation(invitation('i1', 'pending'));
|
||||
expect(getLobby()?.games.map((g) => g.id)).toEqual(['g1']);
|
||||
expect(getLobby()?.incoming).toEqual(incoming);
|
||||
|
||||
@@ -10,6 +10,9 @@ interface LobbySnapshot {
|
||||
games: GameView[];
|
||||
invitations: Invitation[];
|
||||
incoming: AccountRef[];
|
||||
// atGameLimit rides the snapshot so the lobby renders the "New Game" button in its last
|
||||
// known enabled/disabled state instantly (no flicker), then refreshes it in the background.
|
||||
atGameLimit: boolean;
|
||||
}
|
||||
|
||||
let snapshot: LobbySnapshot | null = null;
|
||||
|
||||
@@ -98,6 +98,8 @@ export class MockGateway implements GatewayClient {
|
||||
private feedbackReplyUnread = false;
|
||||
// The most recently opened auto-match game still awaiting an opponent, for the e2e join hook.
|
||||
private openGameId: string | null = null;
|
||||
// gameLimit forces the lobby's at_game_limit flag for the e2e (window.__mock.setGameLimit).
|
||||
private gameLimit = false;
|
||||
private friends: AccountRef[] = MOCK_FRIENDS.map((f) => ({ ...f }));
|
||||
private incoming: AccountRef[] = MOCK_INCOMING.map((f) => ({ ...f }));
|
||||
private outgoing: AccountRef[] = [];
|
||||
@@ -152,7 +154,10 @@ export class MockGateway implements GatewayClient {
|
||||
return { blocked: false, permanent: false, until: '', reason: '' };
|
||||
}
|
||||
async gamesList(): Promise<GameList> {
|
||||
return { games: [...this.games.values()].map((g) => structuredClone(g.view)) };
|
||||
return {
|
||||
games: [...this.games.values()].map((g) => structuredClone(g.view)),
|
||||
atGameLimit: this.gameLimit,
|
||||
};
|
||||
}
|
||||
|
||||
// --- lobby ---
|
||||
@@ -257,6 +262,15 @@ export class MockGateway implements GatewayClient {
|
||||
if (this.openGameId) this.seatOpponent(this.openGameId);
|
||||
}
|
||||
|
||||
// setGameLimit forces the lobby's at_game_limit flag (the e2e hook
|
||||
// window.__mock.setGameLimit), then nudges the lobby to re-fetch games.list so the
|
||||
// "New Game" button and the notice update in place. The lobby refetches on any
|
||||
// non-heartbeat event; an unrecognised notify sub triggers only that refetch.
|
||||
setGameLimit(v: boolean): void {
|
||||
this.gameLimit = v;
|
||||
this.emit({ kind: 'notify', sub: 'game_limit' });
|
||||
}
|
||||
|
||||
async lobbyPoll(): Promise<MatchResult> {
|
||||
if (this.pendingMatch) {
|
||||
const g = this.games.get(this.pendingMatch);
|
||||
|
||||
@@ -293,6 +293,9 @@ export interface History {
|
||||
|
||||
export interface GameList {
|
||||
games: GameView[];
|
||||
/** True when the caller has reached the simultaneous quick-game cap; the lobby then
|
||||
* disables "New Game" and shows a notice. */
|
||||
atGameLimit: boolean;
|
||||
}
|
||||
|
||||
/**
|
||||
|
||||
Reference in New Issue
Block a user