feat(ui): per-kind active-game limit lock on the New Game screen
CI / changes (pull_request) Successful in 2s
CI / unit (pull_request) Successful in 12s
CI / integration (pull_request) Successful in 28s
CI / ui (pull_request) Successful in 1m11s
CI / conformance (pull_request) Successful in 10s
CI / gate (pull_request) Successful in 0s
CI / deploy (pull_request) Successful in 1m46s

Carry the caller's per-tier active-game caps and each game's kind on the
wire (Profile.game_limits + GameView.kind, additive FBS + gateway transcode
+ client codec, committed regen). The New Game screen counts the player's
active games per kind from the lobby and locks a capped start: an outline
button with a lock that opens a funnel modal instead of a game -- a sign-in
prompt for a guest, a "finish a current game first" notice for a signed-in
account (native Telegram popup, in-app modal elsewhere). The lock lifts via
the existing profile refetch after a guest->durable upgrade.

Remove the lobby's old at_game_limit New-Game tab disable + notice: the flag
(now the random-kind cap) conflicted with the per-kind lock -- it hid the
screen where the lock lives and wrongly blocked an unfulfilled kind. The New
Game tab is always enabled; the per-kind start lock is the only gate. The
at_game_limit wire field stays (unused by the client) for a later cleanup.

Tests: client lock logic + codec kind/game_limits roundtrip + gateway
transcode encode + native popup builders (unit); a mock e2e for the lock
badge and the modal.
This commit is contained in:
Ilia Denisov
2026-07-10 10:09:45 +02:00
parent e45167041f
commit 3306a016a0
45 changed files with 811 additions and 130 deletions
+22
View File
@@ -421,6 +421,7 @@ describe('codec', () => {
fb.GameView.addLastActivityUnix(b, BigInt(1717000000));
fb.GameView.addVsAi(b, true);
fb.GameView.addUnreadChat(b, true);
fb.GameView.addKind(b, 2);
const game = fb.GameView.endGameView(b);
const games = fb.GameList.createGamesVector(b, [game]);
fb.GameList.startGameList(b);
@@ -436,6 +437,7 @@ describe('codec', () => {
expect(gl.games[0].lastActivityUnix).toBe(1717000000);
expect(gl.games[0].vsAi).toBe(true);
expect(gl.games[0].unreadChat).toBe(true);
expect(gl.games[0].kind).toBe(2);
expect(gl.atGameLimit).toBe(true);
});
@@ -830,6 +832,26 @@ describe('codec', () => {
});
});
it('decodes the profile active-game limits (guest tier)', () => {
const b = new Builder(64);
const uid = b.createString('u-1');
const limits = fb.GameLimits.createGameLimits(b, 1, 1, 0);
fb.Profile.startProfile(b);
fb.Profile.addUserId(b, uid);
fb.Profile.addGameLimits(b, limits);
b.finish(fb.Profile.endProfile(b));
expect(decodeProfile(b.asUint8Array()).gameLimits).toEqual({ vsAi: 1, random: 1, friends: 0 });
});
it('leaves the profile game limits undefined when absent', () => {
const b = new Builder(64);
const uid = b.createString('u-1');
fb.Profile.startProfile(b);
fb.Profile.addUserId(b, uid);
b.finish(fb.Profile.endProfile(b));
expect(decodeProfile(b.asUint8Array()).gameLimits).toBeUndefined();
});
it('carries the suppressed flag through the ad config', () => {
const b = new Builder(64);
const uid = b.createString('u-1');