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 | — | TODO |
|
| E8 | Guest limits | — | WIP |
|
||||||
| 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,38 +782,79 @@ become bootstrap-only.
|
|||||||
|
|
||||||
## E8 — Guest limits
|
## E8 — Guest limits
|
||||||
|
|
||||||
**Status:** TODO · **standalone** (game-behaviour change; can run in parallel) · depends on:
|
**Status:** WIP · **standalone** (game-behaviour change) · depends on: none · mechanics: PAYMENTS §6.
|
||||||
none · mechanics: PAYMENTS §6.
|
|
||||||
|
|
||||||
**Goal.** A registration funnel: cap what a guest can do, enforced **server-side** (today the
|
**Goal.** A registration funnel: cap what a guest can do, enforced **server-side** (today UI-only),
|
||||||
gating is 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
|
**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:50` `SendFriendRequest`,
|
server — only the UI hides them: `social/friends.go`, `robotfriends.go`, `friendcodes.go`,
|
||||||
`robotfriends.go:41` `RequestInGame`, `friendcodes.go:67` `RedeemFriendCode`,
|
`lobby/invitations.go`. A guest with a valid `X-User-ID` can call them. (b) A **pre-existing** flat
|
||||||
`lobby/invitations.go:208` `CreateInvitation`. A guest with a valid `X-User-ID` can call them
|
cap already existed: `game.MaxActiveQuickGames`=10 — a **combined** cap on `active`+`open` quick
|
||||||
in the UI's stead.
|
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.**
|
**Work.**
|
||||||
|
|
||||||
- **Server guest gate** on friend-request / redeem-code / invitation-create: refuse when the
|
- ~~**PR1 — backend + admin (server-side)** — DONE.~~ The migration (`game_kind` + `backend.config`,
|
||||||
caller `is_guest` (add an `ErrGuestForbidden`-style guard, as `feedback` already has).
|
seeded `1,1,0 / 10,10,10` + jetgen); `game_kind` set on creation and projected onto `game.Game`;
|
||||||
- **Active-game limits** for guests: at most **1 random-opponent game + 1 vs_ai** concurrently
|
the **guest gate** (`ErrGuestForbidden`, mapped to **403 `guest_forbidden`**) on friend-request /
|
||||||
(enforce on game/invitation creation in `lobby`/`game`; today no such limit exists).
|
redeem-code / befriend-in-game / invitation-create; the **active-limit enforce** — the old flat
|
||||||
- Keep the existing UI gating; this adds the server as the source of truth.
|
`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.**
|
**Tests.**
|
||||||
|
|
||||||
- integration: guest is refused friend/redeem/invitation server-side; guest blocked from a
|
- integration (PR1, done): `game_kind` persisted per path; guest refused friend/redeem/invitation
|
||||||
2nd random / 2nd vs_ai; durable account unaffected.
|
(domain + HTTP 403); guest blocked from a 2nd vs_ai / 2nd random (+ `at_game_limit`); durable on the
|
||||||
- UI: guest sees the funnel (existing hides + any new messaging).
|
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;
|
**Done-criteria.** A guest is server-capped (default 1 vs_ai + 1 random, configurable) and cannot
|
||||||
durable accounts unchanged; regression that this does not break existing durable flows.
|
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
|
**Notes/risks.** Game-behaviour change — own PRs, own tests, no payments mixed in. The limit is on
|
||||||
mixed-in payments changes. Decide whether a guest may finish an already-started game (limit
|
creation (existing games grandfathered). The config cache is single-instance (matching the deploy).
|
||||||
on creation, not mid-game) at implementation and record it here.
|
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
|
|||||||
+10
-5
@@ -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
|
another player already waits for the same variant and per-turn word rule, seats the
|
||||||
caller into that open game and starts it — and
|
caller into that open game and starts it — and
|
||||||
friend-game invitations (invite → accept, starting a 2–4 player game once every
|
friend-game invitations (invite → accept, starting a 2–4 player game once every
|
||||||
invitee accepts). A **simultaneous-game cap** (`game.MaxActiveQuickGames` = 10) limits a
|
invitee accepts). **Per-tier, per-kind active-game caps** limit a player's simultaneous
|
||||||
player's active quick games — status `active`/`open`, excluding invitation-linked friend
|
unfinished games by kind — `vs_ai`, `random` (quick auto-match), `friends` — with separate
|
||||||
games (`game.Service.CountActiveQuickGames`); the server refuses `lobby/enqueue` and
|
guest and durable-account tiers held in the single-row `backend.config` table (a `-1` means
|
||||||
`invitations` creation with **409 `game_limit_reached`** at the cap (accepting an invitation
|
unlimited), read through an in-memory cache (`internal/gamelimits`) and tuned live in the admin
|
||||||
is exempt), and the `games.list` response carries an `at_game_limit` flag for the lobby.
|
(`/_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),
|
`internal/social` owns the friend graph (request/accept),
|
||||||
per-user blocks, and per-game chat with nudges folded in as a message kind; chat
|
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,
|
messages are length-capped, content-filtered (no links/emails/phone numbers,
|
||||||
|
|||||||
@@ -28,6 +28,7 @@ import (
|
|||||||
"scrabble/backend/internal/engine"
|
"scrabble/backend/internal/engine"
|
||||||
"scrabble/backend/internal/feedback"
|
"scrabble/backend/internal/feedback"
|
||||||
"scrabble/backend/internal/game"
|
"scrabble/backend/internal/game"
|
||||||
|
"scrabble/backend/internal/gamelimits"
|
||||||
"scrabble/backend/internal/link"
|
"scrabble/backend/internal/link"
|
||||||
"scrabble/backend/internal/lobby"
|
"scrabble/backend/internal/lobby"
|
||||||
"scrabble/backend/internal/notify"
|
"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()))
|
logger.Info("active dictionary version", zap.String("version", games.ActiveVersion()))
|
||||||
games.SetNotifier(hub)
|
games.SetNotifier(hub)
|
||||||
games.SetMetrics(tel.MeterProvider().Meter("scrabble/backend/game"))
|
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)
|
go games.RunSweeper(ctx, cfg.Game.TimeoutSweepInterval)
|
||||||
logger.Info("game turn-timeout sweeper started",
|
logger.Info("game turn-timeout sweeper started",
|
||||||
zap.Duration("interval", cfg.Game.TimeoutSweepInterval))
|
zap.Duration("interval", cfg.Game.TimeoutSweepInterval))
|
||||||
@@ -305,6 +317,7 @@ func run(ctx context.Context, cfg config.Config, logger *zap.Logger) error {
|
|||||||
BanView: banView,
|
BanView: banView,
|
||||||
Ads: adsSvc,
|
Ads: adsSvc,
|
||||||
Payments: paymentsSvc,
|
Payments: paymentsSvc,
|
||||||
|
GameLimits: gameLimits,
|
||||||
Notifier: hub,
|
Notifier: hub,
|
||||||
ExportSignKey: cfg.ExportSignKey,
|
ExportSignKey: cfg.ExportSignKey,
|
||||||
Renderer: renderer,
|
Renderer: renderer,
|
||||||
|
|||||||
@@ -15,6 +15,7 @@
|
|||||||
<a href="/_gm/"{{if eq .ActiveNav "dashboard"}} class="active"{{end}}>Dashboard</a>
|
<a href="/_gm/"{{if eq .ActiveNav "dashboard"}} class="active"{{end}}>Dashboard</a>
|
||||||
<a href="/_gm/users"{{if eq .ActiveNav "users"}} class="active"{{end}}>Users</a>
|
<a href="/_gm/users"{{if eq .ActiveNav "users"}} class="active"{{end}}>Users</a>
|
||||||
<a href="/_gm/games"{{if eq .ActiveNav "games"}} class="active"{{end}}>Games</a>
|
<a href="/_gm/games"{{if eq .ActiveNav "games"}} class="active"{{end}}>Games</a>
|
||||||
|
<a href="/_gm/limits"{{if eq .ActiveNav "limits"}} class="active"{{end}}>Limits</a>
|
||||||
<a href="/_gm/complaints"{{if eq .ActiveNav "complaints"}} class="active"{{end}}>Complaints</a>
|
<a href="/_gm/complaints"{{if eq .ActiveNav "complaints"}} class="active"{{end}}>Complaints</a>
|
||||||
<a href="/_gm/feedback"{{if eq .ActiveNav "feedback"}} class="active"{{end}}>Feedback</a>
|
<a href="/_gm/feedback"{{if eq .ActiveNav "feedback"}} class="active"{{end}}>Feedback</a>
|
||||||
<a href="/_gm/messages"{{if eq .ActiveNav "messages"}} class="active"{{end}}>Messages</a>
|
<a href="/_gm/messages"{{if eq .ActiveNav "messages"}} class="active"{{end}}>Messages</a>
|
||||||
|
|||||||
@@ -8,11 +8,11 @@
|
|||||||
<a href="/_gm/games?status=finished"{{if eq .Status "finished"}} class="active"{{end}}>finished</a>
|
<a href="/_gm/games?status=finished"{{if eq .Status "finished"}} class="active"{{end}}>finished</a>
|
||||||
</nav>
|
</nav>
|
||||||
<table class="list">
|
<table class="list">
|
||||||
<thead><tr><th>Game</th><th>Variant</th><th>Status</th><th>🤖</th><th class="num">Players</th><th>Updated</th></tr></thead>
|
<thead><tr><th>Game</th><th>Variant</th><th>Kind</th><th>Status</th><th>🤖</th><th class="num">Players</th><th>Updated</th></tr></thead>
|
||||||
<tbody>
|
<tbody>
|
||||||
{{range .Items}}
|
{{range .Items}}
|
||||||
<tr><td><a href="/_gm/games/{{.ID}}">{{.ID}}</a></td><td>{{.Variant}}</td><td>{{.Status}}</td><td>{{if .VsAI}}🤖{{end}}</td><td class="num">{{.Players}}</td><td>{{.UpdatedAt}}</td></tr>
|
<tr><td><a href="/_gm/games/{{.ID}}">{{.ID}}</a></td><td>{{.Variant}}</td><td>{{.Kind}}</td><td>{{.Status}}</td><td>{{if .VsAI}}🤖{{end}}</td><td class="num">{{.Players}}</td><td>{{.UpdatedAt}}</td></tr>
|
||||||
{{else}}<tr><td colspan="6"><span class="note">no games</span></td></tr>{{end}}
|
{{else}}<tr><td colspan="7"><span class="note">no games</span></td></tr>{{end}}
|
||||||
</tbody>
|
</tbody>
|
||||||
</table>
|
</table>
|
||||||
<nav class="pager">
|
<nav class="pager">
|
||||||
|
|||||||
@@ -0,0 +1,19 @@
|
|||||||
|
{{define "content" -}}
|
||||||
|
<h1>Active-game limits</h1>
|
||||||
|
{{with .Data}}
|
||||||
|
<p class="note">Per-tier, per-kind caps on a player's simultaneous unfinished games. <strong>-1</strong> = unlimited, <strong>0</strong> = the kind is blocked, a positive number caps concurrent games of that kind. Guests are additionally blocked from friend games outright. Changes apply immediately (no redeploy); games already in progress are never affected.</p>
|
||||||
|
<section class="panel">
|
||||||
|
<form class="form col" method="post" action="/_gm/limits">
|
||||||
|
<h2>Guest</h2>
|
||||||
|
<label>vs AI <input type="number" name="guest_vs_ai" min="-1" value="{{.GuestVsAI}}" required></label>
|
||||||
|
<label>Random <input type="number" name="guest_random" min="-1" value="{{.GuestRandom}}" required></label>
|
||||||
|
<label>Friends <input type="number" name="guest_friends" min="-1" value="{{.GuestFriends}}" required></label>
|
||||||
|
<h2>Durable account</h2>
|
||||||
|
<label>vs AI <input type="number" name="durable_vs_ai" min="-1" value="{{.DurableVsAI}}" required></label>
|
||||||
|
<label>Random <input type="number" name="durable_random" min="-1" value="{{.DurableRandom}}" required></label>
|
||||||
|
<label>Friends <input type="number" name="durable_friends" min="-1" value="{{.DurableFriends}}" required></label>
|
||||||
|
<div><button type="submit">Save</button></div>
|
||||||
|
</form>
|
||||||
|
</section>
|
||||||
|
{{end}}
|
||||||
|
{{- end}}
|
||||||
@@ -211,11 +211,11 @@
|
|||||||
{{end}}
|
{{end}}
|
||||||
<section class="panel"><h2>Games</h2>
|
<section class="panel"><h2>Games</h2>
|
||||||
<table class="list">
|
<table class="list">
|
||||||
<thead><tr><th>Game</th><th>Variant</th><th>Status</th><th class="num">Players</th><th>Updated</th></tr></thead>
|
<thead><tr><th>Game</th><th>Variant</th><th>Kind</th><th>Status</th><th class="num">Players</th><th>Updated</th></tr></thead>
|
||||||
<tbody>
|
<tbody>
|
||||||
{{range .Games}}
|
{{range .Games}}
|
||||||
<tr><td><a href="/_gm/games/{{.ID}}">{{.ID}}</a></td><td>{{.Variant}}</td><td>{{.Status}}</td><td class="num">{{.Players}}</td><td>{{.UpdatedAt}}</td></tr>
|
<tr><td><a href="/_gm/games/{{.ID}}">{{.ID}}</a></td><td>{{.Variant}}</td><td>{{.Kind}}</td><td>{{.Status}}</td><td class="num">{{.Players}}</td><td>{{.UpdatedAt}}</td></tr>
|
||||||
{{else}}<tr><td colspan="5"><span class="note">no games</span></td></tr>{{end}}
|
{{else}}<tr><td colspan="6"><span class="note">no games</span></td></tr>{{end}}
|
||||||
</tbody>
|
</tbody>
|
||||||
</table>
|
</table>
|
||||||
</section>
|
</section>
|
||||||
|
|||||||
@@ -312,6 +312,8 @@ type GameRow struct {
|
|||||||
UpdatedAt string
|
UpdatedAt string
|
||||||
// VsAI marks an honest-AI game (rendered as 🤖 in the list's AI column).
|
// VsAI marks an honest-AI game (rendered as 🤖 in the list's AI column).
|
||||||
VsAI bool
|
VsAI bool
|
||||||
|
// Kind is the game's origin tag label: vs_ai / random / friends / unknown.
|
||||||
|
Kind string
|
||||||
}
|
}
|
||||||
|
|
||||||
// GamesView is the paginated games list, optionally filtered by status.
|
// GamesView is the paginated games list, optionally filtered by status.
|
||||||
@@ -321,6 +323,17 @@ type GamesView struct {
|
|||||||
Pager Pager
|
Pager Pager
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// GameLimitsView is the per-tier, per-kind active-game limit form: each field is a cap where -1
|
||||||
|
// is unlimited, 0 blocks the kind, and a positive value caps concurrent games of that kind.
|
||||||
|
type GameLimitsView struct {
|
||||||
|
GuestVsAI int
|
||||||
|
GuestRandom int
|
||||||
|
GuestFriends int
|
||||||
|
DurableVsAI int
|
||||||
|
DurableRandom int
|
||||||
|
DurableFriends int
|
||||||
|
}
|
||||||
|
|
||||||
// GameDetailView is one game with its seats.
|
// GameDetailView is one game with its seats.
|
||||||
type GameDetailView struct {
|
type GameDetailView struct {
|
||||||
ID string
|
ID string
|
||||||
|
|||||||
@@ -17,6 +17,7 @@ import (
|
|||||||
|
|
||||||
"scrabble/backend/internal/account"
|
"scrabble/backend/internal/account"
|
||||||
"scrabble/backend/internal/engine"
|
"scrabble/backend/internal/engine"
|
||||||
|
"scrabble/backend/internal/gamelimits"
|
||||||
"scrabble/backend/internal/notify"
|
"scrabble/backend/internal/notify"
|
||||||
"scrabble/backend/internal/payments"
|
"scrabble/backend/internal/payments"
|
||||||
"scrabble/backend/internal/session"
|
"scrabble/backend/internal/session"
|
||||||
@@ -57,6 +58,9 @@ type Service struct {
|
|||||||
// nil, only the free per-game allowance is served (no purchased hints). vs_ai hints are
|
// nil, only the free per-game allowance is served (no purchased hints). vs_ai hints are
|
||||||
// wallet-free and never touch it.
|
// wallet-free and never touch it.
|
||||||
hintWallet HintWallet
|
hintWallet HintWallet
|
||||||
|
// limits is the per-tier active-game cap config (cached). Set by SetGameLimits during wiring;
|
||||||
|
// when nil, no active-game limit is enforced (a game is always creatable).
|
||||||
|
limits *gamelimits.Service
|
||||||
// clearNudges, when set, marks the actor's pending nudges in a game read once they
|
// clearNudges, when set, marks the actor's pending nudges in a game read once they
|
||||||
// have committed a move (a nudge answered by moving stops counting as unread). It is
|
// have committed a move (a nudge answered by moving stops counting as unread). It is
|
||||||
// best-effort and kept as a func so the game package never imports the social package.
|
// best-effort and kept as a func so the game package never imports the social package.
|
||||||
@@ -126,6 +130,37 @@ func (svc *Service) SetHintWallet(w HintWallet) {
|
|||||||
svc.hintWallet = w
|
svc.hintWallet = w
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// SetGameLimits installs the active-game limit config (cached), enabling the per-tier caps. When
|
||||||
|
// unset (nil), a game is always creatable.
|
||||||
|
func (svc *Service) SetGameLimits(l *gamelimits.Service) {
|
||||||
|
svc.limits = l
|
||||||
|
}
|
||||||
|
|
||||||
|
// AtGameLimit reports whether accountID has reached its tier's active-game cap for kind — the
|
||||||
|
// per-tier, per-kind limits held in backend.config, read from the in-memory cache. It resolves
|
||||||
|
// the caller's tier (guest vs durable) from the account, then counts its open+active games of that
|
||||||
|
// kind. It reports false (not at the limit) when the limits config is not wired, the account is nil,
|
||||||
|
// or the resolved cap is gamelimits.Unlimited. It backs the new-game gate (the handler aborts 409
|
||||||
|
// game_limit_reached) and the lobby's at-limit flag.
|
||||||
|
func (svc *Service) AtGameLimit(ctx context.Context, accountID uuid.UUID, kind gamelimits.Kind) (bool, error) {
|
||||||
|
if svc.limits == nil || accountID == uuid.Nil {
|
||||||
|
return false, nil
|
||||||
|
}
|
||||||
|
acc, err := svc.accounts.GetByID(ctx, accountID)
|
||||||
|
if err != nil {
|
||||||
|
return false, err
|
||||||
|
}
|
||||||
|
limit := svc.limits.LimitsFor(acc.IsGuest).Cap(kind)
|
||||||
|
if limit == gamelimits.Unlimited {
|
||||||
|
return false, nil
|
||||||
|
}
|
||||||
|
n, err := svc.store.CountActiveByKind(ctx, accountID, kind)
|
||||||
|
if err != nil {
|
||||||
|
return false, err
|
||||||
|
}
|
||||||
|
return n >= limit, nil
|
||||||
|
}
|
||||||
|
|
||||||
// walletContext resolves the payments gate inputs for an account on the current request: the
|
// walletContext resolves the payments gate inputs for an account on the current request: the
|
||||||
// trusted execution context (from the session platform carried on ctx; absent ⇒ untrusted) and
|
// trusted execution context (from the session platform carried on ctx; absent ⇒ untrusted) and
|
||||||
// the account's present identity sources (which chip/benefit segments are awake, §6).
|
// the account's present identity sources (which chip/benefit segments are awake, §6).
|
||||||
@@ -338,6 +373,7 @@ func (svc *Service) Create(ctx context.Context, params CreateParams) (Game, erro
|
|||||||
dropoutTiles: params.DropoutTiles.String(),
|
dropoutTiles: params.DropoutTiles.String(),
|
||||||
multipleWordsPerTurn: params.MultipleWordsPerTurn,
|
multipleWordsPerTurn: params.MultipleWordsPerTurn,
|
||||||
vsAI: params.VsAI,
|
vsAI: params.VsAI,
|
||||||
|
kind: params.Kind,
|
||||||
}
|
}
|
||||||
if err := svc.store.CreateGame(ctx, ins, seats, seeding.draws); err != nil {
|
if err := svc.store.CreateGame(ctx, ins, seats, seeding.draws); err != nil {
|
||||||
return Game{}, err
|
return Game{}, err
|
||||||
@@ -401,6 +437,7 @@ func (svc *Service) OpenOrJoin(ctx context.Context, accountID uuid.UUID, params
|
|||||||
multipleWordsPerTurn: params.MultipleWordsPerTurn,
|
multipleWordsPerTurn: params.MultipleWordsPerTurn,
|
||||||
status: StatusOpen,
|
status: StatusOpen,
|
||||||
openDeadline: &deadline,
|
openDeadline: &deadline,
|
||||||
|
kind: gamelimits.KindRandom,
|
||||||
}
|
}
|
||||||
// Decide the first move now by the official draw, with the not-yet-arrived opponent as a
|
// Decide the first move now by the official draw, with the not-yet-arrived opponent as a
|
||||||
// synthetic placeholder (uuid.Nil): the draw fixes who sits at seat 0 — and so moves
|
// synthetic placeholder (uuid.Nil): the draw fixes who sits at seat 0 — and so moves
|
||||||
@@ -1386,14 +1423,6 @@ func (svc *Service) ListForLobby(ctx context.Context, accountID uuid.UUID) ([]Ga
|
|||||||
return kept, nil
|
return kept, nil
|
||||||
}
|
}
|
||||||
|
|
||||||
// CountActiveQuickGames reports how many in-progress quick games the account holds —
|
|
||||||
// the count the simultaneous-game limit (MaxActiveQuickGames) is checked against. It
|
|
||||||
// counts active and still-open quick games (including honest-AI ones) and excludes
|
|
||||||
// friend games created by invitation and finished games. See Store.CountActiveQuickGames.
|
|
||||||
func (svc *Service) CountActiveQuickGames(ctx context.Context, accountID uuid.UUID) (int, error) {
|
|
||||||
return svc.store.CountActiveQuickGames(ctx, accountID)
|
|
||||||
}
|
|
||||||
|
|
||||||
// HideGame hides a finished game from accountID's own lobby (it stays visible to the other
|
// HideGame hides a finished game from accountID's own lobby (it stays visible to the other
|
||||||
// players); it is irreversible by design. Only a player of a finished game may hide it
|
// players); it is irreversible by design. Only a player of a finished game may hide it
|
||||||
// (ErrNotAPlayer / ErrGameActive otherwise); hiding an already-hidden game is a no-op.
|
// (ErrNotAPlayer / ErrGameActive otherwise); hiding an already-hidden game is a no-op.
|
||||||
|
|||||||
@@ -15,6 +15,7 @@ import (
|
|||||||
|
|
||||||
"scrabble/backend/internal/account"
|
"scrabble/backend/internal/account"
|
||||||
"scrabble/backend/internal/engine"
|
"scrabble/backend/internal/engine"
|
||||||
|
"scrabble/backend/internal/gamelimits"
|
||||||
"scrabble/backend/internal/postgres/jet/backend/model"
|
"scrabble/backend/internal/postgres/jet/backend/model"
|
||||||
"scrabble/backend/internal/postgres/jet/backend/table"
|
"scrabble/backend/internal/postgres/jet/backend/table"
|
||||||
)
|
)
|
||||||
@@ -45,6 +46,8 @@ type gameInsert struct {
|
|||||||
multipleWordsPerTurn bool
|
multipleWordsPerTurn bool
|
||||||
// vsAI marks an honest-AI game (games.vs_ai).
|
// vsAI marks an honest-AI game (games.vs_ai).
|
||||||
vsAI bool
|
vsAI bool
|
||||||
|
// kind tags the game's origin (games.game_kind) for the active-game limits.
|
||||||
|
kind gamelimits.Kind
|
||||||
// status is the lifecycle state to create the game in: StatusActive for a normal
|
// status is the lifecycle state to create the game in: StatusActive for a normal
|
||||||
// seated game, StatusOpen for an auto-match game still awaiting an opponent. An
|
// seated game, StatusOpen for an auto-match game still awaiting an opponent. An
|
||||||
// empty string defaults to StatusActive.
|
// empty string defaults to StatusActive.
|
||||||
@@ -138,6 +141,20 @@ func (s *Store) CreateGame(ctx context.Context, ins gameInsert, seats []seatInse
|
|||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// CountActiveByKind counts the account's active (open or in-progress) games of the given kind — the
|
||||||
|
// per-tier active-game limit is checked against it before a new game of that kind is created.
|
||||||
|
func (s *Store) CountActiveByKind(ctx context.Context, accountID uuid.UUID, kind gamelimits.Kind) (int, error) {
|
||||||
|
var n int
|
||||||
|
if err := s.db.QueryRowContext(ctx,
|
||||||
|
`SELECT count(*) FROM backend.games g
|
||||||
|
JOIN backend.game_players p ON p.game_id = g.game_id
|
||||||
|
WHERE p.account_id = $1 AND g.game_kind = $2 AND g.status IN ('open', 'active')`,
|
||||||
|
accountID, int16(kind)).Scan(&n); err != nil {
|
||||||
|
return 0, fmt.Errorf("game: count active by kind %s: %w", accountID, err)
|
||||||
|
}
|
||||||
|
return n, nil
|
||||||
|
}
|
||||||
|
|
||||||
// insertGameTx inserts the games row and one game_players row per seat (seat 0
|
// insertGameTx inserts the games row and one game_players row per seat (seat 0
|
||||||
// first) on tx, stamping each seat's display-name snapshot. A seat whose account id is
|
// first) on tx, stamping each seat's display-name snapshot. A seat whose account id is
|
||||||
// uuid.Nil is written with a NULL account_id (and an empty snapshot) — the still-empty
|
// uuid.Nil is written with a NULL account_id (and an empty snapshot) — the still-empty
|
||||||
@@ -155,9 +172,9 @@ func insertGameTx(ctx context.Context, tx *sql.Tx, ins gameInsert, seats []seatI
|
|||||||
table.Games.GameID, table.Games.Variant, table.Games.DictVersion, table.Games.Seed,
|
table.Games.GameID, table.Games.Variant, table.Games.DictVersion, table.Games.Seed,
|
||||||
table.Games.Status, table.Games.Players, table.Games.TurnTimeoutSecs,
|
table.Games.Status, table.Games.Players, table.Games.TurnTimeoutSecs,
|
||||||
table.Games.HintsAllowed, table.Games.HintsPerPlayer, table.Games.OpenDeadlineAt,
|
table.Games.HintsAllowed, table.Games.HintsPerPlayer, table.Games.OpenDeadlineAt,
|
||||||
table.Games.DropoutTiles, table.Games.MultipleWordsPerTurn, table.Games.VsAi,
|
table.Games.DropoutTiles, table.Games.MultipleWordsPerTurn, table.Games.VsAi, table.Games.GameKind,
|
||||||
).VALUES(ins.id, ins.variant, ins.dictVersion, ins.seed, status, ins.players,
|
).VALUES(ins.id, ins.variant, ins.dictVersion, ins.seed, status, ins.players,
|
||||||
ins.turnTimeoutSecs, ins.hintsAllowed, ins.hintsPerPlayer, deadline, ins.dropoutTiles, ins.multipleWordsPerTurn, ins.vsAI)
|
ins.turnTimeoutSecs, ins.hintsAllowed, ins.hintsPerPlayer, deadline, ins.dropoutTiles, ins.multipleWordsPerTurn, ins.vsAI, int16(ins.kind))
|
||||||
if _, err := gi.ExecContext(ctx, tx); err != nil {
|
if _, err := gi.ExecContext(ctx, tx); err != nil {
|
||||||
return fmt.Errorf("insert game: %w", err)
|
return fmt.Errorf("insert game: %w", err)
|
||||||
}
|
}
|
||||||
@@ -501,28 +518,6 @@ func (s *Store) ListGamesForAccount(ctx context.Context, accountID uuid.UUID) ([
|
|||||||
return out, nil
|
return out, nil
|
||||||
}
|
}
|
||||||
|
|
||||||
// CountActiveQuickGames counts the account's in-progress quick games — the ones the
|
|
||||||
// simultaneous-game limit (MaxActiveQuickGames) is checked against. It includes both
|
|
||||||
// active and still-open (awaiting-opponent) games, the honest-AI ones among them, and
|
|
||||||
// excludes friend games (those linked to a game_invitations row) and finished games.
|
|
||||||
// A hidden game still occupies a slot, so this is a dedicated count rather than a
|
|
||||||
// filter over ListGamesForAccount (which drops hidden games). Joining on the account's
|
|
||||||
// own seat counts each game once (an open game's empty opponent seat has no account).
|
|
||||||
func (s *Store) CountActiveQuickGames(ctx context.Context, accountID uuid.UUID) (int, error) {
|
|
||||||
// The status literals are game.StatusActive / game.StatusOpen, matching the
|
|
||||||
// games.status CHECK in the baseline migration.
|
|
||||||
const q = `
|
|
||||||
SELECT COUNT(*) FROM backend.games g
|
|
||||||
JOIN backend.game_players gp ON gp.game_id = g.game_id
|
|
||||||
LEFT JOIN backend.game_invitations gi ON gi.game_id = g.game_id
|
|
||||||
WHERE gp.account_id = $1 AND g.status IN ('active', 'open') AND gi.game_id IS NULL`
|
|
||||||
var n int
|
|
||||||
if err := s.db.QueryRowContext(ctx, q, accountID).Scan(&n); err != nil {
|
|
||||||
return 0, fmt.Errorf("game: count active quick games: %w", err)
|
|
||||||
}
|
|
||||||
return n, nil
|
|
||||||
}
|
|
||||||
|
|
||||||
// HideGame hides a game from the account's own lobby list (idempotent). The caller validates the
|
// HideGame hides a game from the account's own lobby list (idempotent). The caller validates the
|
||||||
// game is finished and the account is a player.
|
// game is finished and the account is a player.
|
||||||
func (s *Store) HideGame(ctx context.Context, accountID, gameID uuid.UUID) error {
|
func (s *Store) HideGame(ctx context.Context, accountID, gameID uuid.UUID) error {
|
||||||
@@ -1361,6 +1356,7 @@ func projectGame(g model.Games, seats []model.GamePlayers) (Game, error) {
|
|||||||
}
|
}
|
||||||
out.MultipleWordsPerTurn = g.MultipleWordsPerTurn
|
out.MultipleWordsPerTurn = g.MultipleWordsPerTurn
|
||||||
out.VsAI = g.VsAi
|
out.VsAI = g.VsAi
|
||||||
|
out.Kind = gamelimits.Kind(g.GameKind)
|
||||||
if g.EndReason != nil {
|
if g.EndReason != nil {
|
||||||
out.EndReason = *g.EndReason
|
out.EndReason = *g.EndReason
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -7,6 +7,7 @@ import (
|
|||||||
"github.com/google/uuid"
|
"github.com/google/uuid"
|
||||||
|
|
||||||
"scrabble/backend/internal/engine"
|
"scrabble/backend/internal/engine"
|
||||||
|
"scrabble/backend/internal/gamelimits"
|
||||||
)
|
)
|
||||||
|
|
||||||
// Status values persisted in the games.status column.
|
// Status values persisted in the games.status column.
|
||||||
@@ -94,15 +95,6 @@ const DefaultTurnTimeout = 24 * time.Hour
|
|||||||
// one of AllowedTurnTimeouts (never offered in the creation UI).
|
// one of AllowedTurnTimeouts (never offered in the creation UI).
|
||||||
const AIInactivityTimeout = 7 * 24 * time.Hour
|
const AIInactivityTimeout = 7 * 24 * time.Hour
|
||||||
|
|
||||||
// MaxActiveQuickGames is the cap on a player's simultaneous quick games (human
|
|
||||||
// auto-match and honest-AI), counting both in-progress (StatusActive) and
|
|
||||||
// still-open, awaiting-opponent (StatusOpen) games. Friend games created by
|
|
||||||
// invitation are not counted. At the cap the backend refuses to create a new game
|
|
||||||
// of any kind — quick or by invitation — and the lobby disables "New Game";
|
|
||||||
// accepting an incoming invitation is always allowed. See
|
|
||||||
// Store.CountActiveQuickGames.
|
|
||||||
const MaxActiveQuickGames = 10
|
|
||||||
|
|
||||||
// aiPlayerName labels the robot seat in an honest-AI game's GCG export, so a downloaded
|
// aiPlayerName labels the robot seat in an honest-AI game's GCG export, so a downloaded
|
||||||
// game file shows a clean "AI" rather than the robot's human-like pool name (the in-app
|
// game file shows a clean "AI" rather than the robot's human-like pool name (the in-app
|
||||||
// UI shows 🤖 from the game's vs_ai flag).
|
// UI shows 🤖 from the game's vs_ai flag).
|
||||||
@@ -125,6 +117,10 @@ type CreateParams struct {
|
|||||||
// robot is seated at once, the move clock is AIInactivityTimeout, and chat/nudge
|
// robot is seated at once, the move clock is AIInactivityTimeout, and chat/nudge
|
||||||
// and finish-time statistics are suppressed. Set by the lobby's AI-match path.
|
// and finish-time statistics are suppressed. Set by the lobby's AI-match path.
|
||||||
VsAI bool
|
VsAI bool
|
||||||
|
// Kind tags the game's origin (games.game_kind) for the active-game limits: vs_ai / random /
|
||||||
|
// friends. The lobby sets it; a zero (unknown) kind is never gated. The active-game limit itself
|
||||||
|
// is enforced at the new-game handler (Server.ensureUnderGameLimit), not here.
|
||||||
|
Kind gamelimits.Kind
|
||||||
}
|
}
|
||||||
|
|
||||||
// Game is the persisted state of a match: the games row joined with its seats.
|
// Game is the persisted state of a match: the games row joined with its seats.
|
||||||
@@ -151,6 +147,9 @@ type Game struct {
|
|||||||
// VsAI is true for an honest-AI game (games.vs_ai): the opponent is a robot the
|
// VsAI is true for an honest-AI game (games.vs_ai): the opponent is a robot the
|
||||||
// player knowingly chose, shown as 🤖, with chat/nudge disabled and no statistics.
|
// player knowingly chose, shown as 🤖, with chat/nudge disabled and no statistics.
|
||||||
VsAI bool
|
VsAI bool
|
||||||
|
// Kind is the game's origin tag (games.game_kind) for the active-game limits: vs_ai / random /
|
||||||
|
// friends, or unknown for an untagged game. Read-only projection; set once on creation.
|
||||||
|
Kind gamelimits.Kind
|
||||||
}
|
}
|
||||||
|
|
||||||
// Seat is one player's standing in a game.
|
// Seat is one player's standing in a game.
|
||||||
|
|||||||
@@ -0,0 +1,149 @@
|
|||||||
|
// Package gamelimits holds the per-tier, per-kind active-game caps (a guest funnel) with a hot
|
||||||
|
// in-memory cache over the single-row backend.config, so a login or a game-create never queries it.
|
||||||
|
package gamelimits
|
||||||
|
|
||||||
|
import (
|
||||||
|
"context"
|
||||||
|
"database/sql"
|
||||||
|
"fmt"
|
||||||
|
"sync"
|
||||||
|
|
||||||
|
"github.com/go-jet/jet/v2/postgres"
|
||||||
|
|
||||||
|
"scrabble/backend/internal/postgres/jet/backend/model"
|
||||||
|
"scrabble/backend/internal/postgres/jet/backend/table"
|
||||||
|
)
|
||||||
|
|
||||||
|
// Kind is a game's kind, mirroring backend.games.game_kind. 0 (unknown) is an untagged game, never gated.
|
||||||
|
type Kind int16
|
||||||
|
|
||||||
|
const (
|
||||||
|
KindUnknown Kind = 0
|
||||||
|
KindVsAI Kind = 1
|
||||||
|
KindRandom Kind = 2
|
||||||
|
KindFriends Kind = 3
|
||||||
|
)
|
||||||
|
|
||||||
|
// String returns the kind's label for admin game lists and logs.
|
||||||
|
func (k Kind) String() string {
|
||||||
|
switch k {
|
||||||
|
case KindVsAI:
|
||||||
|
return "vs_ai"
|
||||||
|
case KindRandom:
|
||||||
|
return "random"
|
||||||
|
case KindFriends:
|
||||||
|
return "friends"
|
||||||
|
default:
|
||||||
|
return "unknown"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Unlimited is the limit sentinel meaning no cap.
|
||||||
|
const Unlimited = -1
|
||||||
|
|
||||||
|
// Limits is a tier's active-game caps per kind (-1 = unlimited).
|
||||||
|
type Limits struct {
|
||||||
|
VsAI int
|
||||||
|
Random int
|
||||||
|
Friends int
|
||||||
|
}
|
||||||
|
|
||||||
|
// Cap returns the limit for kind; the unknown kind is never capped.
|
||||||
|
func (l Limits) Cap(kind Kind) int {
|
||||||
|
switch kind {
|
||||||
|
case KindVsAI:
|
||||||
|
return l.VsAI
|
||||||
|
case KindRandom:
|
||||||
|
return l.Random
|
||||||
|
case KindFriends:
|
||||||
|
return l.Friends
|
||||||
|
default:
|
||||||
|
return Unlimited
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Config is the per-tier limit config (the single backend.config row).
|
||||||
|
type Config struct {
|
||||||
|
Guest Limits
|
||||||
|
Durable Limits
|
||||||
|
}
|
||||||
|
|
||||||
|
// Store reads and writes the single-row backend.config.
|
||||||
|
type Store struct{ db *sql.DB }
|
||||||
|
|
||||||
|
// NewStore constructs a Store over db.
|
||||||
|
func NewStore(db *sql.DB) *Store { return &Store{db: db} }
|
||||||
|
|
||||||
|
func (s *Store) load(ctx context.Context) (Config, error) {
|
||||||
|
var c model.Config
|
||||||
|
if err := postgres.SELECT(table.Config.AllColumns).FROM(table.Config).LIMIT(1).
|
||||||
|
QueryContext(ctx, s.db, &c); err != nil {
|
||||||
|
return Config{}, fmt.Errorf("gamelimits: load config: %w", err)
|
||||||
|
}
|
||||||
|
return Config{
|
||||||
|
Guest: Limits{VsAI: int(c.GuestVsAiLimit), Random: int(c.GuestRandomLimit), Friends: int(c.GuestFriendsLimit)},
|
||||||
|
Durable: Limits{VsAI: int(c.DurableVsAiLimit), Random: int(c.DurableRandomLimit), Friends: int(c.DurableFriendsLimit)},
|
||||||
|
}, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func (s *Store) save(ctx context.Context, c Config) error {
|
||||||
|
if _, err := s.db.ExecContext(ctx,
|
||||||
|
`UPDATE backend.config SET guest_vs_ai_limit=$1, guest_random_limit=$2, guest_friends_limit=$3,
|
||||||
|
durable_vs_ai_limit=$4, durable_random_limit=$5, durable_friends_limit=$6 WHERE only_row`,
|
||||||
|
c.Guest.VsAI, c.Guest.Random, c.Guest.Friends, c.Durable.VsAI, c.Durable.Random, c.Durable.Friends); err != nil {
|
||||||
|
return fmt.Errorf("gamelimits: save config: %w", err)
|
||||||
|
}
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// Service fronts the config with an in-memory cache (single-instance, matching the deploy). Load
|
||||||
|
// once at startup; Update after an admin edit refreshes it in place.
|
||||||
|
type Service struct {
|
||||||
|
store *Store
|
||||||
|
mu sync.RWMutex
|
||||||
|
cfg Config
|
||||||
|
}
|
||||||
|
|
||||||
|
// NewService constructs a Service over store. Call Load before serving.
|
||||||
|
func NewService(store *Store) *Service { return &Service{store: store} }
|
||||||
|
|
||||||
|
// Load reads the config into the cache. Call once at startup.
|
||||||
|
func (s *Service) Load(ctx context.Context) error {
|
||||||
|
c, err := s.store.load(ctx)
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
s.set(c)
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func (s *Service) set(c Config) {
|
||||||
|
s.mu.Lock()
|
||||||
|
s.cfg = c
|
||||||
|
s.mu.Unlock()
|
||||||
|
}
|
||||||
|
|
||||||
|
// Get returns the cached config.
|
||||||
|
func (s *Service) Get() Config {
|
||||||
|
s.mu.RLock()
|
||||||
|
defer s.mu.RUnlock()
|
||||||
|
return s.cfg
|
||||||
|
}
|
||||||
|
|
||||||
|
// LimitsFor returns the cached limits for the account tier (guest or durable).
|
||||||
|
func (s *Service) LimitsFor(isGuest bool) Limits {
|
||||||
|
c := s.Get()
|
||||||
|
if isGuest {
|
||||||
|
return c.Guest
|
||||||
|
}
|
||||||
|
return c.Durable
|
||||||
|
}
|
||||||
|
|
||||||
|
// Update saves the config and refreshes the cache in place (the admin edit).
|
||||||
|
func (s *Service) Update(ctx context.Context, c Config) error {
|
||||||
|
if err := s.store.save(ctx, c); err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
s.set(c)
|
||||||
|
return nil
|
||||||
|
}
|
||||||
@@ -0,0 +1,44 @@
|
|||||||
|
package gamelimits
|
||||||
|
|
||||||
|
import "testing"
|
||||||
|
|
||||||
|
// TestLimitsCap checks Cap maps each kind to its field and leaves the unknown kind uncapped, so a
|
||||||
|
// untagged game (game_kind 0) is never gated.
|
||||||
|
func TestLimitsCap(t *testing.T) {
|
||||||
|
l := Limits{VsAI: 1, Random: 2, Friends: 3}
|
||||||
|
for _, tc := range []struct {
|
||||||
|
kind Kind
|
||||||
|
want int
|
||||||
|
}{
|
||||||
|
{KindVsAI, 1},
|
||||||
|
{KindRandom, 2},
|
||||||
|
{KindFriends, 3},
|
||||||
|
{KindUnknown, Unlimited},
|
||||||
|
{Kind(99), Unlimited},
|
||||||
|
} {
|
||||||
|
if got := l.Cap(tc.kind); got != tc.want {
|
||||||
|
t.Errorf("Cap(%d) = %d, want %d", tc.kind, got, tc.want)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// TestServiceLimitsForTier checks LimitsFor selects the guest or durable tier from the cached config.
|
||||||
|
func TestServiceLimitsForTier(t *testing.T) {
|
||||||
|
svc := NewService(nil)
|
||||||
|
svc.set(Config{
|
||||||
|
Guest: Limits{VsAI: 1, Random: 1, Friends: 0},
|
||||||
|
Durable: Limits{VsAI: 10, Random: 10, Friends: 10},
|
||||||
|
})
|
||||||
|
|
||||||
|
if got := svc.LimitsFor(true); got != (Limits{VsAI: 1, Random: 1, Friends: 0}) {
|
||||||
|
t.Errorf("guest limits = %+v, want {1 1 0}", got)
|
||||||
|
}
|
||||||
|
if got := svc.LimitsFor(false); got != (Limits{VsAI: 10, Random: 10, Friends: 10}) {
|
||||||
|
t.Errorf("durable limits = %+v, want {10 10 10}", got)
|
||||||
|
}
|
||||||
|
// The unlimited sentinel resolves through the tier too.
|
||||||
|
svc.set(Config{Durable: Limits{VsAI: Unlimited, Random: Unlimited, Friends: Unlimited}})
|
||||||
|
if got := svc.LimitsFor(false).Cap(KindVsAI); got != Unlimited {
|
||||||
|
t.Errorf("durable vs_ai cap = %d, want unlimited (-1)", got)
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -5,6 +5,7 @@ package inttest
|
|||||||
import (
|
import (
|
||||||
"context"
|
"context"
|
||||||
"encoding/json"
|
"encoding/json"
|
||||||
|
"errors"
|
||||||
"fmt"
|
"fmt"
|
||||||
"net/http"
|
"net/http"
|
||||||
"net/http/httptest"
|
"net/http/httptest"
|
||||||
@@ -18,59 +19,119 @@ import (
|
|||||||
"scrabble/backend/internal/account"
|
"scrabble/backend/internal/account"
|
||||||
"scrabble/backend/internal/engine"
|
"scrabble/backend/internal/engine"
|
||||||
"scrabble/backend/internal/game"
|
"scrabble/backend/internal/game"
|
||||||
|
"scrabble/backend/internal/gamelimits"
|
||||||
|
"scrabble/backend/internal/lobby"
|
||||||
"scrabble/backend/internal/server"
|
"scrabble/backend/internal/server"
|
||||||
|
"scrabble/backend/internal/social"
|
||||||
)
|
)
|
||||||
|
|
||||||
// The game-limit suite covers the simultaneous quick-game cap (game.MaxActiveQuickGames):
|
// The game-limit suite covers the per-tier, per-kind active-game caps (backend.config): the
|
||||||
// the counting rule (Store/Service.CountActiveQuickGames) and the HTTP gate that refuses a
|
// game_kind tag persisted per creation path, the tier resolution + counting rule
|
||||||
// new game once the cap is reached, while accepting an incoming invitation stays allowed.
|
// (game.Service.AtGameLimit), the HTTP gate + lobby at_game_limit flag, the guest gate on friend
|
||||||
|
// actions, and the config hot-cache reflecting an admin edit. Accepting an invitation stays exempt.
|
||||||
|
|
||||||
// TestCountActiveQuickGames checks the count includes active and open quick games (the
|
// newGameLimits builds a gamelimits service over the shared pool and loads the seeded config
|
||||||
// honest-AI ones among them) and excludes finished games, friend games (created by
|
// (guest 1/1/0, durable 10/10/10).
|
||||||
// invitation) and games the account is not seated in.
|
func newGameLimits(t *testing.T) *gamelimits.Service {
|
||||||
func TestCountActiveQuickGames(t *testing.T) {
|
t.Helper()
|
||||||
|
gl := gamelimits.NewService(gamelimits.NewStore(testDB))
|
||||||
|
if err := gl.Load(context.Background()); err != nil {
|
||||||
|
t.Fatalf("load game limits: %v", err)
|
||||||
|
}
|
||||||
|
return gl
|
||||||
|
}
|
||||||
|
|
||||||
|
// restoreDefaultLimits resets the single config row to the migration seed on cleanup, so a test that
|
||||||
|
// edited it never leaks its values into another (the row is a shared singleton).
|
||||||
|
func restoreDefaultLimits(t *testing.T, gl *gamelimits.Service) {
|
||||||
|
t.Helper()
|
||||||
|
t.Cleanup(func() {
|
||||||
|
_ = gl.Update(context.Background(), gamelimits.Config{
|
||||||
|
Guest: gamelimits.Limits{VsAI: 1, Random: 1, Friends: 0},
|
||||||
|
Durable: gamelimits.Limits{VsAI: 10, Random: 10, Friends: 10},
|
||||||
|
})
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
// gameKind reads a game's persisted game_kind tag.
|
||||||
|
func gameKind(t *testing.T, gameID uuid.UUID) int16 {
|
||||||
|
t.Helper()
|
||||||
|
var k int16
|
||||||
|
if err := testDB.QueryRowContext(context.Background(),
|
||||||
|
`SELECT game_kind FROM backend.games WHERE game_id=$1`, gameID).Scan(&k); err != nil {
|
||||||
|
t.Fatalf("read game_kind: %v", err)
|
||||||
|
}
|
||||||
|
return k
|
||||||
|
}
|
||||||
|
|
||||||
|
// mustCreateKind creates an active game seating the accounts, tagged with kind, and returns its id.
|
||||||
|
func mustCreateKind(t *testing.T, games *game.Service, seats []uuid.UUID, kind gamelimits.Kind) uuid.UUID {
|
||||||
|
t.Helper()
|
||||||
|
g, err := games.Create(context.Background(), game.CreateParams{
|
||||||
|
Variant: engine.VariantEnglish, Seats: seats, TurnTimeout: 24 * time.Hour, Kind: kind,
|
||||||
|
})
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("create game (kind=%d): %v", kind, err)
|
||||||
|
}
|
||||||
|
return g.ID
|
||||||
|
}
|
||||||
|
|
||||||
|
// startFriendGameID has inviter invite invitee to a friend game and the invitee accept, returning
|
||||||
|
// the started game's id.
|
||||||
|
func startFriendGameID(t *testing.T, inv *lobby.InvitationService, inviter, invitee uuid.UUID) uuid.UUID {
|
||||||
|
t.Helper()
|
||||||
|
ctx := context.Background()
|
||||||
|
invitation, err := inv.CreateInvitation(ctx, inviter, []uuid.UUID{invitee}, englishInvite())
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("create invitation: %v", err)
|
||||||
|
}
|
||||||
|
got, err := inv.RespondInvitation(ctx, invitation.ID, invitee, true)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("accept invitation: %v", err)
|
||||||
|
}
|
||||||
|
if got.GameID == nil {
|
||||||
|
t.Fatal("accepted invitation has no game id")
|
||||||
|
}
|
||||||
|
return *got.GameID
|
||||||
|
}
|
||||||
|
|
||||||
|
// TestGameKindPersisted checks each creation path stamps the right game_kind: random (auto-match)
|
||||||
|
// =2, vs_ai =1, friend (by invitation) =3.
|
||||||
|
func TestGameKindPersisted(t *testing.T) {
|
||||||
ctx := context.Background()
|
ctx := context.Background()
|
||||||
clearOpenGames(t)
|
clearOpenGames(t)
|
||||||
games := newGameService()
|
games := newGameService()
|
||||||
human := provisionAccount(t)
|
inv := newInvitationService()
|
||||||
opp := provisionAccount(t)
|
human, opp := provisionAccount(t), provisionAccount(t)
|
||||||
|
|
||||||
if n := mustCount(t, games, human); n != 0 {
|
rnd, _, err := games.OpenOrJoin(ctx, human, game.CreateParams{Variant: engine.VariantEnglish, TurnTimeout: 24 * time.Hour}, time.Now().Add(time.Minute), nil)
|
||||||
t.Fatalf("fresh account count = %d, want 0", n)
|
if err != nil {
|
||||||
|
t.Fatalf("open random game: %v", err)
|
||||||
|
}
|
||||||
|
if k := gameKind(t, rnd.ID); k != int16(gamelimits.KindRandom) {
|
||||||
|
t.Errorf("random game_kind = %d, want %d", k, gamelimits.KindRandom)
|
||||||
}
|
}
|
||||||
|
|
||||||
// An open (awaiting-opponent) quick game counts.
|
ai := mustCreateKind(t, games, []uuid.UUID{human, opp}, gamelimits.KindVsAI)
|
||||||
if _, _, err := games.OpenOrJoin(ctx, human, game.CreateParams{
|
if k := gameKind(t, ai); k != int16(gamelimits.KindVsAI) {
|
||||||
Variant: engine.VariantEnglish, TurnTimeout: 24 * time.Hour,
|
t.Errorf("vs_ai game_kind = %d, want %d", k, gamelimits.KindVsAI)
|
||||||
}, time.Now().Add(time.Minute), nil); err != nil {
|
|
||||||
t.Fatalf("open quick game: %v", err)
|
|
||||||
}
|
}
|
||||||
// An active quick game and an honest-AI quick game both count (neither has an invitation row).
|
|
||||||
mustCreateQuick(t, games, []uuid.UUID{human, opp}, false, 1)
|
|
||||||
mustCreateQuick(t, games, []uuid.UUID{human, opp}, true, 2)
|
|
||||||
|
|
||||||
// A finished quick game does NOT count.
|
friend := startFriendGameID(t, inv, human, opp)
|
||||||
fin := mustCreateQuick(t, games, []uuid.UUID{human, opp}, false, 3)
|
if k := gameKind(t, friend); k != int16(gamelimits.KindFriends) {
|
||||||
if _, err := testDB.ExecContext(ctx, `UPDATE backend.games SET status='finished' WHERE game_id=$1`, fin); err != nil {
|
t.Errorf("friend game_kind = %d, want %d", k, gamelimits.KindFriends)
|
||||||
t.Fatalf("finish game: %v", err)
|
|
||||||
}
|
|
||||||
// A game the human is not seated in does NOT count.
|
|
||||||
mustCreateQuick(t, games, []uuid.UUID{opp, provisionAccount(t)}, false, 4)
|
|
||||||
// A friend game (created by invitation) does NOT count, even though it is active.
|
|
||||||
startFriendGame(t, human)
|
|
||||||
|
|
||||||
if n := mustCount(t, games, human); n != 3 {
|
|
||||||
t.Fatalf("active quick count = %d, want 3 (active + AI + open)", n)
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// TestGameLimitGate drives the cap through the assembled HTTP server: under the cap the lobby
|
// TestGuestActiveGameLimitHTTP drives the guest random cap through the assembled server: the first
|
||||||
// reports at_game_limit false; at the cap it flips to true and both new-game endpoints (quick
|
// auto-match opens a game, the lobby then flags at_game_limit, and a second enqueue is refused 409
|
||||||
// enqueue and invitation creation) are refused with 409 game_limit_reached, while accepting an
|
// game_limit_reached. It also checks the guest vs_ai and friends caps resolve at the domain level.
|
||||||
// incoming invitation is still allowed.
|
func TestGuestActiveGameLimitHTTP(t *testing.T) {
|
||||||
func TestGameLimitGate(t *testing.T) {
|
|
||||||
ctx := context.Background()
|
ctx := context.Background()
|
||||||
|
clearOpenGames(t)
|
||||||
|
gl := newGameLimits(t)
|
||||||
games := newGameService()
|
games := newGameService()
|
||||||
|
games.SetGameLimits(gl)
|
||||||
srv := server.New(":0", server.Deps{
|
srv := server.New(":0", server.Deps{
|
||||||
Logger: zaptest.NewLogger(t),
|
Logger: zaptest.NewLogger(t),
|
||||||
DB: testDB,
|
DB: testDB,
|
||||||
@@ -78,88 +139,110 @@ func TestGameLimitGate(t *testing.T) {
|
|||||||
Games: games,
|
Games: games,
|
||||||
Matchmaker: newMatchmaker(t, newRobotService(t, newGameService()), time.Minute, 0),
|
Matchmaker: newMatchmaker(t, newRobotService(t, newGameService()), time.Minute, 0),
|
||||||
Invitations: newInvitationService(),
|
Invitations: newInvitationService(),
|
||||||
|
GameLimits: gl,
|
||||||
})
|
})
|
||||||
|
|
||||||
human := provisionAccount(t)
|
guest := provisionGuest(t)
|
||||||
|
if gamesListAtLimit(t, srv, guest) {
|
||||||
|
t.Fatal("a fresh guest must be under the random game limit")
|
||||||
|
}
|
||||||
|
// The first auto-match opens a random game (guest random cap = 1).
|
||||||
|
if rec := userPost(t, srv, "/api/v1/user/lobby/enqueue", guest, `{"variant":"erudit_ru"}`); rec.Code != http.StatusOK {
|
||||||
|
t.Fatalf("first enqueue = %d (%s), want 200", rec.Code, rec.Body.String())
|
||||||
|
}
|
||||||
|
// At the cap: the lobby flags it and a second enqueue is refused with the stable code.
|
||||||
|
if !gamesListAtLimit(t, srv, guest) {
|
||||||
|
t.Fatal("after one random game the guest must be at the limit")
|
||||||
|
}
|
||||||
|
if rec := userPost(t, srv, "/api/v1/user/lobby/enqueue", guest, `{"variant":"erudit_ru"}`); rec.Code != http.StatusConflict || errorCode(t, rec) != "game_limit_reached" {
|
||||||
|
t.Fatalf("second enqueue = (%d, %q), want (409, game_limit_reached)", rec.Code, errorCode(t, rec))
|
||||||
|
}
|
||||||
|
|
||||||
|
// A guest is refused the friends flow outright at the HTTP edge (403 guest_forbidden).
|
||||||
opp := provisionAccount(t)
|
opp := provisionAccount(t)
|
||||||
|
|
||||||
// Under the cap: the lobby reports the player is not limited.
|
|
||||||
if gamesListAtLimit(t, srv, human) {
|
|
||||||
t.Fatal("a fresh account must be under the game limit")
|
|
||||||
}
|
|
||||||
|
|
||||||
// Reach the cap with active quick games seating the human (no invitation row → quick).
|
|
||||||
for i := 0; i < game.MaxActiveQuickGames; i++ {
|
|
||||||
mustCreateQuick(t, games, []uuid.UUID{human, opp}, false, int64(i+1))
|
|
||||||
}
|
|
||||||
|
|
||||||
// At the cap: the lobby flags it and both create paths are refused with the stable code.
|
|
||||||
if !gamesListAtLimit(t, srv, human) {
|
|
||||||
t.Fatalf("at %d games at_game_limit must be true", game.MaxActiveQuickGames)
|
|
||||||
}
|
|
||||||
// erudit_ru is in the default variant preferences, so the variant gate passes and the
|
|
||||||
// game-limit gate is what fires here.
|
|
||||||
if rec := userPost(t, srv, "/api/v1/user/lobby/enqueue", human, `{"variant":"erudit_ru"}`); rec.Code != http.StatusConflict || errorCode(t, rec) != "game_limit_reached" {
|
|
||||||
t.Fatalf("enqueue at limit = (%d, %q), want (409, game_limit_reached)", rec.Code, errorCode(t, rec))
|
|
||||||
}
|
|
||||||
invBody := fmt.Sprintf(`{"variant":"erudit_ru","invitee_ids":[%q]}`, opp.String())
|
invBody := fmt.Sprintf(`{"variant":"erudit_ru","invitee_ids":[%q]}`, opp.String())
|
||||||
if rec := userPost(t, srv, "/api/v1/user/invitations", human, invBody); rec.Code != http.StatusConflict || errorCode(t, rec) != "game_limit_reached" {
|
if rec := userPost(t, srv, "/api/v1/user/invitations", guest, invBody); rec.Code != http.StatusForbidden || errorCode(t, rec) != "guest_forbidden" {
|
||||||
t.Fatalf("invitation at limit = (%d, %q), want (409, game_limit_reached)", rec.Code, errorCode(t, rec))
|
t.Fatalf("guest invitation = (%d, %q), want (403, guest_forbidden)", rec.Code, errorCode(t, rec))
|
||||||
}
|
}
|
||||||
|
|
||||||
// Accepting an incoming invitation is never blocked, even at the cap: another player invites
|
// The guest vs_ai cap is 1: one vs_ai game puts the guest at the vs_ai limit.
|
||||||
// the capped human, who accepts over HTTP and the game starts (friend games do not count).
|
mustCreateKind(t, games, []uuid.UUID{guest, opp}, gamelimits.KindVsAI)
|
||||||
inviter := provisionAccount(t)
|
if at, err := games.AtGameLimit(ctx, guest, gamelimits.KindVsAI); err != nil || !at {
|
||||||
inv := newInvitationService()
|
t.Fatalf("guest vs_ai at-limit = (%v, %v), want (true, nil)", at, err)
|
||||||
invitation, err := inv.CreateInvitation(ctx, inviter, []uuid.UUID{human}, englishInvite())
|
|
||||||
if err != nil {
|
|
||||||
t.Fatalf("create invitation: %v", err)
|
|
||||||
}
|
}
|
||||||
if rec := userPost(t, srv, "/api/v1/user/invitations/"+invitation.ID.String()+"/accept", human, ""); rec.Code != http.StatusOK {
|
// The guest friends cap is 0: a guest is always at the friends limit (the 403 gate blocks first).
|
||||||
t.Fatalf("accept at limit = %d (%s), want 200 — accept must bypass the cap", rec.Code, rec.Body.String())
|
if at, err := games.AtGameLimit(ctx, guest, gamelimits.KindFriends); err != nil || !at {
|
||||||
|
t.Fatalf("guest friends at-limit = (%v, %v), want (true, nil)", at, err)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// mustCount returns the account's active-quick-game count, failing on error.
|
// TestDurableTierHigherLimit checks a durable account resolves the durable tier, not the guest one:
|
||||||
func mustCount(t *testing.T, games *game.Service, id uuid.UUID) int {
|
// one random game leaves it well under the durable cap (10).
|
||||||
t.Helper()
|
func TestDurableTierHigherLimit(t *testing.T) {
|
||||||
n, err := games.CountActiveQuickGames(context.Background(), id)
|
|
||||||
if err != nil {
|
|
||||||
t.Fatalf("count active quick games: %v", err)
|
|
||||||
}
|
|
||||||
return n
|
|
||||||
}
|
|
||||||
|
|
||||||
// mustCreateQuick creates an active quick game (no invitation) seating the given accounts and
|
|
||||||
// returns its id; vsAI flags an honest-AI game (the service then applies the 7-day clock).
|
|
||||||
func mustCreateQuick(t *testing.T, games *game.Service, seats []uuid.UUID, vsAI bool, seed int64) uuid.UUID {
|
|
||||||
t.Helper()
|
|
||||||
p := game.CreateParams{Variant: engine.VariantEnglish, Seats: seats, TurnTimeout: 24 * time.Hour, Seed: seed}
|
|
||||||
if vsAI {
|
|
||||||
p.VsAI = true
|
|
||||||
p.TurnTimeout = 0 // the service applies the 7-day AI inactivity clock for vs_ai games
|
|
||||||
}
|
|
||||||
g, err := games.Create(context.Background(), p)
|
|
||||||
if err != nil {
|
|
||||||
t.Fatalf("create quick game (vsAI=%v): %v", vsAI, err)
|
|
||||||
}
|
|
||||||
return g.ID
|
|
||||||
}
|
|
||||||
|
|
||||||
// startFriendGame has a fresh inviter invite the given account to a friend game and the invitee
|
|
||||||
// accept it, so the (active) friend game exists with its game_invitations row.
|
|
||||||
func startFriendGame(t *testing.T, invitee uuid.UUID) {
|
|
||||||
t.Helper()
|
|
||||||
ctx := context.Background()
|
ctx := context.Background()
|
||||||
|
gl := newGameLimits(t)
|
||||||
|
games := newGameService()
|
||||||
|
games.SetGameLimits(gl)
|
||||||
|
durable, opp := provisionAccount(t), provisionAccount(t)
|
||||||
|
|
||||||
|
mustCreateKind(t, games, []uuid.UUID{durable, opp}, gamelimits.KindRandom)
|
||||||
|
if at, err := games.AtGameLimit(ctx, durable, gamelimits.KindRandom); err != nil || at {
|
||||||
|
t.Fatalf("durable random at-limit after one game = (%v, %v), want (false, nil)", at, err)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// TestGuestForbiddenFriendActions checks a guest is refused every friend action server-side (the UI
|
||||||
|
// hides them; this is the source of truth): creating an invitation, sending a friend request, and
|
||||||
|
// redeeming a friend code.
|
||||||
|
func TestGuestForbiddenFriendActions(t *testing.T) {
|
||||||
|
ctx := context.Background()
|
||||||
|
guest := provisionGuest(t)
|
||||||
|
other := provisionAccount(t)
|
||||||
|
|
||||||
inv := newInvitationService()
|
inv := newInvitationService()
|
||||||
|
if _, err := inv.CreateInvitation(ctx, guest, []uuid.UUID{other}, englishInvite()); !errors.Is(err, lobby.ErrGuestForbidden) {
|
||||||
|
t.Fatalf("guest CreateInvitation err = %v, want ErrGuestForbidden", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
soc := newSocialService()
|
||||||
|
if err := soc.SendFriendRequest(ctx, guest, other); !errors.Is(err, social.ErrGuestForbidden) {
|
||||||
|
t.Fatalf("guest SendFriendRequest err = %v, want ErrGuestForbidden", err)
|
||||||
|
}
|
||||||
|
if _, err := soc.RedeemFriendCode(ctx, guest, "000000"); !errors.Is(err, social.ErrGuestForbidden) {
|
||||||
|
t.Fatalf("guest RedeemFriendCode err = %v, want ErrGuestForbidden", err)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// TestDurableFriendsCapAndConfigCache lowers the durable friends cap to 1 through the service (the
|
||||||
|
// admin-edit path), checks a durable inviter is refused a second friend game with 409, and that
|
||||||
|
// accepting an incoming invitation is still exempt from the cap.
|
||||||
|
func TestDurableFriendsCapAndConfigCache(t *testing.T) {
|
||||||
|
ctx := context.Background()
|
||||||
|
gl := newGameLimits(t)
|
||||||
|
restoreDefaultLimits(t, gl)
|
||||||
|
cfg := gl.Get()
|
||||||
|
cfg.Durable.Friends = 1
|
||||||
|
if err := gl.Update(ctx, cfg); err != nil {
|
||||||
|
t.Fatalf("update durable friends cap: %v", err)
|
||||||
|
}
|
||||||
|
games := newGameService()
|
||||||
|
games.SetGameLimits(gl)
|
||||||
|
inv := lobby.NewInvitationService(lobby.NewStore(testDB), games, account.NewStore(testDB), newSocialService())
|
||||||
|
|
||||||
inviter := provisionAccount(t)
|
inviter := provisionAccount(t)
|
||||||
invitation, err := inv.CreateInvitation(ctx, inviter, []uuid.UUID{invitee}, englishInvite())
|
d2, d3 := provisionAccount(t), provisionAccount(t)
|
||||||
if err != nil {
|
|
||||||
t.Fatalf("create invitation: %v", err)
|
// The inviter's first friend game reaches the (lowered) cap of 1.
|
||||||
|
startFriendGameID(t, inv, inviter, d2)
|
||||||
|
if at, err := games.AtGameLimit(ctx, inviter, gamelimits.KindFriends); err != nil || !at {
|
||||||
|
t.Fatalf("inviter friends at-limit = (%v, %v), want (true, nil)", at, err)
|
||||||
}
|
}
|
||||||
if _, err := inv.RespondInvitation(ctx, invitation.ID, invitee, true); err != nil {
|
// A second invitation is refused: the cache reflects the edit.
|
||||||
t.Fatalf("accept invitation: %v", err)
|
if _, err := inv.CreateInvitation(ctx, inviter, []uuid.UUID{d3}, englishInvite()); !errors.Is(err, game.ErrGameLimitReached) {
|
||||||
|
t.Fatalf("second invitation err = %v, want ErrGameLimitReached", err)
|
||||||
}
|
}
|
||||||
|
// Accept stays exempt: someone else invites the capped inviter, who accepts and the game starts.
|
||||||
|
startFriendGameID(t, inv, d3, inviter)
|
||||||
}
|
}
|
||||||
|
|
||||||
// gamesListAtLimit fetches /api/v1/user/games and returns its at_game_limit flag.
|
// gamesListAtLimit fetches /api/v1/user/games and returns its at_game_limit flag.
|
||||||
|
|||||||
@@ -15,6 +15,7 @@ import (
|
|||||||
"scrabble/backend/internal/account"
|
"scrabble/backend/internal/account"
|
||||||
"scrabble/backend/internal/engine"
|
"scrabble/backend/internal/engine"
|
||||||
"scrabble/backend/internal/game"
|
"scrabble/backend/internal/game"
|
||||||
|
"scrabble/backend/internal/gamelimits"
|
||||||
"scrabble/backend/internal/notify"
|
"scrabble/backend/internal/notify"
|
||||||
"scrabble/backend/internal/postgres/jet/backend/model"
|
"scrabble/backend/internal/postgres/jet/backend/model"
|
||||||
"scrabble/backend/internal/postgres/jet/backend/table"
|
"scrabble/backend/internal/postgres/jet/backend/table"
|
||||||
@@ -218,6 +219,20 @@ func (svc *InvitationService) CreateInvitation(ctx context.Context, inviterID uu
|
|||||||
if !slices.Contains(game.AllowedTurnTimeouts, settings.TurnTimeout) {
|
if !slices.Contains(game.AllowedTurnTimeouts, settings.TurnTimeout) {
|
||||||
return Invitation{}, fmt.Errorf("%w: turn timeout %s not allowed", ErrInvalidInvitation, settings.TurnTimeout)
|
return Invitation{}, fmt.Errorf("%w: turn timeout %s not allowed", ErrInvalidInvitation, settings.TurnTimeout)
|
||||||
}
|
}
|
||||||
|
// A guest cannot invite: friend games are a durable-account feature. The UI hides the flow;
|
||||||
|
// this is the server source of truth.
|
||||||
|
if acc, err := svc.accounts.GetByID(ctx, inviterID); err != nil {
|
||||||
|
return Invitation{}, err
|
||||||
|
} else if acc.IsGuest {
|
||||||
|
return Invitation{}, ErrGuestForbidden
|
||||||
|
}
|
||||||
|
// A durable inviter is still capped: refuse a new friend game once the per-tier friends limit is
|
||||||
|
// reached. The guest branch above short-circuits before here, so this is the durable cap.
|
||||||
|
if atLimit, err := svc.games.AtGameLimit(ctx, inviterID, gamelimits.KindFriends); err != nil {
|
||||||
|
return Invitation{}, err
|
||||||
|
} else if atLimit {
|
||||||
|
return Invitation{}, game.ErrGameLimitReached
|
||||||
|
}
|
||||||
seen := map[uuid.UUID]bool{inviterID: true}
|
seen := map[uuid.UUID]bool{inviterID: true}
|
||||||
// suppressed collects invitees who have blocked the inviter: the invitation is still
|
// suppressed collects invitees who have blocked the inviter: the invitation is still
|
||||||
// created and persisted for them, but they are never notified and never see it (their
|
// created and persisted for them, but they are never notified and never see it (their
|
||||||
@@ -336,6 +351,7 @@ func (svc *InvitationService) startGame(ctx context.Context, invitationID uuid.U
|
|||||||
HintsPerPlayer: inv.Settings.HintsPerPlayer,
|
HintsPerPlayer: inv.Settings.HintsPerPlayer,
|
||||||
DropoutTiles: inv.Settings.DropoutTiles,
|
DropoutTiles: inv.Settings.DropoutTiles,
|
||||||
MultipleWordsPerTurn: inv.Settings.MultipleWordsPerTurn,
|
MultipleWordsPerTurn: inv.Settings.MultipleWordsPerTurn,
|
||||||
|
Kind: gamelimits.KindFriends,
|
||||||
})
|
})
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return err
|
return err
|
||||||
|
|||||||
@@ -15,17 +15,22 @@ import (
|
|||||||
|
|
||||||
"scrabble/backend/internal/engine"
|
"scrabble/backend/internal/engine"
|
||||||
"scrabble/backend/internal/game"
|
"scrabble/backend/internal/game"
|
||||||
|
"scrabble/backend/internal/gamelimits"
|
||||||
"scrabble/backend/internal/notify"
|
"scrabble/backend/internal/notify"
|
||||||
)
|
)
|
||||||
|
|
||||||
// GameCreator is the slice of the game domain the lobby needs: starting a seated
|
// GameCreator is the slice of the game domain the lobby needs: starting a seated
|
||||||
// game and reading a player's initial view of it. game.Service satisfies it.
|
// game, reading a player's initial view of it, and testing a caller's active-game
|
||||||
|
// cap. game.Service satisfies it.
|
||||||
type GameCreator interface {
|
type GameCreator interface {
|
||||||
Create(ctx context.Context, params game.CreateParams) (game.Game, error)
|
Create(ctx context.Context, params game.CreateParams) (game.Game, error)
|
||||||
// InitialState returns a seated player's full initial view of a started game, used
|
// InitialState returns a seated player's full initial view of a started game, used
|
||||||
// to enrich the game_started event so the client renders the new game without a
|
// to enrich the game_started event so the client renders the new game without a
|
||||||
// follow-up fetch.
|
// follow-up fetch.
|
||||||
InitialState(ctx context.Context, gameID, accountID uuid.UUID) (notify.PlayerState, error)
|
InitialState(ctx context.Context, gameID, accountID uuid.UUID) (notify.PlayerState, error)
|
||||||
|
// AtGameLimit reports whether accountID has reached its tier's active-game cap for kind. The
|
||||||
|
// invitation path uses it to enforce the friends limit before opening one.
|
||||||
|
AtGameLimit(ctx context.Context, accountID uuid.UUID, kind gamelimits.Kind) (bool, error)
|
||||||
}
|
}
|
||||||
|
|
||||||
// RobotProvider supplies a robot account to substitute for a missing human in
|
// RobotProvider supplies a robot account to substitute for a missing human in
|
||||||
@@ -62,6 +67,9 @@ var (
|
|||||||
// ErrInvalidInvitation is returned for a malformed invitation (bad player
|
// ErrInvalidInvitation is returned for a malformed invitation (bad player
|
||||||
// count, duplicate or self invitee, or unacceptable settings).
|
// count, duplicate or self invitee, or unacceptable settings).
|
||||||
ErrInvalidInvitation = errors.New("lobby: invalid invitation")
|
ErrInvalidInvitation = errors.New("lobby: invalid invitation")
|
||||||
|
// ErrGuestForbidden is returned when a guest attempts a durable-only action (invite a
|
||||||
|
// friend); the friend flow is gated to durable accounts.
|
||||||
|
ErrGuestForbidden = errors.New("lobby: guests cannot invite")
|
||||||
// ErrInvitationBlocked is returned when a block stands between the inviter and
|
// ErrInvitationBlocked is returned when a block stands between the inviter and
|
||||||
// an invitee.
|
// an invitee.
|
||||||
ErrInvitationBlocked = errors.New("lobby: invitation blocked between accounts")
|
ErrInvitationBlocked = errors.New("lobby: invitation blocked between accounts")
|
||||||
|
|||||||
@@ -10,6 +10,7 @@ import (
|
|||||||
|
|
||||||
"scrabble/backend/internal/engine"
|
"scrabble/backend/internal/engine"
|
||||||
"scrabble/backend/internal/game"
|
"scrabble/backend/internal/game"
|
||||||
|
"scrabble/backend/internal/gamelimits"
|
||||||
"scrabble/backend/internal/notify"
|
"scrabble/backend/internal/notify"
|
||||||
)
|
)
|
||||||
|
|
||||||
@@ -141,6 +142,7 @@ func (m *Matchmaker) StartVsAI(ctx context.Context, accountID uuid.UUID, variant
|
|||||||
if rand.IntN(2) == 1 {
|
if rand.IntN(2) == 1 {
|
||||||
params.Seats = []uuid.UUID{robotID, accountID}
|
params.Seats = []uuid.UUID{robotID, accountID}
|
||||||
}
|
}
|
||||||
|
params.Kind = gamelimits.KindVsAI
|
||||||
g, err := m.games.Create(ctx, params)
|
g, err := m.games.Create(ctx, params)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return EnqueueResult{}, err
|
return EnqueueResult{}, err
|
||||||
|
|||||||
@@ -0,0 +1,18 @@
|
|||||||
|
//
|
||||||
|
// Code generated by go-jet DO NOT EDIT.
|
||||||
|
//
|
||||||
|
// WARNING: Changes to this file may cause incorrect behavior
|
||||||
|
// and will be lost if the code is regenerated
|
||||||
|
//
|
||||||
|
|
||||||
|
package model
|
||||||
|
|
||||||
|
type Config struct {
|
||||||
|
OnlyRow bool `sql:"primary_key"`
|
||||||
|
GuestVsAiLimit int16
|
||||||
|
GuestRandomLimit int16
|
||||||
|
GuestFriendsLimit int16
|
||||||
|
DurableVsAiLimit int16
|
||||||
|
DurableRandomLimit int16
|
||||||
|
DurableFriendsLimit int16
|
||||||
|
}
|
||||||
@@ -33,4 +33,5 @@ type Games struct {
|
|||||||
DropoutTiles string
|
DropoutTiles string
|
||||||
MultipleWordsPerTurn bool
|
MultipleWordsPerTurn bool
|
||||||
VsAi bool
|
VsAi bool
|
||||||
|
GameKind int16
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -0,0 +1,96 @@
|
|||||||
|
//
|
||||||
|
// Code generated by go-jet DO NOT EDIT.
|
||||||
|
//
|
||||||
|
// WARNING: Changes to this file may cause incorrect behavior
|
||||||
|
// and will be lost if the code is regenerated
|
||||||
|
//
|
||||||
|
|
||||||
|
package table
|
||||||
|
|
||||||
|
import (
|
||||||
|
"github.com/go-jet/jet/v2/postgres"
|
||||||
|
)
|
||||||
|
|
||||||
|
var Config = newConfigTable("backend", "config", "")
|
||||||
|
|
||||||
|
type configTable struct {
|
||||||
|
postgres.Table
|
||||||
|
|
||||||
|
// Columns
|
||||||
|
OnlyRow postgres.ColumnBool
|
||||||
|
GuestVsAiLimit postgres.ColumnInteger
|
||||||
|
GuestRandomLimit postgres.ColumnInteger
|
||||||
|
GuestFriendsLimit postgres.ColumnInteger
|
||||||
|
DurableVsAiLimit postgres.ColumnInteger
|
||||||
|
DurableRandomLimit postgres.ColumnInteger
|
||||||
|
DurableFriendsLimit postgres.ColumnInteger
|
||||||
|
|
||||||
|
AllColumns postgres.ColumnList
|
||||||
|
MutableColumns postgres.ColumnList
|
||||||
|
DefaultColumns postgres.ColumnList
|
||||||
|
}
|
||||||
|
|
||||||
|
type ConfigTable struct {
|
||||||
|
configTable
|
||||||
|
|
||||||
|
EXCLUDED configTable
|
||||||
|
}
|
||||||
|
|
||||||
|
// AS creates new ConfigTable with assigned alias
|
||||||
|
func (a ConfigTable) AS(alias string) *ConfigTable {
|
||||||
|
return newConfigTable(a.SchemaName(), a.TableName(), alias)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Schema creates new ConfigTable with assigned schema name
|
||||||
|
func (a ConfigTable) FromSchema(schemaName string) *ConfigTable {
|
||||||
|
return newConfigTable(schemaName, a.TableName(), a.Alias())
|
||||||
|
}
|
||||||
|
|
||||||
|
// WithPrefix creates new ConfigTable with assigned table prefix
|
||||||
|
func (a ConfigTable) WithPrefix(prefix string) *ConfigTable {
|
||||||
|
return newConfigTable(a.SchemaName(), prefix+a.TableName(), a.TableName())
|
||||||
|
}
|
||||||
|
|
||||||
|
// WithSuffix creates new ConfigTable with assigned table suffix
|
||||||
|
func (a ConfigTable) WithSuffix(suffix string) *ConfigTable {
|
||||||
|
return newConfigTable(a.SchemaName(), a.TableName()+suffix, a.TableName())
|
||||||
|
}
|
||||||
|
|
||||||
|
func newConfigTable(schemaName, tableName, alias string) *ConfigTable {
|
||||||
|
return &ConfigTable{
|
||||||
|
configTable: newConfigTableImpl(schemaName, tableName, alias),
|
||||||
|
EXCLUDED: newConfigTableImpl("", "excluded", ""),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func newConfigTableImpl(schemaName, tableName, alias string) configTable {
|
||||||
|
var (
|
||||||
|
OnlyRowColumn = postgres.BoolColumn("only_row")
|
||||||
|
GuestVsAiLimitColumn = postgres.IntegerColumn("guest_vs_ai_limit")
|
||||||
|
GuestRandomLimitColumn = postgres.IntegerColumn("guest_random_limit")
|
||||||
|
GuestFriendsLimitColumn = postgres.IntegerColumn("guest_friends_limit")
|
||||||
|
DurableVsAiLimitColumn = postgres.IntegerColumn("durable_vs_ai_limit")
|
||||||
|
DurableRandomLimitColumn = postgres.IntegerColumn("durable_random_limit")
|
||||||
|
DurableFriendsLimitColumn = postgres.IntegerColumn("durable_friends_limit")
|
||||||
|
allColumns = postgres.ColumnList{OnlyRowColumn, GuestVsAiLimitColumn, GuestRandomLimitColumn, GuestFriendsLimitColumn, DurableVsAiLimitColumn, DurableRandomLimitColumn, DurableFriendsLimitColumn}
|
||||||
|
mutableColumns = postgres.ColumnList{GuestVsAiLimitColumn, GuestRandomLimitColumn, GuestFriendsLimitColumn, DurableVsAiLimitColumn, DurableRandomLimitColumn, DurableFriendsLimitColumn}
|
||||||
|
defaultColumns = postgres.ColumnList{OnlyRowColumn, GuestVsAiLimitColumn, GuestRandomLimitColumn, GuestFriendsLimitColumn, DurableVsAiLimitColumn, DurableRandomLimitColumn, DurableFriendsLimitColumn}
|
||||||
|
)
|
||||||
|
|
||||||
|
return configTable{
|
||||||
|
Table: postgres.NewTable(schemaName, tableName, alias, allColumns...),
|
||||||
|
|
||||||
|
//Columns
|
||||||
|
OnlyRow: OnlyRowColumn,
|
||||||
|
GuestVsAiLimit: GuestVsAiLimitColumn,
|
||||||
|
GuestRandomLimit: GuestRandomLimitColumn,
|
||||||
|
GuestFriendsLimit: GuestFriendsLimitColumn,
|
||||||
|
DurableVsAiLimit: DurableVsAiLimitColumn,
|
||||||
|
DurableRandomLimit: DurableRandomLimitColumn,
|
||||||
|
DurableFriendsLimit: DurableFriendsLimitColumn,
|
||||||
|
|
||||||
|
AllColumns: allColumns,
|
||||||
|
MutableColumns: mutableColumns,
|
||||||
|
DefaultColumns: defaultColumns,
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -37,6 +37,7 @@ type gamesTable struct {
|
|||||||
DropoutTiles postgres.ColumnString
|
DropoutTiles postgres.ColumnString
|
||||||
MultipleWordsPerTurn postgres.ColumnBool
|
MultipleWordsPerTurn postgres.ColumnBool
|
||||||
VsAi postgres.ColumnBool
|
VsAi postgres.ColumnBool
|
||||||
|
GameKind postgres.ColumnInteger
|
||||||
|
|
||||||
AllColumns postgres.ColumnList
|
AllColumns postgres.ColumnList
|
||||||
MutableColumns postgres.ColumnList
|
MutableColumns postgres.ColumnList
|
||||||
@@ -98,9 +99,10 @@ func newGamesTableImpl(schemaName, tableName, alias string) gamesTable {
|
|||||||
DropoutTilesColumn = postgres.StringColumn("dropout_tiles")
|
DropoutTilesColumn = postgres.StringColumn("dropout_tiles")
|
||||||
MultipleWordsPerTurnColumn = postgres.BoolColumn("multiple_words_per_turn")
|
MultipleWordsPerTurnColumn = postgres.BoolColumn("multiple_words_per_turn")
|
||||||
VsAiColumn = postgres.BoolColumn("vs_ai")
|
VsAiColumn = postgres.BoolColumn("vs_ai")
|
||||||
allColumns = postgres.ColumnList{GameIDColumn, VariantColumn, DictVersionColumn, SeedColumn, StatusColumn, PlayersColumn, ToMoveColumn, TurnStartedAtColumn, TurnTimeoutSecsColumn, HintsAllowedColumn, HintsPerPlayerColumn, MoveCountColumn, EndReasonColumn, CreatedAtColumn, UpdatedAtColumn, FinishedAtColumn, OpenDeadlineAtColumn, DropoutTilesColumn, MultipleWordsPerTurnColumn, VsAiColumn}
|
GameKindColumn = postgres.IntegerColumn("game_kind")
|
||||||
mutableColumns = postgres.ColumnList{VariantColumn, DictVersionColumn, SeedColumn, StatusColumn, PlayersColumn, ToMoveColumn, TurnStartedAtColumn, TurnTimeoutSecsColumn, HintsAllowedColumn, HintsPerPlayerColumn, MoveCountColumn, EndReasonColumn, CreatedAtColumn, UpdatedAtColumn, FinishedAtColumn, OpenDeadlineAtColumn, DropoutTilesColumn, MultipleWordsPerTurnColumn, VsAiColumn}
|
allColumns = postgres.ColumnList{GameIDColumn, VariantColumn, DictVersionColumn, SeedColumn, StatusColumn, PlayersColumn, ToMoveColumn, TurnStartedAtColumn, TurnTimeoutSecsColumn, HintsAllowedColumn, HintsPerPlayerColumn, MoveCountColumn, EndReasonColumn, CreatedAtColumn, UpdatedAtColumn, FinishedAtColumn, OpenDeadlineAtColumn, DropoutTilesColumn, MultipleWordsPerTurnColumn, VsAiColumn, GameKindColumn}
|
||||||
defaultColumns = postgres.ColumnList{StatusColumn, ToMoveColumn, TurnStartedAtColumn, HintsAllowedColumn, HintsPerPlayerColumn, MoveCountColumn, CreatedAtColumn, UpdatedAtColumn, DropoutTilesColumn, MultipleWordsPerTurnColumn, VsAiColumn}
|
mutableColumns = postgres.ColumnList{VariantColumn, DictVersionColumn, SeedColumn, StatusColumn, PlayersColumn, ToMoveColumn, TurnStartedAtColumn, TurnTimeoutSecsColumn, HintsAllowedColumn, HintsPerPlayerColumn, MoveCountColumn, EndReasonColumn, CreatedAtColumn, UpdatedAtColumn, FinishedAtColumn, OpenDeadlineAtColumn, DropoutTilesColumn, MultipleWordsPerTurnColumn, VsAiColumn, GameKindColumn}
|
||||||
|
defaultColumns = postgres.ColumnList{StatusColumn, ToMoveColumn, TurnStartedAtColumn, HintsAllowedColumn, HintsPerPlayerColumn, MoveCountColumn, CreatedAtColumn, UpdatedAtColumn, DropoutTilesColumn, MultipleWordsPerTurnColumn, VsAiColumn, GameKindColumn}
|
||||||
)
|
)
|
||||||
|
|
||||||
return gamesTable{
|
return gamesTable{
|
||||||
@@ -127,6 +129,7 @@ func newGamesTableImpl(schemaName, tableName, alias string) gamesTable {
|
|||||||
DropoutTiles: DropoutTilesColumn,
|
DropoutTiles: DropoutTilesColumn,
|
||||||
MultipleWordsPerTurn: MultipleWordsPerTurnColumn,
|
MultipleWordsPerTurn: MultipleWordsPerTurnColumn,
|
||||||
VsAi: VsAiColumn,
|
VsAi: VsAiColumn,
|
||||||
|
GameKind: GameKindColumn,
|
||||||
|
|
||||||
AllColumns: allColumns,
|
AllColumns: allColumns,
|
||||||
MutableColumns: mutableColumns,
|
MutableColumns: mutableColumns,
|
||||||
|
|||||||
@@ -21,6 +21,7 @@ func UseSchema(schema string) {
|
|||||||
Blocks = Blocks.FromSchema(schema)
|
Blocks = Blocks.FromSchema(schema)
|
||||||
ChatMessages = ChatMessages.FromSchema(schema)
|
ChatMessages = ChatMessages.FromSchema(schema)
|
||||||
Complaints = Complaints.FromSchema(schema)
|
Complaints = Complaints.FromSchema(schema)
|
||||||
|
Config = Config.FromSchema(schema)
|
||||||
DictionaryState = DictionaryState.FromSchema(schema)
|
DictionaryState = DictionaryState.FromSchema(schema)
|
||||||
EmailConfirmations = EmailConfirmations.FromSchema(schema)
|
EmailConfirmations = EmailConfirmations.FromSchema(schema)
|
||||||
FeedbackMessages = FeedbackMessages.FromSchema(schema)
|
FeedbackMessages = FeedbackMessages.FromSchema(schema)
|
||||||
|
|||||||
@@ -0,0 +1,36 @@
|
|||||||
|
-- Guest-limit foundation (E8): tag each game with its kind so the per-tier, per-kind active-game
|
||||||
|
-- limits are enforceable, and add the single-row config that holds those limits (tuned in the admin,
|
||||||
|
-- no release). It replaces the old hardcoded MaxActiveQuickGames=10 combined cap with a per-tier,
|
||||||
|
-- per-kind config. game_kind: 0=unknown (pre-E8 games, never gated), 1=vs_ai, 2=random, 3=friends —
|
||||||
|
-- set on creation. The limits are smallint with -1 = unlimited; a guest defaults to 1 vs_ai + 1
|
||||||
|
-- random (friends 0, moot — the guest gate blocks friend games), a durable account to 10 per kind
|
||||||
|
-- (the old cap, now per kind). Additive only — applies forward via goose with no data rewrite (no
|
||||||
|
-- contour wipe); an image rollback ignores the column + table.
|
||||||
|
-- +goose Up
|
||||||
|
|
||||||
|
ALTER TABLE backend.games
|
||||||
|
ADD COLUMN game_kind smallint DEFAULT 0 NOT NULL;
|
||||||
|
ALTER TABLE backend.games
|
||||||
|
ADD CONSTRAINT games_game_kind_chk CHECK (game_kind >= 0 AND game_kind <= 3);
|
||||||
|
|
||||||
|
CREATE TABLE backend.config (
|
||||||
|
only_row boolean DEFAULT true NOT NULL,
|
||||||
|
guest_vs_ai_limit smallint DEFAULT 1 NOT NULL,
|
||||||
|
guest_random_limit smallint DEFAULT 1 NOT NULL,
|
||||||
|
guest_friends_limit smallint DEFAULT 0 NOT NULL,
|
||||||
|
durable_vs_ai_limit smallint DEFAULT 10 NOT NULL,
|
||||||
|
durable_random_limit smallint DEFAULT 10 NOT NULL,
|
||||||
|
durable_friends_limit smallint DEFAULT 10 NOT NULL,
|
||||||
|
CONSTRAINT config_pkey PRIMARY KEY (only_row),
|
||||||
|
CONSTRAINT config_single_row_chk CHECK (only_row),
|
||||||
|
CONSTRAINT config_limits_chk CHECK (
|
||||||
|
guest_vs_ai_limit >= -1 AND guest_random_limit >= -1 AND guest_friends_limit >= -1 AND
|
||||||
|
durable_vs_ai_limit >= -1 AND durable_random_limit >= -1 AND durable_friends_limit >= -1)
|
||||||
|
);
|
||||||
|
|
||||||
|
INSERT INTO backend.config (only_row) VALUES (true);
|
||||||
|
|
||||||
|
-- +goose Down
|
||||||
|
|
||||||
|
DROP TABLE backend.config;
|
||||||
|
ALTER TABLE backend.games DROP COLUMN game_kind;
|
||||||
@@ -319,6 +319,8 @@ func statusForError(err error) (int, string) {
|
|||||||
return http.StatusConflict, "request_declined"
|
return http.StatusConflict, "request_declined"
|
||||||
case errors.Is(err, social.ErrFriendCodeInvalid):
|
case errors.Is(err, social.ErrFriendCodeInvalid):
|
||||||
return http.StatusUnprocessableEntity, "friend_code_invalid"
|
return http.StatusUnprocessableEntity, "friend_code_invalid"
|
||||||
|
case errors.Is(err, social.ErrGuestForbidden), errors.Is(err, lobby.ErrGuestForbidden):
|
||||||
|
return http.StatusForbidden, "guest_forbidden"
|
||||||
case errors.Is(err, feedback.ErrGuestForbidden):
|
case errors.Is(err, feedback.ErrGuestForbidden):
|
||||||
return http.StatusForbidden, "feedback_guest_forbidden"
|
return http.StatusForbidden, "feedback_guest_forbidden"
|
||||||
case errors.Is(err, feedback.ErrBanned):
|
case errors.Is(err, feedback.ErrBanned):
|
||||||
|
|||||||
@@ -70,6 +70,10 @@ func (s *Server) registerConsole(router *gin.Engine) {
|
|||||||
gm.POST("/bans/unban", s.consoleUnban)
|
gm.POST("/bans/unban", s.consoleUnban)
|
||||||
gm.GET("/games", s.consoleGames)
|
gm.GET("/games", s.consoleGames)
|
||||||
gm.GET("/games/:id", s.consoleGameDetail)
|
gm.GET("/games/:id", s.consoleGameDetail)
|
||||||
|
if s.gamelimits != nil {
|
||||||
|
gm.GET("/limits", s.consoleLimits)
|
||||||
|
gm.POST("/limits", s.consoleUpdateLimits)
|
||||||
|
}
|
||||||
gm.GET("/complaints", s.consoleComplaints)
|
gm.GET("/complaints", s.consoleComplaints)
|
||||||
gm.GET("/complaints/:id", s.consoleComplaintDetail)
|
gm.GET("/complaints/:id", s.consoleComplaintDetail)
|
||||||
gm.POST("/complaints/:id/resolve", s.consoleResolveComplaint)
|
gm.POST("/complaints/:id/resolve", s.consoleResolveComplaint)
|
||||||
@@ -1244,7 +1248,7 @@ func (s *Server) consoleUUID(c *gin.Context, back string) (uuid.UUID, bool) {
|
|||||||
|
|
||||||
// gameRow projects a game summary into its console row.
|
// gameRow projects a game summary into its console row.
|
||||||
func gameRow(g game.Game) adminconsole.GameRow {
|
func gameRow(g game.Game) adminconsole.GameRow {
|
||||||
return adminconsole.GameRow{ID: g.ID.String(), Variant: g.Variant.String(), Status: g.Status, Players: g.Players, UpdatedAt: fmtTime(g.UpdatedAt), VsAI: g.VsAI}
|
return adminconsole.GameRow{ID: g.ID.String(), Variant: g.Variant.String(), Status: g.Status, Players: g.Players, UpdatedAt: fmtTime(g.UpdatedAt), VsAI: g.VsAI, Kind: g.Kind.String()}
|
||||||
}
|
}
|
||||||
|
|
||||||
// trimForm returns the trimmed value of a posted form field.
|
// trimForm returns the trimmed value of a posted form field.
|
||||||
|
|||||||
@@ -0,0 +1,65 @@
|
|||||||
|
package server
|
||||||
|
|
||||||
|
import (
|
||||||
|
"fmt"
|
||||||
|
|
||||||
|
"github.com/gin-gonic/gin"
|
||||||
|
|
||||||
|
"scrabble/backend/internal/adminconsole"
|
||||||
|
"scrabble/backend/internal/gamelimits"
|
||||||
|
)
|
||||||
|
|
||||||
|
// consoleLimits renders the per-tier, per-kind active-game limit form (backend.config), read from
|
||||||
|
// the in-memory cache.
|
||||||
|
func (s *Server) consoleLimits(c *gin.Context) {
|
||||||
|
cfg := s.gamelimits.Get()
|
||||||
|
s.renderConsole(c, "limits", "limits", "Game limits", adminconsole.GameLimitsView{
|
||||||
|
GuestVsAI: cfg.Guest.VsAI,
|
||||||
|
GuestRandom: cfg.Guest.Random,
|
||||||
|
GuestFriends: cfg.Guest.Friends,
|
||||||
|
DurableVsAI: cfg.Durable.VsAI,
|
||||||
|
DurableRandom: cfg.Durable.Random,
|
||||||
|
DurableFriends: cfg.Durable.Friends,
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
// consoleUpdateLimits saves the active-game limits and refreshes the hot cache in place. Each
|
||||||
|
// field is a per-kind cap: -1 is unlimited, 0 blocks the kind, a positive value caps concurrent games.
|
||||||
|
func (s *Server) consoleUpdateLimits(c *gin.Context) {
|
||||||
|
cfg := gamelimits.Config{
|
||||||
|
Guest: gamelimits.Limits{
|
||||||
|
VsAI: atoiForm(c, "guest_vs_ai"),
|
||||||
|
Random: atoiForm(c, "guest_random"),
|
||||||
|
Friends: atoiForm(c, "guest_friends"),
|
||||||
|
},
|
||||||
|
Durable: gamelimits.Limits{
|
||||||
|
VsAI: atoiForm(c, "durable_vs_ai"),
|
||||||
|
Random: atoiForm(c, "durable_random"),
|
||||||
|
Friends: atoiForm(c, "durable_friends"),
|
||||||
|
},
|
||||||
|
}
|
||||||
|
if err := validateGameLimits(cfg); err != nil {
|
||||||
|
s.renderConsoleMessage(c, "Invalid", err.Error(), "/_gm/limits")
|
||||||
|
return
|
||||||
|
}
|
||||||
|
if err := s.gamelimits.Update(c.Request.Context(), cfg); err != nil {
|
||||||
|
s.consoleError(c, err)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
s.renderConsoleMessage(c, "Saved", "game limits updated", "/_gm/limits")
|
||||||
|
}
|
||||||
|
|
||||||
|
// validateGameLimits rejects a limit below -1 (the unlimited sentinel); -1, 0 and any positive count
|
||||||
|
// are valid. It mirrors the backend.config CHECK so a bad value is refused with a clean message
|
||||||
|
// rather than a raw database error.
|
||||||
|
func validateGameLimits(cfg gamelimits.Config) error {
|
||||||
|
for _, v := range []int{
|
||||||
|
cfg.Guest.VsAI, cfg.Guest.Random, cfg.Guest.Friends,
|
||||||
|
cfg.Durable.VsAI, cfg.Durable.Random, cfg.Durable.Friends,
|
||||||
|
} {
|
||||||
|
if v < gamelimits.Unlimited {
|
||||||
|
return fmt.Errorf("a limit must be -1 (unlimited), 0, or a positive count")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return nil
|
||||||
|
}
|
||||||
@@ -10,6 +10,7 @@ import (
|
|||||||
|
|
||||||
"scrabble/backend/internal/engine"
|
"scrabble/backend/internal/engine"
|
||||||
"scrabble/backend/internal/game"
|
"scrabble/backend/internal/game"
|
||||||
|
"scrabble/backend/internal/gamelimits"
|
||||||
)
|
)
|
||||||
|
|
||||||
// The handlers below cover the game and chat operations the UI needs. They follow
|
// The handlers below cover the game and chat operations the UI needs. They follow
|
||||||
@@ -46,10 +47,11 @@ type historyDTO struct {
|
|||||||
}
|
}
|
||||||
|
|
||||||
// gameListDTO is the caller's games (active and finished) for the lobby. AtGameLimit
|
// gameListDTO is the caller's games (active and finished) for the lobby. AtGameLimit
|
||||||
// reports whether the caller has reached the simultaneous quick-game cap
|
// reports whether the caller has reached its tier's active-game cap for the random
|
||||||
// (game.MaxActiveQuickGames); while it is true the lobby disables "New Game" and shows a
|
// (quick auto-match) kind (the per-tier, per-kind limits in backend.config); while
|
||||||
// notice. It rides the lobby response — which the lobby re-fetches on every game event —
|
// it is true the lobby disables "New Game" and shows a notice. It rides the lobby
|
||||||
// instead of a separate request.
|
// response — which the lobby re-fetches on every game event — instead of a separate
|
||||||
|
// request.
|
||||||
type gameListDTO struct {
|
type gameListDTO struct {
|
||||||
Games []gameDTO `json:"games"`
|
Games []gameDTO `json:"games"`
|
||||||
AtGameLimit bool `json:"at_game_limit"`
|
AtGameLimit bool `json:"at_game_limit"`
|
||||||
@@ -395,23 +397,13 @@ func (s *Server) handleSaveDraft(c *gin.Context) {
|
|||||||
c.JSON(http.StatusOK, okResponse{OK: true})
|
c.JSON(http.StatusOK, okResponse{OK: true})
|
||||||
}
|
}
|
||||||
|
|
||||||
// atGameLimit reports whether uid already holds the maximum number of simultaneous
|
// ensureUnderGameLimit aborts the request with 409 game_limit_reached when uid has reached its
|
||||||
// quick games (game.MaxActiveQuickGames). It backs both the lobby's at_game_limit flag
|
// tier's active-game cap for kind (the per-tier, per-kind limits in backend.config), and reports
|
||||||
// and the new-game gate; friend games created by invitation are not counted.
|
// whether the caller may proceed. It guards the quick new-game entry points — auto-match (random)
|
||||||
func (s *Server) atGameLimit(ctx context.Context, uid uuid.UUID) (bool, error) {
|
// and AI (vs_ai). Friend-invitation limits are enforced in the lobby's CreateInvitation, and
|
||||||
n, err := s.games.CountActiveQuickGames(ctx, uid)
|
// accepting an incoming invitation is deliberately exempt.
|
||||||
if err != nil {
|
func (s *Server) ensureUnderGameLimit(c *gin.Context, uid uuid.UUID, kind gamelimits.Kind) bool {
|
||||||
return false, err
|
atLimit, err := s.games.AtGameLimit(c.Request.Context(), uid, kind)
|
||||||
}
|
|
||||||
return n >= game.MaxActiveQuickGames, nil
|
|
||||||
}
|
|
||||||
|
|
||||||
// ensureUnderGameLimit aborts the request with 409 game_limit_reached when uid is at the
|
|
||||||
// simultaneous quick-game cap, and reports whether the caller may proceed. It guards
|
|
||||||
// every new-game entry point — quick auto-match/AI and invitation creation; accepting an
|
|
||||||
// incoming invitation is deliberately exempt.
|
|
||||||
func (s *Server) ensureUnderGameLimit(c *gin.Context, uid uuid.UUID) bool {
|
|
||||||
atLimit, err := s.atGameLimit(c.Request.Context(), uid)
|
|
||||||
if err != nil {
|
if err != nil {
|
||||||
s.abortErr(c, err)
|
s.abortErr(c, err)
|
||||||
return false
|
return false
|
||||||
@@ -437,7 +429,7 @@ func (s *Server) handleListGames(c *gin.Context) {
|
|||||||
s.abortErr(c, err)
|
s.abortErr(c, err)
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
atLimit, err := s.atGameLimit(c.Request.Context(), uid)
|
atLimit, err := s.games.AtGameLimit(c.Request.Context(), uid, gamelimits.KindRandom)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
s.abortErr(c, err)
|
s.abortErr(c, err)
|
||||||
return
|
return
|
||||||
|
|||||||
@@ -133,9 +133,6 @@ func (s *Server) handleCreateInvitation(c *gin.Context) {
|
|||||||
}
|
}
|
||||||
inviteeIDs = append(inviteeIDs, id)
|
inviteeIDs = append(inviteeIDs, id)
|
||||||
}
|
}
|
||||||
if !s.ensureUnderGameLimit(c, uid) {
|
|
||||||
return
|
|
||||||
}
|
|
||||||
inv, err := s.invitations.CreateInvitation(c.Request.Context(), uid, inviteeIDs, settings)
|
inv, err := s.invitations.CreateInvitation(c.Request.Context(), uid, inviteeIDs, settings)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
s.abortErr(c, err)
|
s.abortErr(c, err)
|
||||||
|
|||||||
@@ -8,6 +8,7 @@ import (
|
|||||||
"github.com/google/uuid"
|
"github.com/google/uuid"
|
||||||
|
|
||||||
"scrabble/backend/internal/engine"
|
"scrabble/backend/internal/engine"
|
||||||
|
"scrabble/backend/internal/gamelimits"
|
||||||
)
|
)
|
||||||
|
|
||||||
// The /api/v1/user/* endpoints require X-User-ID (RequireUserID middleware). The
|
// The /api/v1/user/* endpoints require X-User-ID (RequireUserID middleware). The
|
||||||
@@ -189,13 +190,15 @@ func (s *Server) handleEnqueue(c *gin.Context) {
|
|||||||
if !s.ensureVariantAllowed(c, uid, variant.String()) {
|
if !s.ensureVariantAllowed(c, uid, variant.String()) {
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
if !s.ensureUnderGameLimit(c, uid) {
|
kind := gamelimits.KindRandom
|
||||||
return
|
|
||||||
}
|
|
||||||
enter := s.matchmaker.Enqueue
|
enter := s.matchmaker.Enqueue
|
||||||
if req.VsAI {
|
if req.VsAI {
|
||||||
|
kind = gamelimits.KindVsAI
|
||||||
enter = s.matchmaker.StartVsAI
|
enter = s.matchmaker.StartVsAI
|
||||||
}
|
}
|
||||||
|
if !s.ensureUnderGameLimit(c, uid, kind) {
|
||||||
|
return
|
||||||
|
}
|
||||||
res, err := enter(c.Request.Context(), uid, variant, req.MultipleWordsPerTurn)
|
res, err := enter(c.Request.Context(), uid, variant, req.MultipleWordsPerTurn)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
s.abortErr(c, err)
|
s.abortErr(c, err)
|
||||||
|
|||||||
@@ -25,6 +25,7 @@ import (
|
|||||||
"scrabble/backend/internal/engine"
|
"scrabble/backend/internal/engine"
|
||||||
"scrabble/backend/internal/feedback"
|
"scrabble/backend/internal/feedback"
|
||||||
"scrabble/backend/internal/game"
|
"scrabble/backend/internal/game"
|
||||||
|
"scrabble/backend/internal/gamelimits"
|
||||||
"scrabble/backend/internal/link"
|
"scrabble/backend/internal/link"
|
||||||
"scrabble/backend/internal/lobby"
|
"scrabble/backend/internal/lobby"
|
||||||
"scrabble/backend/internal/notify"
|
"scrabble/backend/internal/notify"
|
||||||
@@ -100,6 +101,10 @@ type Deps struct {
|
|||||||
// console routes are registered when the wallet surface lands. A nil Payments
|
// console routes are registered when the wallet surface lands. A nil Payments
|
||||||
// omits them.
|
// omits them.
|
||||||
Payments *payments.Service
|
Payments *payments.Service
|
||||||
|
// GameLimits is the per-tier, per-kind active-game limit config, cached in memory. The
|
||||||
|
// game domain reads it through game.Service (SetGameLimits) for the new-game gate; the admin
|
||||||
|
// console reads and edits it here. A nil GameLimits omits the limits console section.
|
||||||
|
GameLimits *gamelimits.Service
|
||||||
// Notifier publishes live-event intents — here the banner-eligibility re-poll
|
// Notifier publishes live-event intents — here the banner-eligibility re-poll
|
||||||
// signal the banner/hint/role console actions emit. A nil Notifier discards
|
// signal the banner/hint/role console actions emit. A nil Notifier discards
|
||||||
// them (notify.Nop).
|
// them (notify.Nop).
|
||||||
@@ -140,6 +145,7 @@ type Server struct {
|
|||||||
banview *banview.View
|
banview *banview.View
|
||||||
ads *ads.Service
|
ads *ads.Service
|
||||||
payments *payments.Service
|
payments *payments.Service
|
||||||
|
gamelimits *gamelimits.Service
|
||||||
robokassa robokassa.Config
|
robokassa robokassa.Config
|
||||||
notifier notify.Publisher
|
notifier notify.Publisher
|
||||||
console *adminconsole.Renderer
|
console *adminconsole.Renderer
|
||||||
@@ -194,6 +200,7 @@ func New(addr string, deps Deps) *Server {
|
|||||||
banview: deps.BanView,
|
banview: deps.BanView,
|
||||||
ads: deps.Ads,
|
ads: deps.Ads,
|
||||||
payments: deps.Payments,
|
payments: deps.Payments,
|
||||||
|
gamelimits: deps.GameLimits,
|
||||||
robokassa: deps.Robokassa,
|
robokassa: deps.Robokassa,
|
||||||
notifier: notifier,
|
notifier: notifier,
|
||||||
renderer: deps.Renderer,
|
renderer: deps.Renderer,
|
||||||
|
|||||||
@@ -65,6 +65,12 @@ func (svc *Service) IssueFriendCode(ctx context.Context, accountID uuid.UUID) (F
|
|||||||
// ErrRequestBlocked (a block stands between the pair). A redeem bypasses any prior
|
// ErrRequestBlocked (a block stands between the pair). A redeem bypasses any prior
|
||||||
// decline between the two: it clears the old row and writes a fresh friendship.
|
// decline between the two: it clears the old row and writes a fresh friendship.
|
||||||
func (svc *Service) RedeemFriendCode(ctx context.Context, redeemerID uuid.UUID, code string) (uuid.UUID, error) {
|
func (svc *Service) RedeemFriendCode(ctx context.Context, redeemerID uuid.UUID, code string) (uuid.UUID, error) {
|
||||||
|
// A guest cannot use friends: a durable-account feature (the UI hides it).
|
||||||
|
if acc, err := svc.accounts.GetByID(ctx, redeemerID); err != nil {
|
||||||
|
return uuid.UUID{}, err
|
||||||
|
} else if acc.IsGuest {
|
||||||
|
return uuid.UUID{}, ErrGuestForbidden
|
||||||
|
}
|
||||||
issuerID, codeID, err := svc.store.liveFriendCodeByHash(ctx, hashFriendCode(code), svc.now())
|
issuerID, codeID, err := svc.store.liveFriendCodeByHash(ctx, hashFriendCode(code), svc.now())
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return uuid.UUID{}, err
|
return uuid.UUID{}, err
|
||||||
|
|||||||
@@ -51,6 +51,13 @@ func (svc *Service) SendFriendRequest(ctx context.Context, requesterID, addresse
|
|||||||
if requesterID == addresseeID {
|
if requesterID == addresseeID {
|
||||||
return ErrSelfRelation
|
return ErrSelfRelation
|
||||||
}
|
}
|
||||||
|
// A guest cannot use friends — friends are a durable-account feature; the UI hides the
|
||||||
|
// flow, this is the server source of truth.
|
||||||
|
if acc, err := svc.accounts.GetByID(ctx, requesterID); err != nil {
|
||||||
|
return err
|
||||||
|
} else if acc.IsGuest {
|
||||||
|
return ErrGuestForbidden
|
||||||
|
}
|
||||||
iBlockThem, err := svc.store.blockExists(ctx, requesterID, addresseeID)
|
iBlockThem, err := svc.store.blockExists(ctx, requesterID, addresseeID)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return err
|
return err
|
||||||
|
|||||||
@@ -42,6 +42,12 @@ func (svc *Service) RequestInGame(ctx context.Context, requesterID, addresseeID,
|
|||||||
if requesterID == addresseeID {
|
if requesterID == addresseeID {
|
||||||
return ErrSelfRelation
|
return ErrSelfRelation
|
||||||
}
|
}
|
||||||
|
// A guest cannot use friends: a durable-account feature (the UI hides it).
|
||||||
|
if acc, err := svc.accounts.GetByID(ctx, requesterID); err != nil {
|
||||||
|
return err
|
||||||
|
} else if acc.IsGuest {
|
||||||
|
return ErrGuestForbidden
|
||||||
|
}
|
||||||
isRobot, err := svc.accounts.IsRobot(ctx, addresseeID)
|
isRobot, err := svc.accounts.IsRobot(ctx, addresseeID)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return err
|
return err
|
||||||
|
|||||||
@@ -59,6 +59,9 @@ var (
|
|||||||
// ErrRequestBlocked is returned when the addressee does not accept friend
|
// ErrRequestBlocked is returned when the addressee does not accept friend
|
||||||
// requests (their global toggle) or a block stands between the two accounts.
|
// requests (their global toggle) or a block stands between the two accounts.
|
||||||
ErrRequestBlocked = errors.New("social: the addressee is not accepting friend requests")
|
ErrRequestBlocked = errors.New("social: the addressee is not accepting friend requests")
|
||||||
|
// ErrGuestForbidden is returned when a guest attempts a durable-only action (send a friend
|
||||||
|
// request, redeem a friend code); friends are a durable-account feature.
|
||||||
|
ErrGuestForbidden = errors.New("social: guests cannot use friends")
|
||||||
// ErrRequestNotFound is returned when no pending friend request matches.
|
// ErrRequestNotFound is returned when no pending friend request matches.
|
||||||
ErrRequestNotFound = errors.New("social: no pending friend request")
|
ErrRequestNotFound = errors.New("social: no pending friend request")
|
||||||
// ErrNoSharedGame is returned when a friend request targets someone the
|
// ErrNoSharedGame is returned when a friend request targets someone the
|
||||||
|
|||||||
+21
-12
@@ -650,18 +650,27 @@ in either direction (the enqueue excludes the caller's `BlockedWith` set);
|
|||||||
game is `open` the starter may move on their turn, but resign, chat and nudge are
|
game is `open` the starter may move on their turn, but resign, chat and nudge are
|
||||||
refused (no opponent yet) and the lobby and opponent card show a "searching for
|
refused (no opponent yet) and the lobby and opponent card show a "searching for
|
||||||
opponent" placeholder.
|
opponent" placeholder.
|
||||||
- **Simultaneous-game cap**: a player may hold at most `game.MaxActiveQuickGames`
|
- **Active-game caps (per tier, per kind)**: a player's simultaneous unfinished games are
|
||||||
(**10**) active quick games. `game.Service.CountActiveQuickGames` counts the games
|
capped per **kind** — `vs_ai`, `random` (quick auto-match), `friends` — with independent
|
||||||
seating the account in status `active` or `open` **without** a linked
|
**guest** and **durable-account** tiers. The caps live in the single-row `backend.config`
|
||||||
`game_invitations` row — friend games are excluded, and hidden games still occupy a
|
table (`-1` = unlimited), read once at boot into an in-memory cache (`internal/gamelimits`)
|
||||||
slot, so it is a dedicated count rather than a filter over the lobby list. The backend
|
and refreshed in place when an operator edits them in the admin (`/_gm/limits`) — so a
|
||||||
**gate** (`Server.ensureUnderGameLimit`) refuses **both** new-game entry points at the
|
login or game-create never queries the table. The seeded defaults are guest **1 vs_ai / 1
|
||||||
cap — `POST /lobby/enqueue` and `POST /invitations` — with **409 `game_limit_reached`**;
|
random / 0 friends** and durable **10 / 10 / 10** (this replaced the earlier flat
|
||||||
**accepting** an invitation (`POST /invitations/:id/accept`) is never gated, so friend
|
`MaxActiveQuickGames`=10 combined cap). Each game is tagged with `games.game_kind` on
|
||||||
games are capped only at initiation. The lobby learns the state from a boolean
|
creation (0 = an untagged game, never gated). `game.Service.AtGameLimit` resolves the
|
||||||
**`at_game_limit`** carried on the `games.list` response — the lobby already re-fetches
|
account's tier, then counts its `active`/`open` games of the kind (hidden games still
|
||||||
that on entry and on every game event, so the flag needs no separate request or
|
occupy a slot) against the cap. The gate (`Server.ensureUnderGameLimit`) refuses
|
||||||
per-event payload; while it is set the client disables **New Game** and shows a notice.
|
`POST /lobby/enqueue` — random or vs_ai per the request — with **409 `game_limit_reached`**;
|
||||||
|
the `POST /invitations` (friends) path enforces the durable friends cap the same way and
|
||||||
|
refuses a **guest** outright with **403** (guests cannot use friends — the same guest gate
|
||||||
|
covers friend requests and friend-code redemption). **Accepting** an invitation
|
||||||
|
(`POST /invitations/:id/accept`) is never gated, so friend games are capped only at
|
||||||
|
initiation. The lobby learns the random-kind state from a boolean **`at_game_limit`** on
|
||||||
|
the `games.list` response — already re-fetched on entry and every game event; the client
|
||||||
|
additionally counts the listed games per kind against the per-tier caps it reads in the
|
||||||
|
profile, locking a kind's New-Game entry (a login funnel for a guest, an already-at-limit
|
||||||
|
note for a durable account) when that kind is full.
|
||||||
- **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
|
||||||
|
|||||||
Reference in New Issue
Block a user