diff --git a/PLAN.md b/PLAN.md index 55a4138..4557aa2 100644 --- a/PLAN.md +++ b/PLAN.md @@ -37,7 +37,7 @@ status — without re-deriving decisions. | E5 | Payment intake | 2 | DONE | | E6 | Ads | 2 | DONE | | E7 | Admin, reports & catalog | 2 | DONE | -| E8 | Guest limits | — | TODO | +| E8 | Guest limits | — | WIP | | E9 | Tournament fee | future | TODO | **Release 1** = full mechanics with no real money, exercised via `admin_grant` (E0→E1→E2→E3). @@ -782,38 +782,79 @@ become bootstrap-only. ## E8 — Guest limits -**Status:** TODO · **standalone** (game-behaviour change; can run in parallel) · depends on: -none · mechanics: PAYMENTS §6. +**Status:** WIP · **standalone** (game-behaviour change) · depends on: none · mechanics: PAYMENTS §6. -**Goal.** A registration funnel: cap what a guest can do, enforced **server-side** (today the -gating is 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. -**Finding (verified).** The friend/invitation paths do **not** check `is_guest` on the -server — only the UI hides them: `social/friends.go:50` `SendFriendRequest`, -`robotfriends.go:41` `RequestInGame`, `friendcodes.go:67` `RedeemFriendCode`, -`lobby/invitations.go:208` `CreateInvitation`. A guest with a valid `X-User-ID` can call them -in the UI's stead. +**Finding (verified).** Two gaps. (a) The friend/invitation paths do **not** check `is_guest` on the +server — only the UI hides them: `social/friends.go`, `robotfriends.go`, `friendcodes.go`, +`lobby/invitations.go`. A guest with a valid `X-User-ID` can call them. (b) A **pre-existing** flat +cap already existed: `game.MaxActiveQuickGames`=10 — a **combined** cap on `active`+`open` quick +games (vs_ai+random together, friend games excluded), counted by `CountActiveQuickGames`, enforced at +the handler (`ensureUnderGameLimit`) with **409 `game_limit_reached`**, surfaced as `at_game_limit`. +E8's per-tier × per-kind config **subsumes and replaces** it (decided: option A). + +**Delivery & baked decisions (this planning round).** A linear PR stack; E8 is game-behaviour, no +payments mixed in. + +- **`games.game_kind smallint DEFAULT 0`** (0=unknown — pre-E8 games, never gated; 1=vs_ai; 2=random; + 3=friends), set on creation (`StartVsAI`=1, `Enqueue`=2, a friend invitation=3). Existing games + stay 0 and fall outside the gate. +- **Limits are per-tier × per-kind**, in a **new single-row `backend.config`** table: + `guest_{vs_ai,random,friends}_limit` + `durable_{vs_ai,random,friends}_limit` (smallint, + **`-1`=unlimited**), seeded `(1,1,0, 10,10,10)`. A guest is capped at 1 vs_ai + 1 random (friends + is moot — the guest gate blocks friend games); a durable account is **10 per kind** — the old flat + MaxActiveQuickGames=10, now split per kind. Editable in the admin. +- **The old flat cap is removed** (option A, owner-agreed): `MaxActiveQuickGames`, + `CountActiveQuickGames`, `atGameLimit` deleted; the per-tier/kind config is the single mechanism, + enforced at the **same handler gate** (`ensureUnderGameLimit(kind)` for `enqueue` + random/vs_ai) plus the durable **friends cap** inside `CreateInvitation`. The gate stays out of the + game domain — `game.Service.AtGameLimit(account, kind)` only resolves tier + counts. +- **A hot in-memory cache** fronts the config (read once on start, invalidated on the admin edit — + mirrors the payments read-cache) so a login / game-create never queries it. +- **"Active" = `open` + `active`** (an open, unmatched random game holds a slot); the limit is on + **creation** — an existing game is grandfathered, never interrupted. +- **Limits ride the wire (forward-compat, no double break):** `Profile.game_limits` + (`GameLimits{vs_ai, random, friends}` — the caller's tier resolved server-side) + `GameView.kind`, + so the client counts active games **per kind** from its lobby and locks the right start button. On + a guest → durable upgrade (register / link) the client **re-fetches the profile** so the new + (durable) limits apply. The client picks the lock's message by tier: a **guest** → the login funnel; + a **durable** account at its cap → a plain "finish a current game first" notice. **Work.** -- **Server guest gate** on friend-request / redeem-code / invitation-create: refuse when the - caller `is_guest` (add an `ErrGuestForbidden`-style guard, as `feedback` already has). -- **Active-game limits** for guests: at most **1 random-opponent game + 1 vs_ai** concurrently - (enforce on game/invitation creation in `lobby`/`game`; today no such limit exists). -- Keep the existing UI gating; this adds the server as the source of truth. +- ~~**PR1 — backend + admin (server-side)** — DONE.~~ The migration (`game_kind` + `backend.config`, + seeded `1,1,0 / 10,10,10` + jetgen); `game_kind` set on creation and projected onto `game.Game`; + the **guest gate** (`ErrGuestForbidden`, mapped to **403 `guest_forbidden`**) on friend-request / + redeem-code / befriend-in-game / invitation-create; the **active-limit enforce** — the old flat + `MaxActiveQuickGames` mechanism removed and replaced by `ensureUnderGameLimit(kind)` on `enqueue` + (random/vs_ai) plus the durable friends cap in `CreateInvitation`, keyed off + `game.Service.AtGameLimit`; the **`internal/gamelimits` config + hot cache** (loaded at boot, + invalidated on edit); the admin **kind column** in both game lists (`/_gm/games` + the user card) + and a **config editor** (`/_gm/limits`) for the six limits. +- **PR2 — wire + client:** `Profile.game_limits` (the caller's tier) + `GameView.kind` (FBS + + transcode + codec); the client per-kind active count from the lobby; the **lock badge** on a capped + new-game start button, with two messages by tier — a **guest** login-funnel modal ("Войдите или + создайте учётную запись…", Отмена / Вход→profile) and, for a **durable** account at its cap, a plain + notice ("Вы достигли лимита одновременных игр, сначала завершите текущие", ОК; **native** popup in + Telegram — no gesture-gated API follows it); the profile re-fetch after a guest→durable upgrade. **Tests.** -- integration: guest is refused friend/redeem/invitation server-side; guest blocked from a - 2nd random / 2nd vs_ai; durable account unaffected. -- UI: guest sees the funnel (existing hides + any new messaging). +- integration (PR1, done): `game_kind` persisted per path; guest refused friend/redeem/invitation + (domain + HTTP 403); guest blocked from a 2nd vs_ai / 2nd random (+ `at_game_limit`); durable on the + higher tier; the durable friends cap + config cache reflecting an admin edit; accept stays exempt. +- unit (PR1, done): the per-tier/kind limit resolution (`Cap`, `LimitsFor`). +- UI (PR2): the lock + the two modals on a capped button (mock); the profile re-fetch lifts the lock + after register. -**Done-criteria.** Guests are server-limited to 1 random + 1 vs_ai and cannot friend/invite; -durable accounts unchanged; regression that this does not break existing durable flows. +**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 + +the tier-appropriate modal and the cap lifts on registration; durable flows otherwise unchanged. -**Notes/risks.** This changes existing game behaviour — its own tests, its own PR, no -mixed-in payments changes. Decide whether a guest may finish an already-started game (limit -on creation, not mid-game) at implementation and record it here. +**Notes/risks.** Game-behaviour change — own PRs, own tests, no payments mixed in. The limit is on +creation (existing games grandfathered). The config cache is single-instance (matching the deploy). --- diff --git a/backend/README.md b/backend/README.md index 6a7e1c0..2db76cb 100644 --- a/backend/README.md +++ b/backend/README.md @@ -33,11 +33,16 @@ real game seating the caller with an **empty opponent seat** (status `open`) or, another player already waits for the same variant and per-turn word rule, seats the caller into that open game and starts it — and friend-game invitations (invite → accept, starting a 2–4 player game once every -invitee accepts). A **simultaneous-game cap** (`game.MaxActiveQuickGames` = 10) limits a -player's active quick games — status `active`/`open`, excluding invitation-linked friend -games (`game.Service.CountActiveQuickGames`); the server refuses `lobby/enqueue` and -`invitations` creation with **409 `game_limit_reached`** at the cap (accepting an invitation -is exempt), and the `games.list` response carries an `at_game_limit` flag for the lobby. +invitee accepts). **Per-tier, per-kind active-game caps** limit a player's simultaneous +unfinished games by kind — `vs_ai`, `random` (quick auto-match), `friends` — with separate +guest and durable-account tiers held in the single-row `backend.config` table (a `-1` means +unlimited), read through an in-memory cache (`internal/gamelimits`) and tuned live in the admin +(`/_gm/limits`, no redeploy). Each game is tagged with its `games.game_kind` on creation; +`game.Service.AtGameLimit` counts the account's `active`/`open` games of a kind against the +tier's cap. The server refuses `lobby/enqueue` (random/vs_ai) with **409 `game_limit_reached`** +at the cap, and the `invitations` (friends) path enforces the durable friends cap and refuses a +**guest** outright (guests cannot use friends); accepting an invitation is exempt. The +`games.list` response carries an `at_game_limit` flag (the random-kind cap) for the lobby. `internal/social` owns the friend graph (request/accept), per-user blocks, and per-game chat with nudges folded in as a message kind; chat messages are length-capped, content-filtered (no links/emails/phone numbers, diff --git a/backend/cmd/backend/main.go b/backend/cmd/backend/main.go index cbdbc88..fa2ac40 100644 --- a/backend/cmd/backend/main.go +++ b/backend/cmd/backend/main.go @@ -28,6 +28,7 @@ import ( "scrabble/backend/internal/engine" "scrabble/backend/internal/feedback" "scrabble/backend/internal/game" + "scrabble/backend/internal/gamelimits" "scrabble/backend/internal/link" "scrabble/backend/internal/lobby" "scrabble/backend/internal/notify" @@ -156,6 +157,17 @@ func run(ctx context.Context, cfg config.Config, logger *zap.Logger) error { logger.Info("active dictionary version", zap.String("version", games.ActiveVersion())) games.SetNotifier(hub) games.SetMetrics(tel.MeterProvider().Meter("scrabble/backend/game")) + + // Active-game limit config: the per-tier, per-kind caps in backend.config, read once into an + // in-memory cache at boot and refreshed when the admin edits them. A boot-time load fails fast if + // the single config row is missing; the game domain reads the cache on every new-game gate. + gameLimits := gamelimits.NewService(gamelimits.NewStore(db)) + if err := gameLimits.Load(ctx); err != nil { + return fmt.Errorf("load game-limit config: %w", err) + } + games.SetGameLimits(gameLimits) + logger.Info("game-limit config loaded") + go games.RunSweeper(ctx, cfg.Game.TimeoutSweepInterval) logger.Info("game turn-timeout sweeper started", zap.Duration("interval", cfg.Game.TimeoutSweepInterval)) @@ -305,6 +317,7 @@ func run(ctx context.Context, cfg config.Config, logger *zap.Logger) error { BanView: banView, Ads: adsSvc, Payments: paymentsSvc, + GameLimits: gameLimits, Notifier: hub, ExportSignKey: cfg.ExportSignKey, Renderer: renderer, diff --git a/backend/internal/adminconsole/templates/layout.gohtml b/backend/internal/adminconsole/templates/layout.gohtml index c472e03..6a21d6d 100644 --- a/backend/internal/adminconsole/templates/layout.gohtml +++ b/backend/internal/adminconsole/templates/layout.gohtml @@ -15,6 +15,7 @@ Dashboard Users Games + Limits Complaints Feedback Messages diff --git a/backend/internal/adminconsole/templates/pages/games.gohtml b/backend/internal/adminconsole/templates/pages/games.gohtml index e6ab08e..c7bea24 100644 --- a/backend/internal/adminconsole/templates/pages/games.gohtml +++ b/backend/internal/adminconsole/templates/pages/games.gohtml @@ -8,11 +8,11 @@ finished
| Game | Variant | Status | 🤖 | Players | Updated | |
|---|---|---|---|---|---|---|
| Game | Variant | Kind | Status | 🤖 | Players | Updated |
| {{.ID}} | {{.Variant}} | {{.Status}} | {{if .VsAI}}🤖{{end}} | {{.Players}} | {{.UpdatedAt}} | |
| no games | ||||||
| {{.ID}} | {{.Variant}} | {{.Kind}} | {{.Status}} | {{if .VsAI}}🤖{{end}} | {{.Players}} | {{.UpdatedAt}} |
| no games | ||||||