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
+77
View File
@@ -0,0 +1,77 @@
import { describe, it, expect } from 'vitest';
import { activeByKind, capFor, isKindLocked } from './gamelimits';
import { GameKind, type GameView, type GameLimits } from './model';
// game builds a minimal GameView for the count: only kind, status and (optionally) hotseat matter.
function game(kind: number, status: string, extra: Partial<GameView> = {}): GameView {
return {
id: 'g',
variant: 'scrabble_en',
dictVersion: '',
status,
players: 2,
toMove: 0,
turnTimeoutSecs: 0,
multipleWordsPerTurn: true,
moveCount: 0,
endReason: '',
lastActivityUnix: 0,
vsAi: false,
unreadChat: false,
unreadMessages: false,
kind,
seats: [],
...extra,
};
}
describe('activeByKind', () => {
it('counts open+active games per kind, skipping finished, hotseat and untagged (kind 0)', () => {
const by = activeByKind([
game(GameKind.vsAi, 'active'),
game(GameKind.random, 'open'),
game(GameKind.random, 'active'),
game(GameKind.random, 'finished'), // finished → not counted
game(GameKind.vsAi, 'active', { hotseat: true }), // local pass-and-play → not counted
game(0, 'active'), // pre-existing / untagged → not counted
]);
expect(by[GameKind.vsAi]).toBe(1);
expect(by[GameKind.random]).toBe(2);
});
});
describe('capFor', () => {
const limits: GameLimits = { vsAi: 1, random: 10, friends: 0 };
it('maps each kind to its tier cap; unknown kind and absent limits are unlimited (-1)', () => {
expect(capFor(limits, GameKind.vsAi)).toBe(1);
expect(capFor(limits, GameKind.random)).toBe(10);
expect(capFor(limits, GameKind.friends)).toBe(0);
expect(capFor(limits, 0)).toBe(-1);
expect(capFor(undefined, GameKind.vsAi)).toBe(-1);
});
});
describe('isKindLocked', () => {
const guest: GameLimits = { vsAi: 1, random: 1, friends: 0 };
const durable: GameLimits = { vsAi: 10, random: 10, friends: 10 };
it('locks a guest at the per-kind cap, not another kind', () => {
const games = [game(GameKind.vsAi, 'active')];
expect(isKindLocked(games, guest, GameKind.vsAi)).toBe(true);
expect(isKindLocked(games, guest, GameKind.random)).toBe(false);
});
it('a cap of 0 always locks (a guest cannot start friend games)', () => {
expect(isKindLocked([], guest, GameKind.friends)).toBe(true);
});
it('an unlimited cap (-1) or absent limits never lock', () => {
const many = [game(GameKind.random, 'active'), game(GameKind.random, 'active')];
expect(isKindLocked(many, { vsAi: -1, random: -1, friends: -1 }, GameKind.random)).toBe(false);
expect(isKindLocked(many, undefined, GameKind.random)).toBe(false);
});
it('a durable account stays well under its higher cap', () => {
expect(isKindLocked([game(GameKind.random, 'active')], durable, GameKind.random)).toBe(false);
});
});