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
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:
@@ -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);
|
||||
});
|
||||
});
|
||||
Reference in New Issue
Block a user