Release v1.14.0 — monetization launch (E5-E8) #237
@@ -37,7 +37,7 @@ status — without re-deriving decisions.
|
|||||||
| E5 | Payment intake | 2 | DONE |
|
| E5 | Payment intake | 2 | DONE |
|
||||||
| E6 | Ads | 2 | DONE |
|
| E6 | Ads | 2 | DONE |
|
||||||
| E7 | Admin, reports & catalog | 2 | DONE |
|
| E7 | Admin, reports & catalog | 2 | DONE |
|
||||||
| E8 | Guest limits | — | WIP |
|
| E8 | Guest limits | — | DONE |
|
||||||
| E9 | Tournament fee | future | TODO |
|
| E9 | Tournament fee | future | TODO |
|
||||||
|
|
||||||
**Release 1** = full mechanics with no real money, exercised via `admin_grant` (E0→E1→E2→E3).
|
**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
|
## 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),
|
**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.
|
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,
|
`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)
|
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.
|
and a **config editor** (`/_gm/limits`) for the six limits.
|
||||||
- **PR2 — wire + client:** `Profile.game_limits` (the caller's tier) + `GameView.kind` (FBS +
|
- ~~**PR2 — wire + client** — DONE.~~ `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
|
+ gateway transcode + client codec, committed regen); the client counts active games per kind from
|
||||||
new-game start button, with two messages by tier — a **guest** login-funnel modal ("Войдите или
|
the lobby cache (`gamelimits.ts`) and locks a capped new-game start — an **outline 🔒** button that
|
||||||
создайте учётную запись…", Отмена / Вход→profile) and, for a **durable** account at its cap, a plain
|
opens `GameLimitModal` instead of a game, native (Telegram `showPopup`) or the in-app `Modal`
|
||||||
notice ("Вы достигли лимита одновременных игр, сначала завершите текущие", ОК; **native** popup in
|
elsewhere, with two messages by tier: a **guest** sign-in funnel ("Войдите или создайте учётную
|
||||||
Telegram — no gesture-gated API follows it); the profile re-fetch after a guest→durable upgrade.
|
запись…", Отмена / Вход→`/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.**
|
**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
|
(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.
|
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`).
|
- 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
|
- unit + UI (PR2, done): the client lock logic (`gamelimits.ts` — count/cap/lock), the codec kind +
|
||||||
after register.
|
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
|
**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 +
|
friend/invite; a durable account is capped at 10 per kind (configurable); the client shows the lock +
|
||||||
|
|||||||
@@ -72,6 +72,7 @@ func (s *Server) profileResponse(ctx context.Context, acc account.Account) profi
|
|||||||
}
|
}
|
||||||
r.Banner = s.bannerFor(ctx, acc, cxt, present)
|
r.Banner = s.bannerFor(ctx, acc, cxt, present)
|
||||||
r.Ads = s.adsFor(ctx, acc, cxt, present)
|
r.Ads = s.adsFor(ctx, acc, cxt, present)
|
||||||
|
r.GameLimits = s.gameLimitsDTOFor(acc.IsGuest)
|
||||||
s.fillLinkedIdentities(ctx, &r, acc.ID)
|
s.fillLinkedIdentities(ctx, &r, acc.ID)
|
||||||
r.DictVersions = s.currentDictVersions()
|
r.DictVersions = s.currentDictVersions()
|
||||||
return r
|
return r
|
||||||
|
|||||||
@@ -78,6 +78,18 @@ type profileResponse struct {
|
|||||||
// Filled outside the pure projection (it reads the dictionary registry), so it is empty
|
// 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.
|
// for callers that build the DTO without a Server. See Server.profileResponse.
|
||||||
DictVersions []dictVersion `json:"dict_versions,omitempty"`
|
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
|
// 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"`
|
MultipleWordsPerTurn bool `json:"multiple_words_per_turn"`
|
||||||
// VsAI marks an honest-AI game: the opponent is shown as 🤖 and chat/nudge/add-friend
|
// VsAI marks an honest-AI game: the opponent is shown as 🤖 and chat/nudge/add-friend
|
||||||
// are suppressed in the client.
|
// 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"`
|
MoveCount int `json:"move_count"`
|
||||||
EndReason string `json:"end_reason"`
|
EndReason string `json:"end_reason"`
|
||||||
// LastActivityUnix is the lobby sort key: the current turn's start for an active
|
// 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()),
|
TurnTimeoutSecs: int(g.TurnTimeout.Seconds()),
|
||||||
MultipleWordsPerTurn: g.MultipleWordsPerTurn,
|
MultipleWordsPerTurn: g.MultipleWordsPerTurn,
|
||||||
VsAI: g.VsAI,
|
VsAI: g.VsAI,
|
||||||
|
Kind: int(g.Kind),
|
||||||
MoveCount: g.MoveCount,
|
MoveCount: g.MoveCount,
|
||||||
EndReason: g.EndReason,
|
EndReason: g.EndReason,
|
||||||
LastActivityUnix: last.Unix(),
|
LastActivityUnix: last.Unix(),
|
||||||
|
|||||||
@@ -9,6 +9,16 @@ import (
|
|||||||
"scrabble/backend/internal/gamelimits"
|
"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
|
// consoleLimits renders the per-tier, per-kind active-game limit form (backend.config), read from
|
||||||
// the in-memory cache.
|
// the in-memory cache.
|
||||||
func (s *Server) consoleLimits(c *gin.Context) {
|
func (s *Server) consoleLimits(c *gin.Context) {
|
||||||
|
|||||||
@@ -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
|
refuses a **guest** outright with **403** (guests cannot use friends — the same guest gate
|
||||||
covers friend requests and friend-code redemption). **Accepting** an invitation
|
covers friend requests and friend-code redemption). **Accepting** an invitation
|
||||||
(`POST /invitations/:id/accept`) is never gated, so friend games are capped only at
|
(`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
|
initiation. The client counts its lobby games per kind against the per-tier caps it reads on
|
||||||
the `games.list` response — already re-fetched on entry and every game event; the client
|
the profile (`Profile.game_limits`, carrying `GameView.kind`), and locks a capped kind's
|
||||||
additionally counts the listed games per kind against the per-tier caps it reads in the
|
New-Game start — a 🔒 outline button that opens a prompt instead of a game: a sign-in funnel
|
||||||
profile, locking a kind's New-Game entry (a login funnel for a guest, an already-at-limit
|
for a guest, a "finish a current game first" notice for a durable account (native inside
|
||||||
note for a durable account) when that kind is full.
|
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
|
- **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,
|
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
|
SHA-256-hashed, **12 h** TTL, one live code per issuer, single-use, redeem
|
||||||
|
|||||||
+11
-8
@@ -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
|
settings and the game starts once every invitee has accepted — any decline cancels it, and an unanswered invitation
|
||||||
expires after seven days.
|
expires after seven days.
|
||||||
|
|
||||||
**Simultaneous-game cap.** A player may hold at most **10 active quick games** at once —
|
**Active-game caps (per kind).** A player's simultaneous unfinished games are capped **per kind** —
|
||||||
counting both in-progress and still-searching auto-match/AI games, but **not** friend games
|
against the AI, in a random match, and by friend invitation — with separate limits for a **guest**
|
||||||
created by invitation. At the cap the **New Game** button is disabled (greyed) and a plain,
|
and a signed-in (durable) account, tuned by the operator without a release. A **guest** may hold **1**
|
||||||
low-emphasis line under the lists reads *"You've reached the simultaneous games limit."*; both
|
game against the AI and **1** random match at once and cannot start friend games at all; a signed-in
|
||||||
clear automatically once an active game finishes and the count drops below ten. The cap blocks
|
account holds up to **10** of each kind. Only in-progress and still-searching games count; a finished
|
||||||
**starting** any game (a quick game or a friend invitation, which share the New Game entry), but
|
game frees its slot, and games already in progress are never interrupted. On the **New Game** screen a
|
||||||
**accepting an incoming invitation is never blocked** — so friend games are capped from the other
|
start whose kind is at its cap shows a **🔒** and, when tapped, opens a short prompt instead of a game:
|
||||||
end (you cannot initiate one while at the cap) while you can always join a game you are invited to.
|
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
|
### Playing a game
|
||||||
Place tiles, pass, exchange, or resign. Pass and exchange share one control —
|
Place tiles, pass, exchange, or resign. Pass and exchange share one control —
|
||||||
|
|||||||
+12
-9
@@ -197,15 +197,18 @@ e-mail) либо ввод фразы. Активные игры форфейтя
|
|||||||
выбирает настройки, и партия стартует, когда приняли все приглашённые — любой отказ отменяет приглашение, а без
|
выбирает настройки, и партия стартует, когда приняли все приглашённые — любой отказ отменяет приглашение, а без
|
||||||
ответа приглашение протухает через семь дней.
|
ответа приглашение протухает через семь дней.
|
||||||
|
|
||||||
**Лимит одновременных партий.** Игрок может держать одновременно не более **10 активных
|
**Лимит одновременных партий (по видам).** Число одновременных незавершённых партий игрока
|
||||||
быстрых партий** — считаются и идущие, и ещё ищущие соперника авто-подбор/AI-партии, но **не**
|
ограничено **по видам** — против AI, в случайном подборе и по приглашению друзей — с раздельными
|
||||||
игры с друзьями, созданные приглашением. На лимите кнопка **«Новая игра»** становится неактивной
|
лимитами для **гостя** и для вошедшего (постоянного) аккаунта; оператор настраивает их без релиза.
|
||||||
(серой), а под списками появляется ненавязчивая строка *«Вы достигли лимита одновременных
|
**Гость** может держать **1** партию против AI и **1** случайную одновременно и вовсе не может
|
||||||
партий»*; и кнопка, и надпись снимаются автоматически, как только активная партия завершается и
|
создавать игры с друзьями; вошедший аккаунт — до **10** каждого вида. Считаются только идущие и ещё
|
||||||
счётчик опускается ниже десяти. Лимит запрещает **создавать** любую партию (быструю или
|
ищущие соперника партии; завершённая освобождает слот, а уже идущие партии никогда не прерываются.
|
||||||
приглашение в игру с друзьями — у них общий вход «Новая игра»), но **принимать входящее
|
На экране **«Новая игра»** старт того вида, что достиг лимита, показывает **🔒** и по нажатию
|
||||||
приглашение никогда не запрещено** — так игры с друзьями ограничиваются «с другого конца» (на
|
открывает короткое сообщение вместо партии: **гостю** предлагают войти или создать учётную запись
|
||||||
лимите их нельзя инициировать), а присоединиться к партии по приглашению можно всегда.
|
(чтобы играть несколько партий сразу), вошедшему — сначала завершить текущую. **Принимать входящее
|
||||||
|
приглашение никогда не запрещено** — игры с друзьями ограничиваются только при создании. Сервер
|
||||||
|
применяет все лимиты независимо от клиента и напрямую отклоняет заявку в друзья, код друга или
|
||||||
|
приглашение от гостя.
|
||||||
|
|
||||||
### Игровой процесс
|
### Игровой процесс
|
||||||
Выкладывание фишек, пас, обмен или сдача. Пас и обмен — один элемент управления:
|
Выкладывание фишек, пас, обмен или сдача. Пас и обмен — один элемент управления:
|
||||||
|
|||||||
+9
-6
@@ -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
|
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.
|
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`
|
**Active-game caps.** A player's simultaneous unfinished games are capped per kind (vs AI / random /
|
||||||
`at_game_limit`), the lobby's **New Game** tab is **disabled** (greyed via the shared
|
friends) against the caller's per-tier limits (`Profile.game_limits`), counted client-side from the
|
||||||
`.tab:disabled` opacity) and a plain, low-emphasis line — muted, centred, no accent — sits
|
lobby games. The lobby's **New Game** tab is **always enabled** — the lock lives on the per-kind
|
||||||
under the lists with the localized "limit reached" notice. Both follow the flag carried in the
|
start, not the tab. On the **New Game** screen a start whose kind is at its cap renders as an
|
||||||
cached lobby snapshot, so they render in their last-known state on entry (the button stays
|
**outline** button with a **🔒** (not the filled accent CTA), so it reads as gated rather than ready;
|
||||||
enabled on the first, uncached load) and flip in place when an event refreshes the lobby.
|
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
|
## Social, account & history surfaces
|
||||||
|
|
||||||
|
|||||||
@@ -47,6 +47,16 @@ type ProfileResp struct {
|
|||||||
// DictVersions is the current dictionary version per game variant, forwarded verbatim
|
// DictVersions is the current dictionary version per game variant, forwarded verbatim
|
||||||
// into the Profile payload so an offline-capable client preloads the matching dawg.
|
// into the Profile payload so an offline-capable client preloads the matching dawg.
|
||||||
DictVersions []DictVersion `json:"dict_versions,omitempty"`
|
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
|
// 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"`
|
Seats []SeatResp `json:"seats"`
|
||||||
UnreadChat bool `json:"unread_chat"`
|
UnreadChat bool `json:"unread_chat"`
|
||||||
UnreadMessages bool `json:"unread_messages"`
|
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
|
// MoveResultResp is the outcome of a committed move. Rack carries the actor's refilled rack as
|
||||||
|
|||||||
@@ -194,6 +194,15 @@ func encodeProfile(p backendclient.ProfileResp) []byte {
|
|||||||
fb.AdsInfoAddSuppressed(b, p.Ads.Suppressed)
|
fb.AdsInfoAddSuppressed(b, p.Ads.Suppressed)
|
||||||
ads = fb.AdsInfoEnd(b)
|
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.ProfileStart(b)
|
||||||
fb.ProfileAddUserId(b, uid)
|
fb.ProfileAddUserId(b, uid)
|
||||||
fb.ProfileAddDisplayName(b, name)
|
fb.ProfileAddDisplayName(b, name)
|
||||||
@@ -219,6 +228,9 @@ func encodeProfile(p backendclient.ProfileResp) []byte {
|
|||||||
if p.Ads != nil {
|
if p.Ads != nil {
|
||||||
fb.ProfileAddAds(b, ads)
|
fb.ProfileAddAds(b, ads)
|
||||||
}
|
}
|
||||||
|
if p.GameLimits != nil {
|
||||||
|
fb.ProfileAddGameLimits(b, gameLimits)
|
||||||
|
}
|
||||||
b.Finish(fb.ProfileEnd(b))
|
b.Finish(fb.ProfileEnd(b))
|
||||||
return b.FinishedBytes()
|
return b.FinishedBytes()
|
||||||
}
|
}
|
||||||
@@ -624,6 +636,7 @@ func toWireGame(g backendclient.GameResp) wire.GameView {
|
|||||||
LastActivityUnix: g.LastActivityUnix,
|
LastActivityUnix: g.LastActivityUnix,
|
||||||
UnreadChat: g.UnreadChat,
|
UnreadChat: g.UnreadChat,
|
||||||
UnreadMessages: g.UnreadMessages,
|
UnreadMessages: g.UnreadMessages,
|
||||||
|
Kind: g.Kind,
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -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")
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -80,6 +80,11 @@ table GameView {
|
|||||||
// message (not just a nudge). With unread_chat it colours the badge — both set is the regular
|
// 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.
|
// badge, unread_chat alone is the softer nudge-only badge.
|
||||||
unread_messages:bool;
|
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).
|
// 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
|
// ads carries the post-move interstitial config (cooldowns + suppressed) for the client-mirrored
|
||||||
// gate (added trailing — backward-compatible).
|
// gate (added trailing — backward-compatible).
|
||||||
ads:AdsInfo;
|
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
|
// AdsInfo is the post-move interstitial config: the client-mirrored cooldowns (seconds) and whether
|
||||||
@@ -278,6 +287,15 @@ table AdsInfo {
|
|||||||
suppressed:bool;
|
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
|
// 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
|
// 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
|
// screen and stops all push/poll. permanent is false for a temporary block; until is an RFC3339
|
||||||
|
|||||||
@@ -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()
|
||||||
|
}
|
||||||
@@ -209,8 +209,20 @@ func (rcv *GameView) MutateUnreadMessages(n bool) bool {
|
|||||||
return rcv._tab.MutateBoolSlot(32, n)
|
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) {
|
func GameViewStart(builder *flatbuffers.Builder) {
|
||||||
builder.StartObject(15)
|
builder.StartObject(16)
|
||||||
}
|
}
|
||||||
func GameViewAddId(builder *flatbuffers.Builder, id flatbuffers.UOffsetT) {
|
func GameViewAddId(builder *flatbuffers.Builder, id flatbuffers.UOffsetT) {
|
||||||
builder.PrependUOffsetTSlot(0, flatbuffers.UOffsetT(id), 0)
|
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) {
|
func GameViewAddUnreadMessages(builder *flatbuffers.Builder, unreadMessages bool) {
|
||||||
builder.PrependBoolSlot(14, unreadMessages, false)
|
builder.PrependBoolSlot(14, unreadMessages, false)
|
||||||
}
|
}
|
||||||
|
func GameViewAddKind(builder *flatbuffers.Builder, kind int32) {
|
||||||
|
builder.PrependInt32Slot(15, kind, 0)
|
||||||
|
}
|
||||||
func GameViewEnd(builder *flatbuffers.Builder) flatbuffers.UOffsetT {
|
func GameViewEnd(builder *flatbuffers.Builder) flatbuffers.UOffsetT {
|
||||||
return builder.EndObject()
|
return builder.EndObject()
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -244,8 +244,21 @@ func (rcv *Profile) Ads(obj *AdsInfo) *AdsInfo {
|
|||||||
return nil
|
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) {
|
func ProfileStart(builder *flatbuffers.Builder) {
|
||||||
builder.StartObject(18)
|
builder.StartObject(19)
|
||||||
}
|
}
|
||||||
func ProfileAddUserId(builder *flatbuffers.Builder, userId flatbuffers.UOffsetT) {
|
func ProfileAddUserId(builder *flatbuffers.Builder, userId flatbuffers.UOffsetT) {
|
||||||
builder.PrependUOffsetTSlot(0, flatbuffers.UOffsetT(userId), 0)
|
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) {
|
func ProfileAddAds(builder *flatbuffers.Builder, ads flatbuffers.UOffsetT) {
|
||||||
builder.PrependUOffsetTSlot(17, flatbuffers.UOffsetT(ads), 0)
|
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 {
|
func ProfileEnd(builder *flatbuffers.Builder) flatbuffers.UOffsetT {
|
||||||
return builder.EndObject()
|
return builder.EndObject()
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -49,6 +49,9 @@ type GameView struct {
|
|||||||
// UnreadMessages is a per-viewer flag: at least one unread entry is a real message (not just
|
// 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.
|
// a nudge), so the badge can be coloured apart from the nudge-only case.
|
||||||
UnreadMessages bool
|
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
|
// 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.GameViewAddVsAi(b, g.VsAI)
|
||||||
fb.GameViewAddUnreadChat(b, g.UnreadChat)
|
fb.GameViewAddUnreadChat(b, g.UnreadChat)
|
||||||
fb.GameViewAddUnreadMessages(b, g.UnreadMessages)
|
fb.GameViewAddUnreadMessages(b, g.UnreadMessages)
|
||||||
|
fb.GameViewAddKind(b, int32(g.Kind))
|
||||||
return fb.GameViewEnd(b)
|
return fb.GameViewEnd(b)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
+31
-22
@@ -1,31 +1,40 @@
|
|||||||
import { expect, test } from './fixtures';
|
import { expect, test } from './fixtures';
|
||||||
|
|
||||||
// The simultaneous quick-game cap. When the backend reports the player is at the limit
|
// The active-game limit lock on the New Game screen. When a start's kind is at the caller's per-kind
|
||||||
// (games.list at_game_limit), the lobby disables the "New Game" tab and shows a plain,
|
// cap (Profile.game_limits), the start button shows a 🔒 and opens a funnel modal instead of a game;
|
||||||
// low-emphasis notice under the lists; when it clears, the button re-enables and the
|
// when the cap lifts (the profile refetch a guest→durable upgrade would trigger), the lock clears and
|
||||||
// notice goes. Driven by the mock transport's __mock.setGameLimit seam (no backend), which
|
// the button starts a game again. Driven by the mock's __mock.setGameLimits seam (no backend). The
|
||||||
// also nudges the lobby to re-fetch (the lobby refreshes on any live event).
|
// 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.goto('/');
|
||||||
await page.getByRole('button', { name: /guest/i }).click();
|
await page.getByRole('button', { name: /guest/i }).click();
|
||||||
|
|
||||||
// Below the limit: the New Game tab is enabled and no notice is shown.
|
// The New Game button always opens the screen now — the per-kind lock lives on the start button.
|
||||||
const newGame = page.getByRole('button', { name: /🎲/ });
|
await page.getByRole('button', { name: /🎲/ }).click();
|
||||||
await expect(newGame).toBeEnabled();
|
|
||||||
await expect(page.getByText(/reached the simultaneous games limit/i)).toHaveCount(0);
|
|
||||||
|
|
||||||
// Reach the limit: the button greys out (disabled) and the notice appears under the lists.
|
const start = page.getByRole('button', { name: /start game/i });
|
||||||
await page.evaluate(() =>
|
await expect(start).not.toContainText('🔒');
|
||||||
(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();
|
|
||||||
|
|
||||||
// Drop back below the limit (a game finished): the button re-enables and the notice clears.
|
// Cap every kind: the AI start (the default opponent) locks.
|
||||||
await page.evaluate(() =>
|
await page.evaluate(setLimits, { vsAi: 0, random: 0, friends: 0 });
|
||||||
(window as unknown as { __mock: { setGameLimit(v: boolean): void } }).__mock.setGameLimit(false),
|
await expect(start).toContainText('🔒');
|
||||||
);
|
|
||||||
await expect(newGame).toBeEnabled();
|
// Tapping the locked start opens the modal instead of a game, and does not navigate away.
|
||||||
await expect(page.getByText(/reached the simultaneous games limit/i)).toHaveCount(0);
|
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('🔒');
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -0,0 +1,70 @@
|
|||||||
|
<script lang="ts">
|
||||||
|
import { t } from '../lib/i18n/index.svelte';
|
||||||
|
import { insideTelegram, telegramDialogsAvailable, telegramShowPopup } from '../lib/telegram';
|
||||||
|
import { LOGIN_BUTTON_ID, gameLimitGuestPopup, gameLimitDurablePopup } from '../lib/nativedialogs';
|
||||||
|
import Modal from './Modal.svelte';
|
||||||
|
|
||||||
|
// The New-Game screen raises this when the player taps a start whose kind is at its cap. guest
|
||||||
|
// picks the message: a sign-in funnel (a guest plays more by registering) vs a durable account's
|
||||||
|
// plain "finish a current game first" notice. onclose dismisses; onlogin routes a guest to sign-in.
|
||||||
|
let { open, guest, onclose, onlogin }: { open: boolean; guest: boolean; onclose: () => void; onlogin: () => void } = $props();
|
||||||
|
|
||||||
|
const title = $derived(guest ? t('new.limitGuestTitle') : t('new.limitDurableTitle'));
|
||||||
|
const body = $derived(guest ? t('new.limitGuestBody') : t('new.limitDurableBody'));
|
||||||
|
|
||||||
|
// Native path: inside the Mini App with native dialogs, present Telegram's own popup instead of
|
||||||
|
// the in-app modal (evaluated at fire time — the SDK loads after mount). A guest popup's login
|
||||||
|
// button routes to sign-in; any other dismissal closes. The message here is a plain notice with
|
||||||
|
// no gesture-gated follow-up, so a native popup is safe (unlike share/clipboard flows).
|
||||||
|
let shown = false;
|
||||||
|
$effect(() => {
|
||||||
|
if (open && !shown && insideTelegram() && telegramDialogsAvailable()) {
|
||||||
|
shown = true;
|
||||||
|
const params = guest
|
||||||
|
? gameLimitGuestPopup(title, body, t('common.cancel'), t('new.limitLogin'))
|
||||||
|
: gameLimitDurablePopup(title, body, t('common.ok'));
|
||||||
|
void telegramShowPopup(params).then((id) => (guest && id === LOGIN_BUTTON_ID ? onlogin() : onclose()));
|
||||||
|
} else if (!open) {
|
||||||
|
shown = false;
|
||||||
|
}
|
||||||
|
});
|
||||||
|
</script>
|
||||||
|
|
||||||
|
{#if open && !(insideTelegram() && telegramDialogsAvailable())}
|
||||||
|
<Modal {title} onclose={onclose}>
|
||||||
|
<p class="msg">{body}</p>
|
||||||
|
<div class="actions">
|
||||||
|
{#if guest}
|
||||||
|
<button class="cancel" onclick={onclose}>{t('common.cancel')}</button>
|
||||||
|
<button class="primary" onclick={onlogin}>{t('new.limitLogin')}</button>
|
||||||
|
{:else}
|
||||||
|
<button class="primary" onclick={onclose}>{t('common.ok')}</button>
|
||||||
|
{/if}
|
||||||
|
</div>
|
||||||
|
</Modal>
|
||||||
|
{/if}
|
||||||
|
|
||||||
|
<style>
|
||||||
|
.msg {
|
||||||
|
margin: 0 0 16px;
|
||||||
|
line-height: 1.5;
|
||||||
|
}
|
||||||
|
.actions {
|
||||||
|
display: flex;
|
||||||
|
gap: 8px;
|
||||||
|
}
|
||||||
|
.actions button {
|
||||||
|
flex: 1;
|
||||||
|
padding: 10px 12px;
|
||||||
|
border-radius: var(--radius-sm);
|
||||||
|
border: 1px solid var(--accent);
|
||||||
|
}
|
||||||
|
.cancel {
|
||||||
|
background: transparent;
|
||||||
|
color: var(--accent);
|
||||||
|
}
|
||||||
|
.primary {
|
||||||
|
background: var(--accent);
|
||||||
|
color: var(--accent-text);
|
||||||
|
}
|
||||||
|
</style>
|
||||||
@@ -42,6 +42,7 @@ export { FriendCode } from './scrabblefb/friend-code.js';
|
|||||||
export { FriendList } from './scrabblefb/friend-list.js';
|
export { FriendList } from './scrabblefb/friend-list.js';
|
||||||
export { FriendRespondRequest } from './scrabblefb/friend-respond-request.js';
|
export { FriendRespondRequest } from './scrabblefb/friend-respond-request.js';
|
||||||
export { GameActionRequest } from './scrabblefb/game-action-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 { GameList } from './scrabblefb/game-list.js';
|
||||||
export { GameOverEvent } from './scrabblefb/game-over-event.js';
|
export { GameOverEvent } from './scrabblefb/game-over-event.js';
|
||||||
export { GameView } from './scrabblefb/game-view.js';
|
export { GameView } from './scrabblefb/game-view.js';
|
||||||
|
|||||||
@@ -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);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -113,8 +113,13 @@ unreadMessages():boolean {
|
|||||||
return offset ? !!this.bb!.readInt8(this.bb_pos + offset) : false;
|
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) {
|
static startGameView(builder:flatbuffers.Builder) {
|
||||||
builder.startObject(15);
|
builder.startObject(16);
|
||||||
}
|
}
|
||||||
|
|
||||||
static addId(builder:flatbuffers.Builder, idOffset:flatbuffers.Offset) {
|
static addId(builder:flatbuffers.Builder, idOffset:flatbuffers.Offset) {
|
||||||
@@ -189,12 +194,16 @@ static addUnreadMessages(builder:flatbuffers.Builder, unreadMessages:boolean) {
|
|||||||
builder.addFieldInt8(14, +unreadMessages, +false);
|
builder.addFieldInt8(14, +unreadMessages, +false);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
static addKind(builder:flatbuffers.Builder, kind:number) {
|
||||||
|
builder.addFieldInt32(15, kind, 0);
|
||||||
|
}
|
||||||
|
|
||||||
static endGameView(builder:flatbuffers.Builder):flatbuffers.Offset {
|
static endGameView(builder:flatbuffers.Builder):flatbuffers.Offset {
|
||||||
const offset = builder.endObject();
|
const offset = builder.endObject();
|
||||||
return offset;
|
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.startGameView(builder);
|
||||||
GameView.addId(builder, idOffset);
|
GameView.addId(builder, idOffset);
|
||||||
GameView.addVariant(builder, variantOffset);
|
GameView.addVariant(builder, variantOffset);
|
||||||
@@ -211,6 +220,7 @@ static createGameView(builder:flatbuffers.Builder, idOffset:flatbuffers.Offset,
|
|||||||
GameView.addVsAi(builder, vsAi);
|
GameView.addVsAi(builder, vsAi);
|
||||||
GameView.addUnreadChat(builder, unreadChat);
|
GameView.addUnreadChat(builder, unreadChat);
|
||||||
GameView.addUnreadMessages(builder, unreadMessages);
|
GameView.addUnreadMessages(builder, unreadMessages);
|
||||||
|
GameView.addKind(builder, kind);
|
||||||
return GameView.endGameView(builder);
|
return GameView.endGameView(builder);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -5,6 +5,7 @@ import * as flatbuffers from 'flatbuffers';
|
|||||||
import { AdsInfo } from '../scrabblefb/ads-info.js';
|
import { AdsInfo } from '../scrabblefb/ads-info.js';
|
||||||
import { BannerInfo } from '../scrabblefb/banner-info.js';
|
import { BannerInfo } from '../scrabblefb/banner-info.js';
|
||||||
import { DictVersion } from '../scrabblefb/dict-version.js';
|
import { DictVersion } from '../scrabblefb/dict-version.js';
|
||||||
|
import { GameLimits } from '../scrabblefb/game-limits.js';
|
||||||
|
|
||||||
|
|
||||||
export class Profile {
|
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;
|
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) {
|
static startProfile(builder:flatbuffers.Builder) {
|
||||||
builder.startObject(18);
|
builder.startObject(19);
|
||||||
}
|
}
|
||||||
|
|
||||||
static addUserId(builder:flatbuffers.Builder, userIdOffset:flatbuffers.Offset) {
|
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);
|
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 {
|
static endProfile(builder:flatbuffers.Builder):flatbuffers.Offset {
|
||||||
const offset = builder.endObject();
|
const offset = builder.endObject();
|
||||||
return offset;
|
return offset;
|
||||||
|
|||||||
@@ -421,6 +421,7 @@ describe('codec', () => {
|
|||||||
fb.GameView.addLastActivityUnix(b, BigInt(1717000000));
|
fb.GameView.addLastActivityUnix(b, BigInt(1717000000));
|
||||||
fb.GameView.addVsAi(b, true);
|
fb.GameView.addVsAi(b, true);
|
||||||
fb.GameView.addUnreadChat(b, true);
|
fb.GameView.addUnreadChat(b, true);
|
||||||
|
fb.GameView.addKind(b, 2);
|
||||||
const game = fb.GameView.endGameView(b);
|
const game = fb.GameView.endGameView(b);
|
||||||
const games = fb.GameList.createGamesVector(b, [game]);
|
const games = fb.GameList.createGamesVector(b, [game]);
|
||||||
fb.GameList.startGameList(b);
|
fb.GameList.startGameList(b);
|
||||||
@@ -436,6 +437,7 @@ describe('codec', () => {
|
|||||||
expect(gl.games[0].lastActivityUnix).toBe(1717000000);
|
expect(gl.games[0].lastActivityUnix).toBe(1717000000);
|
||||||
expect(gl.games[0].vsAi).toBe(true);
|
expect(gl.games[0].vsAi).toBe(true);
|
||||||
expect(gl.games[0].unreadChat).toBe(true);
|
expect(gl.games[0].unreadChat).toBe(true);
|
||||||
|
expect(gl.games[0].kind).toBe(2);
|
||||||
expect(gl.atGameLimit).toBe(true);
|
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', () => {
|
it('carries the suppressed flag through the ad config', () => {
|
||||||
const b = new Builder(64);
|
const b = new Builder(64);
|
||||||
const uid = b.createString('u-1');
|
const uid = b.createString('u-1');
|
||||||
|
|||||||
@@ -15,6 +15,7 @@ import type {
|
|||||||
RobotFriendRequestEntry,
|
RobotFriendRequestEntry,
|
||||||
Banner,
|
Banner,
|
||||||
AdsConfig,
|
AdsConfig,
|
||||||
|
GameLimits,
|
||||||
BannerCampaign,
|
BannerCampaign,
|
||||||
BestMove,
|
BestMove,
|
||||||
BestMoveTile,
|
BestMoveTile,
|
||||||
@@ -314,6 +315,7 @@ function decodeGameView(g: fb.GameView): GameView {
|
|||||||
vsAi: g.vsAi(),
|
vsAi: g.vsAi(),
|
||||||
unreadChat: g.unreadChat(),
|
unreadChat: g.unreadChat(),
|
||||||
unreadMessages: g.unreadMessages(),
|
unreadMessages: g.unreadMessages(),
|
||||||
|
kind: g.kind(),
|
||||||
seats,
|
seats,
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
@@ -467,9 +469,18 @@ export function decodeProfile(buf: Uint8Array): Profile {
|
|||||||
telegramLinked: p.telegramLinked(),
|
telegramLinked: p.telegramLinked(),
|
||||||
vkLinked: p.vkLinked(),
|
vkLinked: p.vkLinked(),
|
||||||
dictVersions: decodeDictVersions(p),
|
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
|
// 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-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.
|
// variant. An entry with an unknown variant or empty version is skipped.
|
||||||
@@ -1137,6 +1148,7 @@ function emptyGame(): GameView {
|
|||||||
vsAi: false,
|
vsAi: false,
|
||||||
unreadChat: false,
|
unreadChat: false,
|
||||||
unreadMessages: false,
|
unreadMessages: false,
|
||||||
|
kind: 0,
|
||||||
seats: [],
|
seats: [],
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -10,6 +10,7 @@ function gameView(id: string): GameView {
|
|||||||
vsAi: false,
|
vsAi: false,
|
||||||
unreadChat: false,
|
unreadChat: false,
|
||||||
unreadMessages: false,
|
unreadMessages: false,
|
||||||
|
kind: 0,
|
||||||
status: 'active',
|
status: 'active',
|
||||||
players: 2,
|
players: 2,
|
||||||
toMove: 0,
|
toMove: 0,
|
||||||
|
|||||||
@@ -11,6 +11,7 @@ function gameView(moveCount: number, over = false): GameView {
|
|||||||
vsAi: false,
|
vsAi: false,
|
||||||
unreadChat: false,
|
unreadChat: false,
|
||||||
unreadMessages: false,
|
unreadMessages: false,
|
||||||
|
kind: 0,
|
||||||
status: over ? 'finished' : 'active',
|
status: over ? 'finished' : 'active',
|
||||||
players: 2,
|
players: 2,
|
||||||
toMove: 1,
|
toMove: 1,
|
||||||
|
|||||||
@@ -14,6 +14,7 @@ function game(seats: Seat[], over = true): GameView {
|
|||||||
vsAi: false,
|
vsAi: false,
|
||||||
unreadChat: false,
|
unreadChat: false,
|
||||||
unreadMessages: false,
|
unreadMessages: false,
|
||||||
|
kind: 0,
|
||||||
status: over ? 'finished' : 'active',
|
status: over ? 'finished' : 'active',
|
||||||
players: seats.length,
|
players: seats.length,
|
||||||
toMove: 0,
|
toMove: 0,
|
||||||
|
|||||||
@@ -0,0 +1,77 @@
|
|||||||
|
import { describe, it, expect } from 'vitest';
|
||||||
|
import { activeByKind, capFor, isKindLocked } from './gamelimits';
|
||||||
|
import { GameKind, type GameView, type GameLimits } from './model';
|
||||||
|
|
||||||
|
// game builds a minimal GameView for the count: only kind, status and (optionally) hotseat matter.
|
||||||
|
function game(kind: number, status: string, extra: Partial<GameView> = {}): GameView {
|
||||||
|
return {
|
||||||
|
id: 'g',
|
||||||
|
variant: 'scrabble_en',
|
||||||
|
dictVersion: '',
|
||||||
|
status,
|
||||||
|
players: 2,
|
||||||
|
toMove: 0,
|
||||||
|
turnTimeoutSecs: 0,
|
||||||
|
multipleWordsPerTurn: true,
|
||||||
|
moveCount: 0,
|
||||||
|
endReason: '',
|
||||||
|
lastActivityUnix: 0,
|
||||||
|
vsAi: false,
|
||||||
|
unreadChat: false,
|
||||||
|
unreadMessages: false,
|
||||||
|
kind,
|
||||||
|
seats: [],
|
||||||
|
...extra,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
describe('activeByKind', () => {
|
||||||
|
it('counts open+active games per kind, skipping finished, hotseat and untagged (kind 0)', () => {
|
||||||
|
const by = activeByKind([
|
||||||
|
game(GameKind.vsAi, 'active'),
|
||||||
|
game(GameKind.random, 'open'),
|
||||||
|
game(GameKind.random, 'active'),
|
||||||
|
game(GameKind.random, 'finished'), // finished → not counted
|
||||||
|
game(GameKind.vsAi, 'active', { hotseat: true }), // local pass-and-play → not counted
|
||||||
|
game(0, 'active'), // pre-existing / untagged → not counted
|
||||||
|
]);
|
||||||
|
expect(by[GameKind.vsAi]).toBe(1);
|
||||||
|
expect(by[GameKind.random]).toBe(2);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('capFor', () => {
|
||||||
|
const limits: GameLimits = { vsAi: 1, random: 10, friends: 0 };
|
||||||
|
it('maps each kind to its tier cap; unknown kind and absent limits are unlimited (-1)', () => {
|
||||||
|
expect(capFor(limits, GameKind.vsAi)).toBe(1);
|
||||||
|
expect(capFor(limits, GameKind.random)).toBe(10);
|
||||||
|
expect(capFor(limits, GameKind.friends)).toBe(0);
|
||||||
|
expect(capFor(limits, 0)).toBe(-1);
|
||||||
|
expect(capFor(undefined, GameKind.vsAi)).toBe(-1);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('isKindLocked', () => {
|
||||||
|
const guest: GameLimits = { vsAi: 1, random: 1, friends: 0 };
|
||||||
|
const durable: GameLimits = { vsAi: 10, random: 10, friends: 10 };
|
||||||
|
|
||||||
|
it('locks a guest at the per-kind cap, not another kind', () => {
|
||||||
|
const games = [game(GameKind.vsAi, 'active')];
|
||||||
|
expect(isKindLocked(games, guest, GameKind.vsAi)).toBe(true);
|
||||||
|
expect(isKindLocked(games, guest, GameKind.random)).toBe(false);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('a cap of 0 always locks (a guest cannot start friend games)', () => {
|
||||||
|
expect(isKindLocked([], guest, GameKind.friends)).toBe(true);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('an unlimited cap (-1) or absent limits never lock', () => {
|
||||||
|
const many = [game(GameKind.random, 'active'), game(GameKind.random, 'active')];
|
||||||
|
expect(isKindLocked(many, { vsAi: -1, random: -1, friends: -1 }, GameKind.random)).toBe(false);
|
||||||
|
expect(isKindLocked(many, undefined, GameKind.random)).toBe(false);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('a durable account stays well under its higher cap', () => {
|
||||||
|
expect(isKindLocked([game(GameKind.random, 'active')], durable, GameKind.random)).toBe(false);
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -0,0 +1,50 @@
|
|||||||
|
// Client-side active-game limit logic: count the caller's active games per kind and test a kind
|
||||||
|
// against the caller's per-tier cap so the New-Game screen can lock a capped start. The server is
|
||||||
|
// the source of truth (it refuses a capped create); this only drives the pre-emptive UI lock. Kept
|
||||||
|
// pure (no DOM / stores) so it unit-tests in the node environment.
|
||||||
|
|
||||||
|
import { GameKind, type GameLimits, type GameView } from './model';
|
||||||
|
|
||||||
|
/**
|
||||||
|
* activeByKind counts the caller's active (status open or in-progress) games per kind. Finished
|
||||||
|
* games, local pass-and-play (hotseat) games and untagged games (kind 0, pre-existing) do not
|
||||||
|
* count — matching the server's per-kind cap, which is keyed on games.game_kind for open/active
|
||||||
|
* games only.
|
||||||
|
*/
|
||||||
|
export function activeByKind(games: GameView[]): Record<number, number> {
|
||||||
|
const out: Record<number, number> = {};
|
||||||
|
for (const g of games) {
|
||||||
|
if (g.hotseat) continue;
|
||||||
|
if (g.kind === 0) continue;
|
||||||
|
if (g.status !== 'active' && g.status !== 'open') continue;
|
||||||
|
out[g.kind] = (out[g.kind] ?? 0) + 1;
|
||||||
|
}
|
||||||
|
return out;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** capFor returns the caller's tier cap for a kind (-1 = unlimited), or -1 when the limits are
|
||||||
|
* absent (an old backend) — the lock then never applies. */
|
||||||
|
export function capFor(limits: GameLimits | undefined, kind: number): number {
|
||||||
|
if (!limits) return -1;
|
||||||
|
switch (kind) {
|
||||||
|
case GameKind.vsAi:
|
||||||
|
return limits.vsAi;
|
||||||
|
case GameKind.random:
|
||||||
|
return limits.random;
|
||||||
|
case GameKind.friends:
|
||||||
|
return limits.friends;
|
||||||
|
default:
|
||||||
|
return -1;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* isKindLocked reports whether the caller has reached its cap for kind, so the New-Game start for
|
||||||
|
* that kind is locked. An unlimited cap (-1) or absent limits never lock; a cap of 0 always locks
|
||||||
|
* (the kind is disabled for the tier).
|
||||||
|
*/
|
||||||
|
export function isKindLocked(games: GameView[], limits: GameLimits | undefined, kind: number): boolean {
|
||||||
|
const cap = capFor(limits, kind);
|
||||||
|
if (cap < 0) return false;
|
||||||
|
return (activeByKind(games)[kind] ?? 0) >= cap;
|
||||||
|
}
|
||||||
@@ -39,7 +39,7 @@ if (isMock && typeof window !== 'undefined') {
|
|||||||
joinOpponent(): void;
|
joinOpponent(): void;
|
||||||
joinOpponentSilently(): void;
|
joinOpponentSilently(): void;
|
||||||
adminReply(): void;
|
adminReply(): void;
|
||||||
setGameLimit(v: boolean): void;
|
setGameLimits(l: { vsAi: number; random: number; friends: number }): void;
|
||||||
clearEmail(): void;
|
clearEmail(): void;
|
||||||
confirmEmailOutOfBand(email: string): void;
|
confirmEmailOutOfBand(email: string): void;
|
||||||
setLocalSeed(seed: string): void;
|
setLocalSeed(seed: string): void;
|
||||||
@@ -49,7 +49,7 @@ if (isMock && typeof window !== 'undefined') {
|
|||||||
joinOpponent: () => (gateway as MockGateway).joinPendingOpponent(),
|
joinOpponent: () => (gateway as MockGateway).joinPendingOpponent(),
|
||||||
joinOpponentSilently: () => (gateway as MockGateway).joinPendingOpponentSilently(),
|
joinOpponentSilently: () => (gateway as MockGateway).joinPendingOpponentSilently(),
|
||||||
adminReply: () => (gateway as MockGateway).mockAdminReply(),
|
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(),
|
clearEmail: () => (gateway as MockGateway).mockClearEmail(),
|
||||||
confirmEmailOutOfBand: (email: string) => (gateway as MockGateway).mockConfirmEmailOutOfBand(email),
|
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
|
// Pin the next local game's bag seed, so the offline e2e deals a known rack (a bigint as a
|
||||||
|
|||||||
@@ -379,6 +379,11 @@ export const en = {
|
|||||||
'new.multipleWordsPerTurn': 'Multiple words per turn',
|
'new.multipleWordsPerTurn': 'Multiple words per turn',
|
||||||
'new.start': 'Start game',
|
'new.start': 'Start game',
|
||||||
'new.invited': 'Invitation sent.',
|
'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.noFriends': 'Add friends first to invite them.',
|
||||||
'new.opponentAI': 'AI',
|
'new.opponentAI': 'AI',
|
||||||
'new.opponentRandom': 'Random player',
|
'new.opponentRandom': 'Random player',
|
||||||
|
|||||||
@@ -379,6 +379,11 @@ export const ru: Record<MessageKey, string> = {
|
|||||||
'new.multipleWordsPerTurn': 'Несколько слов за ход',
|
'new.multipleWordsPerTurn': 'Несколько слов за ход',
|
||||||
'new.start': 'Начать игру',
|
'new.start': 'Начать игру',
|
||||||
'new.invited': 'Приглашение отправлено.',
|
'new.invited': 'Приглашение отправлено.',
|
||||||
|
'new.limitGuestTitle': 'Больше партий?',
|
||||||
|
'new.limitGuestBody': 'Войдите или создайте учётную запись, чтобы играть несколько партий одновременно.',
|
||||||
|
'new.limitDurableTitle': 'Лимит игр',
|
||||||
|
'new.limitDurableBody': 'Вы достигли лимита одновременных игр, сначала завершите текущие.',
|
||||||
|
'new.limitLogin': 'Вход',
|
||||||
'new.noFriends': 'Сначала добавьте друзей, чтобы пригласить их.',
|
'new.noFriends': 'Сначала добавьте друзей, чтобы пригласить их.',
|
||||||
'new.opponentAI': 'ИИ',
|
'new.opponentAI': 'ИИ',
|
||||||
'new.opponentRandom': 'Случайный игрок',
|
'new.opponentRandom': 'Случайный игрок',
|
||||||
|
|||||||
@@ -27,6 +27,7 @@ function gameView(id: string, status: GameView['status'] = 'active', toMove = 0)
|
|||||||
vsAi: false,
|
vsAi: false,
|
||||||
unreadChat: false,
|
unreadChat: false,
|
||||||
unreadMessages: false,
|
unreadMessages: false,
|
||||||
|
kind: 0,
|
||||||
status,
|
status,
|
||||||
players: 2,
|
players: 2,
|
||||||
toMove,
|
toMove,
|
||||||
@@ -43,7 +44,7 @@ beforeEach(() => clearLobby());
|
|||||||
|
|
||||||
describe('patchLobbyGame', () => {
|
describe('patchLobbyGame', () => {
|
||||||
it('replaces the matching game by id and leaves the others untouched', () => {
|
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.
|
// The player's own move flipped game "a" to the opponent's turn.
|
||||||
patchLobbyGame(gameView('a', 'active', 1));
|
patchLobbyGame(gameView('a', 'active', 1));
|
||||||
const snap = getLobby(false);
|
const snap = getLobby(false);
|
||||||
@@ -54,14 +55,14 @@ describe('patchLobbyGame', () => {
|
|||||||
|
|
||||||
it('preserves invitations and incoming when patching a game', () => {
|
it('preserves invitations and incoming when patching a game', () => {
|
||||||
const incoming: AccountRef[] = [{ accountId: 'u9', displayName: 'Nine' }];
|
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'));
|
patchLobbyGame(gameView('a', 'finished'));
|
||||||
expect(getLobby(false)?.incoming).toEqual(incoming);
|
expect(getLobby(false)?.incoming).toEqual(incoming);
|
||||||
expect(getLobby(false)?.games[0].status).toBe('finished');
|
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)', () => {
|
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'));
|
patchLobbyGame(gameView('z', 'active'));
|
||||||
// Order is irrelevant — the lobby re-groups and re-sorts on render — so assert membership.
|
// 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']);
|
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', () => {
|
it('does not mutate the previous snapshot array', () => {
|
||||||
const games = [gameView('a', 'active', 0)];
|
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));
|
patchLobbyGame(gameView('a', 'active', 1));
|
||||||
expect(games[0].toMove).toBe(0); // the original array/object is left intact
|
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', () => {
|
describe('patchLobbyInvitation', () => {
|
||||||
it('adds a new pending invitation to the cached lobby', () => {
|
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'));
|
patchLobbyInvitation(invitation('i1', 'pending'));
|
||||||
expect(getLobby(false)?.invitations.map((x) => x.id)).toEqual(['i1']);
|
expect(getLobby(false)?.invitations.map((x) => x.id)).toEqual(['i1']);
|
||||||
});
|
});
|
||||||
|
|
||||||
it('replaces a pending invitation already in the list (e.g. an updated response)', () => {
|
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 };
|
const updated = { ...invitation('i1', 'pending'), turnTimeoutSecs: 999 };
|
||||||
patchLobbyInvitation(updated);
|
patchLobbyInvitation(updated);
|
||||||
expect(getLobby(false)?.invitations.map((x) => x.id)).toEqual(['i1', 'i2']);
|
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', () => {
|
it('removes an invitation that reached a terminal status', () => {
|
||||||
for (const terminal of ['declined', 'cancelled', 'started', 'expired']) {
|
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));
|
patchLobbyInvitation(invitation('i1', terminal));
|
||||||
expect(getLobby(false)?.invitations.map((x) => x.id)).toEqual(['i2']);
|
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', () => {
|
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'));
|
patchLobbyInvitation(invitation('i1', 'declined'));
|
||||||
expect(getLobby(false)?.invitations.map((x) => x.id)).toEqual(['i2']);
|
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', () => {
|
it('preserves games and incoming when patching an invitation', () => {
|
||||||
const incoming: AccountRef[] = [{ accountId: 'u9', displayName: 'Nine' }];
|
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'));
|
patchLobbyInvitation(invitation('i1', 'pending'));
|
||||||
expect(getLobby(false)?.games.map((g) => g.id)).toEqual(['g1']);
|
expect(getLobby(false)?.games.map((g) => g.id)).toEqual(['g1']);
|
||||||
expect(getLobby(false)?.incoming).toEqual(incoming);
|
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)', () => {
|
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', () => {
|
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']);
|
expect(getLobby(false)?.games.map((g) => g.id)).toEqual(['online1']);
|
||||||
// Reading it as the OTHER (offline) mode must not surface the online snapshot.
|
// Reading it as the OTHER (offline) mode must not surface the online snapshot.
|
||||||
expect(getLobby(true)).toBeNull();
|
expect(getLobby(true)).toBeNull();
|
||||||
});
|
});
|
||||||
|
|
||||||
it('replaces the snapshot when the mode changes, so the stale mode is dropped', () => {
|
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('online1')], invitations: [], incoming: [], offline: false });
|
||||||
setLobby({ games: [gameView('local1')], invitations: [], incoming: [], atGameLimit: false, offline: true });
|
setLobby({ games: [gameView('local1')], invitations: [], incoming: [], offline: true });
|
||||||
expect(getLobby(false)).toBeNull();
|
expect(getLobby(false)).toBeNull();
|
||||||
expect(getLobby(true)?.games.map((g) => g.id)).toEqual(['local1']);
|
expect(getLobby(true)?.games.map((g) => g.id)).toEqual(['local1']);
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -10,9 +10,6 @@ interface LobbySnapshot {
|
|||||||
games: GameView[];
|
games: GameView[];
|
||||||
invitations: Invitation[];
|
invitations: Invitation[];
|
||||||
incoming: AccountRef[];
|
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
|
// 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 —
|
// 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.
|
// or lets the player open — the other mode's games before the background refresh replaces them.
|
||||||
|
|||||||
@@ -20,6 +20,7 @@ function game(id: string, status: GameView['status'], toMove: number, lastActivi
|
|||||||
vsAi: false,
|
vsAi: false,
|
||||||
unreadChat: false,
|
unreadChat: false,
|
||||||
unreadMessages: false,
|
unreadMessages: false,
|
||||||
|
kind: 0,
|
||||||
status,
|
status,
|
||||||
players: 2,
|
players: 2,
|
||||||
toMove,
|
toMove,
|
||||||
|
|||||||
@@ -481,6 +481,7 @@ export class LocalSource implements GameLoopSource {
|
|||||||
hotseat: !!record.hotseat,
|
hotseat: !!record.hotseat,
|
||||||
unreadChat: false,
|
unreadChat: false,
|
||||||
unreadMessages: false,
|
unreadMessages: false,
|
||||||
|
kind: 0,
|
||||||
seats,
|
seats,
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|||||||
+11
-10
@@ -33,6 +33,7 @@ import type {
|
|||||||
MoveResult,
|
MoveResult,
|
||||||
OutgoingList,
|
OutgoingList,
|
||||||
Profile,
|
Profile,
|
||||||
|
GameLimits,
|
||||||
ProfileUpdate,
|
ProfileUpdate,
|
||||||
PushEvent,
|
PushEvent,
|
||||||
Session,
|
Session,
|
||||||
@@ -105,8 +106,6 @@ export class MockGateway implements GatewayClient {
|
|||||||
private feedbackReplyUnread = false;
|
private feedbackReplyUnread = false;
|
||||||
// The most recently opened auto-match game still awaiting an opponent, for the e2e join hook.
|
// The most recently opened auto-match game still awaiting an opponent, for the e2e join hook.
|
||||||
private openGameId: string | null = null;
|
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 friends: AccountRef[] = MOCK_FRIENDS.map((f) => ({ ...f }));
|
||||||
private incoming: AccountRef[] = MOCK_INCOMING.map((f) => ({ ...f }));
|
private incoming: AccountRef[] = MOCK_INCOMING.map((f) => ({ ...f }));
|
||||||
private outgoing: AccountRef[] = [];
|
private outgoing: AccountRef[] = [];
|
||||||
@@ -267,7 +266,7 @@ export class MockGateway implements GatewayClient {
|
|||||||
async gamesList(): Promise<GameList> {
|
async gamesList(): Promise<GameList> {
|
||||||
return {
|
return {
|
||||||
games: [...this.games.values()].map((g) => structuredClone(g.view)),
|
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,
|
vsAi: true,
|
||||||
unreadChat: false,
|
unreadChat: false,
|
||||||
unreadMessages: false,
|
unreadMessages: false,
|
||||||
|
kind: 1,
|
||||||
seats: [
|
seats: [
|
||||||
{ seat: 0, accountId: ME, displayName: 'You', score: 0, hintsUsed: 0, isWinner: false },
|
{ seat: 0, accountId: ME, displayName: 'You', score: 0, hintsUsed: 0, isWinner: false },
|
||||||
{ seat: 1, accountId: 'robot', displayName: 'Robot', 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,
|
vsAi: false,
|
||||||
unreadChat: false,
|
unreadChat: false,
|
||||||
unreadMessages: false,
|
unreadMessages: false,
|
||||||
|
kind: 2,
|
||||||
seats: [
|
seats: [
|
||||||
{ seat: 0, accountId: ME, displayName: 'You', score: 0, hintsUsed: 0, isWinner: false },
|
{ seat: 0, accountId: ME, displayName: 'You', score: 0, hintsUsed: 0, isWinner: false },
|
||||||
{ seat: 1, accountId: '', displayName: '', 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);
|
if (this.openGameId) this.seatOpponent(this.openGameId);
|
||||||
}
|
}
|
||||||
|
|
||||||
// setGameLimit forces the lobby's at_game_limit flag (the e2e hook
|
// setGameLimits overrides the profile's per-kind active-game caps (the e2e hook
|
||||||
// window.__mock.setGameLimit), then nudges the lobby to re-fetch games.list so the
|
// window.__mock.setGameLimits), then emits a profile notify so the app re-fetches the profile and
|
||||||
// "New Game" button and the notice update in place. The lobby refetches on any
|
// the New-Game screen's per-kind lock recomputes in place — the same path a guest→durable upgrade
|
||||||
// non-heartbeat event; an unrecognised notify sub triggers only that refetch.
|
// takes to lift the lock.
|
||||||
setGameLimit(v: boolean): void {
|
setGameLimits(l: GameLimits): void {
|
||||||
this.gameLimit = v;
|
this.profile.gameLimits = { ...l };
|
||||||
this.emit({ kind: 'notify', sub: 'game_limit' });
|
this.emit({ kind: 'notify', sub: 'profile' });
|
||||||
}
|
}
|
||||||
|
|
||||||
async lobbyPoll(): Promise<MatchResult> {
|
async lobbyPoll(): Promise<MatchResult> {
|
||||||
|
|||||||
@@ -47,6 +47,9 @@ export const PROFILE: Profile = {
|
|||||||
dictVersions: { erudit_ru: 'v1.3.0', scrabble_ru: 'v1.3.0', scrabble_en: 'v1.3.0' },
|
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.
|
// Post-move interstitial config (short cooldowns so the mock stub is exercisable); not suppressed.
|
||||||
ads: { cooldownGlobalS: 300, cooldownVsAiS: 1800, cooldownHintS: 60, suppressed: false },
|
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
|
// Seed social/account data for the mock (pnpm start + Playwright). The mock profile
|
||||||
@@ -189,6 +192,7 @@ function activeGame(): MockGame {
|
|||||||
vsAi: false,
|
vsAi: false,
|
||||||
unreadChat: false,
|
unreadChat: false,
|
||||||
unreadMessages: false,
|
unreadMessages: false,
|
||||||
|
kind: 2,
|
||||||
status: 'active',
|
status: 'active',
|
||||||
players: 2,
|
players: 2,
|
||||||
toMove: 0,
|
toMove: 0,
|
||||||
@@ -227,6 +231,7 @@ function finishedG2(): MockGame {
|
|||||||
vsAi: false,
|
vsAi: false,
|
||||||
unreadChat: false,
|
unreadChat: false,
|
||||||
unreadMessages: false,
|
unreadMessages: false,
|
||||||
|
kind: 2,
|
||||||
status: 'finished',
|
status: 'finished',
|
||||||
players: 2,
|
players: 2,
|
||||||
toMove: 0,
|
toMove: 0,
|
||||||
@@ -266,6 +271,7 @@ function finishedG3(): MockGame {
|
|||||||
vsAi: false,
|
vsAi: false,
|
||||||
unreadChat: false,
|
unreadChat: false,
|
||||||
unreadMessages: false,
|
unreadMessages: false,
|
||||||
|
kind: 2,
|
||||||
status: 'finished',
|
status: 'finished',
|
||||||
players: 2,
|
players: 2,
|
||||||
toMove: 0,
|
toMove: 0,
|
||||||
|
|||||||
@@ -62,6 +62,11 @@ export interface GameView {
|
|||||||
* badge can be coloured apart from the nudge-only case. Same REST-seeded lifecycle as
|
* badge can be coloured apart from the nudge-only case. Same REST-seeded lifecycle as
|
||||||
* unreadChat; the live-event GameView leaves it false. */
|
* unreadChat; the live-event GameView leaves it false. */
|
||||||
unreadMessages: boolean;
|
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[];
|
seats: Seat[];
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -234,6 +239,21 @@ export interface Profile {
|
|||||||
* offline-capable PWA reads it to preload the matching dawg for each enabled variant off
|
* 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. */
|
* this cold-start response. Empty only in a degenerate deployment with no resident dictionary. */
|
||||||
dictVersions: Partial<Record<Variant, string>>;
|
dictVersions: Partial<Record<Variant, string>>;
|
||||||
|
/** The caller's tier active-game caps per kind (-1 = unlimited); the New-Game screen counts the
|
||||||
|
* player's active games per kind from the lobby and locks a capped start. Absent from an old
|
||||||
|
* backend (then no lock applies). */
|
||||||
|
gameLimits?: GameLimits;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** GameKind labels the game_kind wire values for the active-game limits. */
|
||||||
|
export const GameKind = { vsAi: 1, random: 2, friends: 3 } as const;
|
||||||
|
|
||||||
|
/** The caller's tier active-game caps per kind (-1 = unlimited), resolved to the guest or durable
|
||||||
|
* tier server-side. The New-Game screen locks a start whose kind is at its cap. */
|
||||||
|
export interface GameLimits {
|
||||||
|
vsAi: number;
|
||||||
|
random: number;
|
||||||
|
friends: number;
|
||||||
}
|
}
|
||||||
|
|
||||||
/** The post-move interstitial-ad config for the client-mirrored gate: the cooldowns (seconds) and
|
/** The post-move interstitial-ad config for the client-mirrored gate: the cooldowns (seconds) and
|
||||||
|
|||||||
@@ -1,5 +1,5 @@
|
|||||||
import { describe, expect, it } from 'vitest';
|
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', () => {
|
describe('botInfoPopup', () => {
|
||||||
it('inlines the bot handle and adds an open-bot button', () => {
|
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.');
|
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' }]);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|||||||
@@ -8,6 +8,21 @@ import type { TelegramPopupParams } from './telegram';
|
|||||||
/** BOT_BUTTON_ID is the showPopup button id that means "open the bot chat". */
|
/** BOT_BUTTON_ID is the showPopup button id that means "open the bot chat". */
|
||||||
export const BOT_BUTTON_ID = 'bot';
|
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}`
|
* 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
|
* token replaced by the `@username` as plain text (a native popup has no inline link, unlike the
|
||||||
|
|||||||
@@ -22,6 +22,7 @@ function gameView(id: string, status: GameView['status'] = 'active'): GameView {
|
|||||||
vsAi: false,
|
vsAi: false,
|
||||||
unreadChat: false,
|
unreadChat: false,
|
||||||
unreadMessages: false,
|
unreadMessages: false,
|
||||||
|
kind: 0,
|
||||||
status,
|
status,
|
||||||
players: 2,
|
players: 2,
|
||||||
toMove: 0,
|
toMove: 0,
|
||||||
|
|||||||
@@ -20,6 +20,7 @@ function game(seats: Seat[], status = 'finished', toMove = 0): GameView {
|
|||||||
vsAi: false,
|
vsAi: false,
|
||||||
unreadChat: false,
|
unreadChat: false,
|
||||||
unreadMessages: false,
|
unreadMessages: false,
|
||||||
|
kind: 0,
|
||||||
status,
|
status,
|
||||||
players: seats.length,
|
players: seats.length,
|
||||||
toMove,
|
toMove,
|
||||||
|
|||||||
@@ -23,12 +23,6 @@
|
|||||||
let games = $state<GameView[]>([]);
|
let games = $state<GameView[]>([]);
|
||||||
let invitations = $state<Invitation[]>([]);
|
let invitations = $state<Invitation[]>([]);
|
||||||
let incoming = $state<AccountRef[]>([]);
|
let incoming = $state<AccountRef[]>([]);
|
||||||
// True when the player has reached the simultaneous quick-game cap: the "New Game" tab is
|
|
||||||
// disabled and a notice shows under the lists. It rides the games.list response (which the
|
|
||||||
// lobby refetches on entry and on every game event) and the cached snapshot, so it renders
|
|
||||||
// in its last known state instantly and refreshes in the background. Optimistic default
|
|
||||||
// (enabled) for the very first, uncached load; the backend gate is the authority.
|
|
||||||
let atGameLimit = $state(false);
|
|
||||||
|
|
||||||
const guest = $derived(app.profile?.isGuest ?? true);
|
const guest = $derived(app.profile?.isGuest ?? true);
|
||||||
// The lobby ⚙️ badge is the shared count: incoming friend requests plus an awaiting
|
// The lobby ⚙️ badge is the shared count: incoming friend requests plus an awaiting
|
||||||
@@ -51,9 +45,8 @@
|
|||||||
games = local;
|
games = local;
|
||||||
invitations = [];
|
invitations = [];
|
||||||
incoming = [];
|
incoming = [];
|
||||||
atGameLimit = false;
|
|
||||||
app.notifications = 0;
|
app.notifications = 0;
|
||||||
setLobby({ games, invitations, incoming, atGameLimit, offline: offlineMode.active });
|
setLobby({ games, invitations, incoming, offline: offlineMode.active });
|
||||||
app.lobbyReady = true;
|
app.lobbyReady = true;
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
@@ -64,7 +57,6 @@
|
|||||||
// Seed the per-game unread badges from the authoritative list. The live stream only raises
|
// 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.
|
// 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);
|
for (const g of games) seedChatUnread(g.id, g.unreadChat, g.unreadMessages);
|
||||||
atGameLimit = list.atGameLimit;
|
|
||||||
if (!guest) {
|
if (!guest) {
|
||||||
const [inv, inc] = await Promise.all([gateway.invitationsList(), gateway.friendsIncoming()]);
|
const [inv, inc] = await Promise.all([gateway.invitationsList(), gateway.friendsIncoming()]);
|
||||||
if (seq !== loadSeq) return;
|
if (seq !== loadSeq) return;
|
||||||
@@ -75,7 +67,7 @@
|
|||||||
app.notifications = incoming.length;
|
app.notifications = incoming.length;
|
||||||
void refreshFeedbackBadge();
|
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
|
// 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
|
// 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).
|
// lobby refresh cheaply warms any newly appeared game (already-cached ones are skipped).
|
||||||
@@ -103,7 +95,6 @@
|
|||||||
games = cached.games;
|
games = cached.games;
|
||||||
invitations = cached.invitations;
|
invitations = cached.invitations;
|
||||||
incoming = cached.incoming;
|
incoming = cached.incoming;
|
||||||
atGameLimit = cached.atGameLimit;
|
|
||||||
}
|
}
|
||||||
void load();
|
void load();
|
||||||
});
|
});
|
||||||
@@ -231,7 +222,7 @@
|
|||||||
revealedId = null;
|
revealedId = null;
|
||||||
const prev = games;
|
const prev = games;
|
||||||
games = games.filter((g) => g.id !== id); // optimistic; the backend already filters it out
|
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 {
|
try {
|
||||||
// A local (offline) game is deleted from the device store; an online game is hidden on the
|
// 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
|
// 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);
|
else await gateway.hideGame(id);
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
games = prev;
|
games = prev;
|
||||||
setLobby({ games, invitations, incoming, atGameLimit, offline: offlineMode.active });
|
setLobby({ games, invitations, incoming, offline: offlineMode.active });
|
||||||
handleError(e);
|
handleError(e);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -379,10 +370,6 @@
|
|||||||
{#if !games.length && !invitations.length}
|
{#if !games.length && !invitations.length}
|
||||||
<p class="empty">{t('lobby.noActive')}</p>
|
<p class="empty">{t('lobby.noActive')}</p>
|
||||||
{/if}
|
{/if}
|
||||||
|
|
||||||
{#if atGameLimit}
|
|
||||||
<p class="limit">{t('lobby.limitReached')}</p>
|
|
||||||
{/if}
|
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{#if declineTarget}
|
{#if declineTarget}
|
||||||
@@ -411,7 +398,7 @@
|
|||||||
|
|
||||||
{#snippet tabbar()}
|
{#snippet tabbar()}
|
||||||
<TabBar>
|
<TabBar>
|
||||||
<button class="tab" disabled={atGameLimit} onclick={() => navigate('/new')}>
|
<button class="tab" onclick={() => navigate('/new')}>
|
||||||
<span class="sq" data-coach="lobby-new">🎲</span><span class="lbl">{t('lobby.new')}</span>
|
<span class="sq" data-coach="lobby-new">🎲</span><span class="lbl">{t('lobby.new')}</span>
|
||||||
</button>
|
</button>
|
||||||
<button class="tab" disabled={offlineMode.active} onclick={() => navigate('/stats')}>
|
<button class="tab" disabled={offlineMode.active} onclick={() => navigate('/stats')}>
|
||||||
@@ -444,13 +431,6 @@
|
|||||||
font-size: 0.9rem;
|
font-size: 0.9rem;
|
||||||
margin: 0;
|
margin: 0;
|
||||||
}
|
}
|
||||||
/* A plain, low-emphasis line under the lists when the simultaneous-game cap is reached. */
|
|
||||||
.limit {
|
|
||||||
color: var(--text-muted);
|
|
||||||
font-size: 0.9rem;
|
|
||||||
margin: 0;
|
|
||||||
text-align: center;
|
|
||||||
}
|
|
||||||
.invite {
|
.invite {
|
||||||
display: flex;
|
display: flex;
|
||||||
align-items: center;
|
align-items: center;
|
||||||
|
|||||||
@@ -2,7 +2,11 @@
|
|||||||
import { onMount } from 'svelte';
|
import { onMount } from 'svelte';
|
||||||
import Screen from '../components/Screen.svelte';
|
import Screen from '../components/Screen.svelte';
|
||||||
import Modal from '../components/Modal.svelte';
|
import Modal from '../components/Modal.svelte';
|
||||||
|
import GameLimitModal from '../components/GameLimitModal.svelte';
|
||||||
import PinPad, { type PinResult } from '../components/PinPad.svelte';
|
import PinPad, { type PinResult } from '../components/PinPad.svelte';
|
||||||
|
import { getLobby } from '../lib/lobbycache';
|
||||||
|
import { isKindLocked } from '../lib/gamelimits';
|
||||||
|
import { GameKind } from '../lib/model';
|
||||||
import { gateway } from '../lib/gateway';
|
import { gateway } from '../lib/gateway';
|
||||||
import { app, handleError, showToast } from '../lib/app.svelte';
|
import { app, handleError, showToast } from '../lib/app.svelte';
|
||||||
import { connection } from '../lib/connection.svelte';
|
import { connection } from '../lib/connection.svelte';
|
||||||
@@ -51,6 +55,14 @@
|
|||||||
// or a random human via auto-match. AI is the default.
|
// or a random human via auto-match. AI is the default.
|
||||||
let opponent = $state<'ai' | 'random'>('ai');
|
let opponent = $state<'ai' | 'random'>('ai');
|
||||||
|
|
||||||
|
// Active-game limit lock: the auto-start kind (vs_ai or random by the opponent choice) tested
|
||||||
|
// against the caller's per-kind cap over the online lobby's active games. A capped start opens the
|
||||||
|
// funnel modal instead of enqueuing (the server also refuses it); offline never locks (local
|
||||||
|
// games are uncapped). The lock lifts when the profile is re-fetched after a guest→durable upgrade.
|
||||||
|
const autoKind = $derived(opponent === 'ai' ? GameKind.vsAi : GameKind.random);
|
||||||
|
const autoLocked = $derived(!offlineMode.active && isKindLocked(getLobby(false)?.games ?? [], app.profile?.gameLimits, autoKind));
|
||||||
|
let limitOpen = $state(false);
|
||||||
|
|
||||||
// --- auto-match ---
|
// --- auto-match ---
|
||||||
// Enqueue drops the player straight into a real game — a freshly opened one awaiting an
|
// Enqueue drops the player straight into a real game — a freshly opened one awaiting an
|
||||||
// opponent, or another player's open game they just joined — so we navigate into it at once
|
// opponent, or another player's open game they just joined — so we navigate into it at once
|
||||||
@@ -329,9 +341,16 @@
|
|||||||
{#if opponent === 'random'}<p class="searchhint">{t('new.searchHint')}</p>{/if}
|
{#if opponent === 'random'}<p class="searchhint">{t('new.searchHint')}</p>{/if}
|
||||||
<button
|
<button
|
||||||
class="invite"
|
class="invite"
|
||||||
disabled={!selectedAuto || (!connection.online && !offlineMode.active) || starting}
|
class:locked={autoLocked}
|
||||||
onclick={() => selectedAuto && find(selectedAuto)}
|
disabled={(autoLocked ? false : !selectedAuto) || (!connection.online && !offlineMode.active) || starting}
|
||||||
>{t('new.start')}</button>
|
onclick={() => (autoLocked ? (limitOpen = true) : selectedAuto && find(selectedAuto))}
|
||||||
|
>{#if autoLocked}🔒 {/if}{t('new.start')}</button>
|
||||||
|
<GameLimitModal
|
||||||
|
open={limitOpen}
|
||||||
|
{guest}
|
||||||
|
onclose={() => (limitOpen = false)}
|
||||||
|
onlogin={() => { limitOpen = false; navigate('/settings'); }}
|
||||||
|
/>
|
||||||
{:else if offlineMode.active}
|
{:else if offlineMode.active}
|
||||||
<!-- Offline pass-and-play (hotseat): a device-local 2-4 human game. The host sets a master
|
<!-- Offline pass-and-play (hotseat): a device-local 2-4 human game. The host sets a master
|
||||||
PIN first (it gates the roster); each seat may add its own PIN. -->
|
PIN first (it gates the roster); each seat may add its own PIN. -->
|
||||||
@@ -660,6 +679,12 @@
|
|||||||
.invite:disabled {
|
.invite:disabled {
|
||||||
opacity: 0.5;
|
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 {
|
.searchhint {
|
||||||
margin: 0;
|
margin: 0;
|
||||||
text-align: center;
|
text-align: center;
|
||||||
|
|||||||
Reference in New Issue
Block a user