From 3306a016a0ebc0c1fa379b169be79d8a87c6b82f Mon Sep 17 00:00:00 2001 From: Ilia Denisov Date: Fri, 10 Jul 2026 10:09:45 +0200 Subject: [PATCH 1/2] feat(ui): per-kind active-game limit lock on the New Game screen 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. --- PLAN.md | 29 ++++-- backend/internal/server/banner.go | 1 + backend/internal/server/dto.go | 19 +++- .../internal/server/handlers_admin_limits.go | 10 ++ docs/ARCHITECTURE.md | 13 ++- docs/FUNCTIONAL.md | 19 ++-- docs/FUNCTIONAL_ru.md | 21 +++-- docs/UI_DESIGN.md | 15 +-- gateway/internal/backendclient/api.go | 11 +++ gateway/internal/transcode/encode.go | 13 +++ .../transcode/transcode_gamelimits_test.go | 61 ++++++++++++ pkg/fbs/scrabble.fbs | 18 ++++ pkg/fbs/scrabblefb/GameLimits.go | 94 +++++++++++++++++++ pkg/fbs/scrabblefb/GameView.go | 17 +++- pkg/fbs/scrabblefb/Profile.go | 18 +++- pkg/wire/build.go | 4 + ui/e2e/gamelimit.spec.ts | 53 ++++++----- ui/src/components/GameLimitModal.svelte | 70 ++++++++++++++ ui/src/gen/fbs/scrabblefb.ts | 1 + ui/src/gen/fbs/scrabblefb/game-limits.ts | 66 +++++++++++++ ui/src/gen/fbs/scrabblefb/game-view.ts | 14 ++- ui/src/gen/fbs/scrabblefb/profile.ts | 12 ++- ui/src/lib/codec.test.ts | 22 +++++ ui/src/lib/codec.ts | 12 +++ ui/src/lib/gamecache.test.ts | 1 + ui/src/lib/gamedelta.test.ts | 1 + ui/src/lib/gameimage.test.ts | 1 + ui/src/lib/gamelimits.test.ts | 77 +++++++++++++++ ui/src/lib/gamelimits.ts | 50 ++++++++++ ui/src/lib/gateway.ts | 4 +- ui/src/lib/i18n/en.ts | 5 + ui/src/lib/i18n/ru.ts | 5 + ui/src/lib/lobbycache.test.ts | 33 +++---- ui/src/lib/lobbycache.ts | 3 - ui/src/lib/lobbysort.test.ts | 1 + ui/src/lib/localgame/source.ts | 1 + ui/src/lib/mock/client.ts | 21 +++-- ui/src/lib/mock/data.ts | 6 ++ ui/src/lib/model.ts | 20 ++++ ui/src/lib/nativedialogs.test.ts | 21 ++++- ui/src/lib/nativedialogs.ts | 15 +++ ui/src/lib/preload.test.ts | 1 + ui/src/lib/result.test.ts | 1 + ui/src/screens/Lobby.svelte | 30 +----- ui/src/screens/NewGame.svelte | 31 +++++- 45 files changed, 811 insertions(+), 130 deletions(-) create mode 100644 gateway/internal/transcode/transcode_gamelimits_test.go create mode 100644 pkg/fbs/scrabblefb/GameLimits.go create mode 100644 ui/src/components/GameLimitModal.svelte create mode 100644 ui/src/gen/fbs/scrabblefb/game-limits.ts create mode 100644 ui/src/lib/gamelimits.test.ts create mode 100644 ui/src/lib/gamelimits.ts diff --git a/PLAN.md b/PLAN.md index 4557aa2..e3995bb 100644 --- a/PLAN.md +++ b/PLAN.md @@ -37,7 +37,7 @@ status — without re-deriving decisions. | E5 | Payment intake | 2 | DONE | | E6 | Ads | 2 | DONE | | E7 | Admin, reports & catalog | 2 | DONE | -| E8 | Guest limits | — | WIP | +| E8 | Guest limits | — | DONE | | E9 | Tournament fee | future | TODO | **Release 1** = full mechanics with no real money, exercised via `admin_grant` (E0→E1→E2→E3). @@ -782,7 +782,7 @@ become bootstrap-only. ## E8 — Guest limits -**Status:** WIP · **standalone** (game-behaviour change) · depends on: none · mechanics: PAYMENTS §6. +**Status:** DONE · **standalone** (game-behaviour change) · depends on: none · mechanics: PAYMENTS §6. **Goal.** A registration funnel: cap what a guest can do, enforced **server-side** (today UI-only), with configurable per-tier × per-kind limits so the pressure tunes without a release. @@ -833,12 +833,19 @@ payments mixed in. `game.Service.AtGameLimit`; the **`internal/gamelimits` config + hot cache** (loaded at boot, invalidated on edit); the admin **kind column** in both game lists (`/_gm/games` + the user card) and a **config editor** (`/_gm/limits`) for the six limits. -- **PR2 — wire + client:** `Profile.game_limits` (the caller's tier) + `GameView.kind` (FBS + - transcode + codec); the client per-kind active count from the lobby; the **lock badge** on a capped - new-game start button, with two messages by tier — a **guest** login-funnel modal ("Войдите или - создайте учётную запись…", Отмена / Вход→profile) and, for a **durable** account at its cap, a plain - notice ("Вы достигли лимита одновременных игр, сначала завершите текущие", ОК; **native** popup in - Telegram — no gesture-gated API follows it); the profile re-fetch after a guest→durable upgrade. +- ~~**PR2 — wire + client** — DONE.~~ `Profile.game_limits` (the caller's tier) + `GameView.kind` (FBS + + gateway transcode + client codec, committed regen); the client counts active games per kind from + the lobby cache (`gamelimits.ts`) and locks a capped new-game start — an **outline 🔒** button that + opens `GameLimitModal` instead of a game, native (Telegram `showPopup`) or the in-app `Modal` + elsewhere, with two messages by tier: a **guest** sign-in funnel ("Войдите или создайте учётную + запись…", Отмена / Вход→`/settings`) and, for a **durable** account at its cap, a plain notice + ("Вы достигли лимита одновременных игр, сначала завершите текущие", ОК). The lock lifts via the + existing profile re-fetch after a guest→durable upgrade. + - **Decision (owner-agreed): the lobby's old `at_game_limit` New-Game tab-disable + notice is + removed.** The `at_game_limit` flag (now the random-kind cap) conflicted with the per-kind lock — + it hid the New-Game screen where the lock lives, and wrongly blocked starting an unfulfilled kind. + The tab is always enabled; the per-kind lock on the start button is the only gate. The wire field + `GameList.at_game_limit` stays (unused by the client) for a later cleanup. **Tests.** @@ -846,8 +853,10 @@ payments mixed in. (domain + HTTP 403); guest blocked from a 2nd vs_ai / 2nd random (+ `at_game_limit`); durable on the higher tier; the durable friends cap + config cache reflecting an admin edit; accept stays exempt. - unit (PR1, done): the per-tier/kind limit resolution (`Cap`, `LimitsFor`). -- UI (PR2): the lock + the two modals on a capped button (mock); the profile re-fetch lifts the lock - after register. +- unit + UI (PR2, done): the client lock logic (`gamelimits.ts` — count/cap/lock), the codec kind + + game_limits roundtrip, the gateway transcode game_limits encode, the popup builders, and a mock e2e + (`gamelimit.spec.ts`) — a capped start shows 🔒, opens the modal without navigating, and the lock + clears when the profile refetch lifts the cap. **Done-criteria.** A guest is server-capped (default 1 vs_ai + 1 random, configurable) and cannot friend/invite; a durable account is capped at 10 per kind (configurable); the client shows the lock + diff --git a/backend/internal/server/banner.go b/backend/internal/server/banner.go index 8b68979..61a2b36 100644 --- a/backend/internal/server/banner.go +++ b/backend/internal/server/banner.go @@ -72,6 +72,7 @@ func (s *Server) profileResponse(ctx context.Context, acc account.Account) profi } r.Banner = s.bannerFor(ctx, acc, cxt, present) r.Ads = s.adsFor(ctx, acc, cxt, present) + r.GameLimits = s.gameLimitsDTOFor(acc.IsGuest) s.fillLinkedIdentities(ctx, &r, acc.ID) r.DictVersions = s.currentDictVersions() return r diff --git a/backend/internal/server/dto.go b/backend/internal/server/dto.go index 634d583..a4c2dba 100644 --- a/backend/internal/server/dto.go +++ b/backend/internal/server/dto.go @@ -78,6 +78,18 @@ type profileResponse struct { // Filled outside the pure projection (it reads the dictionary registry), so it is empty // for callers that build the DTO without a Server. See Server.profileResponse. DictVersions []dictVersion `json:"dict_versions,omitempty"` + // GameLimits carries the caller's tier active-game caps per kind (-1 = unlimited); the client + // counts its active games per kind from the lobby and locks a capped New-Game start. Filled + // outside the pure projection (it reads the limits config). See Server.profileResponse. + GameLimits *gameLimitsDTO `json:"game_limits,omitempty"` +} + +// gameLimitsDTO is the caller's tier active-game caps per kind (-1 = unlimited), for the client's +// per-kind New-Game lock. profileResponse.GameLimits carries it. +type gameLimitsDTO struct { + VsAI int `json:"vs_ai"` + Random int `json:"random"` + Friends int `json:"friends"` } // dictVersion pairs a game variant's stable label (engine.Variant.String) with its current @@ -134,7 +146,11 @@ type gameDTO struct { MultipleWordsPerTurn bool `json:"multiple_words_per_turn"` // VsAI marks an honest-AI game: the opponent is shown as 🤖 and chat/nudge/add-friend // are suppressed in the client. - VsAI bool `json:"vs_ai"` + VsAI bool `json:"vs_ai"` + // Kind is the game's origin for the active-game limits (game.Kind): 0 unknown (a pre-existing + // game), 1 vs_ai, 2 random, 3 friends. The lobby counts active games per kind to lock a capped + // New-Game start. + Kind int `json:"kind"` MoveCount int `json:"move_count"` EndReason string `json:"end_reason"` // LastActivityUnix is the lobby sort key: the current turn's start for an active @@ -277,6 +293,7 @@ func gameDTOFromGame(g game.Game) gameDTO { TurnTimeoutSecs: int(g.TurnTimeout.Seconds()), MultipleWordsPerTurn: g.MultipleWordsPerTurn, VsAI: g.VsAI, + Kind: int(g.Kind), MoveCount: g.MoveCount, EndReason: g.EndReason, LastActivityUnix: last.Unix(), diff --git a/backend/internal/server/handlers_admin_limits.go b/backend/internal/server/handlers_admin_limits.go index f85ff4d..dfb99f7 100644 --- a/backend/internal/server/handlers_admin_limits.go +++ b/backend/internal/server/handlers_admin_limits.go @@ -9,6 +9,16 @@ import ( "scrabble/backend/internal/gamelimits" ) +// gameLimitsDTOFor resolves the caller's tier active-game caps into the profile DTO, so the client +// can lock a capped New-Game start per kind. It returns nil when the limits config is not wired. +func (s *Server) gameLimitsDTOFor(isGuest bool) *gameLimitsDTO { + if s.gamelimits == nil { + return nil + } + l := s.gamelimits.LimitsFor(isGuest) + return &gameLimitsDTO{VsAI: l.VsAI, Random: l.Random, Friends: l.Friends} +} + // consoleLimits renders the per-tier, per-kind active-game limit form (backend.config), read from // the in-memory cache. func (s *Server) consoleLimits(c *gin.Context) { diff --git a/docs/ARCHITECTURE.md b/docs/ARCHITECTURE.md index db2df09..fcf7749 100644 --- a/docs/ARCHITECTURE.md +++ b/docs/ARCHITECTURE.md @@ -666,11 +666,14 @@ in either direction (the enqueue excludes the caller's `BlockedWith` set); refuses a **guest** outright with **403** (guests cannot use friends — the same guest gate covers friend requests and friend-code redemption). **Accepting** an invitation (`POST /invitations/:id/accept`) is never gated, so friend games are capped only at - initiation. The lobby learns the random-kind state from a boolean **`at_game_limit`** on - the `games.list` response — already re-fetched on entry and every game event; the client - additionally counts the listed games per kind against the per-tier caps it reads in the - profile, locking a kind's New-Game entry (a login funnel for a guest, an already-at-limit - note for a durable account) when that kind is full. + initiation. The client counts its lobby games per kind against the per-tier caps it reads on + the profile (`Profile.game_limits`, carrying `GameView.kind`), and locks a capped kind's + New-Game start — a 🔒 outline button that opens a prompt instead of a game: a sign-in funnel + for a guest, a "finish a current game first" notice for a durable account (native inside + Telegram, an in-app modal elsewhere), lifting when the profile is re-fetched after a + guest→durable upgrade. The `games.list` `at_game_limit` boolean (now the random-kind cap) is + still carried but no longer drives the UI — the per-kind start lock superseded the old + New-Game-tab disable. - **Friends**: two add paths over one `friendships` table. A **one-time code** the to-be-added player issues (a `friend_codes` row: 6-digit numeric, SHA-256-hashed, **12 h** TTL, one live code per issuer, single-use, redeem diff --git a/docs/FUNCTIONAL.md b/docs/FUNCTIONAL.md index ff7a7ab..930ba72 100644 --- a/docs/FUNCTIONAL.md +++ b/docs/FUNCTIONAL.md @@ -190,14 +190,17 @@ Mini App, the link only opens the app and the recipient enters the copied code b settings and the game starts once every invitee has accepted — any decline cancels it, and an unanswered invitation expires after seven days. -**Simultaneous-game cap.** A player may hold at most **10 active quick games** at once — -counting both in-progress and still-searching auto-match/AI games, but **not** friend games -created by invitation. At the cap the **New Game** button is disabled (greyed) and a plain, -low-emphasis line under the lists reads *"You've reached the simultaneous games limit."*; both -clear automatically once an active game finishes and the count drops below ten. The cap blocks -**starting** any game (a quick game or a friend invitation, which share the New Game entry), but -**accepting an incoming invitation is never blocked** — so friend games are capped from the other -end (you cannot initiate one while at the cap) while you can always join a game you are invited to. +**Active-game caps (per kind).** A player's simultaneous unfinished games are capped **per kind** — +against the AI, in a random match, and by friend invitation — with separate limits for a **guest** +and a signed-in (durable) account, tuned by the operator without a release. A **guest** may hold **1** +game against the AI and **1** random match at once and cannot start friend games at all; a signed-in +account holds up to **10** of each kind. Only in-progress and still-searching games count; a finished +game frees its slot, and games already in progress are never interrupted. On the **New Game** screen a +start whose kind is at its cap shows a **🔒** and, when tapped, opens a short prompt instead of a game: +a **guest** is invited to sign in or create an account (to play several games at once), a signed-in +player is told to finish a current game first. **Accepting an incoming invitation is never blocked** — +friend games are capped only when you start one. The server enforces every cap regardless of the +client, and refuses a guest's friend request, friend code or invitation outright. ### Playing a game Place tiles, pass, exchange, or resign. Pass and exchange share one control — diff --git a/docs/FUNCTIONAL_ru.md b/docs/FUNCTIONAL_ru.md index 00dc508..c89f15d 100644 --- a/docs/FUNCTIONAL_ru.md +++ b/docs/FUNCTIONAL_ru.md @@ -197,15 +197,18 @@ e-mail) либо ввод фразы. Активные игры форфейтя выбирает настройки, и партия стартует, когда приняли все приглашённые — любой отказ отменяет приглашение, а без ответа приглашение протухает через семь дней. -**Лимит одновременных партий.** Игрок может держать одновременно не более **10 активных -быстрых партий** — считаются и идущие, и ещё ищущие соперника авто-подбор/AI-партии, но **не** -игры с друзьями, созданные приглашением. На лимите кнопка **«Новая игра»** становится неактивной -(серой), а под списками появляется ненавязчивая строка *«Вы достигли лимита одновременных -партий»*; и кнопка, и надпись снимаются автоматически, как только активная партия завершается и -счётчик опускается ниже десяти. Лимит запрещает **создавать** любую партию (быструю или -приглашение в игру с друзьями — у них общий вход «Новая игра»), но **принимать входящее -приглашение никогда не запрещено** — так игры с друзьями ограничиваются «с другого конца» (на -лимите их нельзя инициировать), а присоединиться к партии по приглашению можно всегда. +**Лимит одновременных партий (по видам).** Число одновременных незавершённых партий игрока +ограничено **по видам** — против AI, в случайном подборе и по приглашению друзей — с раздельными +лимитами для **гостя** и для вошедшего (постоянного) аккаунта; оператор настраивает их без релиза. +**Гость** может держать **1** партию против AI и **1** случайную одновременно и вовсе не может +создавать игры с друзьями; вошедший аккаунт — до **10** каждого вида. Считаются только идущие и ещё +ищущие соперника партии; завершённая освобождает слот, а уже идущие партии никогда не прерываются. +На экране **«Новая игра»** старт того вида, что достиг лимита, показывает **🔒** и по нажатию +открывает короткое сообщение вместо партии: **гостю** предлагают войти или создать учётную запись +(чтобы играть несколько партий сразу), вошедшему — сначала завершить текущую. **Принимать входящее +приглашение никогда не запрещено** — игры с друзьями ограничиваются только при создании. Сервер +применяет все лимиты независимо от клиента и напрямую отклоняет заявку в друзья, код друга или +приглашение от гостя. ### Игровой процесс Выкладывание фишек, пас, обмен или сдача. Пас и обмен — один элемент управления: diff --git a/docs/UI_DESIGN.md b/docs/UI_DESIGN.md index cf904b0..2bb4fbb 100644 --- a/docs/UI_DESIGN.md +++ b/docs/UI_DESIGN.md @@ -347,12 +347,15 @@ its entrance (`components/Toast.svelte`, re-keyed on a per-message sequence). An lives ~2 s, drifting up by about the tab-bar height as it fades (a plain fade, no travel, under reduce-motion); an **error** toast dwells longer (~4 s). Either dismisses instantly on tap. -**Simultaneous-game cap.** When the player is at the active-quick-game cap (`games.list` -`at_game_limit`), the lobby's **New Game** tab is **disabled** (greyed via the shared -`.tab:disabled` opacity) and a plain, low-emphasis line — muted, centred, no accent — sits -under the lists with the localized "limit reached" notice. Both follow the flag carried in the -cached lobby snapshot, so they render in their last-known state on entry (the button stays -enabled on the first, uncached load) and flip in place when an event refreshes the lobby. +**Active-game caps.** A player's simultaneous unfinished games are capped per kind (vs AI / random / +friends) against the caller's per-tier limits (`Profile.game_limits`), counted client-side from the +lobby games. The lobby's **New Game** tab is **always enabled** — the lock lives on the per-kind +start, not the tab. On the **New Game** screen a start whose kind is at its cap renders as an +**outline** button with a **🔒** (not the filled accent CTA), so it reads as gated rather than ready; +tapped, it opens a short prompt instead of a game — inside Telegram a **native popup**, elsewhere the +in-app **Modal**. A **guest** sees a sign-in funnel (Cancel / Sign in → the account screen); a +signed-in account at its cap sees a plain "finish a current game first" notice (OK). The lock lifts in +place when the profile is re-fetched after a guest→durable upgrade. ## Social, account & history surfaces diff --git a/gateway/internal/backendclient/api.go b/gateway/internal/backendclient/api.go index e31b533..29d0dbe 100644 --- a/gateway/internal/backendclient/api.go +++ b/gateway/internal/backendclient/api.go @@ -47,6 +47,16 @@ type ProfileResp struct { // DictVersions is the current dictionary version per game variant, forwarded verbatim // into the Profile payload so an offline-capable client preloads the matching dawg. DictVersions []DictVersion `json:"dict_versions,omitempty"` + // GameLimits is the caller's tier active-game caps per kind (-1 = unlimited), forwarded verbatim + // into the Profile payload for the client's per-kind New-Game lock. + GameLimits *GameLimitsResp `json:"game_limits,omitempty"` +} + +// GameLimitsResp is the caller's tier active-game caps per kind (-1 = unlimited) in the profile. +type GameLimitsResp struct { + VsAI int `json:"vs_ai"` + Random int `json:"random"` + Friends int `json:"friends"` } // AdsResp is the post-move interstitial config in the profile: the client-mirrored cooldowns @@ -170,6 +180,7 @@ type GameResp struct { Seats []SeatResp `json:"seats"` UnreadChat bool `json:"unread_chat"` UnreadMessages bool `json:"unread_messages"` + Kind int `json:"kind"` } // MoveResultResp is the outcome of a committed move. Rack carries the actor's refilled rack as diff --git a/gateway/internal/transcode/encode.go b/gateway/internal/transcode/encode.go index 77a9c83..8f9f9aa 100644 --- a/gateway/internal/transcode/encode.go +++ b/gateway/internal/transcode/encode.go @@ -194,6 +194,15 @@ func encodeProfile(p backendclient.ProfileResp) []byte { fb.AdsInfoAddSuppressed(b, p.Ads.Suppressed) ads = fb.AdsInfoEnd(b) } + // The active-game limits table (scalars only), built before Profile is opened. + var gameLimits flatbuffers.UOffsetT + if p.GameLimits != nil { + fb.GameLimitsStart(b) + fb.GameLimitsAddVsAi(b, int32(p.GameLimits.VsAI)) + fb.GameLimitsAddRandom(b, int32(p.GameLimits.Random)) + fb.GameLimitsAddFriends(b, int32(p.GameLimits.Friends)) + gameLimits = fb.GameLimitsEnd(b) + } fb.ProfileStart(b) fb.ProfileAddUserId(b, uid) fb.ProfileAddDisplayName(b, name) @@ -219,6 +228,9 @@ func encodeProfile(p backendclient.ProfileResp) []byte { if p.Ads != nil { fb.ProfileAddAds(b, ads) } + if p.GameLimits != nil { + fb.ProfileAddGameLimits(b, gameLimits) + } b.Finish(fb.ProfileEnd(b)) return b.FinishedBytes() } @@ -624,6 +636,7 @@ func toWireGame(g backendclient.GameResp) wire.GameView { LastActivityUnix: g.LastActivityUnix, UnreadChat: g.UnreadChat, UnreadMessages: g.UnreadMessages, + Kind: g.Kind, } } diff --git a/gateway/internal/transcode/transcode_gamelimits_test.go b/gateway/internal/transcode/transcode_gamelimits_test.go new file mode 100644 index 0000000..95e3bf8 --- /dev/null +++ b/gateway/internal/transcode/transcode_gamelimits_test.go @@ -0,0 +1,61 @@ +package transcode_test + +import ( + "context" + "net/http" + "testing" + + "scrabble/gateway/internal/transcode" + fb "scrabble/pkg/fbs/scrabblefb" +) + +// TestProfileGetEncodesGameLimits verifies the gateway forwards the backend's per-kind active-game +// caps into the Profile payload — the caps the client's New-Game lock reads. The encode is not +// exercised by the mock e2e (it bypasses the codec), so a dropped field would only surface here. +func TestProfileGetEncodesGameLimits(t *testing.T) { + backend, cleanup := fakeBackend(t, func(w http.ResponseWriter, r *http.Request) { + if r.Method != http.MethodGet || r.URL.Path != "/api/v1/user/profile" { + t.Errorf("unexpected %s %q", r.Method, r.URL.Path) + } + _, _ = w.Write([]byte(`{"user_id":"u-1","display_name":"Kaya","preferred_language":"en",` + + `"game_limits":{"vs_ai":1,"random":1,"friends":0}}`)) + }) + defer cleanup() + + reg := transcode.NewRegistry(backend, nil) + op, ok := reg.Lookup(transcode.MsgProfileGet) + if !ok { + t.Fatal("profile.get not registered") + } + payload, err := op.Handler(context.Background(), transcode.Request{UserID: "u-1"}) + if err != nil { + t.Fatalf("handler: %v", err) + } + + gl := fb.GetRootAsProfile(payload, 0).GameLimits(nil) + if gl == nil { + t.Fatal("profile carries no game_limits block") + } + if gl.VsAi() != 1 || gl.Random() != 1 || gl.Friends() != 0 { + t.Errorf("game limits = %d/%d/%d, want 1/1/0", gl.VsAi(), gl.Random(), gl.Friends()) + } +} + +// TestProfileGetNoGameLimits verifies a profile without a game_limits block encodes none (the +// backend omits it only when the limits config is unwired; normally the block is present). +func TestProfileGetNoGameLimits(t *testing.T) { + backend, cleanup := fakeBackend(t, func(w http.ResponseWriter, r *http.Request) { + _, _ = w.Write([]byte(`{"user_id":"u-1","display_name":"Kaya","preferred_language":"en"}`)) + }) + defer cleanup() + + reg := transcode.NewRegistry(backend, nil) + op, _ := reg.Lookup(transcode.MsgProfileGet) + payload, err := op.Handler(context.Background(), transcode.Request{UserID: "u-1"}) + if err != nil { + t.Fatalf("handler: %v", err) + } + if p := fb.GetRootAsProfile(payload, 0); p.GameLimits(nil) != nil { + t.Error("profile without a game_limits block unexpectedly carries one") + } +} diff --git a/pkg/fbs/scrabble.fbs b/pkg/fbs/scrabble.fbs index 5530869..55ef736 100644 --- a/pkg/fbs/scrabble.fbs +++ b/pkg/fbs/scrabble.fbs @@ -80,6 +80,11 @@ table GameView { // message (not just a nudge). With unread_chat it colours the badge — both set is the regular // badge, unread_chat alone is the softer nudge-only badge. unread_messages:bool; + // kind is 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.game_limits) to lock a capped New-Game start (added + // trailing — backward-compatible). + kind:int; } // MoveRecord is one decoded move (a committed play, or a hint preview). @@ -267,6 +272,10 @@ table Profile { // ads carries the post-move interstitial config (cooldowns + suppressed) for the client-mirrored // gate (added trailing — backward-compatible). ads:AdsInfo; + // game_limits carries the caller's tier active-game caps per kind (-1 = unlimited); the client + // counts its active games per kind from the lobby and locks a capped New-Game start (added + // trailing — backward-compatible). + game_limits:GameLimits; } // AdsInfo is the post-move interstitial config: the client-mirrored cooldowns (seconds) and whether @@ -278,6 +287,15 @@ table AdsInfo { suppressed:bool; } +// GameLimits is the caller's tier active-game caps per kind (-1 = unlimited), resolved to the +// guest or durable tier server-side. It rides Profile.game_limits for the client's per-kind +// New-Game lock. +table GameLimits { + vs_ai:int; + random:int; + friends:int; +} + // BlockStatus reports the caller's current manual block. The UI fetches it after any operation // returns the "account_blocked" result code, then replaces every screen with the terminal blocked // screen and stops all push/poll. permanent is false for a temporary block; until is an RFC3339 diff --git a/pkg/fbs/scrabblefb/GameLimits.go b/pkg/fbs/scrabblefb/GameLimits.go new file mode 100644 index 0000000..08419cd --- /dev/null +++ b/pkg/fbs/scrabblefb/GameLimits.go @@ -0,0 +1,94 @@ +// Code generated by the FlatBuffers compiler. DO NOT EDIT. + +package scrabblefb + +import ( + flatbuffers "github.com/google/flatbuffers/go" +) + +type GameLimits struct { + _tab flatbuffers.Table +} + +func GetRootAsGameLimits(buf []byte, offset flatbuffers.UOffsetT) *GameLimits { + n := flatbuffers.GetUOffsetT(buf[offset:]) + x := &GameLimits{} + x.Init(buf, n+offset) + return x +} + +func FinishGameLimitsBuffer(builder *flatbuffers.Builder, offset flatbuffers.UOffsetT) { + builder.Finish(offset) +} + +func GetSizePrefixedRootAsGameLimits(buf []byte, offset flatbuffers.UOffsetT) *GameLimits { + n := flatbuffers.GetUOffsetT(buf[offset+flatbuffers.SizeUint32:]) + x := &GameLimits{} + x.Init(buf, n+offset+flatbuffers.SizeUint32) + return x +} + +func FinishSizePrefixedGameLimitsBuffer(builder *flatbuffers.Builder, offset flatbuffers.UOffsetT) { + builder.FinishSizePrefixed(offset) +} + +func (rcv *GameLimits) Init(buf []byte, i flatbuffers.UOffsetT) { + rcv._tab.Bytes = buf + rcv._tab.Pos = i +} + +func (rcv *GameLimits) Table() flatbuffers.Table { + return rcv._tab +} + +func (rcv *GameLimits) VsAi() int32 { + o := flatbuffers.UOffsetT(rcv._tab.Offset(4)) + if o != 0 { + return rcv._tab.GetInt32(o + rcv._tab.Pos) + } + return 0 +} + +func (rcv *GameLimits) MutateVsAi(n int32) bool { + return rcv._tab.MutateInt32Slot(4, n) +} + +func (rcv *GameLimits) Random() int32 { + o := flatbuffers.UOffsetT(rcv._tab.Offset(6)) + if o != 0 { + return rcv._tab.GetInt32(o + rcv._tab.Pos) + } + return 0 +} + +func (rcv *GameLimits) MutateRandom(n int32) bool { + return rcv._tab.MutateInt32Slot(6, n) +} + +func (rcv *GameLimits) Friends() int32 { + o := flatbuffers.UOffsetT(rcv._tab.Offset(8)) + if o != 0 { + return rcv._tab.GetInt32(o + rcv._tab.Pos) + } + return 0 +} + +func (rcv *GameLimits) MutateFriends(n int32) bool { + return rcv._tab.MutateInt32Slot(8, n) +} + +func GameLimitsStart(builder *flatbuffers.Builder) { + builder.StartObject(3) +} +func GameLimitsAddVsAi(builder *flatbuffers.Builder, vsAi int32) { + builder.PrependInt32Slot(0, vsAi, 0) +} +func GameLimitsAddRandom(builder *flatbuffers.Builder, random int32) { + builder.PrependInt32Slot(1, random, 0) +} +func GameLimitsAddFriends(builder *flatbuffers.Builder, friends int32) { + builder.PrependInt32Slot(2, friends, 0) +} +func GameLimitsEnd(builder *flatbuffers.Builder) flatbuffers.UOffsetT { + return builder.EndObject() +} diff --git a/pkg/fbs/scrabblefb/GameView.go b/pkg/fbs/scrabblefb/GameView.go index 0969946..31c8ce3 100644 --- a/pkg/fbs/scrabblefb/GameView.go +++ b/pkg/fbs/scrabblefb/GameView.go @@ -209,8 +209,20 @@ func (rcv *GameView) MutateUnreadMessages(n bool) bool { return rcv._tab.MutateBoolSlot(32, n) } +func (rcv *GameView) Kind() int32 { + o := flatbuffers.UOffsetT(rcv._tab.Offset(34)) + if o != 0 { + return rcv._tab.GetInt32(o + rcv._tab.Pos) + } + return 0 +} + +func (rcv *GameView) MutateKind(n int32) bool { + return rcv._tab.MutateInt32Slot(34, n) +} + func GameViewStart(builder *flatbuffers.Builder) { - builder.StartObject(15) + builder.StartObject(16) } func GameViewAddId(builder *flatbuffers.Builder, id flatbuffers.UOffsetT) { builder.PrependUOffsetTSlot(0, flatbuffers.UOffsetT(id), 0) @@ -260,6 +272,9 @@ func GameViewAddUnreadChat(builder *flatbuffers.Builder, unreadChat bool) { func GameViewAddUnreadMessages(builder *flatbuffers.Builder, unreadMessages bool) { builder.PrependBoolSlot(14, unreadMessages, false) } +func GameViewAddKind(builder *flatbuffers.Builder, kind int32) { + builder.PrependInt32Slot(15, kind, 0) +} func GameViewEnd(builder *flatbuffers.Builder) flatbuffers.UOffsetT { return builder.EndObject() } diff --git a/pkg/fbs/scrabblefb/Profile.go b/pkg/fbs/scrabblefb/Profile.go index 6b402e1..ed879cb 100644 --- a/pkg/fbs/scrabblefb/Profile.go +++ b/pkg/fbs/scrabblefb/Profile.go @@ -244,8 +244,21 @@ func (rcv *Profile) Ads(obj *AdsInfo) *AdsInfo { return nil } +func (rcv *Profile) GameLimits(obj *GameLimits) *GameLimits { + o := flatbuffers.UOffsetT(rcv._tab.Offset(40)) + if o != 0 { + x := rcv._tab.Indirect(o + rcv._tab.Pos) + if obj == nil { + obj = new(GameLimits) + } + obj.Init(rcv._tab.Bytes, x) + return obj + } + return nil +} + func ProfileStart(builder *flatbuffers.Builder) { - builder.StartObject(18) + builder.StartObject(19) } func ProfileAddUserId(builder *flatbuffers.Builder, userId flatbuffers.UOffsetT) { builder.PrependUOffsetTSlot(0, flatbuffers.UOffsetT(userId), 0) @@ -307,6 +320,9 @@ func ProfileStartDictVersionsVector(builder *flatbuffers.Builder, numElems int) func ProfileAddAds(builder *flatbuffers.Builder, ads flatbuffers.UOffsetT) { builder.PrependUOffsetTSlot(17, flatbuffers.UOffsetT(ads), 0) } +func ProfileAddGameLimits(builder *flatbuffers.Builder, gameLimits flatbuffers.UOffsetT) { + builder.PrependUOffsetTSlot(18, flatbuffers.UOffsetT(gameLimits), 0) +} func ProfileEnd(builder *flatbuffers.Builder) flatbuffers.UOffsetT { return builder.EndObject() } diff --git a/pkg/wire/build.go b/pkg/wire/build.go index 1412ee7..5fd62dd 100644 --- a/pkg/wire/build.go +++ b/pkg/wire/build.go @@ -49,6 +49,9 @@ type GameView struct { // UnreadMessages is a per-viewer flag: at least one unread entry is a real message (not just // a nudge), so the badge can be coloured apart from the nudge-only case. UnreadMessages bool + // Kind is the game's origin for the active-game limits: 0 unknown (a pre-existing game), 1 vs_ai, + // 2 random, 3 friends. The lobby counts active games per kind to lock a capped New-Game start. + Kind int } // TileRecord is one tile in a decoded MoveRecord (the concrete letter, "?" for a blank @@ -169,6 +172,7 @@ func BuildGameView(b *flatbuffers.Builder, g GameView) flatbuffers.UOffsetT { fb.GameViewAddVsAi(b, g.VsAI) fb.GameViewAddUnreadChat(b, g.UnreadChat) fb.GameViewAddUnreadMessages(b, g.UnreadMessages) + fb.GameViewAddKind(b, int32(g.Kind)) return fb.GameViewEnd(b) } diff --git a/ui/e2e/gamelimit.spec.ts b/ui/e2e/gamelimit.spec.ts index b316a0f..3e54f54 100644 --- a/ui/e2e/gamelimit.spec.ts +++ b/ui/e2e/gamelimit.spec.ts @@ -1,31 +1,40 @@ import { expect, test } from './fixtures'; -// The simultaneous quick-game cap. When the backend reports the player is at the limit -// (games.list at_game_limit), the lobby disables the "New Game" tab and shows a plain, -// low-emphasis notice under the lists; when it clears, the button re-enables and the -// notice goes. Driven by the mock transport's __mock.setGameLimit seam (no backend), which -// also nudges the lobby to re-fetch (the lobby refreshes on any live event). +// The active-game limit lock on the New Game screen. When a start's kind is at the caller's per-kind +// cap (Profile.game_limits), the start button shows a 🔒 and opens a funnel modal instead of a game; +// when the cap lifts (the profile refetch a guest→durable upgrade would trigger), the lock clears and +// the button starts a game again. Driven by the mock's __mock.setGameLimits seam (no backend). The +// native Telegram popup path is verified on the contour; here the cross-platform in-app modal shows +// (the mock profile is a durable account, so it is the "finish a current game first" notice). -test('lobby: New Game disables and a notice shows at the simultaneous-game limit', async ({ page }) => { +type Limits = { vsAi: number; random: number; friends: number }; +function setLimits(l: Limits) { + (window as unknown as { __mock: { setGameLimits(l: Limits): void } }).__mock.setGameLimits(l); +} + +test('new game: a start locks at its per-kind cap and the funnel modal opens', async ({ page }) => { await page.goto('/'); await page.getByRole('button', { name: /guest/i }).click(); - // Below the limit: the New Game tab is enabled and no notice is shown. - const newGame = page.getByRole('button', { name: /🎲/ }); - await expect(newGame).toBeEnabled(); - await expect(page.getByText(/reached the simultaneous games limit/i)).toHaveCount(0); + // The New Game button always opens the screen now — the per-kind lock lives on the start button. + await page.getByRole('button', { name: /🎲/ }).click(); - // Reach the limit: the button greys out (disabled) and the notice appears under the lists. - await page.evaluate(() => - (window as unknown as { __mock: { setGameLimit(v: boolean): void } }).__mock.setGameLimit(true), - ); - await expect(newGame).toBeDisabled(); - await expect(page.getByText(/reached the simultaneous games limit/i)).toBeVisible(); + const start = page.getByRole('button', { name: /start game/i }); + await expect(start).not.toContainText('🔒'); - // Drop back below the limit (a game finished): the button re-enables and the notice clears. - await page.evaluate(() => - (window as unknown as { __mock: { setGameLimit(v: boolean): void } }).__mock.setGameLimit(false), - ); - await expect(newGame).toBeEnabled(); - await expect(page.getByText(/reached the simultaneous games limit/i)).toHaveCount(0); + // Cap every kind: the AI start (the default opponent) locks. + await page.evaluate(setLimits, { vsAi: 0, random: 0, friends: 0 }); + await expect(start).toContainText('🔒'); + + // Tapping the locked start opens the modal instead of a game, and does not navigate away. + await start.click(); + const notice = page.getByText(/reached the limit of simultaneous games/i); + await expect(notice).toBeVisible(); + await expect(page).toHaveURL(/\/new$/); + await page.getByRole('button', { name: /^ok$/i }).click(); + await expect(notice).toHaveCount(0); + + // Lift the cap (the same profile-refetch path a registration takes): the lock clears. + await page.evaluate(setLimits, { vsAi: 10, random: 10, friends: 10 }); + await expect(start).not.toContainText('🔒'); }); diff --git a/ui/src/components/GameLimitModal.svelte b/ui/src/components/GameLimitModal.svelte new file mode 100644 index 0000000..61c47eb --- /dev/null +++ b/ui/src/components/GameLimitModal.svelte @@ -0,0 +1,70 @@ + + +{#if open && !(insideTelegram() && telegramDialogsAvailable())} + +

