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/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/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..00b5a6c 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/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..12aa3c2
--- /dev/null
+++ b/ui/src/lib/gamelimits.test.ts
@@ -0,0 +1,85 @@
+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);
+ });
+
+ 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/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..97e7def 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 use all the game features.',
+ 'new.limitDurableTitle': 'Game limit',
+ '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',
'new.opponentRandom': 'Random player',
diff --git a/ui/src/lib/i18n/ru.ts b/ui/src/lib/i18n/ru.ts
index 75dec54..63e1403 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}