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
+70
View File
@@ -0,0 +1,70 @@
<script lang="ts">
import { t } from '../lib/i18n/index.svelte';
import { insideTelegram, telegramDialogsAvailable, telegramShowPopup } from '../lib/telegram';
import { LOGIN_BUTTON_ID, gameLimitGuestPopup, gameLimitDurablePopup } from '../lib/nativedialogs';
import Modal from './Modal.svelte';
// The New-Game screen raises this when the player taps a start whose kind is at its cap. guest
// picks the message: a sign-in funnel (a guest plays more by registering) vs a durable account's
// plain "finish a current game first" notice. onclose dismisses; onlogin routes a guest to sign-in.
let { open, guest, onclose, onlogin }: { open: boolean; guest: boolean; onclose: () => void; onlogin: () => void } = $props();
const title = $derived(guest ? t('new.limitGuestTitle') : t('new.limitDurableTitle'));
const body = $derived(guest ? t('new.limitGuestBody') : t('new.limitDurableBody'));
// Native path: inside the Mini App with native dialogs, present Telegram's own popup instead of
// the in-app modal (evaluated at fire time — the SDK loads after mount). A guest popup's login
// button routes to sign-in; any other dismissal closes. The message here is a plain notice with
// no gesture-gated follow-up, so a native popup is safe (unlike share/clipboard flows).
let shown = false;
$effect(() => {
if (open && !shown && insideTelegram() && telegramDialogsAvailable()) {
shown = true;
const params = guest
? gameLimitGuestPopup(title, body, t('common.cancel'), t('new.limitLogin'))
: gameLimitDurablePopup(title, body, t('common.ok'));
void telegramShowPopup(params).then((id) => (guest && id === LOGIN_BUTTON_ID ? onlogin() : onclose()));
} else if (!open) {
shown = false;
}
});
</script>
{#if open && !(insideTelegram() && telegramDialogsAvailable())}
<Modal {title} onclose={onclose}>
<p class="msg">{body}</p>
<div class="actions">
{#if guest}
<button class="cancel" onclick={onclose}>{t('common.cancel')}</button>
<button class="primary" onclick={onlogin}>{t('new.limitLogin')}</button>
{:else}
<button class="primary" onclick={onclose}>{t('common.ok')}</button>
{/if}
</div>
</Modal>
{/if}
<style>
.msg {
margin: 0 0 16px;
line-height: 1.5;
}
.actions {
display: flex;
gap: 8px;
}
.actions button {
flex: 1;
padding: 10px 12px;
border-radius: var(--radius-sm);
border: 1px solid var(--accent);
}
.cancel {
background: transparent;
color: var(--accent);
}
.primary {
background: var(--accent);
color: var(--accent-text);
}
</style>
+1
View File
@@ -42,6 +42,7 @@ export { FriendCode } from './scrabblefb/friend-code.js';
export { FriendList } from './scrabblefb/friend-list.js';
export { FriendRespondRequest } from './scrabblefb/friend-respond-request.js';
export { GameActionRequest } from './scrabblefb/game-action-request.js';
export { GameLimits } from './scrabblefb/game-limits.js';
export { GameList } from './scrabblefb/game-list.js';
export { GameOverEvent } from './scrabblefb/game-over-event.js';
export { GameView } from './scrabblefb/game-view.js';
+66
View File
@@ -0,0 +1,66 @@
// automatically generated by the FlatBuffers compiler, do not modify
import * as flatbuffers from 'flatbuffers';
export class GameLimits {
bb: flatbuffers.ByteBuffer|null = null;
bb_pos = 0;
__init(i:number, bb:flatbuffers.ByteBuffer):GameLimits {
this.bb_pos = i;
this.bb = bb;
return this;
}
static getRootAsGameLimits(bb:flatbuffers.ByteBuffer, obj?:GameLimits):GameLimits {
return (obj || new GameLimits()).__init(bb.readInt32(bb.position()) + bb.position(), bb);
}
static getSizePrefixedRootAsGameLimits(bb:flatbuffers.ByteBuffer, obj?:GameLimits):GameLimits {
bb.setPosition(bb.position() + flatbuffers.SIZE_PREFIX_LENGTH);
return (obj || new GameLimits()).__init(bb.readInt32(bb.position()) + bb.position(), bb);
}
vsAi():number {
const offset = this.bb!.__offset(this.bb_pos, 4);
return offset ? this.bb!.readInt32(this.bb_pos + offset) : 0;
}
random():number {
const offset = this.bb!.__offset(this.bb_pos, 6);
return offset ? this.bb!.readInt32(this.bb_pos + offset) : 0;
}
friends():number {
const offset = this.bb!.__offset(this.bb_pos, 8);
return offset ? this.bb!.readInt32(this.bb_pos + offset) : 0;
}
static startGameLimits(builder:flatbuffers.Builder) {
builder.startObject(3);
}
static addVsAi(builder:flatbuffers.Builder, vsAi:number) {
builder.addFieldInt32(0, vsAi, 0);
}
static addRandom(builder:flatbuffers.Builder, random:number) {
builder.addFieldInt32(1, random, 0);
}
static addFriends(builder:flatbuffers.Builder, friends:number) {
builder.addFieldInt32(2, friends, 0);
}
static endGameLimits(builder:flatbuffers.Builder):flatbuffers.Offset {
const offset = builder.endObject();
return offset;
}
static createGameLimits(builder:flatbuffers.Builder, vsAi:number, random:number, friends:number):flatbuffers.Offset {
GameLimits.startGameLimits(builder);
GameLimits.addVsAi(builder, vsAi);
GameLimits.addRandom(builder, random);
GameLimits.addFriends(builder, friends);
return GameLimits.endGameLimits(builder);
}
}
+12 -2
View File
@@ -113,8 +113,13 @@ unreadMessages():boolean {
return offset ? !!this.bb!.readInt8(this.bb_pos + offset) : false;
}
kind():number {
const offset = this.bb!.__offset(this.bb_pos, 34);
return offset ? this.bb!.readInt32(this.bb_pos + offset) : 0;
}
static startGameView(builder:flatbuffers.Builder) {
builder.startObject(15);
builder.startObject(16);
}
static addId(builder:flatbuffers.Builder, idOffset:flatbuffers.Offset) {
@@ -189,12 +194,16 @@ static addUnreadMessages(builder:flatbuffers.Builder, unreadMessages:boolean) {
builder.addFieldInt8(14, +unreadMessages, +false);
}
static addKind(builder:flatbuffers.Builder, kind:number) {
builder.addFieldInt32(15, kind, 0);
}
static endGameView(builder:flatbuffers.Builder):flatbuffers.Offset {
const offset = builder.endObject();
return offset;
}
static createGameView(builder:flatbuffers.Builder, idOffset:flatbuffers.Offset, variantOffset:flatbuffers.Offset, dictVersionOffset:flatbuffers.Offset, statusOffset:flatbuffers.Offset, players:number, toMove:number, turnTimeoutSecs:number, multipleWordsPerTurn:boolean, moveCount:number, endReasonOffset:flatbuffers.Offset, seatsOffset:flatbuffers.Offset, lastActivityUnix:bigint, vsAi:boolean, unreadChat:boolean, unreadMessages:boolean):flatbuffers.Offset {
static createGameView(builder:flatbuffers.Builder, idOffset:flatbuffers.Offset, variantOffset:flatbuffers.Offset, dictVersionOffset:flatbuffers.Offset, statusOffset:flatbuffers.Offset, players:number, toMove:number, turnTimeoutSecs:number, multipleWordsPerTurn:boolean, moveCount:number, endReasonOffset:flatbuffers.Offset, seatsOffset:flatbuffers.Offset, lastActivityUnix:bigint, vsAi:boolean, unreadChat:boolean, unreadMessages:boolean, kind:number):flatbuffers.Offset {
GameView.startGameView(builder);
GameView.addId(builder, idOffset);
GameView.addVariant(builder, variantOffset);
@@ -211,6 +220,7 @@ static createGameView(builder:flatbuffers.Builder, idOffset:flatbuffers.Offset,
GameView.addVsAi(builder, vsAi);
GameView.addUnreadChat(builder, unreadChat);
GameView.addUnreadMessages(builder, unreadMessages);
GameView.addKind(builder, kind);
return GameView.endGameView(builder);
}
}
+11 -1
View File
@@ -5,6 +5,7 @@ import * as flatbuffers from 'flatbuffers';
import { AdsInfo } from '../scrabblefb/ads-info.js';
import { BannerInfo } from '../scrabblefb/banner-info.js';
import { DictVersion } from '../scrabblefb/dict-version.js';
import { GameLimits } from '../scrabblefb/game-limits.js';
export class Profile {
@@ -141,8 +142,13 @@ ads(obj?:AdsInfo):AdsInfo|null {
return offset ? (obj || new AdsInfo()).__init(this.bb!.__indirect(this.bb_pos + offset), this.bb!) : null;
}
gameLimits(obj?:GameLimits):GameLimits|null {
const offset = this.bb!.__offset(this.bb_pos, 40);
return offset ? (obj || new GameLimits()).__init(this.bb!.__indirect(this.bb_pos + offset), this.bb!) : null;
}
static startProfile(builder:flatbuffers.Builder) {
builder.startObject(18);
builder.startObject(19);
}
static addUserId(builder:flatbuffers.Builder, userIdOffset:flatbuffers.Offset) {
@@ -241,6 +247,10 @@ static addAds(builder:flatbuffers.Builder, adsOffset:flatbuffers.Offset) {
builder.addFieldOffset(17, adsOffset, 0);
}
static addGameLimits(builder:flatbuffers.Builder, gameLimitsOffset:flatbuffers.Offset) {
builder.addFieldOffset(18, gameLimitsOffset, 0);
}
static endProfile(builder:flatbuffers.Builder):flatbuffers.Offset {
const offset = builder.endObject();
return offset;
+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');
+12
View File
@@ -15,6 +15,7 @@ import type {
RobotFriendRequestEntry,
Banner,
AdsConfig,
GameLimits,
BannerCampaign,
BestMove,
BestMoveTile,
@@ -314,6 +315,7 @@ function decodeGameView(g: fb.GameView): GameView {
vsAi: g.vsAi(),
unreadChat: g.unreadChat(),
unreadMessages: g.unreadMessages(),
kind: g.kind(),
seats,
};
}
@@ -467,9 +469,18 @@ export function decodeProfile(buf: Uint8Array): Profile {
telegramLinked: p.telegramLinked(),
vkLinked: p.vkLinked(),
dictVersions: decodeDictVersions(p),
gameLimits: decodeGameLimits(p),
};
}
// decodeGameLimits projects the caller's tier active-game caps (per kind, -1 = unlimited), or
// undefined when absent (an old backend); the New-Game screen then applies no lock.
function decodeGameLimits(p: fb.Profile): GameLimits | undefined {
const g = p.gameLimits();
if (!g) return undefined;
return { vsAi: g.vsAi(), random: g.random(), friends: g.friends() };
}
// decodeDictVersions reads the profile's per-variant current dictionary versions into a
// variant-keyed map, so the offline preloader can look up the version for each enabled
// variant. An entry with an unknown variant or empty version is skipped.
@@ -1137,6 +1148,7 @@ function emptyGame(): GameView {
vsAi: false,
unreadChat: false,
unreadMessages: false,
kind: 0,
seats: [],
};
}
+1
View File
@@ -10,6 +10,7 @@ function gameView(id: string): GameView {
vsAi: false,
unreadChat: false,
unreadMessages: false,
kind: 0,
status: 'active',
players: 2,
toMove: 0,
+1
View File
@@ -11,6 +11,7 @@ function gameView(moveCount: number, over = false): GameView {
vsAi: false,
unreadChat: false,
unreadMessages: false,
kind: 0,
status: over ? 'finished' : 'active',
players: 2,
toMove: 1,
+1
View File
@@ -14,6 +14,7 @@ function game(seats: Seat[], over = true): GameView {
vsAi: false,
unreadChat: false,
unreadMessages: false,
kind: 0,
status: over ? 'finished' : 'active',
players: seats.length,
toMove: 0,
+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);
});
});
+50
View File
@@ -0,0 +1,50 @@
// Client-side active-game limit logic: count the caller's active games per kind and test a kind
// against the caller's per-tier cap so the New-Game screen can lock a capped start. The server is
// the source of truth (it refuses a capped create); this only drives the pre-emptive UI lock. Kept
// pure (no DOM / stores) so it unit-tests in the node environment.
import { GameKind, type GameLimits, type GameView } from './model';
/**
* activeByKind counts the caller's active (status open or in-progress) games per kind. Finished
* games, local pass-and-play (hotseat) games and untagged games (kind 0, pre-existing) do not
* count — matching the server's per-kind cap, which is keyed on games.game_kind for open/active
* games only.
*/
export function activeByKind(games: GameView[]): Record<number, number> {
const out: Record<number, number> = {};
for (const g of games) {
if (g.hotseat) continue;
if (g.kind === 0) continue;
if (g.status !== 'active' && g.status !== 'open') continue;
out[g.kind] = (out[g.kind] ?? 0) + 1;
}
return out;
}
/** capFor returns the caller's tier cap for a kind (-1 = unlimited), or -1 when the limits are
* absent (an old backend) — the lock then never applies. */
export function capFor(limits: GameLimits | undefined, kind: number): number {
if (!limits) return -1;
switch (kind) {
case GameKind.vsAi:
return limits.vsAi;
case GameKind.random:
return limits.random;
case GameKind.friends:
return limits.friends;
default:
return -1;
}
}
/**
* isKindLocked reports whether the caller has reached its cap for kind, so the New-Game start for
* that kind is locked. An unlimited cap (-1) or absent limits never lock; a cap of 0 always locks
* (the kind is disabled for the tier).
*/
export function isKindLocked(games: GameView[], limits: GameLimits | undefined, kind: number): boolean {
const cap = capFor(limits, kind);
if (cap < 0) return false;
return (activeByKind(games)[kind] ?? 0) >= cap;
}
+2 -2
View File
@@ -39,7 +39,7 @@ if (isMock && typeof window !== 'undefined') {
joinOpponent(): void;
joinOpponentSilently(): void;
adminReply(): void;
setGameLimit(v: boolean): void;
setGameLimits(l: { vsAi: number; random: number; friends: number }): void;
clearEmail(): void;
confirmEmailOutOfBand(email: string): void;
setLocalSeed(seed: string): void;
@@ -49,7 +49,7 @@ if (isMock && typeof window !== 'undefined') {
joinOpponent: () => (gateway as MockGateway).joinPendingOpponent(),
joinOpponentSilently: () => (gateway as MockGateway).joinPendingOpponentSilently(),
adminReply: () => (gateway as MockGateway).mockAdminReply(),
setGameLimit: (v: boolean) => (gateway as MockGateway).setGameLimit(v),
setGameLimits: (l: { vsAi: number; random: number; friends: number }) => (gateway as MockGateway).setGameLimits(l),
clearEmail: () => (gateway as MockGateway).mockClearEmail(),
confirmEmailOutOfBand: (email: string) => (gateway as MockGateway).mockConfirmEmailOutOfBand(email),
// Pin the next local game's bag seed, so the offline e2e deals a known rack (a bigint as a
+5
View File
@@ -379,6 +379,11 @@ export const en = {
'new.multipleWordsPerTurn': 'Multiple words per turn',
'new.start': 'Start game',
'new.invited': 'Invitation sent.',
'new.limitGuestTitle': 'More games?',
'new.limitGuestBody': 'Sign in or create an account to play several games at once.',
'new.limitDurableTitle': 'Game limit',
'new.limitDurableBody': 'You have reached the limit of simultaneous games — finish a current one first.',
'new.limitLogin': 'Sign in',
'new.noFriends': 'Add friends first to invite them.',
'new.opponentAI': 'AI',
'new.opponentRandom': 'Random player',
+5
View File
@@ -379,6 +379,11 @@ export const ru: Record<MessageKey, string> = {
'new.multipleWordsPerTurn': 'Несколько слов за ход',
'new.start': 'Начать игру',
'new.invited': 'Приглашение отправлено.',
'new.limitGuestTitle': 'Больше партий?',
'new.limitGuestBody': 'Войдите или создайте учётную запись, чтобы играть несколько партий одновременно.',
'new.limitDurableTitle': 'Лимит игр',
'new.limitDurableBody': 'Вы достигли лимита одновременных игр, сначала завершите текущие.',
'new.limitLogin': 'Вход',
'new.noFriends': 'Сначала добавьте друзей, чтобы пригласить их.',
'new.opponentAI': 'ИИ',
'new.opponentRandom': 'Случайный игрок',
+13 -20
View File
@@ -27,6 +27,7 @@ function gameView(id: string, status: GameView['status'] = 'active', toMove = 0)
vsAi: false,
unreadChat: false,
unreadMessages: false,
kind: 0,
status,
players: 2,
toMove,
@@ -43,7 +44,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: [], atGameLimit: false, offline: false });
setLobby({ games: [gameView('a', 'active', 0), gameView('b', 'active', 0)], invitations: [], incoming: [], offline: false });
// The player's own move flipped game "a" to the opponent's turn.
patchLobbyGame(gameView('a', 'active', 1));
const snap = getLobby(false);
@@ -54,14 +55,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, atGameLimit: false, offline: false });
setLobby({ games: [gameView('a')], invitations: [], incoming, offline: false });
patchLobbyGame(gameView('a', 'finished'));
expect(getLobby(false)?.incoming).toEqual(incoming);
expect(getLobby(false)?.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: [], atGameLimit: false, offline: false });
setLobby({ games: [gameView('a')], invitations: [], incoming: [], offline: false });
patchLobbyGame(gameView('z', 'active'));
// Order is irrelevant — the lobby re-groups and re-sorts on render — so assert membership.
expect(getLobby(false)?.games.map((g) => g.id).sort()).toEqual(['a', 'z']);
@@ -74,29 +75,21 @@ describe('patchLobbyGame', () => {
it('does not mutate the previous snapshot array', () => {
const games = [gameView('a', 'active', 0)];
setLobby({ games, invitations: [], incoming: [], atGameLimit: false, offline: false });
setLobby({ games, invitations: [], incoming: [], offline: 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, offline: false });
patchLobbyGame(gameView('a', 'finished'));
expect(getLobby(false)?.atGameLimit).toBe(true);
});
});
describe('patchLobbyInvitation', () => {
it('adds a new pending invitation to the cached lobby', () => {
setLobby({ games: [], invitations: [], incoming: [], atGameLimit: false, offline: false });
setLobby({ games: [], invitations: [], incoming: [], offline: false });
patchLobbyInvitation(invitation('i1', 'pending'));
expect(getLobby(false)?.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: [], atGameLimit: false, offline: false });
setLobby({ games: [], invitations: [invitation('i1', 'pending'), invitation('i2', 'pending')], incoming: [], offline: false });
const updated = { ...invitation('i1', 'pending'), turnTimeoutSecs: 999 };
patchLobbyInvitation(updated);
expect(getLobby(false)?.invitations.map((x) => x.id)).toEqual(['i1', 'i2']);
@@ -105,14 +98,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: [], atGameLimit: false, offline: false });
setLobby({ games: [], invitations: [invitation('i1', 'pending'), invitation('i2', 'pending')], incoming: [], offline: false });
patchLobbyInvitation(invitation('i1', terminal));
expect(getLobby(false)?.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: [], atGameLimit: false, offline: false });
setLobby({ games: [], invitations: [invitation('i2', 'pending')], incoming: [], offline: false });
patchLobbyInvitation(invitation('i1', 'declined'));
expect(getLobby(false)?.invitations.map((x) => x.id)).toEqual(['i2']);
});
@@ -124,7 +117,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, atGameLimit: false, offline: false });
setLobby({ games: [gameView('g1')], invitations: [], incoming, offline: false });
patchLobbyInvitation(invitation('i1', 'pending'));
expect(getLobby(false)?.games.map((g) => g.id)).toEqual(['g1']);
expect(getLobby(false)?.incoming).toEqual(incoming);
@@ -133,15 +126,15 @@ describe('patchLobbyInvitation', () => {
describe('getLobby is mode-aware (a mode flip must not render the other modes games)', () => {
it('returns the snapshot only for the mode it was stored under', () => {
setLobby({ games: [gameView('online1')], invitations: [], incoming: [], atGameLimit: false, offline: false });
setLobby({ games: [gameView('online1')], invitations: [], incoming: [], offline: false });
expect(getLobby(false)?.games.map((g) => g.id)).toEqual(['online1']);
// Reading it as the OTHER (offline) mode must not surface the online snapshot.
expect(getLobby(true)).toBeNull();
});
it('replaces the snapshot when the mode changes, so the stale mode is dropped', () => {
setLobby({ games: [gameView('online1')], invitations: [], incoming: [], atGameLimit: false, offline: false });
setLobby({ games: [gameView('local1')], invitations: [], incoming: [], atGameLimit: false, offline: true });
setLobby({ games: [gameView('online1')], invitations: [], incoming: [], offline: false });
setLobby({ games: [gameView('local1')], invitations: [], incoming: [], offline: true });
expect(getLobby(false)).toBeNull();
expect(getLobby(true)?.games.map((g) => g.id)).toEqual(['local1']);
});
-3
View File
@@ -10,9 +10,6 @@ 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;
// Which mode the snapshot belongs to (offline = device-local games, online = server games). The
// lobby renders it instantly only when it matches the current mode, so a mode flip never flashes —
// or lets the player open — the other mode's games before the background refresh replaces them.
+1
View File
@@ -20,6 +20,7 @@ function game(id: string, status: GameView['status'], toMove: number, lastActivi
vsAi: false,
unreadChat: false,
unreadMessages: false,
kind: 0,
status,
players: 2,
toMove,
+1
View File
@@ -481,6 +481,7 @@ export class LocalSource implements GameLoopSource {
hotseat: !!record.hotseat,
unreadChat: false,
unreadMessages: false,
kind: 0,
seats,
};
}
+11 -10
View File
@@ -33,6 +33,7 @@ import type {
MoveResult,
OutgoingList,
Profile,
GameLimits,
ProfileUpdate,
PushEvent,
Session,
@@ -105,8 +106,6 @@ 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[] = [];
@@ -267,7 +266,7 @@ export class MockGateway implements GatewayClient {
async gamesList(): Promise<GameList> {
return {
games: [...this.games.values()].map((g) => structuredClone(g.view)),
atGameLimit: this.gameLimit,
atGameLimit: false,
};
}
@@ -293,6 +292,7 @@ export class MockGateway implements GatewayClient {
vsAi: true,
unreadChat: false,
unreadMessages: false,
kind: 1,
seats: [
{ seat: 0, accountId: ME, displayName: 'You', score: 0, hintsUsed: 0, isWinner: false },
{ seat: 1, accountId: 'robot', displayName: 'Robot', score: 0, hintsUsed: 0, isWinner: false },
@@ -326,6 +326,7 @@ export class MockGateway implements GatewayClient {
vsAi: false,
unreadChat: false,
unreadMessages: false,
kind: 2,
seats: [
{ seat: 0, accountId: ME, displayName: 'You', score: 0, hintsUsed: 0, isWinner: false },
{ seat: 1, accountId: '', displayName: '', score: 0, hintsUsed: 0, isWinner: false },
@@ -377,13 +378,13 @@ 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' });
// setGameLimits overrides the profile's per-kind active-game caps (the e2e hook
// window.__mock.setGameLimits), then emits a profile notify so the app re-fetches the profile and
// the New-Game screen's per-kind lock recomputes in place — the same path a guest→durable upgrade
// takes to lift the lock.
setGameLimits(l: GameLimits): void {
this.profile.gameLimits = { ...l };
this.emit({ kind: 'notify', sub: 'profile' });
}
async lobbyPoll(): Promise<MatchResult> {
+6
View File
@@ -47,6 +47,9 @@ export const PROFILE: Profile = {
dictVersions: { erudit_ru: 'v1.3.0', scrabble_ru: 'v1.3.0', scrabble_en: 'v1.3.0' },
// Post-move interstitial config (short cooldowns so the mock stub is exercisable); not suppressed.
ads: { cooldownGlobalS: 300, cooldownVsAiS: 1800, cooldownHintS: 60, suppressed: false },
// The durable tier's per-kind active-game caps (the seeded defaults); the e2e lowers them through
// window.__mock.setGameLimits to exercise the New-Game lock.
gameLimits: { vsAi: 10, random: 10, friends: 10 },
};
// Seed social/account data for the mock (pnpm start + Playwright). The mock profile
@@ -189,6 +192,7 @@ function activeGame(): MockGame {
vsAi: false,
unreadChat: false,
unreadMessages: false,
kind: 2,
status: 'active',
players: 2,
toMove: 0,
@@ -227,6 +231,7 @@ function finishedG2(): MockGame {
vsAi: false,
unreadChat: false,
unreadMessages: false,
kind: 2,
status: 'finished',
players: 2,
toMove: 0,
@@ -266,6 +271,7 @@ function finishedG3(): MockGame {
vsAi: false,
unreadChat: false,
unreadMessages: false,
kind: 2,
status: 'finished',
players: 2,
toMove: 0,
+20
View File
@@ -62,6 +62,11 @@ export interface GameView {
* badge can be coloured apart from the nudge-only case. Same REST-seeded lifecycle as
* unreadChat; the live-event GameView leaves it false. */
unreadMessages: boolean;
/** The game's origin for the active-game limits: 0 unknown (a pre-existing game, never gated),
* 1 vs_ai, 2 random, 3 friends. The lobby counts active games per kind against the caller's
* per-kind limits (Profile.gameLimits) to lock a capped New-Game start. Local/offline games
* leave it 0 (they never count toward the online limits). */
kind: number;
seats: Seat[];
}
@@ -234,6 +239,21 @@ export interface Profile {
* offline-capable PWA reads it to preload the matching dawg for each enabled variant off
* this cold-start response. Empty only in a degenerate deployment with no resident dictionary. */
dictVersions: Partial<Record<Variant, string>>;
/** The caller's tier active-game caps per kind (-1 = unlimited); the New-Game screen counts the
* player's active games per kind from the lobby and locks a capped start. Absent from an old
* backend (then no lock applies). */
gameLimits?: GameLimits;
}
/** GameKind labels the game_kind wire values for the active-game limits. */
export const GameKind = { vsAi: 1, random: 2, friends: 3 } as const;
/** The caller's tier active-game caps per kind (-1 = unlimited), resolved to the guest or durable
* tier server-side. The New-Game screen locks a start whose kind is at its cap. */
export interface GameLimits {
vsAi: number;
random: number;
friends: number;
}
/** The post-move interstitial-ad config for the client-mirrored gate: the cooldowns (seconds) and
+20 -1
View File
@@ -1,5 +1,5 @@
import { describe, expect, it } from 'vitest';
import { BOT_BUTTON_ID, botInfoPopup } from './nativedialogs';
import { BOT_BUTTON_ID, LOGIN_BUTTON_ID, botInfoPopup, gameLimitGuestPopup, gameLimitDurablePopup } from './nativedialogs';
describe('botInfoPopup', () => {
it('inlines the bot handle and adds an open-bot button', () => {
@@ -23,3 +23,22 @@ describe('botInfoPopup', () => {
expect(p.message).toBe('Welcome!\n\nUse @b now.');
});
});
describe('gameLimitGuestPopup', () => {
it('offers a cancel and a login button (the sign-in funnel)', () => {
const p = gameLimitGuestPopup('More games?', 'Sign in to play more.', 'Cancel', 'Sign in');
expect(p.title).toBe('More games?');
expect(p.message).toBe('Sign in to play more.');
expect(p.buttons).toEqual([
{ id: 'cancel', text: 'Cancel' },
{ id: LOGIN_BUTTON_ID, text: 'Sign in' },
]);
});
});
describe('gameLimitDurablePopup', () => {
it('is an OK-only notice', () => {
const p = gameLimitDurablePopup('Game limit', 'Finish a current game first.', 'OK');
expect(p.buttons).toEqual([{ id: 'ok', text: 'OK' }]);
});
});
+15
View File
@@ -8,6 +8,21 @@ import type { TelegramPopupParams } from './telegram';
/** BOT_BUTTON_ID is the showPopup button id that means "open the bot chat". */
export const BOT_BUTTON_ID = 'bot';
/** LOGIN_BUTTON_ID is the game-limit popup button that opens the sign-in funnel. */
export const LOGIN_BUTTON_ID = 'login';
/** gameLimitGuestPopup builds the native popup that nudges a guest to sign in when a New-Game start
* is capped for guests: a cancel button and a login button (the sign-in funnel). */
export function gameLimitGuestPopup(title: string, message: string, cancelText: string, loginText: string): TelegramPopupParams {
return { title, message, buttons: [{ id: 'cancel', text: cancelText }, { id: LOGIN_BUTTON_ID, text: loginText }] };
}
/** gameLimitDurablePopup builds the native notice shown to a durable account at its per-kind cap:
* an OK button only (finish a current game first). */
export function gameLimitDurablePopup(title: string, message: string, okText: string): TelegramPopupParams {
return { title, message, buttons: [{ id: 'ok', text: okText }] };
}
/**
* botInfoPopup builds the native popup for a deep-link info modal: the message with the `{bot}`
* token replaced by the `@username` as plain text (a native popup has no inline link, unlike the
+1
View File
@@ -22,6 +22,7 @@ function gameView(id: string, status: GameView['status'] = 'active'): GameView {
vsAi: false,
unreadChat: false,
unreadMessages: false,
kind: 0,
status,
players: 2,
toMove: 0,
+1
View File
@@ -20,6 +20,7 @@ function game(seats: Seat[], status = 'finished', toMove = 0): GameView {
vsAi: false,
unreadChat: false,
unreadMessages: false,
kind: 0,
status,
players: seats.length,
toMove,
+5 -25
View File
@@ -23,12 +23,6 @@
let games = $state<GameView[]>([]);
let invitations = $state<Invitation[]>([]);
let incoming = $state<AccountRef[]>([]);
// True when the player has reached the simultaneous quick-game cap: the "New Game" tab is
// disabled and a notice shows under the lists. It rides the games.list response (which the
// lobby refetches on entry and on every game event) and the cached snapshot, so it renders
// in its last known state instantly and refreshes in the background. Optimistic default
// (enabled) for the very first, uncached load; the backend gate is the authority.
let atGameLimit = $state(false);
const guest = $derived(app.profile?.isGuest ?? true);
// The lobby ⚙️ badge is the shared count: incoming friend requests plus an awaiting
@@ -51,9 +45,8 @@
games = local;
invitations = [];
incoming = [];
atGameLimit = false;
app.notifications = 0;
setLobby({ games, invitations, incoming, atGameLimit, offline: offlineMode.active });
setLobby({ games, invitations, incoming, offline: offlineMode.active });
app.lobbyReady = true;
return;
}
@@ -64,7 +57,6 @@
// Seed the per-game unread badges from the authoritative list. The live stream only raises
// unread (it never seeds it from a GameView), so a persisted unread survives a reload here.
for (const g of games) seedChatUnread(g.id, g.unreadChat, g.unreadMessages);
atGameLimit = list.atGameLimit;
if (!guest) {
const [inv, inc] = await Promise.all([gateway.invitationsList(), gateway.friendsIncoming()]);
if (seq !== loadSeq) return;
@@ -75,7 +67,7 @@
app.notifications = incoming.length;
void refreshFeedbackBadge();
}
setLobby({ games, invitations, incoming, atGameLimit, offline: offlineMode.active });
setLobby({ games, invitations, incoming, offline: offlineMode.active });
// Warm the cache for the ongoing games so opening one from the lobby is instant. The list
// just loaded, so the connection is up; the call is non-blocking and re-running it on each
// lobby refresh cheaply warms any newly appeared game (already-cached ones are skipped).
@@ -103,7 +95,6 @@
games = cached.games;
invitations = cached.invitations;
incoming = cached.incoming;
atGameLimit = cached.atGameLimit;
}
void load();
});
@@ -231,7 +222,7 @@
revealedId = null;
const prev = games;
games = games.filter((g) => g.id !== id); // optimistic; the backend already filters it out
setLobby({ games, invitations, incoming, atGameLimit, offline: offlineMode.active });
setLobby({ games, invitations, incoming, offline: offlineMode.active });
try {
// A local (offline) game is deleted from the device store; an online game is hidden on the
// backend. Routing by id keeps the delete off the network for a local game (the transport
@@ -240,7 +231,7 @@
else await gateway.hideGame(id);
} catch (e) {
games = prev;
setLobby({ games, invitations, incoming, atGameLimit, offline: offlineMode.active });
setLobby({ games, invitations, incoming, offline: offlineMode.active });
handleError(e);
}
}
@@ -379,10 +370,6 @@
{#if !games.length && !invitations.length}
<p class="empty">{t('lobby.noActive')}</p>
{/if}
{#if atGameLimit}
<p class="limit">{t('lobby.limitReached')}</p>
{/if}
</div>
{#if declineTarget}
@@ -411,7 +398,7 @@
{#snippet tabbar()}
<TabBar>
<button class="tab" disabled={atGameLimit} onclick={() => navigate('/new')}>
<button class="tab" onclick={() => navigate('/new')}>
<span class="sq" data-coach="lobby-new">🎲</span><span class="lbl">{t('lobby.new')}</span>
</button>
<button class="tab" disabled={offlineMode.active} onclick={() => navigate('/stats')}>
@@ -444,13 +431,6 @@
font-size: 0.9rem;
margin: 0;
}
/* A plain, low-emphasis line under the lists when the simultaneous-game cap is reached. */
.limit {
color: var(--text-muted);
font-size: 0.9rem;
margin: 0;
text-align: center;
}
.invite {
display: flex;
align-items: center;
+28 -3
View File
@@ -2,7 +2,11 @@
import { onMount } from 'svelte';
import Screen from '../components/Screen.svelte';
import Modal from '../components/Modal.svelte';
import GameLimitModal from '../components/GameLimitModal.svelte';
import PinPad, { type PinResult } from '../components/PinPad.svelte';
import { getLobby } from '../lib/lobbycache';
import { isKindLocked } from '../lib/gamelimits';
import { GameKind } from '../lib/model';
import { gateway } from '../lib/gateway';
import { app, handleError, showToast } from '../lib/app.svelte';
import { connection } from '../lib/connection.svelte';
@@ -51,6 +55,14 @@
// or a random human via auto-match. AI is the default.
let opponent = $state<'ai' | 'random'>('ai');
// Active-game limit lock: the auto-start kind (vs_ai or random by the opponent choice) tested
// against the caller's per-kind cap over the online lobby's active games. A capped start opens the
// funnel modal instead of enqueuing (the server also refuses it); offline never locks (local
// games are uncapped). The lock lifts when the profile is re-fetched after a guest→durable upgrade.
const autoKind = $derived(opponent === 'ai' ? GameKind.vsAi : GameKind.random);
const autoLocked = $derived(!offlineMode.active && isKindLocked(getLobby(false)?.games ?? [], app.profile?.gameLimits, autoKind));
let limitOpen = $state(false);
// --- auto-match ---
// Enqueue drops the player straight into a real game — a freshly opened one awaiting an
// opponent, or another player's open game they just joined — so we navigate into it at once
@@ -329,9 +341,16 @@
{#if opponent === 'random'}<p class="searchhint">{t('new.searchHint')}</p>{/if}
<button
class="invite"
disabled={!selectedAuto || (!connection.online && !offlineMode.active) || starting}
onclick={() => selectedAuto && find(selectedAuto)}
>{t('new.start')}</button>
class:locked={autoLocked}
disabled={(autoLocked ? false : !selectedAuto) || (!connection.online && !offlineMode.active) || starting}
onclick={() => (autoLocked ? (limitOpen = true) : selectedAuto && find(selectedAuto))}
>{#if autoLocked}🔒 {/if}{t('new.start')}</button>
<GameLimitModal
open={limitOpen}
{guest}
onclose={() => (limitOpen = false)}
onlogin={() => { limitOpen = false; navigate('/settings'); }}
/>
{:else if offlineMode.active}
<!-- Offline pass-and-play (hotseat): a device-local 2-4 human game. The host sets a master
PIN first (it gates the roster); each seat may add its own PIN. -->
@@ -660,6 +679,12 @@
.invite:disabled {
opacity: 0.5;
}
/* A capped start (the active-game limit): an outline instead of the filled CTA, with a 🔒, so it
reads as gated rather than ready. Tapping it opens the funnel modal, never a game. */
.invite.locked {
background: transparent;
color: var(--accent);
}
.searchhint {
margin: 0;
text-align: center;