{body}

+
+ {#if guest} + + + {:else} + + {/if} +
+
+{/if} + + diff --git a/ui/src/gen/fbs/scrabblefb.ts b/ui/src/gen/fbs/scrabblefb.ts index d9055d9..59bc337 100644 --- a/ui/src/gen/fbs/scrabblefb.ts +++ b/ui/src/gen/fbs/scrabblefb.ts @@ -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'; diff --git a/ui/src/gen/fbs/scrabblefb/game-limits.ts b/ui/src/gen/fbs/scrabblefb/game-limits.ts new file mode 100644 index 0000000..d91d05c --- /dev/null +++ b/ui/src/gen/fbs/scrabblefb/game-limits.ts @@ -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); +} +} diff --git a/ui/src/gen/fbs/scrabblefb/game-view.ts b/ui/src/gen/fbs/scrabblefb/game-view.ts index 35ec956..e56cad1 100644 --- a/ui/src/gen/fbs/scrabblefb/game-view.ts +++ b/ui/src/gen/fbs/scrabblefb/game-view.ts @@ -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); } } diff --git a/ui/src/gen/fbs/scrabblefb/profile.ts b/ui/src/gen/fbs/scrabblefb/profile.ts index 06e536e..30b5731 100644 --- a/ui/src/gen/fbs/scrabblefb/profile.ts +++ b/ui/src/gen/fbs/scrabblefb/profile.ts @@ -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; diff --git a/ui/src/lib/codec.test.ts b/ui/src/lib/codec.test.ts index 537890b..bec02e8 100644 --- a/ui/src/lib/codec.test.ts +++ b/ui/src/lib/codec.test.ts @@ -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'); diff --git a/ui/src/lib/codec.ts b/ui/src/lib/codec.ts index f99fde5..d4135c4 100644 --- a/ui/src/lib/codec.ts +++ b/ui/src/lib/codec.ts @@ -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: [], }; } diff --git a/ui/src/lib/gamecache.test.ts b/ui/src/lib/gamecache.test.ts index 5cc4788..58631a0 100644 --- a/ui/src/lib/gamecache.test.ts +++ b/ui/src/lib/gamecache.test.ts @@ -10,6 +10,7 @@ function gameView(id: string): GameView { vsAi: false, unreadChat: false, unreadMessages: false, + kind: 0, status: 'active', players: 2, toMove: 0, diff --git a/ui/src/lib/gamedelta.test.ts b/ui/src/lib/gamedelta.test.ts index 7c6be9a..b3d0760 100644 --- a/ui/src/lib/gamedelta.test.ts +++ b/ui/src/lib/gamedelta.test.ts @@ -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, diff --git a/ui/src/lib/gameimage.test.ts b/ui/src/lib/gameimage.test.ts index b4683d8..904178d 100644 --- a/ui/src/lib/gameimage.test.ts +++ b/ui/src/lib/gameimage.test.ts @@ -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, diff --git a/ui/src/lib/gamelimits.test.ts b/ui/src/lib/gamelimits.test.ts new file mode 100644 index 0000000..0098d50 --- /dev/null +++ b/ui/src/lib/gamelimits.test.ts @@ -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 { + 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); + }); +}); diff --git a/ui/src/lib/gamelimits.ts b/ui/src/lib/gamelimits.ts new file mode 100644 index 0000000..73d07ae --- /dev/null +++ b/ui/src/lib/gamelimits.ts @@ -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 { + const out: Record = {}; + 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; +} diff --git a/ui/src/lib/gateway.ts b/ui/src/lib/gateway.ts index cfefa9c..e755136 100644 --- a/ui/src/lib/gateway.ts +++ b/ui/src/lib/gateway.ts @@ -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 diff --git a/ui/src/lib/i18n/en.ts b/ui/src/lib/i18n/en.ts index ad9f10d..4453d39 100644 --- a/ui/src/lib/i18n/en.ts +++ b/ui/src/lib/i18n/en.ts @@ -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', diff --git a/ui/src/lib/i18n/ru.ts b/ui/src/lib/i18n/ru.ts index 75dec54..c35230b 100644 --- a/ui/src/lib/i18n/ru.ts +++ b/ui/src/lib/i18n/ru.ts @@ -379,6 +379,11 @@ export const ru: Record = { 'new.multipleWordsPerTurn': 'Несколько слов за ход', 'new.start': 'Начать игру', 'new.invited': 'Приглашение отправлено.', + 'new.limitGuestTitle': 'Больше партий?', + 'new.limitGuestBody': 'Войдите или создайте учётную запись, чтобы играть несколько партий одновременно.', + 'new.limitDurableTitle': 'Лимит игр', + 'new.limitDurableBody': 'Вы достигли лимита одновременных игр, сначала завершите текущие.', + 'new.limitLogin': 'Вход', 'new.noFriends': 'Сначала добавьте друзей, чтобы пригласить их.', 'new.opponentAI': 'ИИ', 'new.opponentRandom': 'Случайный игрок', diff --git a/ui/src/lib/lobbycache.test.ts b/ui/src/lib/lobbycache.test.ts index 4e43c00..1754a96 100644 --- a/ui/src/lib/lobbycache.test.ts +++ b/ui/src/lib/lobbycache.test.ts @@ -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 mode’s 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']); }); diff --git a/ui/src/lib/lobbycache.ts b/ui/src/lib/lobbycache.ts index e6f0e41..9ffc0e1 100644 --- a/ui/src/lib/lobbycache.ts +++ b/ui/src/lib/lobbycache.ts @@ -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. diff --git a/ui/src/lib/lobbysort.test.ts b/ui/src/lib/lobbysort.test.ts index e4f852d..792535b 100644 --- a/ui/src/lib/lobbysort.test.ts +++ b/ui/src/lib/lobbysort.test.ts @@ -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, diff --git a/ui/src/lib/localgame/source.ts b/ui/src/lib/localgame/source.ts index a10e93d..bf6f04a 100644 --- a/ui/src/lib/localgame/source.ts +++ b/ui/src/lib/localgame/source.ts @@ -481,6 +481,7 @@ export class LocalSource implements GameLoopSource { hotseat: !!record.hotseat, unreadChat: false, unreadMessages: false, + kind: 0, seats, }; } diff --git a/ui/src/lib/mock/client.ts b/ui/src/lib/mock/client.ts index b8c1393..43bd128 100644 --- a/ui/src/lib/mock/client.ts +++ b/ui/src/lib/mock/client.ts @@ -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 { 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 { diff --git a/ui/src/lib/mock/data.ts b/ui/src/lib/mock/data.ts index 143e4d2..b4a60f8 100644 --- a/ui/src/lib/mock/data.ts +++ b/ui/src/lib/mock/data.ts @@ -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, diff --git a/ui/src/lib/model.ts b/ui/src/lib/model.ts index d309e42..34f1146 100644 --- a/ui/src/lib/model.ts +++ b/ui/src/lib/model.ts @@ -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>; + /** 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 diff --git a/ui/src/lib/nativedialogs.test.ts b/ui/src/lib/nativedialogs.test.ts index 5727488..325cce7 100644 --- a/ui/src/lib/nativedialogs.test.ts +++ b/ui/src/lib/nativedialogs.test.ts @@ -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' }]); + }); +}); diff --git a/ui/src/lib/nativedialogs.ts b/ui/src/lib/nativedialogs.ts index 42d5c39..a0809fb 100644 --- a/ui/src/lib/nativedialogs.ts +++ b/ui/src/lib/nativedialogs.ts @@ -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 diff --git a/ui/src/lib/preload.test.ts b/ui/src/lib/preload.test.ts index 6f2ab7e..4694313 100644 --- a/ui/src/lib/preload.test.ts +++ b/ui/src/lib/preload.test.ts @@ -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, diff --git a/ui/src/lib/result.test.ts b/ui/src/lib/result.test.ts index 2ff3dc8..ec3ad3a 100644 --- a/ui/src/lib/result.test.ts +++ b/ui/src/lib/result.test.ts @@ -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, diff --git a/ui/src/screens/Lobby.svelte b/ui/src/screens/Lobby.svelte index f9b5736..0363a67 100644 --- a/ui/src/screens/Lobby.svelte +++ b/ui/src/screens/Lobby.svelte @@ -23,12 +23,6 @@ let games = $state([]); let invitations = $state([]); let incoming = $state([]); - // 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}

{t('lobby.noActive')}

{/if} - - {#if atGameLimit} -

{t('lobby.limitReached')}

- {/if} {#if declineTarget} @@ -411,7 +398,7 @@ {#snippet tabbar()} - + 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')} + (limitOpen = false)} + onlogin={() => { limitOpen = false; navigate('/settings'); }} + /> {:else if offlineMode.active} @@ -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; From e40adfb0c770d6f7e53394d180e72284895b97fa Mon Sep 17 00:00:00 2001 From: Ilia Denisov Date: Fri, 10 Jul 2026 10:55:48 +0200 Subject: [PATCH 2/2] fix(games): carry game kind on live events + fix the New Game lock funnel MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Address review of the active-game limit lock: - Live-event GameViews (opponent_moved, match_found, game_started, ...) did not carry game_kind, so a lobby patch from an event zeroed a game's kind and the client's per-kind count under-counted — a capped kind read as free (a disabled start instead of the lock, and a wrong per-kind result). Thread Kind through notify.GameSummary → the event GameView. - New Game screen refreshes the lobby games on mount so the per-kind count reflects the current set, not a stale cached snapshot. - The guest funnel's login button routes to the profile screen (the account controls), not settings. - Copy: the guest prompt is "sign in to use all the game's features"; the durable notice is "finish your active games to start a new one". Regression tests: the notify opponent-moved payload carries kind; the client lock locks only the reached kind (vs_ai at cap leaves random open). --- backend/internal/game/eventwire.go | 1 + backend/internal/notify/encode.go | 1 + backend/internal/notify/notify_test.go | 4 ++-- backend/internal/notify/payload.go | 3 +++ ui/e2e/gamelimit.spec.ts | 2 +- ui/src/lib/gamelimits.test.ts | 8 ++++++++ ui/src/lib/i18n/en.ts | 4 ++-- ui/src/lib/i18n/ru.ts | 4 ++-- ui/src/screens/NewGame.svelte | 19 ++++++++++++++++--- 9 files changed, 36 insertions(+), 10 deletions(-) diff --git a/backend/internal/game/eventwire.go b/backend/internal/game/eventwire.go index c9d2b31..54262d2 100644 --- a/backend/internal/game/eventwire.go +++ b/backend/internal/game/eventwire.go @@ -52,6 +52,7 @@ func gameSummary(g Game, names []string) notify.GameSummary { TurnTimeoutSecs: int(g.TurnTimeout.Seconds()), MultipleWordsPerTurn: g.MultipleWordsPerTurn, VsAI: g.VsAI, + Kind: int(g.Kind), MoveCount: g.MoveCount, EndReason: g.EndReason, Seats: seats, diff --git a/backend/internal/notify/encode.go b/backend/internal/notify/encode.go index d2b1914..eb9109f 100644 --- a/backend/internal/notify/encode.go +++ b/backend/internal/notify/encode.go @@ -37,6 +37,7 @@ func toWireGame(g GameSummary) wire.GameView { TurnTimeoutSecs: g.TurnTimeoutSecs, MultipleWordsPerTurn: g.MultipleWordsPerTurn, VsAI: g.VsAI, + Kind: g.Kind, MoveCount: g.MoveCount, EndReason: g.EndReason, Seats: seats, diff --git a/backend/internal/notify/notify_test.go b/backend/internal/notify/notify_test.go index 5b34d8c..fb7e26f 100644 --- a/backend/internal/notify/notify_test.go +++ b/backend/internal/notify/notify_test.go @@ -115,7 +115,7 @@ func TestGameOverPayloadRoundTrips(t *testing.T) { func TestOpponentMovedPayloadRoundTrips(t *testing.T) { uid, gid := uuid.New(), uuid.New() move := engine.MoveRecord{Player: 1, Action: engine.ActionPlay, Words: []string{"STOOL"}, Score: 24, Total: 130} - summary := notify.GameSummary{ID: gid.String(), MoveCount: 9, ToMove: 0, Seats: []notify.SeatStanding{{Seat: 1, Score: 130}}} + summary := notify.GameSummary{ID: gid.String(), MoveCount: 9, ToMove: 0, Kind: 2, Seats: []notify.SeatStanding{{Seat: 1, Score: 130}}} in := notify.OpponentMoved(uid, gid, move, summary, 42) if in.Kind != notify.KindOpponentMoved { t.Fatalf("kind = %q", in.Kind) @@ -132,7 +132,7 @@ func TestOpponentMovedPayloadRoundTrips(t *testing.T) { if m == nil || m.Player() != 1 || string(m.Action()) != "play" || m.Total() != 130 { t.Fatalf("move wrong: %+v", m) } - if g := ev.Game(nil); g == nil || g.MoveCount() != 9 || g.ToMove() != 0 { + if g := ev.Game(nil); g == nil || g.MoveCount() != 9 || g.ToMove() != 0 || g.Kind() != 2 { t.Fatalf("game summary wrong: %+v", ev.Game(nil)) } } diff --git a/backend/internal/notify/payload.go b/backend/internal/notify/payload.go index abb4c95..37641e5 100644 --- a/backend/internal/notify/payload.go +++ b/backend/internal/notify/payload.go @@ -35,6 +35,9 @@ type GameSummary struct { EndReason string Seats []SeatStanding LastActivityUnix int64 + // Kind is the game's origin for the active-game limits (0 unknown, 1 vs_ai, 2 random, 3 friends); + // it rides live events so a lobby patch keeps the per-kind count correct. + Kind int } // AlphabetLetter is one variant alphabet entry (a display-only row) embedded in an diff --git a/ui/e2e/gamelimit.spec.ts b/ui/e2e/gamelimit.spec.ts index 3e54f54..00b5a6c 100644 --- a/ui/e2e/gamelimit.spec.ts +++ b/ui/e2e/gamelimit.spec.ts @@ -28,7 +28,7 @@ test('new game: a start locks at its per-kind cap and the funnel modal opens', a // Tapping the locked start opens the modal instead of a game, and does not navigate away. await start.click(); - const notice = page.getByText(/reached the limit of simultaneous games/i); + const notice = page.getByText(/reached the limit/i); await expect(notice).toBeVisible(); await expect(page).toHaveURL(/\/new$/); await page.getByRole('button', { name: /^ok$/i }).click(); diff --git a/ui/src/lib/gamelimits.test.ts b/ui/src/lib/gamelimits.test.ts index 0098d50..12aa3c2 100644 --- a/ui/src/lib/gamelimits.test.ts +++ b/ui/src/lib/gamelimits.test.ts @@ -74,4 +74,12 @@ describe('isKindLocked', () => { it('a durable account stays well under its higher cap', () => { expect(isKindLocked([game(GameKind.random, 'active')], durable, GameKind.random)).toBe(false); }); + + it('locks only the reached kind: at the vs_ai cap, random with no games stays open', () => { + const limits = { vsAi: 3, random: 2, friends: 1 }; + const games = [game(GameKind.vsAi, 'active'), game(GameKind.vsAi, 'active'), game(GameKind.vsAi, 'active')]; + expect(isKindLocked(games, limits, GameKind.vsAi)).toBe(true); + expect(isKindLocked(games, limits, GameKind.random)).toBe(false); + expect(isKindLocked(games, limits, GameKind.friends)).toBe(false); + }); }); diff --git a/ui/src/lib/i18n/en.ts b/ui/src/lib/i18n/en.ts index 4453d39..97e7def 100644 --- a/ui/src/lib/i18n/en.ts +++ b/ui/src/lib/i18n/en.ts @@ -380,9 +380,9 @@ export const en = { '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.limitGuestBody': 'Sign in or create an account to use all the game features.', 'new.limitDurableTitle': 'Game limit', - 'new.limitDurableBody': 'You have reached the limit of simultaneous games — finish a current one first.', + 'new.limitDurableBody': 'You have reached the limit. Finish your active games to start a new one.', 'new.limitLogin': 'Sign in', 'new.noFriends': 'Add friends first to invite them.', 'new.opponentAI': 'AI', diff --git a/ui/src/lib/i18n/ru.ts b/ui/src/lib/i18n/ru.ts index c35230b..63e1403 100644 --- a/ui/src/lib/i18n/ru.ts +++ b/ui/src/lib/i18n/ru.ts @@ -380,9 +380,9 @@ export const ru: Record = { 'new.start': 'Начать игру', 'new.invited': 'Приглашение отправлено.', 'new.limitGuestTitle': 'Больше партий?', - 'new.limitGuestBody': 'Войдите или создайте учётную запись, чтобы играть несколько партий одновременно.', + 'new.limitGuestBody': 'Войдите или создайте учётную запись, чтобы воспользоваться всеми функциями игры.', 'new.limitDurableTitle': 'Лимит игр', - 'new.limitDurableBody': 'Вы достигли лимита одновременных игр, сначала завершите текущие.', + 'new.limitDurableBody': 'Вы достигли лимита. Доиграйте активные партии, чтобы начать новую.', 'new.limitLogin': 'Вход', 'new.noFriends': 'Сначала добавьте друзей, чтобы пригласить их.', 'new.opponentAI': 'ИИ', diff --git a/ui/src/screens/NewGame.svelte b/ui/src/screens/NewGame.svelte index d93fe4b..54a4c4f 100644 --- a/ui/src/screens/NewGame.svelte +++ b/ui/src/screens/NewGame.svelte @@ -18,7 +18,7 @@ import { validDisplayName } from '../lib/profileValidation'; import { verifyPin, type PinLock } from '../lib/pin'; import { commitName, rosterReady, buildSeats, shuffleSeeded, type RosterRow } from '../lib/roster'; - import type { AccountRef, Variant } from '../lib/model'; + import type { AccountRef, GameView, Variant } from '../lib/model'; import { availableVariants, VARIANT_FLAG, @@ -60,7 +60,10 @@ // 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)); + // The player's current games for the per-kind lock: seeded from the lobby snapshot for an instant + // render, then refreshed on mount (below) so a game created since the last lobby visit is counted. + let lobbyGames = $state(getLobby(false)?.games ?? []); + const autoLocked = $derived(!offlineMode.active && isKindLocked(lobbyGames, app.profile?.gameLimits, autoKind)); let limitOpen = $state(false); // --- auto-match --- @@ -129,6 +132,16 @@ ); onMount(async () => { + // Refresh the lobby games so the per-kind lock counts the current set — the cached snapshot can + // lag a game created since the last lobby visit. Online only; the lock never applies offline, and + // the server enforces every cap regardless. + if (!offlineMode.active && connection.online) { + try { + lobbyGames = (await gateway.gamesList()).games; + } catch { + // Keep the cached seed on a transient failure. + } + } // Offline mode offers only the local vs_ai flow (the friends section is hidden), and the network // is off, so skip the friend fetch — it would be refused by the transport and toast an error. if (guest || offlineMode.active) return; @@ -349,7 +362,7 @@ open={limitOpen} {guest} onclose={() => (limitOpen = false)} - onlogin={() => { limitOpen = false; navigate('/settings'); }} + onlogin={() => { limitOpen = false; navigate('/profile'); }} /> {:else if offlineMode.active}