Compare commits
58 Commits
c3eecf16b3
...
v1.16.0
| Author | SHA1 | Date | |
|---|---|---|---|
| 516ffbe5f0 | |||
| 3ce460f72a | |||
| 4ba9da6721 | |||
| ad1cc361e9 | |||
| 2c2316fb5e | |||
| bb71e7b1c7 | |||
| 5c1f64c7d1 | |||
| b3cf024e9e | |||
| 6e120bdaa7 | |||
| 40acbcccdd | |||
| b54371845f | |||
| b6c2598710 | |||
| 6badc20078 | |||
| a241e43d79 | |||
| 8ce986922a | |||
| 3469278260 | |||
| c66bf1eceb | |||
| 7b4d2421e2 | |||
| bf46b9492d | |||
| 1e1117c28e | |||
| 77a690fcf6 | |||
| 0ca01133b5 | |||
| 2683103fc1 | |||
| 2d2dd2bc47 | |||
| 45f0b34881 | |||
| df9eace09f | |||
| 0a0a9e5a8d | |||
| 0c9678c42b | |||
| e40adfb0c7 | |||
| 3306a016a0 | |||
| e45167041f | |||
| ed53e25e57 | |||
| 2e5136b22a | |||
| 1bf612a087 | |||
| ec5c6afa23 | |||
| 82648a4398 | |||
| d2d6955cbf | |||
| 18785efc8c | |||
| 780ff68ec2 | |||
| 57ff2d03f8 | |||
| a9d0986e74 | |||
| 45957bdcd6 | |||
| 829e29a726 | |||
| 399508f2f0 | |||
| 3a18e683ca | |||
| 93d086a8a3 | |||
| 8fe1bdba6b | |||
| 7923b3cc09 | |||
| 4891216749 | |||
| f1b8769c89 | |||
| b6f28a2423 | |||
| e32ee9ce68 | |||
| dc946a1faf | |||
| 384bd143d0 | |||
| c5d22fceca | |||
| deaa7a29c5 | |||
| 24017bcb7f | |||
| 2c4f4b10dc |
@@ -510,15 +510,20 @@ jobs:
|
|||||||
- name: Probe the /offer/ public offer page is served
|
- name: Probe the /offer/ public offer page is served
|
||||||
run: |
|
run: |
|
||||||
set -u
|
set -u
|
||||||
# /offer/ is a static page baked into the landing image (rendered from
|
# /offer/ is rendered by the render sidecar: it splices the live catalog price list
|
||||||
# ui/legal/offer_ru.md). If the landing Caddyfile stops routing it, the request
|
# (fetched from the backend's internal endpoint) into the committed ui/legal/offer_ru.md.
|
||||||
# silently falls through to the landing shell (also 200) — so assert offer-specific
|
# If the @offer caddy route is missing, the request falls to the landing shell (also 200),
|
||||||
# content, never just the status.
|
# and if the backend fetch fails the sidecar returns 502 — so assert offer-specific content
|
||||||
|
# (the seller INN, §11) AND that the pricing marker was substituted (proves the fetch +
|
||||||
|
# splice ran), never just the status. Data-independent: an empty catalog still substitutes
|
||||||
|
# the marker with nothing, so "pricing_template" must be absent either way.
|
||||||
out="$(docker run --rm --network edge alpine:3.20 wget -q -O - http://scrabble/offer/ 2>&1 || true)"
|
out="$(docker run --rm --network edge alpine:3.20 wget -q -O - http://scrabble/offer/ 2>&1 || true)"
|
||||||
if echo "$out" | grep -q "290210610742"; then
|
if echo "$out" | grep -q "290210610742" && ! echo "$out" | grep -q "pricing_template"; then
|
||||||
echo "ok: /offer/ serves the public offer page"
|
echo "ok: /offer/ serves the rendered offer with the price list spliced in"
|
||||||
else
|
else
|
||||||
echo "FAIL: /offer/ did not serve the offer page (fell through to the landing shell?)"
|
echo "FAIL: /offer/ did not serve the rendered offer (route fell through, or the price splice failed)"
|
||||||
|
docker logs --tail 50 scrabble-renderer || true
|
||||||
|
docker logs --tail 50 scrabble-backend || true
|
||||||
docker logs --tail 50 scrabble-landing || true
|
docker logs --tail 50 scrabble-landing || true
|
||||||
exit 1
|
exit 1
|
||||||
fi
|
fi
|
||||||
|
|||||||
@@ -99,6 +99,14 @@ jobs:
|
|||||||
GRAFANA_ADMIN_PASSWORD: ${{ secrets.PROD_GRAFANA_ADMIN_PASSWORD }}
|
GRAFANA_ADMIN_PASSWORD: ${{ secrets.PROD_GRAFANA_ADMIN_PASSWORD }}
|
||||||
TELEGRAM_BOT_TOKEN: ${{ secrets.PROD_TELEGRAM_BOT_TOKEN }}
|
TELEGRAM_BOT_TOKEN: ${{ secrets.PROD_TELEGRAM_BOT_TOKEN }}
|
||||||
GATEWAY_VK_APP_SECRET: ${{ secrets.GATEWAY_VK_APP_SECRET }}
|
GATEWAY_VK_APP_SECRET: ${{ secrets.GATEWAY_VK_APP_SECRET }}
|
||||||
|
# Robokassa direct-rail (backend BACKEND_ROBOKASSA_*): the prod shop login + the pass phrases
|
||||||
|
# that sign the launch request / verify the Result callback, and the test-mode flag — a var so
|
||||||
|
# go-live is a flag flip, not a secret redeploy ("1" runs test payments against the test
|
||||||
|
# passwords; empty/"0" is live). An empty login leaves the direct rail disabled.
|
||||||
|
ROBOKASSA_MERCHANT_LOGIN: ${{ secrets.PROD_BACKEND_ROBOKASSA_MERCHANT_LOGIN }}
|
||||||
|
ROBOKASSA_PASSWORD1: ${{ secrets.PROD_BACKEND_ROBOKASSA_PASSWORD1 }}
|
||||||
|
ROBOKASSA_PASSWORD2: ${{ secrets.PROD_BACKEND_ROBOKASSA_PASSWORD2 }}
|
||||||
|
ROBOKASSA_TEST: ${{ vars.PROD_BACKEND_ROBOKASSA_TEST }}
|
||||||
# VK ID web login: the "Web" app id (the gateway reuses it as GATEWAY_VK_ID_APP_ID at
|
# VK ID web login: the "Web" app id (the gateway reuses it as GATEWAY_VK_ID_APP_ID at
|
||||||
# runtime) + the app's protected key. Both shared across contours. The redirect URL is
|
# runtime) + the app's protected key. Both shared across contours. The redirect URL is
|
||||||
# derived from PUBLIC_BASE_URL in deploy/write-prod-env.sh.
|
# derived from PUBLIC_BASE_URL in deploy/write-prod-env.sh.
|
||||||
@@ -107,6 +115,11 @@ jobs:
|
|||||||
# Planted honeytoken bearer: presenting it earns a 24h IP ban + a high-severity alarm.
|
# Planted honeytoken bearer: presenting it earns a 24h IP ban + a high-severity alarm.
|
||||||
# Per-contour secret; empty = trap off. Rendered by deploy/write-prod-env.sh.
|
# Per-contour secret; empty = trap off. Rendered by deploy/write-prod-env.sh.
|
||||||
GATEWAY_HONEYTOKEN: ${{ secrets.PROD_GATEWAY_HONEYTOKEN }}
|
GATEWAY_HONEYTOKEN: ${{ secrets.PROD_GATEWAY_HONEYTOKEN }}
|
||||||
|
# Community IP blocklist (Spamhaus DROP): opt-in. Set _ENABLED=true + _URL (the feed) once
|
||||||
|
# verified; _ALLOW is a comma-separated never-block set (own infra). Empty ⇒ off.
|
||||||
|
GATEWAY_BLOCKLIST_ENABLED: ${{ vars.PROD_GATEWAY_BLOCKLIST_ENABLED }}
|
||||||
|
GATEWAY_BLOCKLIST_URL: ${{ vars.PROD_GATEWAY_BLOCKLIST_URL }}
|
||||||
|
GATEWAY_BLOCKLIST_ALLOW: ${{ vars.PROD_GATEWAY_BLOCKLIST_ALLOW }}
|
||||||
# Signs the finished-game export download URLs (backend BACKEND_EXPORT_SIGN_KEY).
|
# Signs the finished-game export download URLs (backend BACKEND_EXPORT_SIGN_KEY).
|
||||||
EXPORT_SIGN_KEY: ${{ secrets.PROD_EXPORT_SIGN_KEY }}
|
EXPORT_SIGN_KEY: ${{ secrets.PROD_EXPORT_SIGN_KEY }}
|
||||||
# Transactional email via the shared Selectel relay (confirm-codes): one account for
|
# Transactional email via the shared Selectel relay (confirm-codes): one account for
|
||||||
|
|||||||
@@ -61,9 +61,19 @@ jobs:
|
|||||||
# the SAME env.sh (email / VK login / Grafana alerts survive a rollback). TELEGRAM_MINIAPP_URL
|
# the SAME env.sh (email / VK login / Grafana alerts survive a rollback). TELEGRAM_MINIAPP_URL
|
||||||
# and GRAFANA_ROOT_URL are derived from PUBLIC_BASE_URL in deploy/write-prod-env.sh.
|
# and GRAFANA_ROOT_URL are derived from PUBLIC_BASE_URL in deploy/write-prod-env.sh.
|
||||||
GATEWAY_VK_APP_SECRET: ${{ secrets.GATEWAY_VK_APP_SECRET }}
|
GATEWAY_VK_APP_SECRET: ${{ secrets.GATEWAY_VK_APP_SECRET }}
|
||||||
|
# Robokassa direct-rail: the rollback re-renders the same runtime env (write-prod-env.sh), so
|
||||||
|
# it must carry the same credentials or the direct rail goes dark after a rollback.
|
||||||
|
ROBOKASSA_MERCHANT_LOGIN: ${{ secrets.PROD_BACKEND_ROBOKASSA_MERCHANT_LOGIN }}
|
||||||
|
ROBOKASSA_PASSWORD1: ${{ secrets.PROD_BACKEND_ROBOKASSA_PASSWORD1 }}
|
||||||
|
ROBOKASSA_PASSWORD2: ${{ secrets.PROD_BACKEND_ROBOKASSA_PASSWORD2 }}
|
||||||
|
ROBOKASSA_TEST: ${{ vars.PROD_BACKEND_ROBOKASSA_TEST }}
|
||||||
VITE_VK_APP_ID: ${{ vars.VITE_VK_APP_ID }}
|
VITE_VK_APP_ID: ${{ vars.VITE_VK_APP_ID }}
|
||||||
GATEWAY_VK_ID_CLIENT_SECRET: ${{ secrets.GATEWAY_VK_ID_CLIENT_SECRET }}
|
GATEWAY_VK_ID_CLIENT_SECRET: ${{ secrets.GATEWAY_VK_ID_CLIENT_SECRET }}
|
||||||
GATEWAY_HONEYTOKEN: ${{ secrets.PROD_GATEWAY_HONEYTOKEN }}
|
GATEWAY_HONEYTOKEN: ${{ secrets.PROD_GATEWAY_HONEYTOKEN }}
|
||||||
|
# Community IP blocklist — rendered on rollback too so a rollback keeps the same edge policy.
|
||||||
|
GATEWAY_BLOCKLIST_ENABLED: ${{ vars.PROD_GATEWAY_BLOCKLIST_ENABLED }}
|
||||||
|
GATEWAY_BLOCKLIST_URL: ${{ vars.PROD_GATEWAY_BLOCKLIST_URL }}
|
||||||
|
GATEWAY_BLOCKLIST_ALLOW: ${{ vars.PROD_GATEWAY_BLOCKLIST_ALLOW }}
|
||||||
EXPORT_SIGN_KEY: ${{ secrets.PROD_EXPORT_SIGN_KEY }}
|
EXPORT_SIGN_KEY: ${{ secrets.PROD_EXPORT_SIGN_KEY }}
|
||||||
SMTP_RELAY_USER: ${{ secrets.SMTP_RELAY_USER }}
|
SMTP_RELAY_USER: ${{ secrets.SMTP_RELAY_USER }}
|
||||||
SMTP_RELAY_PASS: ${{ secrets.SMTP_RELAY_PASS }}
|
SMTP_RELAY_PASS: ${{ secrets.SMTP_RELAY_PASS }}
|
||||||
|
|||||||
@@ -36,8 +36,8 @@ status — without re-deriving decisions.
|
|||||||
| E4 | Durability (PITR) | 2 | DONE |
|
| E4 | Durability (PITR) | 2 | DONE |
|
||||||
| E5 | Payment intake | 2 | DONE |
|
| E5 | Payment intake | 2 | DONE |
|
||||||
| E6 | Ads | 2 | DONE |
|
| E6 | Ads | 2 | DONE |
|
||||||
| E7 | Admin, reports & catalog | 2 | TODO |
|
| E7 | Admin, reports & catalog | 2 | DONE |
|
||||||
| E8 | Guest limits | — | TODO |
|
| E8 | Guest limits | — | DONE |
|
||||||
| E9 | Tournament fee | future | TODO |
|
| E9 | Tournament fee | future | TODO |
|
||||||
|
|
||||||
**Release 1** = full mechanics with no real money, exercised via `admin_grant` (E0→E1→E2→E3).
|
**Release 1** = full mechanics with no real money, exercised via `admin_grant` (E0→E1→E2→E3).
|
||||||
@@ -695,7 +695,7 @@ it tunes without a store release.
|
|||||||
|
|
||||||
## E7 — Admin, reports & catalog
|
## E7 — Admin, reports & catalog
|
||||||
|
|
||||||
**Status:** TODO · **Release 2** · depends on: E2 (ledger/grant/spend), E5 (payments/refunds),
|
**Status:** DONE · **Release 2** · depends on: E2 (ledger/grant/spend), E5 (payments/refunds),
|
||||||
E6 (D31 retired the legacy `hint_balance`/`paid_account`) · mechanics: PAYMENTS §11, §12, D32.
|
E6 (D31 retired the legacy `hint_balance`/`paid_account`) · mechanics: PAYMENTS §11, §12, D32.
|
||||||
|
|
||||||
**Delivery & baked decisions (this planning round).** A linear PR stack into `development`.
|
**Delivery & baked decisions (this planning round).** A linear PR stack into `development`.
|
||||||
@@ -763,15 +763,16 @@ ledger export — plus the **configurable product catalog editor** (D32).
|
|||||||
products + prices and delete only never-transacted ones; grant concrete values raw or by-product
|
products + prices and delete only never-transacted ones; grant concrete values raw or by-product
|
||||||
(origin-picked, never chips/tournament); refund an order in full; export the ledger.
|
(origin-picked, never chips/tournament); refund an order in full; export the ledger.
|
||||||
|
|
||||||
**PR stack (linear into `development`).**
|
**PR stack (linear into `development`, all merged).**
|
||||||
|
|
||||||
1. **Per-user financial panel** (read-only ledger/segments/benefits on the user card; retires the
|
1. ~~**Per-user financial panel**~~ (#230) — read-only ledger/segments/benefits on the user card;
|
||||||
`PaidAccount`/`HintBalance` display).
|
retired the `PaidAccount`/`HintBalance` display.
|
||||||
2. **Catalog editor** (product/atom/price CRUD + archive/unarchive + delete-if-clean + shape
|
2. ~~**Catalog editor**~~ (#231) — product/atom/price CRUD + archive/unarchive + delete-if-clean +
|
||||||
validation).
|
shape validation.
|
||||||
3. **Admin grant** (raw + by-product, refuse chips/tournament, origin-picked, `admin_grant` +
|
3. ~~**Admin grant**~~ (#232, + the D31 hint-wallet wire cleanup that surfaced there) — raw +
|
||||||
snapshot).
|
by-product, refuse chips/tournament, origin-picked, `admin_grant` + snapshot.
|
||||||
4. **Manual refund** (full order via E5 `Refund`) + **ledger export** (CSV/JSON).
|
4. ~~**Manual refund** (full order via E5 `Refund`) + **ledger CSV export**~~ — `RefundOrderFull`
|
||||||
|
(idempotent, floor-0 revoke) on each fund row; `/_gm/ledger.csv`.
|
||||||
|
|
||||||
**Notes/risks.** High-blast-radius (money, ledger — append-only, trigger-enforced). No mixed-in
|
**Notes/risks.** High-blast-radius (money, ledger — append-only, trigger-enforced). No mixed-in
|
||||||
refactors. The catalog editor becomes the source of truth for products; the contour SQL seeds
|
refactors. The catalog editor becomes the source of truth for products; the contour SQL seeds
|
||||||
@@ -781,38 +782,88 @@ become bootstrap-only.
|
|||||||
|
|
||||||
## E8 — Guest limits
|
## E8 — Guest limits
|
||||||
|
|
||||||
**Status:** TODO · **standalone** (game-behaviour change; can run in parallel) · depends on:
|
**Status:** DONE · **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** — DONE.~~ `Profile.game_limits` (the caller's tier) + `GameView.kind` (FBS
|
||||||
|
+ gateway transcode + client codec, committed regen); the client counts active games per kind from
|
||||||
|
the lobby cache (`gamelimits.ts`) and locks a capped new-game start — an **outline 🔒** button that
|
||||||
|
opens `GameLimitModal` instead of a game, native (Telegram `showPopup`) or the in-app `Modal`
|
||||||
|
elsewhere, with two messages by tier: a **guest** sign-in funnel ("Войдите или создайте учётную
|
||||||
|
запись…", Отмена / Вход→`/settings`) and, for a **durable** account at its cap, a plain notice
|
||||||
|
("Вы достигли лимита одновременных игр, сначала завершите текущие", ОК). The lock lifts via the
|
||||||
|
existing profile re-fetch after a guest→durable upgrade.
|
||||||
|
- **Decision (owner-agreed): the lobby's old `at_game_limit` New-Game tab-disable + notice is
|
||||||
|
removed.** The `at_game_limit` flag (now the random-kind cap) conflicted with the per-kind lock —
|
||||||
|
it hid the New-Game screen where the lock lives, and wrongly blocked starting an unfulfilled kind.
|
||||||
|
The tab is always enabled; the per-kind lock on the start button is the only gate. The wire field
|
||||||
|
`GameList.at_game_limit` stays (unused by the client) for a later cleanup.
|
||||||
|
|
||||||
**Tests.**
|
**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`).
|
||||||
|
- unit + UI (PR2, done): the client lock logic (`gamelimits.ts` — count/cap/lock), the codec kind +
|
||||||
|
game_limits roundtrip, the gateway transcode game_limits encode, the popup builders, and a mock e2e
|
||||||
|
(`gamelimit.spec.ts`) — a capped start shows 🔒, opens the modal without navigating, and the lock
|
||||||
|
clears when the profile refetch lifts the cap.
|
||||||
|
|
||||||
**Done-criteria.** 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.
|
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
|
|||||||
+19
-6
@@ -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,
|
||||||
@@ -146,7 +151,15 @@ funding segment, benefits per origin, the recorded refund risk, and the append-o
|
|||||||
(`/_gm/catalog`, `handlers_admin_catalog.go`) is the source of truth for products (D32): create /
|
(`/_gm/catalog`, `handlers_admin_catalog.go`) is the source of truth for products (D32): create /
|
||||||
edit / archive-unarchive (the `product.active` flag) products, their atoms and per-rail prices, and
|
edit / archive-unarchive (the `product.active` flag) products, their atoms and per-rail prices, and
|
||||||
hard-delete only a **never-transacted** product (an order/ledger reference forces archive-only,
|
hard-delete only a **never-transacted** product (an order/ledger reference forces archive-only,
|
||||||
backed by the FK); a `tournament`-bearing product is composable but not sellable yet. The shared wire
|
backed by the FK); a `tournament`-bearing product is composable but not sellable yet. The user card
|
||||||
|
also carries an admin **grant** panel: grant raw benefit atoms (hints / no-ads days / forever) or a
|
||||||
|
defined **value product** (a reward bundle, including an archived one), origin-picked; both write an
|
||||||
|
`admin_grant` ledger row via `payments.Grant` / `GrantProduct` and **refuse** a chips or `tournament`
|
||||||
|
atom (never grant currency; no tournament target yet). Each fund row in the panel carries a **Refund**
|
||||||
|
action (`payments.RefundOrderFull`): a full-order refund the operator records after refunding on the
|
||||||
|
rail — a `refund` ledger row + a floor-0 chip revoke, idempotent. A **ledger CSV export**
|
||||||
|
(`/_gm/ledger.csv`, `payments.LedgerExport`) dumps the whole append-only ledger for tax +
|
||||||
|
reconciliation. The shared wire
|
||||||
contracts live in the sibling [`../pkg`](../pkg) module.
|
contracts live in the sibling [`../pkg`](../pkg) module.
|
||||||
|
|
||||||
**Account linking & merge** (`/api/v1/user/link/*`). `internal/link`
|
**Account linking & merge** (`/api/v1/user/link/*`). `internal/link`
|
||||||
|
|||||||
@@ -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))
|
||||||
@@ -271,6 +283,13 @@ func run(ctx context.Context, cfg config.Config, logger *zap.Logger) error {
|
|||||||
}
|
}
|
||||||
logger.Info("payments domain ready")
|
logger.Info("payments domain ready")
|
||||||
|
|
||||||
|
// Warm the public-offer price list cache so /offer/ serves the current catalog from the first
|
||||||
|
// request; it is reprojected lazily thereafter on any catalog edit. Non-fatal — a transient
|
||||||
|
// failure here only defers the projection to the first read.
|
||||||
|
if _, err := paymentsSvc.OfferPricing(ctx); err != nil {
|
||||||
|
logger.Warn("offer pricing warm failed; will project on first request", zap.Error(err))
|
||||||
|
}
|
||||||
|
|
||||||
// Wire the payments surface into the domains that consume it: the online-game hint wallet
|
// Wire the payments surface into the domains that consume it: the online-game hint wallet
|
||||||
// and the account-merge wallet fold. Done after the reachability check so a broken payments
|
// and the account-merge wallet fold. Done after the reachability check so a broken payments
|
||||||
// schema fails boot before anything depends on it.
|
// schema fails boot before anything depends on it.
|
||||||
@@ -305,6 +324,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,6 +8,7 @@
|
|||||||
<li><b>Dictionary</b> {{.DictVersion}}</li>
|
<li><b>Dictionary</b> {{.DictVersion}}</li>
|
||||||
<li><b>Status</b> {{.Status}}{{if .EndReason}} ({{.EndReason}}){{end}}</li>
|
<li><b>Status</b> {{.Status}}{{if .EndReason}} ({{.EndReason}}){{end}}</li>
|
||||||
<li><b>AI game</b> {{if .VsAI}}🤖 yes{{else}}no{{end}}</li>
|
<li><b>AI game</b> {{if .VsAI}}🤖 yes{{else}}no{{end}}</li>
|
||||||
|
<li><b>Word rule</b> {{if .MultipleWordsPerTurn}}multiple words per turn{{else}}single word per turn{{end}}</li>
|
||||||
<li><b>Players</b> {{.Players}}</li>
|
<li><b>Players</b> {{.Players}}</li>
|
||||||
<li><b>To move</b> seat {{.ToMove}}</li>
|
<li><b>To move</b> seat {{.ToMove}}</li>
|
||||||
<li><b>Moves</b> {{.MoveCount}}</li>
|
<li><b>Moves</b> {{.MoveCount}}</li>
|
||||||
|
|||||||
@@ -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}}
|
||||||
@@ -73,18 +73,41 @@
|
|||||||
</ul>
|
</ul>
|
||||||
{{else}}<p class="note">no balances or benefits</p>{{end}}
|
{{else}}<p class="note">no balances or benefits</p>{{end}}
|
||||||
<h3>Ledger</h3>
|
<h3>Ledger</h3>
|
||||||
|
{{$uid := .ID}}
|
||||||
{{if .Finance.Ledger}}
|
{{if .Finance.Ledger}}
|
||||||
<table class="list">
|
<table class="list">
|
||||||
<thead><tr><th>Time</th><th>Kind</th><th>Source</th><th>Origin</th><th>Chips</th><th>Order</th><th>Provider</th><th>Detail</th></tr></thead>
|
<thead><tr><th>Time</th><th>Kind</th><th>Source</th><th>Origin</th><th>Chips</th><th>Order</th><th>Provider</th><th>Detail</th><th></th></tr></thead>
|
||||||
<tbody>
|
<tbody>
|
||||||
{{range .Finance.Ledger}}
|
{{range .Finance.Ledger}}
|
||||||
<tr><td>{{.At}}</td><td>{{.Kind}}</td><td>{{.Source}}</td><td>{{.Origin}}</td><td>{{.ChipsDelta}}</td><td>{{if .Order}}<code>{{.Order}}</code>{{end}}</td><td>{{.Provider}}</td><td>{{if .Snapshot}}<code>{{.Snapshot}}</code>{{end}}</td></tr>
|
<tr><td>{{.At}}</td><td>{{.Kind}}</td><td>{{.Source}}</td><td>{{.Origin}}</td><td>{{.ChipsDelta}}</td><td>{{if .Order}}<code>{{.Order}}</code>{{end}}</td><td>{{.Provider}}</td><td>{{if .Snapshot}}<code>{{.Snapshot}}</code>{{end}}</td>
|
||||||
|
<td>{{if and (eq .Kind "fund") .Order}}<form class="form" method="post" action="/_gm/users/{{$uid}}/refund" onsubmit="return confirm('Refund this order in full? Record the money refund on the rail first; this revokes the chips (floored at 0).')"><input type="hidden" name="order_id" value="{{.Order}}"><button type="submit">Refund</button></form>{{end}}</td></tr>
|
||||||
{{end}}
|
{{end}}
|
||||||
</tbody>
|
</tbody>
|
||||||
</table>
|
</table>
|
||||||
|
<p class="note"><a href="/_gm/ledger.csv">Export the full ledger (CSV)</a> — all accounts, for tax + reconciliation.</p>
|
||||||
{{else}}<p class="note">no ledger entries</p>{{end}}
|
{{else}}<p class="note">no ledger entries</p>{{end}}
|
||||||
{{else}}<p class="note">payments not enabled</p>{{end}}
|
{{else}}<p class="note">payments not enabled</p>{{end}}
|
||||||
</section>
|
</section>
|
||||||
|
<section class="panel"><h2>Grant benefits</h2>
|
||||||
|
{{if .Grant.Present}}
|
||||||
|
<p class="note">A zero-price admin sale of a value — <strong>never chips</strong>. The origin is your compliance choice. The by-product grant applies a defined bundle, including an archived reward product.</p>
|
||||||
|
<form class="form col" method="post" action="/_gm/users/{{.ID}}/grant">
|
||||||
|
<label>Origin <select name="origin">{{range .Grant.Origins}}<option value="{{.}}">{{.}}</option>{{end}}</select></label>
|
||||||
|
<label>Hints <input type="number" name="hints" min="0" value="0"></label>
|
||||||
|
<label>No-ads days <input type="number" name="noads" min="0" value="0"></label>
|
||||||
|
<label><input type="checkbox" name="forever" value="true"> No-ads forever</label>
|
||||||
|
<div><button type="submit">Grant</button></div>
|
||||||
|
</form>
|
||||||
|
{{if .Grant.Products}}
|
||||||
|
<h3>Grant a product</h3>
|
||||||
|
<form class="form col" method="post" action="/_gm/users/{{.ID}}/grant-product">
|
||||||
|
<label>Origin <select name="origin">{{range .Grant.Origins}}<option value="{{.}}">{{.}}</option>{{end}}</select></label>
|
||||||
|
<label>Product <select name="product_id">{{range .Grant.Products}}<option value="{{.ID}}">{{.Title}} ({{.Summary}}){{if .Archived}} — archived{{end}}</option>{{end}}</select></label>
|
||||||
|
<div><button type="submit">Grant product</button></div>
|
||||||
|
</form>
|
||||||
|
{{else}}<p class="note">no grantable products — create a value product in the <a href="/_gm/catalog">catalog</a></p>{{end}}
|
||||||
|
{{else}}<p class="note">payments not enabled</p>{{end}}
|
||||||
|
</section>
|
||||||
<section class="panel"><h2>Roles</h2>
|
<section class="panel"><h2>Roles</h2>
|
||||||
{{$id := .ID}}
|
{{$id := .ID}}
|
||||||
{{if .Roles}}
|
{{if .Roles}}
|
||||||
@@ -188,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>
|
||||||
|
|||||||
@@ -198,6 +198,9 @@ type UserDetailView struct {
|
|||||||
// Finance is the account's payments picture (balances, benefits, refund risk, ledger). Present
|
// Finance is the account's payments picture (balances, benefits, refund risk, ledger). Present
|
||||||
// is false when the payments domain is unwired.
|
// is false when the payments domain is unwired.
|
||||||
Finance FinanceView
|
Finance FinanceView
|
||||||
|
// Grant is the admin-grant panel (origin picker + grantable products). Present is false when the
|
||||||
|
// payments domain is unwired.
|
||||||
|
Grant GrantFormView
|
||||||
}
|
}
|
||||||
|
|
||||||
// FinanceView is the account's payments picture on the user card: chip balances per funding
|
// FinanceView is the account's payments picture on the user card: chip balances per funding
|
||||||
@@ -309,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.
|
||||||
@@ -318,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
|
||||||
@@ -332,8 +348,12 @@ type GameDetailView struct {
|
|||||||
UpdatedAt string
|
UpdatedAt string
|
||||||
FinishedAt string
|
FinishedAt string
|
||||||
// VsAI marks an honest-AI game (shown as a 🤖 flag in the summary).
|
// VsAI marks an honest-AI game (shown as a 🤖 flag in the summary).
|
||||||
VsAI bool
|
VsAI bool
|
||||||
Seats []SeatRow
|
// MultipleWordsPerTurn is the game's cross-word rule: true = standard Scrabble (every cross-word
|
||||||
|
// is validated and scored), false = the single-word rule (only the main word along the play
|
||||||
|
// direction counts). Shown in the summary so an operator can tell the rule at a glance.
|
||||||
|
MultipleWordsPerTurn bool
|
||||||
|
Seats []SeatRow
|
||||||
// HasRobot is true when any seat is a robot, gating the robot-target caption;
|
// HasRobot is true when any seat is a robot, gating the robot-target caption;
|
||||||
// RobotTargetPct is the configured global play-to-win rate, in percent.
|
// RobotTargetPct is the configured global play-to-win rate, in percent.
|
||||||
HasRobot bool
|
HasRobot bool
|
||||||
@@ -701,3 +721,21 @@ type ProductFormView struct {
|
|||||||
PriceChip int64
|
PriceChip int64
|
||||||
Transacted bool
|
Transacted bool
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// GrantFormView is the admin-grant panel on the user card: the origin picker and the grantable
|
||||||
|
// products (value bundles — hints / no-ads days — including archived ones; chips and tournament
|
||||||
|
// products are excluded). Present is false when the payments domain is unwired.
|
||||||
|
type GrantFormView struct {
|
||||||
|
Present bool
|
||||||
|
Origins []string
|
||||||
|
Products []GrantProductOption
|
||||||
|
}
|
||||||
|
|
||||||
|
// GrantProductOption is one grantable product in the by-product picker: its id, title, an atom
|
||||||
|
// summary, and whether it is archived (the common case for a non-public reward bundle).
|
||||||
|
type GrantProductOption struct {
|
||||||
|
ID string
|
||||||
|
Title string
|
||||||
|
Summary string
|
||||||
|
Archived bool
|
||||||
|
}
|
||||||
|
|||||||
@@ -52,6 +52,7 @@ func gameSummary(g Game, names []string) notify.GameSummary {
|
|||||||
TurnTimeoutSecs: int(g.TurnTimeout.Seconds()),
|
TurnTimeoutSecs: int(g.TurnTimeout.Seconds()),
|
||||||
MultipleWordsPerTurn: g.MultipleWordsPerTurn,
|
MultipleWordsPerTurn: g.MultipleWordsPerTurn,
|
||||||
VsAI: g.VsAI,
|
VsAI: g.VsAI,
|
||||||
|
Kind: int(g.Kind),
|
||||||
MoveCount: g.MoveCount,
|
MoveCount: g.MoveCount,
|
||||||
EndReason: g.EndReason,
|
EndReason: g.EndReason,
|
||||||
Seats: seats,
|
Seats: seats,
|
||||||
@@ -74,7 +75,6 @@ func playerState(v StateView, names []string, includeAlphabet bool) (notify.Play
|
|||||||
Rack: rack,
|
Rack: rack,
|
||||||
BagLen: v.BagLen,
|
BagLen: v.BagLen,
|
||||||
HintsRemaining: v.HintsRemaining,
|
HintsRemaining: v.HintsRemaining,
|
||||||
WalletBalance: v.WalletBalance,
|
|
||||||
}
|
}
|
||||||
if includeAlphabet {
|
if includeAlphabet {
|
||||||
tab, err := engine.AlphabetTable(v.Game.Variant)
|
tab, err := engine.AlphabetTable(v.Game.Variant)
|
||||||
|
|||||||
@@ -55,16 +55,16 @@ func TestPayloadExchangeRoundTrip(t *testing.T) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
func TestHintsRemaining(t *testing.T) {
|
func TestHintsRemaining(t *testing.T) {
|
||||||
cases := []struct{ allowance, used, wallet, want int }{
|
cases := []struct{ allowance, used, want int }{
|
||||||
{1, 0, 3, 4},
|
{1, 0, 1},
|
||||||
{1, 1, 3, 3},
|
{1, 1, 0},
|
||||||
{1, 2, 3, 3}, // used past allowance clamps to 0
|
{1, 2, 0}, // used past allowance clamps to 0
|
||||||
{0, 0, 5, 5},
|
{3, 1, 2},
|
||||||
{2, 1, 0, 1},
|
{0, 0, 0},
|
||||||
}
|
}
|
||||||
for _, c := range cases {
|
for _, c := range cases {
|
||||||
if got := hintsRemaining(c.allowance, c.used, c.wallet); got != c.want {
|
if got := hintsRemaining(c.allowance, c.used); got != c.want {
|
||||||
t.Errorf("hintsRemaining(%d,%d,%d) = %d, want %d", c.allowance, c.used, c.wallet, got, c.want)
|
t.Errorf("hintsRemaining(%d,%d) = %d, want %d", c.allowance, c.used, got, c.want)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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
|
||||||
@@ -1207,7 +1244,7 @@ func (svc *Service) Hint(ctx context.Context, gameID, accountID uuid.UUID) (Hint
|
|||||||
return HintResult{}, err
|
return HintResult{}, err
|
||||||
}
|
}
|
||||||
used++
|
used++
|
||||||
return HintResult{Move: move, HintsRemaining: hintsRemaining(pre.HintsPerPlayer, used, walletAfter), WalletBalance: walletAfter}, nil
|
return HintResult{Move: move, HintsRemaining: hintsRemaining(pre.HintsPerPlayer, used), WalletBalance: walletAfter}, nil
|
||||||
}
|
}
|
||||||
|
|
||||||
// Candidates returns the to-move player's legal plays for a seated player on
|
// Candidates returns the to-move player's legal plays for a seated player on
|
||||||
@@ -1290,11 +1327,9 @@ func (svc *Service) GameState(ctx context.Context, gameID, accountID uuid.UUID)
|
|||||||
Seat: seat,
|
Seat: seat,
|
||||||
Rack: g.Hand(seat),
|
Rack: g.Hand(seat),
|
||||||
BagLen: g.BagLen(),
|
BagLen: g.BagLen(),
|
||||||
// The hint wallet moved to payments (svc.hintWallet); the deprecated accounts.hint_balance
|
// HintsRemaining is the per-seat allowance only; the purchasable wallet lives on the profile
|
||||||
// is no longer read, so the wire wallet is 0 and HintsRemaining is the per-seat allowance.
|
// (payments) and the client adds it (lib/hints.hintsLeft).
|
||||||
// The client adds the profile's payments hint balance on top (lib/hints.hintsLeft).
|
HintsRemaining: hintsRemaining(pre.HintsPerPlayer, pre.Seats[seat].HintsUsed),
|
||||||
HintsRemaining: hintsRemaining(pre.HintsPerPlayer, pre.Seats[seat].HintsUsed, 0),
|
|
||||||
WalletBalance: 0,
|
|
||||||
// vs_ai idle-hint gate (seconds left; 0 for a human game / first move / not your turn).
|
// vs_ai idle-hint gate (seconds left; 0 for a human game / first move / not your turn).
|
||||||
HintUnlockLeftSeconds: hintUnlockLeftSeconds(pre, seat, svc.clock()),
|
HintUnlockLeftSeconds: hintUnlockLeftSeconds(pre, seat, svc.clock()),
|
||||||
}, nil
|
}, nil
|
||||||
@@ -1388,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.
|
||||||
@@ -1775,10 +1802,10 @@ func (svc *Service) DictBytes(variant engine.Variant, version string) ([]byte, e
|
|||||||
return svc.registry.DictBytes(variant, version)
|
return svc.registry.DictBytes(variant, version)
|
||||||
}
|
}
|
||||||
|
|
||||||
// hintsRemaining is a player's remaining hint budget: the unspent per-game
|
// hintsRemaining is the unspent per-game hint allowance. The purchasable wallet is separate,
|
||||||
// allowance plus the profile wallet.
|
// carried on the profile (payments), and the client adds it (lib/hints.hintsLeft).
|
||||||
func hintsRemaining(allowance, used, wallet int) int {
|
func hintsRemaining(allowance, used int) int {
|
||||||
return max(0, allowance-used) + wallet
|
return max(0, allowance-used)
|
||||||
}
|
}
|
||||||
|
|
||||||
// allowedTimeout reports whether d is one of the offered move clocks.
|
// allowedTimeout reports whether d is one of the offered move clocks.
|
||||||
|
|||||||
@@ -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.
|
||||||
@@ -211,10 +210,9 @@ type MoveResult struct {
|
|||||||
BagLen int
|
BagLen int
|
||||||
}
|
}
|
||||||
|
|
||||||
// HintResult is a revealed hint and the requesting player's remaining hint
|
// HintResult is a revealed hint with the per-seat allowance remaining (HintsRemaining) and the
|
||||||
// budget (per-seat allowance plus profile wallet) after spending one. WalletBalance is
|
// purchasable hint wallet after spending one (WalletBalance, from payments). The client adopts
|
||||||
// the global wallet alone, so the client can keep its live wallet authoritative and
|
// WalletBalance into the profile so the badge stays live across games (lib/hints).
|
||||||
// re-derive the per-game allowance (HintsRemaining - WalletBalance).
|
|
||||||
type HintResult struct {
|
type HintResult struct {
|
||||||
Move engine.MoveRecord
|
Move engine.MoveRecord
|
||||||
HintsRemaining int
|
HintsRemaining int
|
||||||
@@ -241,9 +239,6 @@ type StateView struct {
|
|||||||
Rack []string
|
Rack []string
|
||||||
BagLen int
|
BagLen int
|
||||||
HintsRemaining int
|
HintsRemaining int
|
||||||
// WalletBalance is the player's global hint-wallet balance alone (HintsRemaining folds
|
|
||||||
// it in with the per-game allowance), so the client keeps the wallet live across games.
|
|
||||||
WalletBalance int
|
|
||||||
// HintUnlockLeftSeconds is, for a vs_ai game on the requesting player's turn, the seconds left
|
// HintUnlockLeftSeconds is, for a vs_ai game on the requesting player's turn, the seconds left
|
||||||
// until the idle hint unlocks (the robot's last move plus the idle window, from the server clock);
|
// until the idle hint unlocks (the robot's last move plus the idle window, from the server clock);
|
||||||
// 0 for the human's first move, when it is not their turn, or a non-vs_ai game. The vs_ai hint is
|
// 0 for the human's first move, when it is not their turn, or a non-vs_ai game. The vs_ai hint is
|
||||||
|
|||||||
@@ -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)
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,79 @@
|
|||||||
|
//go:build integration
|
||||||
|
|
||||||
|
package inttest
|
||||||
|
|
||||||
|
import (
|
||||||
|
"context"
|
||||||
|
"net/http"
|
||||||
|
"strings"
|
||||||
|
"testing"
|
||||||
|
|
||||||
|
"github.com/google/uuid"
|
||||||
|
|
||||||
|
"scrabble/backend/internal/payments"
|
||||||
|
)
|
||||||
|
|
||||||
|
// benefitFor returns the account's benefit on the given origin from its statement.
|
||||||
|
func benefitFor(t *testing.T, pay *payments.Service, id uuid.UUID, origin payments.Source) payments.OriginBenefit {
|
||||||
|
t.Helper()
|
||||||
|
stmt, err := pay.AccountStatement(context.Background(), id)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("statement: %v", err)
|
||||||
|
}
|
||||||
|
for _, b := range stmt.Benefits {
|
||||||
|
if b.Origin == origin {
|
||||||
|
return b
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return payments.OriginBenefit{}
|
||||||
|
}
|
||||||
|
|
||||||
|
// TestConsoleAdminGrant drives the admin grant: a raw benefit grant, a by-product grant of a reward
|
||||||
|
// bundle, and a refusal to grant a chips pack; the create is CSRF-guarded.
|
||||||
|
func TestConsoleAdminGrant(t *testing.T) {
|
||||||
|
ctx := context.Background()
|
||||||
|
srv, _, pay := bannerServer(t)
|
||||||
|
h := srv.Handler()
|
||||||
|
id := provisionAccount(t)
|
||||||
|
const origin = "http://admin.test"
|
||||||
|
base := "http://admin.test/_gm/users/" + id.String()
|
||||||
|
|
||||||
|
// CSRF: a grant without the origin header is refused.
|
||||||
|
if code, _ := consoleDo(h, http.MethodPost, base+"/grant", "origin=direct&hints=1", ""); code != http.StatusForbidden {
|
||||||
|
t.Fatalf("grant without origin = %d, want 403", code)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Raw grant: 5 hints + 30 no-ads days to the direct origin.
|
||||||
|
if code, body := consoleDo(h, http.MethodPost, base+"/grant", "origin=direct&hints=5&noads=30", origin); code != http.StatusOK || !strings.Contains(body, "Granted") {
|
||||||
|
t.Fatalf("raw grant = %d, has 'Granted' = %v", code, strings.Contains(body, "Granted"))
|
||||||
|
}
|
||||||
|
if b := benefitFor(t, pay, id, payments.SourceDirect); b.Hints != 5 || b.AdsPaidUntil.IsZero() {
|
||||||
|
t.Fatalf("after raw grant: hints=%d adsUntil-zero=%v, want 5 hints + a no-ads term", b.Hints, b.AdsPaidUntil.IsZero())
|
||||||
|
}
|
||||||
|
|
||||||
|
// By-product grant: an archived reward bundle (3 hints) to vk.
|
||||||
|
reward, err := pay.CreateProduct(ctx, payments.ProductInput{
|
||||||
|
Title: "reward-3-hints", Atoms: []payments.AtomLine{{Atom: "hints", Quantity: 3}},
|
||||||
|
}, false)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("create reward: %v", err)
|
||||||
|
}
|
||||||
|
if code, body := consoleDo(h, http.MethodPost, base+"/grant-product", "origin=vk&product_id="+reward.String(), origin); code != http.StatusOK || !strings.Contains(body, "Granted") {
|
||||||
|
t.Fatalf("product grant = %d, has 'Granted' = %v", code, strings.Contains(body, "Granted"))
|
||||||
|
}
|
||||||
|
if b := benefitFor(t, pay, id, payments.SourceVK); b.Hints != 3 {
|
||||||
|
t.Fatalf("after product grant: vk hints=%d, want 3", b.Hints)
|
||||||
|
}
|
||||||
|
|
||||||
|
// A chips pack cannot be granted.
|
||||||
|
pack, err := pay.CreateProduct(ctx, payments.ProductInput{
|
||||||
|
Title: "grant-pack", Atoms: []payments.AtomLine{{Atom: "chips", Quantity: 100}},
|
||||||
|
Prices: []payments.PriceLine{{Method: "direct", Currency: payments.CurrencyRUB, Amount: 14900}},
|
||||||
|
}, true)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("create pack: %v", err)
|
||||||
|
}
|
||||||
|
if code, body := consoleDo(h, http.MethodPost, base+"/grant-product", "origin=direct&product_id="+pack.String(), origin); code != http.StatusOK || !strings.Contains(body, "cannot grant chips") {
|
||||||
|
t.Fatalf("chips grant = %d, has 'cannot grant chips' = %v", code, strings.Contains(body, "cannot grant chips"))
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,77 @@
|
|||||||
|
//go:build integration
|
||||||
|
|
||||||
|
package inttest
|
||||||
|
|
||||||
|
import (
|
||||||
|
"context"
|
||||||
|
"net/http"
|
||||||
|
"strings"
|
||||||
|
"testing"
|
||||||
|
|
||||||
|
"scrabble/backend/internal/payments"
|
||||||
|
)
|
||||||
|
|
||||||
|
// TestConsoleRefundAndExport drives the manual refund and the ledger CSV export: a funded order is
|
||||||
|
// refunded in full (chips revoked, a refund row), the refund is idempotent, and the export carries
|
||||||
|
// the fund + refund rows; the refund POST is CSRF-guarded.
|
||||||
|
func TestConsoleRefundAndExport(t *testing.T) {
|
||||||
|
ctx := context.Background()
|
||||||
|
srv, _, pay := bannerServer(t)
|
||||||
|
h := srv.Handler()
|
||||||
|
id := provisionAccount(t)
|
||||||
|
const origin = "http://admin.test"
|
||||||
|
base := "http://admin.test/_gm/users/" + id.String()
|
||||||
|
|
||||||
|
// Fund a pack: 100 chips + a fund ledger row.
|
||||||
|
prod := seedPackProduct(t, 100, methodPrice{method: "direct", currency: "RUB", amount: 14900})
|
||||||
|
res, err := pay.CreateOrder(ctx, id, payments.NewContext("direct", "web"), []payments.Source{payments.SourceDirect}, prod, "robokassa")
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("order: %v", err)
|
||||||
|
}
|
||||||
|
paid, _ := payments.MoneyFromMinor(14900, payments.CurrencyRUB)
|
||||||
|
if _, err := pay.Fund(ctx, res.OrderID, "robokassa", res.OrderID.String(), paid); err != nil {
|
||||||
|
t.Fatalf("fund: %v", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
// CSRF: a refund without the origin header is refused.
|
||||||
|
if code, _ := consoleDo(h, http.MethodPost, base+"/refund", "order_id="+res.OrderID.String(), ""); code != http.StatusForbidden {
|
||||||
|
t.Fatalf("refund without origin = %d, want 403", code)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Refund the order in full → 100 chips revoked (none spent).
|
||||||
|
if code, body := consoleDo(h, http.MethodPost, base+"/refund", "order_id="+res.OrderID.String(), origin); code != http.StatusOK || !strings.Contains(body, "revoked 100 chips") {
|
||||||
|
t.Fatalf("refund = %d, has 'revoked 100 chips' = %v", code, strings.Contains(body, "revoked 100 chips"))
|
||||||
|
}
|
||||||
|
stmt, err := pay.AccountStatement(ctx, id)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("statement: %v", err)
|
||||||
|
}
|
||||||
|
for _, sg := range stmt.Segments {
|
||||||
|
if sg.Source == payments.SourceDirect && sg.Chips != 0 {
|
||||||
|
t.Errorf("after refund: direct chips = %d, want 0", sg.Chips)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
kinds := map[string]int{}
|
||||||
|
for _, e := range stmt.Ledger {
|
||||||
|
kinds[e.Kind]++
|
||||||
|
}
|
||||||
|
if kinds["fund"] != 1 || kinds["refund"] != 1 {
|
||||||
|
t.Errorf("ledger kinds = %v, want one fund + one refund", kinds)
|
||||||
|
}
|
||||||
|
|
||||||
|
// A second refund of the same order is idempotent.
|
||||||
|
if code, body := consoleDo(h, http.MethodPost, base+"/refund", "order_id="+res.OrderID.String(), origin); code != http.StatusOK || !strings.Contains(body, "Already refunded") {
|
||||||
|
t.Fatalf("second refund = %d, has 'Already refunded' = %v", code, strings.Contains(body, "Already refunded"))
|
||||||
|
}
|
||||||
|
|
||||||
|
// Export the whole ledger as CSV: the header, this account, and its fund + refund rows.
|
||||||
|
code, body := consoleDo(h, http.MethodGet, "http://admin.test/_gm/ledger.csv", "", "")
|
||||||
|
if code != http.StatusOK {
|
||||||
|
t.Fatalf("export = %d, want 200", code)
|
||||||
|
}
|
||||||
|
for _, want := range []string{"created_at,account_id,kind", id.String(), ",fund,", ",refund,"} {
|
||||||
|
if !strings.Contains(body, want) {
|
||||||
|
t.Errorf("ledger CSV missing %q", want)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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.
|
||||||
|
|||||||
@@ -424,13 +424,13 @@ func TestHintPolicy(t *testing.T) {
|
|||||||
t.Fatalf("first hint: %v", err)
|
t.Fatalf("first hint: %v", err)
|
||||||
}
|
}
|
||||||
// The allowance is spent before the wallet: with an empty wallet, the state now reports no
|
// The allowance is spent before the wallet: with an empty wallet, the state now reports no
|
||||||
// hints left and a zero wallet, so the per-game allowance (HintsRemaining-WalletBalance) is 0.
|
// per-game allowance left (HintsRemaining is the allowance alone; the wallet lives on the profile).
|
||||||
st, err := svc.GameState(ctx, g.ID, seats[0])
|
st, err := svc.GameState(ctx, g.ID, seats[0])
|
||||||
if err != nil {
|
if err != nil {
|
||||||
t.Fatalf("state: %v", err)
|
t.Fatalf("state: %v", err)
|
||||||
}
|
}
|
||||||
if st.HintsRemaining != 0 || st.WalletBalance != 0 {
|
if st.HintsRemaining != 0 {
|
||||||
t.Errorf("after allowance hint: hints=%d wallet=%d, want 0/0", st.HintsRemaining, st.WalletBalance)
|
t.Errorf("after allowance hint: hints=%d, want 0", st.HintsRemaining)
|
||||||
}
|
}
|
||||||
if _, err := svc.Hint(ctx, g.ID, seats[0]); !errors.Is(err, game.ErrNoHintsLeft) {
|
if _, err := svc.Hint(ctx, g.ID, seats[0]); !errors.Is(err, game.ErrNoHintsLeft) {
|
||||||
t.Fatalf("second hint = %v, want ErrNoHintsLeft", err)
|
t.Fatalf("second hint = %v, want ErrNoHintsLeft", err)
|
||||||
@@ -444,9 +444,10 @@ func TestHintPolicy(t *testing.T) {
|
|||||||
if err != nil {
|
if err != nil {
|
||||||
t.Fatalf("wallet hint: %v", err)
|
t.Fatalf("wallet hint: %v", err)
|
||||||
}
|
}
|
||||||
// The allowance stays exhausted; the wallet dropped 2->1, and WalletBalance carries it alone.
|
// The allowance stays exhausted (HintsRemaining is the allowance alone, so 0); the wallet dropped
|
||||||
if res.HintsRemaining != 1 || res.WalletBalance != 1 {
|
// 2->1 and WalletBalance carries it (the client adopts it into the profile).
|
||||||
t.Errorf("wallet hint: hints=%d wallet=%d, want 1/1", res.HintsRemaining, res.WalletBalance)
|
if res.HintsRemaining != 0 || res.WalletBalance != 1 {
|
||||||
|
t.Errorf("wallet hint: hints=%d wallet=%d, want 0/1", res.HintsRemaining, res.WalletBalance)
|
||||||
}
|
}
|
||||||
// game_players.hints_used counts BOTH hints (1 allowance + 1 wallet) — the per-game total
|
// game_players.hints_used counts BOTH hints (1 allowance + 1 wallet) — the per-game total
|
||||||
// that feeds the player's lifetime hint statistics, not just the allowance.
|
// that feeds the player's lifetime hint statistics, not just the allowance.
|
||||||
|
|||||||
@@ -4,6 +4,7 @@ package inttest
|
|||||||
|
|
||||||
import (
|
import (
|
||||||
"context"
|
"context"
|
||||||
|
"strings"
|
||||||
"testing"
|
"testing"
|
||||||
|
|
||||||
"github.com/google/uuid"
|
"github.com/google/uuid"
|
||||||
@@ -111,3 +112,44 @@ func TestPaymentsCatalogExcludesDeactivated(t *testing.T) {
|
|||||||
t.Error("deactivated product must not appear in the storefront")
|
t.Error("deactivated product must not appear in the storefront")
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// TestOfferPricingReflectsCatalogEdits verifies the public-offer price list (§4.4) is projected from
|
||||||
|
// the live catalog and its cache is invalidated on a catalog mutation: a newly created pack appears
|
||||||
|
// with its per-rail prices, and archiving it through the service drops it from the next read.
|
||||||
|
func TestOfferPricingReflectsCatalogEdits(t *testing.T) {
|
||||||
|
svc := newPaymentsService()
|
||||||
|
ctx := context.Background()
|
||||||
|
title := "OfferTest " + uuid.NewString()
|
||||||
|
id, err := svc.CreateProduct(ctx, payments.ProductInput{
|
||||||
|
Title: title,
|
||||||
|
Atoms: []payments.AtomLine{{Atom: "chips", Quantity: 50}},
|
||||||
|
Prices: []payments.PriceLine{
|
||||||
|
{Method: "direct", Currency: payments.CurrencyRUB, Amount: 20000},
|
||||||
|
{Method: "vk", Currency: payments.CurrencyVote, Amount: 30},
|
||||||
|
{Method: "telegram", Currency: payments.CurrencyStar, Amount: 100},
|
||||||
|
},
|
||||||
|
}, true)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("create pack: %v", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
md, err := svc.OfferPricing(ctx)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("offer pricing: %v", err)
|
||||||
|
}
|
||||||
|
if row := "| " + title + " | 200.00 | 30 | 100 |"; !strings.Contains(md, row) {
|
||||||
|
t.Fatalf("offer pricing missing the new pack row %q\n%s", row, md)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Archiving through the service marks the cache stale; the next read must reproject without it.
|
||||||
|
if err := svc.SetProductActive(ctx, id, false); err != nil {
|
||||||
|
t.Fatalf("archive: %v", err)
|
||||||
|
}
|
||||||
|
md, err = svc.OfferPricing(ctx)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("offer pricing after archive: %v", err)
|
||||||
|
}
|
||||||
|
if strings.Contains(md, title) {
|
||||||
|
t.Errorf("archived pack must drop from the offer pricing:\n%s", md)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|||||||
@@ -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
|
||||||
|
|||||||
@@ -37,6 +37,7 @@ func toWireGame(g GameSummary) wire.GameView {
|
|||||||
TurnTimeoutSecs: g.TurnTimeoutSecs,
|
TurnTimeoutSecs: g.TurnTimeoutSecs,
|
||||||
MultipleWordsPerTurn: g.MultipleWordsPerTurn,
|
MultipleWordsPerTurn: g.MultipleWordsPerTurn,
|
||||||
VsAI: g.VsAI,
|
VsAI: g.VsAI,
|
||||||
|
Kind: g.Kind,
|
||||||
MoveCount: g.MoveCount,
|
MoveCount: g.MoveCount,
|
||||||
EndReason: g.EndReason,
|
EndReason: g.EndReason,
|
||||||
Seats: seats,
|
Seats: seats,
|
||||||
@@ -83,7 +84,6 @@ func buildStateView(b *flatbuffers.Builder, s PlayerState) flatbuffers.UOffsetT
|
|||||||
Rack: s.Rack,
|
Rack: s.Rack,
|
||||||
BagLen: s.BagLen,
|
BagLen: s.BagLen,
|
||||||
HintsRemaining: s.HintsRemaining,
|
HintsRemaining: s.HintsRemaining,
|
||||||
WalletBalance: s.WalletBalance,
|
|
||||||
Alphabet: alphabet,
|
Alphabet: alphabet,
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -115,7 +115,7 @@ func TestGameOverPayloadRoundTrips(t *testing.T) {
|
|||||||
func TestOpponentMovedPayloadRoundTrips(t *testing.T) {
|
func TestOpponentMovedPayloadRoundTrips(t *testing.T) {
|
||||||
uid, gid := uuid.New(), uuid.New()
|
uid, gid := uuid.New(), uuid.New()
|
||||||
move := engine.MoveRecord{Player: 1, Action: engine.ActionPlay, Words: []string{"STOOL"}, Score: 24, Total: 130}
|
move := engine.MoveRecord{Player: 1, Action: engine.ActionPlay, Words: []string{"STOOL"}, Score: 24, Total: 130}
|
||||||
summary := notify.GameSummary{ID: gid.String(), MoveCount: 9, ToMove: 0, Seats: []notify.SeatStanding{{Seat: 1, Score: 130}}}
|
summary := notify.GameSummary{ID: gid.String(), MoveCount: 9, ToMove: 0, Kind: 2, Seats: []notify.SeatStanding{{Seat: 1, Score: 130}}}
|
||||||
in := notify.OpponentMoved(uid, gid, move, summary, 42)
|
in := notify.OpponentMoved(uid, gid, move, summary, 42)
|
||||||
if in.Kind != notify.KindOpponentMoved {
|
if in.Kind != notify.KindOpponentMoved {
|
||||||
t.Fatalf("kind = %q", in.Kind)
|
t.Fatalf("kind = %q", in.Kind)
|
||||||
@@ -132,7 +132,7 @@ func TestOpponentMovedPayloadRoundTrips(t *testing.T) {
|
|||||||
if m == nil || m.Player() != 1 || string(m.Action()) != "play" || m.Total() != 130 {
|
if m == nil || m.Player() != 1 || string(m.Action()) != "play" || m.Total() != 130 {
|
||||||
t.Fatalf("move wrong: %+v", m)
|
t.Fatalf("move wrong: %+v", m)
|
||||||
}
|
}
|
||||||
if g := ev.Game(nil); g == nil || g.MoveCount() != 9 || g.ToMove() != 0 {
|
if g := ev.Game(nil); g == nil || g.MoveCount() != 9 || g.ToMove() != 0 || g.Kind() != 2 {
|
||||||
t.Fatalf("game summary wrong: %+v", ev.Game(nil))
|
t.Fatalf("game summary wrong: %+v", ev.Game(nil))
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -35,6 +35,9 @@ type GameSummary struct {
|
|||||||
EndReason string
|
EndReason string
|
||||||
Seats []SeatStanding
|
Seats []SeatStanding
|
||||||
LastActivityUnix int64
|
LastActivityUnix int64
|
||||||
|
// Kind is the game's origin for the active-game limits (0 unknown, 1 vs_ai, 2 random, 3 friends);
|
||||||
|
// it rides live events so a lobby patch keeps the per-kind count correct.
|
||||||
|
Kind int
|
||||||
}
|
}
|
||||||
|
|
||||||
// AlphabetLetter is one variant alphabet entry (a display-only row) embedded in an
|
// AlphabetLetter is one variant alphabet entry (a display-only row) embedded in an
|
||||||
@@ -56,7 +59,6 @@ type PlayerState struct {
|
|||||||
Rack []int
|
Rack []int
|
||||||
BagLen int
|
BagLen int
|
||||||
HintsRemaining int
|
HintsRemaining int
|
||||||
WalletBalance int
|
|
||||||
Alphabet []AlphabetLetter
|
Alphabet []AlphabetLetter
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -1,9 +1,12 @@
|
|||||||
package payments
|
package payments
|
||||||
|
|
||||||
import (
|
import (
|
||||||
|
"cmp"
|
||||||
"context"
|
"context"
|
||||||
"errors"
|
"errors"
|
||||||
"fmt"
|
"fmt"
|
||||||
|
"math"
|
||||||
|
"slices"
|
||||||
"strings"
|
"strings"
|
||||||
|
|
||||||
"github.com/google/uuid"
|
"github.com/google/uuid"
|
||||||
@@ -147,13 +150,76 @@ func (s *Service) AdminCatalog(ctx context.Context) ([]AdminProduct, error) {
|
|||||||
return s.store.adminCatalog(ctx)
|
return s.store.adminCatalog(ctx)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// SortAdminCatalog orders an admin catalog list in place the same way the public offer lists products
|
||||||
|
// ([projectOfferPricing]): chip packs first, ascending by rouble price; then chip-priced values,
|
||||||
|
// grouped (hints only, no-ads only, no-ads + hints, tournament) and ascending by chip price within a
|
||||||
|
// group. It is stable, so equal-keyed products keep their incoming order, and it keys archived
|
||||||
|
// products the same as active ones (the admin list shows both).
|
||||||
|
func SortAdminCatalog(products []AdminProduct) {
|
||||||
|
slices.SortStableFunc(products, func(a, b AdminProduct) int {
|
||||||
|
if pa, pb := adminIsPack(a), adminIsPack(b); pa != pb {
|
||||||
|
if pa {
|
||||||
|
return -1 // packs (sales) before values (chip exchange)
|
||||||
|
}
|
||||||
|
return 1
|
||||||
|
} else if pa {
|
||||||
|
return cmp.Compare(adminPriceAmount(a, string(SourceDirect), CurrencyRUB), adminPriceAmount(b, string(SourceDirect), CurrencyRUB))
|
||||||
|
}
|
||||||
|
if d := cmp.Compare(adminValueGroup(a), adminValueGroup(b)); d != 0 {
|
||||||
|
return d
|
||||||
|
}
|
||||||
|
return cmp.Compare(adminPriceAmount(a, "", CurrencyChip), adminPriceAmount(b, "", CurrencyChip))
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
// adminIsPack reports whether the product is a chip pack (it carries the chips atom).
|
||||||
|
func adminIsPack(p AdminProduct) bool {
|
||||||
|
for _, a := range p.Atoms {
|
||||||
|
if a.Atom == atomChips {
|
||||||
|
return true
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
|
||||||
|
// adminValueGroup ranks a chip-priced value into the shared listing groups (see [valueGroup]).
|
||||||
|
func adminValueGroup(p AdminProduct) int {
|
||||||
|
hasHints, hasNoAds, hasTournament := false, false, false
|
||||||
|
for _, a := range p.Atoms {
|
||||||
|
switch a.Atom {
|
||||||
|
case "hints":
|
||||||
|
hasHints = true
|
||||||
|
case "noads_days":
|
||||||
|
hasNoAds = true
|
||||||
|
case "tournament":
|
||||||
|
hasTournament = true
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return valueGroup(hasHints, hasNoAds, hasTournament)
|
||||||
|
}
|
||||||
|
|
||||||
|
// adminPriceAmount returns the minor-unit amount of the product's price for the method and currency,
|
||||||
|
// or math.MaxInt64 when it carries no such price (so a misconfigured product sorts last, not first).
|
||||||
|
func adminPriceAmount(p AdminProduct, method string, cur Currency) int64 {
|
||||||
|
for _, pr := range p.Prices {
|
||||||
|
if pr.Method == method && pr.Currency == cur {
|
||||||
|
return pr.Amount
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return math.MaxInt64
|
||||||
|
}
|
||||||
|
|
||||||
// CreateProduct validates and inserts a new product with its atoms and prices, returning its id. A
|
// CreateProduct validates and inserts a new product with its atoms and prices, returning its id. A
|
||||||
// product created active must satisfy the sellable shape.
|
// product created active must satisfy the sellable shape.
|
||||||
func (s *Service) CreateProduct(ctx context.Context, in ProductInput, active bool) (uuid.UUID, error) {
|
func (s *Service) CreateProduct(ctx context.Context, in ProductInput, active bool) (uuid.UUID, error) {
|
||||||
if err := validateProduct(in, active); err != nil {
|
if err := validateProduct(in, active); err != nil {
|
||||||
return uuid.Nil, err
|
return uuid.Nil, err
|
||||||
}
|
}
|
||||||
return s.store.createProduct(ctx, in, active, s.clock())
|
id, err := s.store.createProduct(ctx, in, active, s.clock())
|
||||||
|
if err == nil {
|
||||||
|
s.markOfferStale()
|
||||||
|
}
|
||||||
|
return id, err
|
||||||
}
|
}
|
||||||
|
|
||||||
// UpdateProduct validates and replaces a product's title, atoms and prices. An active product must
|
// UpdateProduct validates and replaces a product's title, atoms and prices. An active product must
|
||||||
@@ -166,7 +232,11 @@ func (s *Service) UpdateProduct(ctx context.Context, id uuid.UUID, in ProductInp
|
|||||||
if err := validateProduct(in, active); err != nil {
|
if err := validateProduct(in, active); err != nil {
|
||||||
return err
|
return err
|
||||||
}
|
}
|
||||||
return s.store.updateProduct(ctx, id, in, s.clock())
|
if err := s.store.updateProduct(ctx, id, in, s.clock()); err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
s.markOfferStale()
|
||||||
|
return nil
|
||||||
}
|
}
|
||||||
|
|
||||||
// SetProductActive archives (active=false) or unarchives a product. Unarchiving revalidates the
|
// SetProductActive archives (active=false) or unarchives a product. Unarchiving revalidates the
|
||||||
@@ -181,11 +251,19 @@ func (s *Service) SetProductActive(ctx context.Context, id uuid.UUID, active boo
|
|||||||
return err
|
return err
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
return s.store.setProductActive(ctx, id, active, s.clock())
|
if err := s.store.setProductActive(ctx, id, active, s.clock()); err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
s.markOfferStale()
|
||||||
|
return nil
|
||||||
}
|
}
|
||||||
|
|
||||||
// DeleteProduct hard-deletes a product only when it has never been transacted (no order or ledger
|
// DeleteProduct hard-deletes a product only when it has never been transacted (no order or ledger
|
||||||
// row references it); otherwise it returns ErrProductTransacted and the caller archives instead.
|
// row references it); otherwise it returns ErrProductTransacted and the caller archives instead.
|
||||||
func (s *Service) DeleteProduct(ctx context.Context, id uuid.UUID) error {
|
func (s *Service) DeleteProduct(ctx context.Context, id uuid.UUID) error {
|
||||||
return s.store.deleteProduct(ctx, id)
|
if err := s.store.deleteProduct(ctx, id); err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
s.markOfferStale()
|
||||||
|
return nil
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -134,3 +134,47 @@ func TestProjectCatalog_Empty(t *testing.T) {
|
|||||||
t.Errorf("empty catalog projected %d products, want 0", len(got.Products))
|
t.Errorf("empty catalog projected %d products, want 0", len(got.Products))
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// TestSortAdminCatalog checks the admin list is ordered like the public offer: chip packs first,
|
||||||
|
// ascending by rouble price; then values, grouped (hints only -> no-ads only -> no-ads + hints) and
|
||||||
|
// ascending by chip price within a group. Stable and applied to active + archived alike.
|
||||||
|
func TestSortAdminCatalog(t *testing.T) {
|
||||||
|
pack := func(title string, rub int64) AdminProduct {
|
||||||
|
return AdminProduct{
|
||||||
|
Title: title,
|
||||||
|
Atoms: []AtomLine{{Atom: atomChips, Quantity: 1}},
|
||||||
|
Prices: []PriceLine{{Method: string(SourceDirect), Currency: CurrencyRUB, Amount: rub}},
|
||||||
|
}
|
||||||
|
}
|
||||||
|
value := func(title string, chips int64, atoms ...string) AdminProduct {
|
||||||
|
p := AdminProduct{Title: title, Prices: []PriceLine{{Currency: CurrencyChip, Amount: chips}}}
|
||||||
|
for _, a := range atoms {
|
||||||
|
p.Atoms = append(p.Atoms, AtomLine{Atom: a, Quantity: 1})
|
||||||
|
}
|
||||||
|
return p
|
||||||
|
}
|
||||||
|
products := []AdminProduct{
|
||||||
|
pack("packDear", 30000),
|
||||||
|
value("bundle", 500, "hints", "noads_days"),
|
||||||
|
pack("packCheap", 10000),
|
||||||
|
value("adsOnly", 150, "noads_days"),
|
||||||
|
value("hintsBig", 200, "hints"),
|
||||||
|
value("hintsSmall", 50, "hints"),
|
||||||
|
}
|
||||||
|
SortAdminCatalog(products)
|
||||||
|
want := []string{"packCheap", "packDear", "hintsSmall", "hintsBig", "adsOnly", "bundle"}
|
||||||
|
for i, p := range products {
|
||||||
|
if p.Title != want[i] {
|
||||||
|
t.Fatalf("order[%d] = %q, want %q (full: %v)", i, p.Title, want[i], titles(products))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// titles extracts the product titles for a failure message.
|
||||||
|
func titles(products []AdminProduct) []string {
|
||||||
|
out := make([]string, len(products))
|
||||||
|
for i, p := range products {
|
||||||
|
out[i] = p.Title
|
||||||
|
}
|
||||||
|
return out
|
||||||
|
}
|
||||||
|
|||||||
@@ -0,0 +1,218 @@
|
|||||||
|
package payments
|
||||||
|
|
||||||
|
import (
|
||||||
|
"cmp"
|
||||||
|
"context"
|
||||||
|
"fmt"
|
||||||
|
"math"
|
||||||
|
"slices"
|
||||||
|
"strings"
|
||||||
|
)
|
||||||
|
|
||||||
|
// pricingMarker is the token the owner-edited offer markdown (ui/legal/offer_ru.md, §4.4) carries
|
||||||
|
// where the price list belongs. The render sidecar replaces it with the markdown [Service.OfferPricing]
|
||||||
|
// returns before rendering the /offer/ page; the backend only produces the tables, never the marker.
|
||||||
|
const pricingMarker = "<#pricing_template#>"
|
||||||
|
|
||||||
|
// OfferPricing returns the public-offer price list (§4.4) as two markdown tables projected from the
|
||||||
|
// active catalog: first the chip packs (funding chips with money, priced per rail — roubles / VK
|
||||||
|
// votes / Telegram Stars), then the chip-priced values (what a player exchanges chips for). The
|
||||||
|
// result is cached in memory and reprojected only after a catalog mutation (see [Service.markOfferStale]),
|
||||||
|
// so a steady-state read issues no query — only the first read after an edit reprojects. The render
|
||||||
|
// sidecar fetches it and splices it into the offer markdown at the pricing marker.
|
||||||
|
func (s *Service) OfferPricing(ctx context.Context) (string, error) {
|
||||||
|
s.offerMu.Lock()
|
||||||
|
defer s.offerMu.Unlock()
|
||||||
|
if s.offerFresh {
|
||||||
|
return s.offerMD, nil
|
||||||
|
}
|
||||||
|
md, err := s.buildOfferPricing(ctx)
|
||||||
|
if err != nil {
|
||||||
|
return "", err
|
||||||
|
}
|
||||||
|
s.offerMD = md
|
||||||
|
s.offerFresh = true
|
||||||
|
return md, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// markOfferStale marks the cached offer price list for reprojection on the next [Service.OfferPricing]
|
||||||
|
// read. Every catalog mutation calls it; it takes no I/O, so it never fails the mutation that triggers it.
|
||||||
|
func (s *Service) markOfferStale() {
|
||||||
|
s.offerMu.Lock()
|
||||||
|
s.offerFresh = false
|
||||||
|
s.offerMu.Unlock()
|
||||||
|
}
|
||||||
|
|
||||||
|
// buildOfferPricing loads the active catalog and projects it into the offer tables.
|
||||||
|
func (s *Service) buildOfferPricing(ctx context.Context) (string, error) {
|
||||||
|
entries, err := s.store.loadCatalog(ctx)
|
||||||
|
if err != nil {
|
||||||
|
return "", err
|
||||||
|
}
|
||||||
|
return projectOfferPricing(entries), nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// projectOfferPricing renders the active catalog into the two offer tables. A chip pack (it carries
|
||||||
|
// the chips atom) lists its per-rail money price; a value (no chips atom) lists its uniform chip
|
||||||
|
// price. Packs are ordered by ascending rouble price; values are grouped by what they grant (hints
|
||||||
|
// only, then no-ads only, then no-ads + hints, then tournament — see [offerValueGroup]) and, within
|
||||||
|
// each group, ordered by ascending chip price. Price columns are right-aligned. An empty section is
|
||||||
|
// omitted. Amounts are rendered through [Money] so no floating point ever reaches the page (roubles
|
||||||
|
// show kopecks as "200.00", whole-unit rails as integers); a missing rail price shows an em dash.
|
||||||
|
func projectOfferPricing(entries []catalogEntry) string {
|
||||||
|
var packs, values []catalogEntry
|
||||||
|
for _, e := range entries {
|
||||||
|
if isPackEntry(e) {
|
||||||
|
packs = append(packs, e)
|
||||||
|
} else {
|
||||||
|
values = append(values, e)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Packs: ascending by the rouble price (the offer's base currency); a pack with no rouble price
|
||||||
|
// sorts last. Values: by group, then ascending chip price. Stable, so the catalog order breaks ties.
|
||||||
|
slices.SortStableFunc(packs, func(a, b catalogEntry) int {
|
||||||
|
return cmp.Compare(offerSortAmount(a, string(SourceDirect), CurrencyRUB), offerSortAmount(b, string(SourceDirect), CurrencyRUB))
|
||||||
|
})
|
||||||
|
slices.SortStableFunc(values, func(a, b catalogEntry) int {
|
||||||
|
if d := cmp.Compare(offerValueGroup(a), offerValueGroup(b)); d != 0 {
|
||||||
|
return d
|
||||||
|
}
|
||||||
|
return cmp.Compare(offerSortAmount(a, "", CurrencyChip), offerSortAmount(b, "", CurrencyChip))
|
||||||
|
})
|
||||||
|
|
||||||
|
var b strings.Builder
|
||||||
|
if len(packs) > 0 {
|
||||||
|
b.WriteString("Приобретение внутриигровой валюты «Фишка»:\n\n")
|
||||||
|
b.WriteString("| Наименование | Рубли | Голоса в VK | Stars в Telegram |\n")
|
||||||
|
b.WriteString("| --- | ---: | ---: | ---: |\n")
|
||||||
|
for _, e := range packs {
|
||||||
|
fmt.Fprintf(&b, "| %s | %s | %s | %s |\n",
|
||||||
|
offerCell(e.title),
|
||||||
|
offerPrice(e, string(SourceDirect), CurrencyRUB),
|
||||||
|
offerPrice(e, string(SourceVK), CurrencyVote),
|
||||||
|
offerPrice(e, string(SourceTelegram), CurrencyStar),
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if len(values) > 0 {
|
||||||
|
if len(packs) > 0 {
|
||||||
|
b.WriteString("\n")
|
||||||
|
}
|
||||||
|
b.WriteString("Использование внутриигровой валюты «Фишка»:\n\n")
|
||||||
|
b.WriteString("| Наименование | «Фишки» |\n")
|
||||||
|
b.WriteString("| --- | ---: |\n")
|
||||||
|
for _, e := range values {
|
||||||
|
fmt.Fprintf(&b, "| %s | %s |\n", offerCell(e.title), offerPrice(e, "", CurrencyChip))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return strings.TrimRight(b.String(), "\n")
|
||||||
|
}
|
||||||
|
|
||||||
|
// offerValueGroup ranks a chip-priced value into the usage groups (see [valueGroup]).
|
||||||
|
func offerValueGroup(e catalogEntry) int {
|
||||||
|
hasHints, hasNoAds, hasTournament := false, false, false
|
||||||
|
for _, a := range e.atoms {
|
||||||
|
switch a.atomType {
|
||||||
|
case "hints":
|
||||||
|
hasHints = true
|
||||||
|
case "noads_days":
|
||||||
|
hasNoAds = true
|
||||||
|
case "tournament":
|
||||||
|
hasTournament = true
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return valueGroup(hasHints, hasNoAds, hasTournament)
|
||||||
|
}
|
||||||
|
|
||||||
|
// valueGroup ranks a chip-priced value into the listing groups shared by the public offer and the
|
||||||
|
// admin catalog, in listing order: hints only (0), no-ads only (1), no-ads + hints (2), then anything
|
||||||
|
// carrying the tournament atom (3). Tournament products are not sellable yet (validateProduct forbids
|
||||||
|
// an active one), so group 3 is empty today; the rank reserves their place for when the tournament
|
||||||
|
// economy lands. A value with no recognised benefit atom sorts after the known groups (defensive —
|
||||||
|
// the catalog shape forbids it).
|
||||||
|
func valueGroup(hasHints, hasNoAds, hasTournament bool) int {
|
||||||
|
switch {
|
||||||
|
case hasTournament:
|
||||||
|
return 3
|
||||||
|
case hasHints && hasNoAds:
|
||||||
|
return 2
|
||||||
|
case hasNoAds:
|
||||||
|
return 1
|
||||||
|
case hasHints:
|
||||||
|
return 0
|
||||||
|
default:
|
||||||
|
return 4
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// offerSortAmount returns the entry's price in the given method and currency for ordering, or
|
||||||
|
// math.MaxInt64 when it carries no such price, so a misconfigured row sorts last rather than leading.
|
||||||
|
func offerSortAmount(e catalogEntry, method string, cur Currency) int64 {
|
||||||
|
if amt, ok := offerAmount(e, method, cur); ok {
|
||||||
|
return amt
|
||||||
|
}
|
||||||
|
return math.MaxInt64
|
||||||
|
}
|
||||||
|
|
||||||
|
// isPackEntry reports whether the catalog entry is a chip pack — it carries the chips atom (funds
|
||||||
|
// chips with money) rather than being a chip-priced value.
|
||||||
|
func isPackEntry(e catalogEntry) bool {
|
||||||
|
for _, a := range e.atoms {
|
||||||
|
if a.atomType == atomChips {
|
||||||
|
return true
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
|
||||||
|
// offerPrice formats the entry's price for the given payment method and currency as a major-unit
|
||||||
|
// string, or an em dash when the entry carries no such price.
|
||||||
|
func offerPrice(e catalogEntry, method string, cur Currency) string {
|
||||||
|
amt, ok := offerAmount(e, method, cur)
|
||||||
|
if !ok {
|
||||||
|
return "—"
|
||||||
|
}
|
||||||
|
m, err := MoneyFromMinor(amt, cur)
|
||||||
|
if err != nil {
|
||||||
|
return "—"
|
||||||
|
}
|
||||||
|
return m.Major()
|
||||||
|
}
|
||||||
|
|
||||||
|
// offerAmount returns the raw minor-unit amount of the entry's price for the payment method and
|
||||||
|
// currency, and whether such a price exists.
|
||||||
|
func offerAmount(e catalogEntry, method string, cur Currency) (int64, bool) {
|
||||||
|
for _, pr := range e.prices {
|
||||||
|
if pr.method == method && pr.currency == cur {
|
||||||
|
return pr.amount, true
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return 0, false
|
||||||
|
}
|
||||||
|
|
||||||
|
// offerCellReplacer neutralises every metacharacter of an admin-entered title so it renders as
|
||||||
|
// literal text in the public offer. The title is operator input (the /_gm catalog editor) that flows
|
||||||
|
// into a markdown table cell and then through marked into the /offer/ HTML, which is deliberately not
|
||||||
|
// sanitised — so escaping here is the trust boundary. It covers HTML (no tag or entity reaches the
|
||||||
|
// page), the markdown table pipe and the row newline, and the link brackets (a title must never
|
||||||
|
// become a "javascript:" link). marked passes the entities through unchanged, so the reader sees the
|
||||||
|
// exact title. NewReplacer scans once and never re-scans its own output, so "&" → "&" does not
|
||||||
|
// double-escape the entities the other rules emit.
|
||||||
|
var offerCellReplacer = strings.NewReplacer(
|
||||||
|
"&", "&",
|
||||||
|
"<", "<",
|
||||||
|
">", ">",
|
||||||
|
`"`, """,
|
||||||
|
"'", "'",
|
||||||
|
"|", `\|`,
|
||||||
|
"[", `\[`,
|
||||||
|
"]", `\]`,
|
||||||
|
"\n", " ",
|
||||||
|
)
|
||||||
|
|
||||||
|
// offerCell escapes an admin-entered title for safe, literal rendering in a markdown table cell of
|
||||||
|
// the public offer (see [offerCellReplacer]).
|
||||||
|
func offerCell(s string) string {
|
||||||
|
return offerCellReplacer.Replace(s)
|
||||||
|
}
|
||||||
@@ -0,0 +1,137 @@
|
|||||||
|
package payments
|
||||||
|
|
||||||
|
import (
|
||||||
|
"strings"
|
||||||
|
"testing"
|
||||||
|
|
||||||
|
"github.com/google/uuid"
|
||||||
|
)
|
||||||
|
|
||||||
|
// TestProjectOfferPricing checks the happy path: a chip pack priced on every rail and a chip-priced
|
||||||
|
// value render into the two tables, pack table first, money formatted through Money.
|
||||||
|
func TestProjectOfferPricing(t *testing.T) {
|
||||||
|
entries := []catalogEntry{
|
||||||
|
{
|
||||||
|
id: uuid.New(),
|
||||||
|
title: "50 «Фишек»",
|
||||||
|
atoms: []atomQty{{atomType: atomChips, quantity: 50}},
|
||||||
|
prices: []priceRow{
|
||||||
|
{method: string(SourceDirect), currency: CurrencyRUB, amount: 20000},
|
||||||
|
{method: string(SourceVK), currency: CurrencyVote, amount: 30},
|
||||||
|
{method: string(SourceTelegram), currency: CurrencyStar, amount: 100},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: uuid.New(),
|
||||||
|
title: "200 подсказок",
|
||||||
|
atoms: []atomQty{{atomType: "hints", quantity: 200}},
|
||||||
|
prices: []priceRow{{method: "", currency: CurrencyChip, amount: 50}},
|
||||||
|
},
|
||||||
|
}
|
||||||
|
md := projectOfferPricing(entries)
|
||||||
|
for _, want := range []string{
|
||||||
|
"| Наименование | Рубли | Голоса в VK | Stars в Telegram |",
|
||||||
|
"| --- | ---: | ---: | ---: |", // price columns right-aligned
|
||||||
|
"| 50 «Фишек» | 200.00 | 30 | 100 |",
|
||||||
|
"| Наименование | «Фишки» |",
|
||||||
|
"| --- | ---: |",
|
||||||
|
"| 200 подсказок | 50 |",
|
||||||
|
} {
|
||||||
|
if !strings.Contains(md, want) {
|
||||||
|
t.Errorf("projection missing %q\n---\n%s", want, md)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if strings.Index(md, "Приобретение") > strings.Index(md, "Использование") {
|
||||||
|
t.Errorf("the pack table must precede the values table:\n%s", md)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// TestProjectOfferPricingOrdering checks packs sort by ascending rouble price, and values sort by
|
||||||
|
// group (hints only → no-ads only → no-ads + hints) then ascending chip price within a group.
|
||||||
|
func TestProjectOfferPricingOrdering(t *testing.T) {
|
||||||
|
pack := func(title string, rub int64) catalogEntry {
|
||||||
|
return catalogEntry{
|
||||||
|
id: uuid.New(),
|
||||||
|
title: title,
|
||||||
|
atoms: []atomQty{{atomType: atomChips, quantity: 1}},
|
||||||
|
prices: []priceRow{{method: string(SourceDirect), currency: CurrencyRUB, amount: rub}},
|
||||||
|
}
|
||||||
|
}
|
||||||
|
value := func(title string, chips int64, atoms ...string) catalogEntry {
|
||||||
|
e := catalogEntry{id: uuid.New(), title: title, prices: []priceRow{{method: "", currency: CurrencyChip, amount: chips}}}
|
||||||
|
for _, a := range atoms {
|
||||||
|
e.atoms = append(e.atoms, atomQty{atomType: a, quantity: 1})
|
||||||
|
}
|
||||||
|
return e
|
||||||
|
}
|
||||||
|
// Deliberately out of order on input.
|
||||||
|
entries := []catalogEntry{
|
||||||
|
pack("packDear", 30000),
|
||||||
|
pack("packCheap", 10000),
|
||||||
|
value("bundle", 500, "hints", "noads_days"),
|
||||||
|
value("adsOnly", 150, "noads_days"),
|
||||||
|
value("hintsBig", 200, "hints"),
|
||||||
|
value("hintsSmall", 50, "hints"),
|
||||||
|
}
|
||||||
|
md := projectOfferPricing(entries)
|
||||||
|
order := []string{"packCheap", "packDear", "hintsSmall", "hintsBig", "adsOnly", "bundle"}
|
||||||
|
last := -1
|
||||||
|
for _, title := range order {
|
||||||
|
i := strings.Index(md, "| "+title+" |")
|
||||||
|
if i < 0 {
|
||||||
|
t.Fatalf("row %q missing:\n%s", title, md)
|
||||||
|
}
|
||||||
|
if i < last {
|
||||||
|
t.Errorf("row %q out of order (want sequence %v):\n%s", title, order, md)
|
||||||
|
}
|
||||||
|
last = i
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// TestProjectOfferPricingMissingRailAndEscaping checks a pack missing a rail shows an em dash and a
|
||||||
|
// title carrying a pipe is escaped so the table layout survives.
|
||||||
|
func TestProjectOfferPricingMissingRailAndEscaping(t *testing.T) {
|
||||||
|
entries := []catalogEntry{{
|
||||||
|
id: uuid.New(),
|
||||||
|
title: "Bonus | pack",
|
||||||
|
atoms: []atomQty{{atomType: atomChips, quantity: 10}},
|
||||||
|
// A roubles price only — no VK, no Telegram.
|
||||||
|
prices: []priceRow{{method: string(SourceDirect), currency: CurrencyRUB, amount: 9900}},
|
||||||
|
}}
|
||||||
|
md := projectOfferPricing(entries)
|
||||||
|
if want := `| Bonus \| pack | 99.00 | — | — |`; !strings.Contains(md, want) {
|
||||||
|
t.Errorf("want row %q in:\n%s", want, md)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// TestProjectOfferPricingEscapesHTMLAndLinks checks an admin title carrying HTML or a markdown link
|
||||||
|
// is neutralised so it cannot inject markup into the public offer: the tag becomes entities and the
|
||||||
|
// link brackets are escaped (so no "javascript:" anchor forms). The raw metacharacters must not
|
||||||
|
// survive into the projected markdown.
|
||||||
|
func TestProjectOfferPricingEscapesHTMLAndLinks(t *testing.T) {
|
||||||
|
entries := []catalogEntry{{
|
||||||
|
id: uuid.New(),
|
||||||
|
title: `<script>alert(1)</script> [x](javascript:alert(2)) & "q"`,
|
||||||
|
atoms: []atomQty{{atomType: "hints", quantity: 1}},
|
||||||
|
prices: []priceRow{{method: "", currency: CurrencyChip, amount: 5}},
|
||||||
|
}}
|
||||||
|
md := projectOfferPricing(entries)
|
||||||
|
for _, bad := range []string{"<script>", "</script>", "[x]", `& "q"`} {
|
||||||
|
if strings.Contains(md, bad) {
|
||||||
|
t.Errorf("unescaped %q survived into the projection:\n%s", bad, md)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
for _, want := range []string{"<script>", `\[x\]`, "&", ""q""} {
|
||||||
|
if !strings.Contains(md, want) {
|
||||||
|
t.Errorf("want escaped %q in:\n%s", want, md)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// TestProjectOfferPricingEmpty checks an empty catalog projects to the empty string (no stray table
|
||||||
|
// headers), so the offer's pricing marker is replaced with nothing.
|
||||||
|
func TestProjectOfferPricingEmpty(t *testing.T) {
|
||||||
|
if got := projectOfferPricing(nil); got != "" {
|
||||||
|
t.Errorf("empty catalog must project to empty string, got %q", got)
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,41 @@
|
|||||||
|
package payments
|
||||||
|
|
||||||
|
import (
|
||||||
|
"context"
|
||||||
|
|
||||||
|
"github.com/google/uuid"
|
||||||
|
)
|
||||||
|
|
||||||
|
// providerAdmin tags an operator-initiated refund in the ledger, distinct from a rail's own refund
|
||||||
|
// (robokassa / vk / telegram). The refund idempotency key (providerAdmin, order id) allows exactly
|
||||||
|
// one manual full refund per order.
|
||||||
|
const providerAdmin = "admin"
|
||||||
|
|
||||||
|
// RefundOrderFull refunds a paid order in full at the operator's request: it revokes the funded
|
||||||
|
// chips best-effort (floored at 0, never negative — D27), records a refund ledger row, and is
|
||||||
|
// idempotent (a second call reports AlreadyRefunded). The operator performs the actual money refund
|
||||||
|
// on the rail (Robokassa cabinet / VK support / Telegram refundStarPayment); this records it.
|
||||||
|
func (s *Service) RefundOrderFull(ctx context.Context, orderID uuid.UUID) (RefundOutcome, error) {
|
||||||
|
o, err := s.store.orderByID(ctx, orderID)
|
||||||
|
if err != nil {
|
||||||
|
return RefundOutcome{}, err
|
||||||
|
}
|
||||||
|
refunded, err := MoneyFromMinor(o.expectedAmount, Currency(o.currency))
|
||||||
|
if err != nil {
|
||||||
|
return RefundOutcome{}, err
|
||||||
|
}
|
||||||
|
return s.store.refund(ctx, orderID, providerAdmin, orderID.String(), refunded, s.clock())
|
||||||
|
}
|
||||||
|
|
||||||
|
// LedgerExportRow is one append-only ledger row for the tax / reconciliation export, carrying the
|
||||||
|
// account it belongs to alongside the entry fields.
|
||||||
|
type LedgerExportRow struct {
|
||||||
|
AccountID string
|
||||||
|
LedgerEntry
|
||||||
|
}
|
||||||
|
|
||||||
|
// LedgerExport reads the entire append-only ledger (all accounts, newest first) for a CSV/JSON
|
||||||
|
// export — tax reporting and future rail reconciliation. Uncached, admin-only.
|
||||||
|
func (s *Service) LedgerExport(ctx context.Context) ([]LedgerExportRow, error) {
|
||||||
|
return s.store.allLedger(ctx)
|
||||||
|
}
|
||||||
@@ -5,6 +5,7 @@ import (
|
|||||||
"database/sql"
|
"database/sql"
|
||||||
"encoding/json"
|
"encoding/json"
|
||||||
"fmt"
|
"fmt"
|
||||||
|
"sync"
|
||||||
"time"
|
"time"
|
||||||
|
|
||||||
"github.com/google/uuid"
|
"github.com/google/uuid"
|
||||||
@@ -19,6 +20,13 @@ import (
|
|||||||
type Service struct {
|
type Service struct {
|
||||||
store *Store
|
store *Store
|
||||||
clock func() time.Time
|
clock func() time.Time
|
||||||
|
|
||||||
|
// offerMu guards the cached public-offer price list (§4.4). offerMD holds the projected
|
||||||
|
// markdown tables and offerFresh whether they are current; a catalog mutation clears offerFresh
|
||||||
|
// (markOfferStale) and the next OfferPricing read reprojects, so a served render issues no query.
|
||||||
|
offerMu sync.Mutex
|
||||||
|
offerMD string
|
||||||
|
offerFresh bool
|
||||||
}
|
}
|
||||||
|
|
||||||
// NewService constructs a Service over store with a wall-clock time source.
|
// NewService constructs a Service over store with a wall-clock time source.
|
||||||
@@ -156,6 +164,41 @@ func (s *Service) Grant(ctx context.Context, accountID uuid.UUID, origin Source,
|
|||||||
return s.store.grant(ctx, accountID, origin, d, snapshot, s.clock())
|
return s.store.grant(ctx, accountID, origin, d, snapshot, s.clock())
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// GrantProduct grants a product's benefit atoms (hints, no-ads days) to an origin as a zero-price
|
||||||
|
// admin sale, recording the source product on the ledger row (auditable to it). It refuses a
|
||||||
|
// product carrying the chips atom (never granted — D16) or the tournament atom (no credit target
|
||||||
|
// yet), and one whose atoms yield no grantable benefit.
|
||||||
|
func (s *Service) GrantProduct(ctx context.Context, accountID uuid.UUID, origin Source, productID uuid.UUID) error {
|
||||||
|
if !origin.Valid() {
|
||||||
|
return fmt.Errorf("payments: invalid grant origin %q", origin)
|
||||||
|
}
|
||||||
|
in, err := s.store.productInput(ctx, productID)
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
var d benefitDelta
|
||||||
|
for _, a := range in.Atoms {
|
||||||
|
switch a.Atom {
|
||||||
|
case "hints":
|
||||||
|
d.hintsAdd += a.Quantity
|
||||||
|
case "noads_days":
|
||||||
|
d.noAdsDays += a.Quantity
|
||||||
|
case atomChips:
|
||||||
|
return ErrCannotGrantChips
|
||||||
|
case "tournament":
|
||||||
|
return ErrCannotGrantTournament
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if d.zero() {
|
||||||
|
return ErrNothingToGrant
|
||||||
|
}
|
||||||
|
snapshot, err := marshalGrantProduct(productID, in.Title, d)
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
return s.store.grantProduct(ctx, accountID, origin, productID, d, snapshot, s.clock())
|
||||||
|
}
|
||||||
|
|
||||||
// MergeTx merges the secondary account's segments and benefits into the primary inside the
|
// MergeTx merges the secondary account's segments and benefits into the primary inside the
|
||||||
// caller's transaction (the account-merge flow). The caller invalidates the affected caches
|
// caller's transaction (the account-merge flow). The caller invalidates the affected caches
|
||||||
// after committing (Invalidate).
|
// after committing (Invalidate).
|
||||||
@@ -244,3 +287,20 @@ func marshalGrant(d benefitDelta) ([]byte, error) {
|
|||||||
}
|
}
|
||||||
return b, nil
|
return b, nil
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// marshalGrantProduct builds the snapshot for an admin grant-by-product: the source product and the
|
||||||
|
// benefit atoms it granted (price 0).
|
||||||
|
func marshalGrantProduct(productID uuid.UUID, title string, d benefitDelta) ([]byte, error) {
|
||||||
|
atoms := map[string]int{}
|
||||||
|
if d.hintsAdd > 0 {
|
||||||
|
atoms["hints"] = d.hintsAdd
|
||||||
|
}
|
||||||
|
if d.noAdsDays > 0 {
|
||||||
|
atoms["noads_days"] = d.noAdsDays
|
||||||
|
}
|
||||||
|
b, err := json.Marshal(purchaseSnapshot{ProductID: productID.String(), Title: title, Atoms: atoms, PriceChips: 0})
|
||||||
|
if err != nil {
|
||||||
|
return nil, fmt.Errorf("payments: marshal product grant snapshot: %w", err)
|
||||||
|
}
|
||||||
|
return b, nil
|
||||||
|
}
|
||||||
|
|||||||
@@ -77,6 +77,36 @@ func (s *Store) accountStatement(ctx context.Context, accountID uuid.UUID) (Stat
|
|||||||
return out, nil
|
return out, nil
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// allLedger reads the entire append-only ledger (all accounts, newest first) for the admin export.
|
||||||
|
func (s *Store) allLedger(ctx context.Context) ([]LedgerExportRow, error) {
|
||||||
|
var rows []model.Ledger
|
||||||
|
if err := postgres.SELECT(table.Ledger.AllColumns).
|
||||||
|
FROM(table.Ledger).
|
||||||
|
ORDER_BY(table.Ledger.CreatedAt.DESC()).
|
||||||
|
QueryContext(ctx, s.db, &rows); err != nil {
|
||||||
|
return nil, fmt.Errorf("payments: export ledger: %w", err)
|
||||||
|
}
|
||||||
|
out := make([]LedgerExportRow, 0, len(rows))
|
||||||
|
for _, r := range rows {
|
||||||
|
out = append(out, LedgerExportRow{
|
||||||
|
AccountID: r.AccountID.String(),
|
||||||
|
LedgerEntry: LedgerEntry{
|
||||||
|
Kind: r.Kind,
|
||||||
|
Source: derefStr(r.Source),
|
||||||
|
Origin: derefStr(r.Origin),
|
||||||
|
ChipsDelta: int(r.ChipsDelta),
|
||||||
|
ProductID: derefUUID(r.ProductID),
|
||||||
|
OrderID: derefUUID(r.OrderID),
|
||||||
|
Provider: derefStr(r.Provider),
|
||||||
|
ProviderPaymentID: derefStr(r.ProviderPaymentID),
|
||||||
|
Snapshot: derefStr(r.Snapshot),
|
||||||
|
CreatedAt: r.CreatedAt,
|
||||||
|
},
|
||||||
|
})
|
||||||
|
}
|
||||||
|
return out, nil
|
||||||
|
}
|
||||||
|
|
||||||
// derefStr returns the pointed-to string, or "" when the pointer is nil (a NULL column).
|
// derefStr returns the pointed-to string, or "" when the pointer is nil (a NULL column).
|
||||||
func derefStr(p *string) string {
|
func derefStr(p *string) string {
|
||||||
if p == nil {
|
if p == nil {
|
||||||
|
|||||||
@@ -26,6 +26,14 @@ var (
|
|||||||
// ErrNotAValue means the product has no chip price (it is a chip pack or unpriced), so it
|
// ErrNotAValue means the product has no chip price (it is a chip pack or unpriced), so it
|
||||||
// cannot be bought with chips.
|
// cannot be bought with chips.
|
||||||
ErrNotAValue = errors.New("payments: product is not a chip-priced value")
|
ErrNotAValue = errors.New("payments: product is not a chip-priced value")
|
||||||
|
// ErrCannotGrantChips means an admin grant targeted a product carrying the chips atom — the
|
||||||
|
// admin never grants currency (a gifted balance would bypass the cash desk, D16).
|
||||||
|
ErrCannotGrantChips = errors.New("payments: cannot grant chips")
|
||||||
|
// ErrCannotGrantTournament means an admin grant targeted a product carrying the tournament atom,
|
||||||
|
// which has no credit target until the tournament stage.
|
||||||
|
ErrCannotGrantTournament = errors.New("payments: cannot grant a tournament atom yet")
|
||||||
|
// ErrNothingToGrant means the product's atoms yield no grantable benefit (hints / no-ads days).
|
||||||
|
ErrNothingToGrant = errors.New("payments: product has nothing to grant")
|
||||||
)
|
)
|
||||||
|
|
||||||
// withTx runs fn inside a transaction on db, rolling back on error or panic.
|
// withTx runs fn inside a transaction on db, rolling back on error or panic.
|
||||||
@@ -312,6 +320,22 @@ func (s *Store) grant(ctx context.Context, accountID uuid.UUID, origin Source, d
|
|||||||
return nil
|
return nil
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// grantProduct is grant with the source product recorded on the ledger row (product_id), for an
|
||||||
|
// admin grant-by-product — the benefit is the product's atoms, the ledger stays auditable to it.
|
||||||
|
func (s *Store) grantProduct(ctx context.Context, accountID uuid.UUID, origin Source, productID uuid.UUID, d benefitDelta, snapshot []byte, now time.Time) error {
|
||||||
|
err := withTx(ctx, s.db, func(tx *sql.Tx) error {
|
||||||
|
if err := insertLedgerTx(ctx, tx, accountID, "admin_grant", nil, &origin, 0, &productID, nil, nil, nil, snapshot, now); err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
return applyBenefitTx(ctx, tx, accountID, origin, d, now)
|
||||||
|
})
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
s.cache.invalidate(accountID)
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
// consumeHint decrements one hint from the first applicable origin (in the given priority order)
|
// consumeHint decrements one hint from the first applicable origin (in the given priority order)
|
||||||
// that has one, with a guarded update. It returns whether a hint was spent.
|
// that has one, with a guarded update. It returns whether a hint was spent.
|
||||||
func (s *Store) consumeHint(ctx context.Context, accountID uuid.UUID, origins []Source, now time.Time) (bool, error) {
|
func (s *Store) consumeHint(ctx context.Context, accountID uuid.UUID, origins []Source, now time.Time) (bool, error) {
|
||||||
|
|||||||
@@ -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;
|
||||||
@@ -72,6 +72,7 @@ func (s *Server) profileResponse(ctx context.Context, acc account.Account) profi
|
|||||||
}
|
}
|
||||||
r.Banner = s.bannerFor(ctx, acc, cxt, present)
|
r.Banner = s.bannerFor(ctx, acc, cxt, present)
|
||||||
r.Ads = s.adsFor(ctx, acc, cxt, present)
|
r.Ads = s.adsFor(ctx, acc, cxt, present)
|
||||||
|
r.GameLimits = s.gameLimitsDTOFor(acc.IsGuest)
|
||||||
s.fillLinkedIdentities(ctx, &r, acc.ID)
|
s.fillLinkedIdentities(ctx, &r, acc.ID)
|
||||||
r.DictVersions = s.currentDictVersions()
|
r.DictVersions = s.currentDictVersions()
|
||||||
return r
|
return r
|
||||||
|
|||||||
@@ -78,6 +78,18 @@ type profileResponse struct {
|
|||||||
// Filled outside the pure projection (it reads the dictionary registry), so it is empty
|
// Filled outside the pure projection (it reads the dictionary registry), so it is empty
|
||||||
// for callers that build the DTO without a Server. See Server.profileResponse.
|
// for callers that build the DTO without a Server. See Server.profileResponse.
|
||||||
DictVersions []dictVersion `json:"dict_versions,omitempty"`
|
DictVersions []dictVersion `json:"dict_versions,omitempty"`
|
||||||
|
// GameLimits carries the caller's tier active-game caps per kind (-1 = unlimited); the client
|
||||||
|
// counts its active games per kind from the lobby and locks a capped New-Game start. Filled
|
||||||
|
// outside the pure projection (it reads the limits config). See Server.profileResponse.
|
||||||
|
GameLimits *gameLimitsDTO `json:"game_limits,omitempty"`
|
||||||
|
}
|
||||||
|
|
||||||
|
// gameLimitsDTO is the caller's tier active-game caps per kind (-1 = unlimited), for the client's
|
||||||
|
// per-kind New-Game lock. profileResponse.GameLimits carries it.
|
||||||
|
type gameLimitsDTO struct {
|
||||||
|
VsAI int `json:"vs_ai"`
|
||||||
|
Random int `json:"random"`
|
||||||
|
Friends int `json:"friends"`
|
||||||
}
|
}
|
||||||
|
|
||||||
// dictVersion pairs a game variant's stable label (engine.Variant.String) with its current
|
// dictVersion pairs a game variant's stable label (engine.Variant.String) with its current
|
||||||
@@ -134,7 +146,11 @@ type gameDTO struct {
|
|||||||
MultipleWordsPerTurn bool `json:"multiple_words_per_turn"`
|
MultipleWordsPerTurn bool `json:"multiple_words_per_turn"`
|
||||||
// VsAI marks an honest-AI game: the opponent is shown as 🤖 and chat/nudge/add-friend
|
// VsAI marks an honest-AI game: the opponent is shown as 🤖 and chat/nudge/add-friend
|
||||||
// are suppressed in the client.
|
// are suppressed in the client.
|
||||||
VsAI bool `json:"vs_ai"`
|
VsAI bool `json:"vs_ai"`
|
||||||
|
// Kind is the game's origin for the active-game limits (game.Kind): 0 unknown (a pre-existing
|
||||||
|
// game), 1 vs_ai, 2 random, 3 friends. The lobby counts active games per kind to lock a capped
|
||||||
|
// New-Game start.
|
||||||
|
Kind int `json:"kind"`
|
||||||
MoveCount int `json:"move_count"`
|
MoveCount int `json:"move_count"`
|
||||||
EndReason string `json:"end_reason"`
|
EndReason string `json:"end_reason"`
|
||||||
// LastActivityUnix is the lobby sort key: the current turn's start for an active
|
// LastActivityUnix is the lobby sort key: the current turn's start for an active
|
||||||
@@ -177,7 +193,6 @@ type stateDTO struct {
|
|||||||
Rack []int `json:"rack"`
|
Rack []int `json:"rack"`
|
||||||
BagLen int `json:"bag_len"`
|
BagLen int `json:"bag_len"`
|
||||||
HintsRemaining int `json:"hints_remaining"`
|
HintsRemaining int `json:"hints_remaining"`
|
||||||
WalletBalance int `json:"wallet_balance"`
|
|
||||||
// HintUnlockLeftSeconds is the vs_ai idle-hint gate: seconds until the hint unlocks (0 for a human
|
// HintUnlockLeftSeconds is the vs_ai idle-hint gate: seconds until the hint unlocks (0 for a human
|
||||||
// game / first move / not your turn). The client anchors a monotonic countdown to it.
|
// game / first move / not your turn). The client anchors a monotonic countdown to it.
|
||||||
HintUnlockLeftSeconds int `json:"hint_unlock_left_seconds"`
|
HintUnlockLeftSeconds int `json:"hint_unlock_left_seconds"`
|
||||||
@@ -278,6 +293,7 @@ func gameDTOFromGame(g game.Game) gameDTO {
|
|||||||
TurnTimeoutSecs: int(g.TurnTimeout.Seconds()),
|
TurnTimeoutSecs: int(g.TurnTimeout.Seconds()),
|
||||||
MultipleWordsPerTurn: g.MultipleWordsPerTurn,
|
MultipleWordsPerTurn: g.MultipleWordsPerTurn,
|
||||||
VsAI: g.VsAI,
|
VsAI: g.VsAI,
|
||||||
|
Kind: int(g.Kind),
|
||||||
MoveCount: g.MoveCount,
|
MoveCount: g.MoveCount,
|
||||||
EndReason: g.EndReason,
|
EndReason: g.EndReason,
|
||||||
LastActivityUnix: last.Unix(),
|
LastActivityUnix: last.Unix(),
|
||||||
@@ -334,7 +350,6 @@ func stateDTOFrom(v game.StateView, includeAlphabet bool) (stateDTO, error) {
|
|||||||
Rack: rack,
|
Rack: rack,
|
||||||
BagLen: v.BagLen,
|
BagLen: v.BagLen,
|
||||||
HintsRemaining: v.HintsRemaining,
|
HintsRemaining: v.HintsRemaining,
|
||||||
WalletBalance: v.WalletBalance,
|
|
||||||
HintUnlockLeftSeconds: v.HintUnlockLeftSeconds,
|
HintUnlockLeftSeconds: v.HintUnlockLeftSeconds,
|
||||||
}
|
}
|
||||||
if includeAlphabet {
|
if includeAlphabet {
|
||||||
|
|||||||
@@ -74,6 +74,9 @@ func (s *Server) registerRoutes() {
|
|||||||
u.POST("/wallet/buy", s.handleWalletBuy)
|
u.POST("/wallet/buy", s.handleWalletBuy)
|
||||||
// A rewarded-video credit (VK ads): client-attested + a config daily cap.
|
// A rewarded-video credit (VK ads): client-attested + a config daily cap.
|
||||||
u.POST("/wallet/reward", s.handleWalletReward)
|
u.POST("/wallet/reward", s.handleWalletReward)
|
||||||
|
// The public-offer price list (§4.4) as markdown, for the render sidecar that serves the
|
||||||
|
// /offer/ page. Internal (off the edge allow-list); called by the renderer, not the gateway.
|
||||||
|
s.internal.GET("/offer/pricing", s.handleOfferPricing)
|
||||||
}
|
}
|
||||||
if s.payments != nil {
|
if s.payments != nil {
|
||||||
// The money order endpoint dispatches by rail (direct → Robokassa, vk → VK); an
|
// The money order endpoint dispatches by rail (direct → Robokassa, vk → VK); an
|
||||||
@@ -319,6 +322,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):
|
||||||
|
|||||||
@@ -1,11 +1,14 @@
|
|||||||
package server
|
package server
|
||||||
|
|
||||||
import (
|
import (
|
||||||
|
"context"
|
||||||
"errors"
|
"errors"
|
||||||
|
"fmt"
|
||||||
"strconv"
|
"strconv"
|
||||||
"strings"
|
"strings"
|
||||||
|
|
||||||
"github.com/gin-gonic/gin"
|
"github.com/gin-gonic/gin"
|
||||||
|
"github.com/google/uuid"
|
||||||
|
|
||||||
"scrabble/backend/internal/adminconsole"
|
"scrabble/backend/internal/adminconsole"
|
||||||
"scrabble/backend/internal/payments"
|
"scrabble/backend/internal/payments"
|
||||||
@@ -38,6 +41,9 @@ func (s *Server) consoleCatalog(c *gin.Context) {
|
|||||||
s.consoleError(c, err)
|
s.consoleError(c, err)
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
// Order the list like the public offer: sales (chip packs) first, then the chip-exchange values,
|
||||||
|
// grouped and price-sorted the same way, so the console mirrors what a buyer sees.
|
||||||
|
payments.SortAdminCatalog(products)
|
||||||
var view adminconsole.CatalogView
|
var view adminconsole.CatalogView
|
||||||
for _, p := range products {
|
for _, p := range products {
|
||||||
view.Products = append(view.Products, catalogRow(p))
|
view.Products = append(view.Products, catalogRow(p))
|
||||||
@@ -181,3 +187,95 @@ func (s *Server) consoleDeleteProductAction(c *gin.Context) {
|
|||||||
}
|
}
|
||||||
s.renderConsoleMessage(c, "Deleted", "the product was deleted", catalogBack)
|
s.renderConsoleMessage(c, "Deleted", "the product was deleted", catalogBack)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// consoleGrant grants raw benefit atoms (hints / no-ads days / forever) to a chosen origin — a
|
||||||
|
// zero-price admin sale.
|
||||||
|
func (s *Server) consoleGrant(c *gin.Context) {
|
||||||
|
id, ok := s.consoleUUID(c, "/_gm/users")
|
||||||
|
if !ok {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
back := "/_gm/users/" + id.String()
|
||||||
|
if s.payments == nil {
|
||||||
|
s.renderConsoleMessage(c, "Unavailable", "payments are not enabled", back)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
origin := payments.Source(c.PostForm("origin"))
|
||||||
|
hints, _ := strconv.Atoi(strings.TrimSpace(c.PostForm("hints")))
|
||||||
|
noads, _ := strconv.Atoi(strings.TrimSpace(c.PostForm("noads")))
|
||||||
|
forever := c.PostForm("forever") != ""
|
||||||
|
if err := s.payments.Grant(c.Request.Context(), id, origin, hints, noads, forever); err != nil {
|
||||||
|
s.renderConsoleMessage(c, "Grant failed", err.Error(), back)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
s.publishBannerChange(id)
|
||||||
|
s.renderConsoleMessage(c, "Granted", "the benefit was granted", back)
|
||||||
|
}
|
||||||
|
|
||||||
|
// consoleGrantProduct grants a value product's atoms (a reward bundle, possibly archived) to a
|
||||||
|
// chosen origin. It refuses a product carrying chips or the tournament atom (payments enforces it).
|
||||||
|
func (s *Server) consoleGrantProduct(c *gin.Context) {
|
||||||
|
id, ok := s.consoleUUID(c, "/_gm/users")
|
||||||
|
if !ok {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
back := "/_gm/users/" + id.String()
|
||||||
|
if s.payments == nil {
|
||||||
|
s.renderConsoleMessage(c, "Unavailable", "payments are not enabled", back)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
origin := payments.Source(c.PostForm("origin"))
|
||||||
|
productID, err := uuid.Parse(strings.TrimSpace(c.PostForm("product_id")))
|
||||||
|
if err != nil {
|
||||||
|
s.renderConsoleMessage(c, "Grant failed", "choose a product", back)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
if err := s.payments.GrantProduct(c.Request.Context(), id, origin, productID); err != nil {
|
||||||
|
s.renderConsoleMessage(c, "Grant failed", err.Error(), back)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
s.publishBannerChange(id)
|
||||||
|
s.renderConsoleMessage(c, "Granted", "the product was granted", back)
|
||||||
|
}
|
||||||
|
|
||||||
|
// grantForm builds the admin-grant panel: the origin picker and the grantable products (value
|
||||||
|
// bundles, including archived ones — chips and tournament products are excluded).
|
||||||
|
func (s *Server) grantForm(ctx context.Context) adminconsole.GrantFormView {
|
||||||
|
fv := adminconsole.GrantFormView{Present: true, Origins: []string{"direct", "vk", "telegram"}}
|
||||||
|
products, err := s.payments.AdminCatalog(ctx)
|
||||||
|
if err != nil {
|
||||||
|
return fv
|
||||||
|
}
|
||||||
|
for _, p := range products {
|
||||||
|
if grantableProduct(p) {
|
||||||
|
fv.Products = append(fv.Products, adminconsole.GrantProductOption{
|
||||||
|
ID: p.ID.String(), Title: p.Title, Summary: atomSummary(p.Atoms), Archived: !p.Active,
|
||||||
|
})
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return fv
|
||||||
|
}
|
||||||
|
|
||||||
|
// grantableProduct reports whether a product can be admin-granted: it carries at least one benefit
|
||||||
|
// atom (hints / no-ads days) and no chips or tournament atom.
|
||||||
|
func grantableProduct(p payments.AdminProduct) bool {
|
||||||
|
benefit := false
|
||||||
|
for _, a := range p.Atoms {
|
||||||
|
switch a.Atom {
|
||||||
|
case "chips", "tournament":
|
||||||
|
return false
|
||||||
|
case "hints", "noads_days":
|
||||||
|
benefit = true
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return benefit
|
||||||
|
}
|
||||||
|
|
||||||
|
// atomSummary renders a product's atoms as "hints×5, noads_days×30".
|
||||||
|
func atomSummary(atoms []payments.AtomLine) string {
|
||||||
|
parts := make([]string, 0, len(atoms))
|
||||||
|
for _, a := range atoms {
|
||||||
|
parts = append(parts, fmt.Sprintf("%s×%d", a.Atom, a.Quantity))
|
||||||
|
}
|
||||||
|
return strings.Join(parts, ", ")
|
||||||
|
}
|
||||||
|
|||||||
@@ -58,6 +58,9 @@ func (s *Server) registerConsole(router *gin.Engine) {
|
|||||||
gm.POST("/users/:id/grant-role", s.consoleGrantRole)
|
gm.POST("/users/:id/grant-role", s.consoleGrantRole)
|
||||||
gm.POST("/users/:id/revoke-role", s.consoleRevokeRole)
|
gm.POST("/users/:id/revoke-role", s.consoleRevokeRole)
|
||||||
gm.POST("/users/:id/remove-email", s.consoleRemoveEmail)
|
gm.POST("/users/:id/remove-email", s.consoleRemoveEmail)
|
||||||
|
gm.POST("/users/:id/grant", s.consoleGrant)
|
||||||
|
gm.POST("/users/:id/grant-product", s.consoleGrantProduct)
|
||||||
|
gm.POST("/users/:id/refund", s.consoleRefund)
|
||||||
gm.POST("/users/:id/delete", s.consoleDeleteUser)
|
gm.POST("/users/:id/delete", s.consoleDeleteUser)
|
||||||
gm.GET("/reasons", s.consoleReasons)
|
gm.GET("/reasons", s.consoleReasons)
|
||||||
gm.POST("/reasons", s.consoleCreateReason)
|
gm.POST("/reasons", s.consoleCreateReason)
|
||||||
@@ -67,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)
|
||||||
@@ -109,6 +116,7 @@ func (s *Server) registerConsole(router *gin.Engine) {
|
|||||||
gm.POST("/catalog/:id", s.consoleUpdateProduct)
|
gm.POST("/catalog/:id", s.consoleUpdateProduct)
|
||||||
gm.POST("/catalog/:id/archive", s.consoleArchiveProduct)
|
gm.POST("/catalog/:id/archive", s.consoleArchiveProduct)
|
||||||
gm.POST("/catalog/:id/delete", s.consoleDeleteProductAction)
|
gm.POST("/catalog/:id/delete", s.consoleDeleteProductAction)
|
||||||
|
gm.GET("/ledger.csv", s.consoleLedgerExport)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -451,6 +459,7 @@ func (s *Server) consoleUserDetail(c *gin.Context) {
|
|||||||
} else {
|
} else {
|
||||||
s.log.Warn("console: account statement failed", zap.String("account", id.String()), zap.Error(err))
|
s.log.Warn("console: account statement failed", zap.String("account", id.String()), zap.Error(err))
|
||||||
}
|
}
|
||||||
|
view.Grant = s.grantForm(ctx)
|
||||||
}
|
}
|
||||||
s.renderConsole(c, "user_detail", "users", acc.DisplayName, view)
|
s.renderConsole(c, "user_detail", "users", acc.DisplayName, view)
|
||||||
}
|
}
|
||||||
@@ -557,6 +566,7 @@ func (s *Server) consoleGameDetail(c *gin.Context) {
|
|||||||
Status: g.Status, Players: g.Players, ToMove: g.ToMove, EndReason: g.EndReason,
|
Status: g.Status, Players: g.Players, ToMove: g.ToMove, EndReason: g.EndReason,
|
||||||
MoveCount: g.MoveCount, CreatedAt: fmtTime(g.CreatedAt), UpdatedAt: fmtTime(g.UpdatedAt),
|
MoveCount: g.MoveCount, CreatedAt: fmtTime(g.CreatedAt), UpdatedAt: fmtTime(g.UpdatedAt),
|
||||||
FinishedAt: fmtTimePtr(g.FinishedAt), VsAI: g.VsAI,
|
FinishedAt: fmtTimePtr(g.FinishedAt), VsAI: g.VsAI,
|
||||||
|
MultipleWordsPerTurn: g.MultipleWordsPerTurn,
|
||||||
}
|
}
|
||||||
// Resolve seats and detect robot seats; capture the human opponent's timezone, which
|
// Resolve seats and detect robot seats; capture the human opponent's timezone, which
|
||||||
// anchors the robot's sleep window for the next-move ETA.
|
// anchors the robot's sleep window for the next-move ETA.
|
||||||
@@ -1239,7 +1249,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,75 @@
|
|||||||
|
package server
|
||||||
|
|
||||||
|
import (
|
||||||
|
"fmt"
|
||||||
|
|
||||||
|
"github.com/gin-gonic/gin"
|
||||||
|
|
||||||
|
"scrabble/backend/internal/adminconsole"
|
||||||
|
"scrabble/backend/internal/gamelimits"
|
||||||
|
)
|
||||||
|
|
||||||
|
// gameLimitsDTOFor resolves the caller's tier active-game caps into the profile DTO, so the client
|
||||||
|
// can lock a capped New-Game start per kind. It returns nil when the limits config is not wired.
|
||||||
|
func (s *Server) gameLimitsDTOFor(isGuest bool) *gameLimitsDTO {
|
||||||
|
if s.gamelimits == nil {
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
l := s.gamelimits.LimitsFor(isGuest)
|
||||||
|
return &gameLimitsDTO{VsAI: l.VsAI, Random: l.Random, Friends: l.Friends}
|
||||||
|
}
|
||||||
|
|
||||||
|
// consoleLimits renders the per-tier, per-kind active-game limit form (backend.config), read from
|
||||||
|
// the in-memory cache.
|
||||||
|
func (s *Server) consoleLimits(c *gin.Context) {
|
||||||
|
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
|
||||||
|
}
|
||||||
@@ -0,0 +1,71 @@
|
|||||||
|
package server
|
||||||
|
|
||||||
|
import (
|
||||||
|
"encoding/csv"
|
||||||
|
"fmt"
|
||||||
|
"strconv"
|
||||||
|
"strings"
|
||||||
|
"time"
|
||||||
|
|
||||||
|
"github.com/gin-gonic/gin"
|
||||||
|
"github.com/google/uuid"
|
||||||
|
)
|
||||||
|
|
||||||
|
// consoleRefund refunds a paid order in full at the operator's request. The operator performs the
|
||||||
|
// actual money refund on the rail (Robokassa cabinet / VK support / Telegram refundStarPayment);
|
||||||
|
// this records it — a refund ledger row and a floor-0 chip revoke (never negative, D27). Idempotent.
|
||||||
|
func (s *Server) consoleRefund(c *gin.Context) {
|
||||||
|
id, ok := s.consoleUUID(c, "/_gm/users")
|
||||||
|
if !ok {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
back := "/_gm/users/" + id.String()
|
||||||
|
if s.payments == nil {
|
||||||
|
s.renderConsoleMessage(c, "Unavailable", "payments are not enabled", back)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
orderID, err := uuid.Parse(strings.TrimSpace(c.PostForm("order_id")))
|
||||||
|
if err != nil {
|
||||||
|
s.renderConsoleMessage(c, "Refund failed", "no order to refund", back)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
out, err := s.payments.RefundOrderFull(c.Request.Context(), orderID)
|
||||||
|
if err != nil {
|
||||||
|
s.renderConsoleMessage(c, "Refund failed", err.Error(), back)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
if out.AlreadyRefunded {
|
||||||
|
s.renderConsoleMessage(c, "Already refunded", "this order was already refunded", back)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
s.publishBannerChange(id)
|
||||||
|
msg := fmt.Sprintf("revoked %d chips", out.Revoked)
|
||||||
|
if out.Loss > 0 {
|
||||||
|
msg += fmt.Sprintf("; %d chips were already spent (recorded as a loss + abuse flag)", out.Loss)
|
||||||
|
}
|
||||||
|
s.renderConsoleMessage(c, "Refunded", msg, back)
|
||||||
|
}
|
||||||
|
|
||||||
|
// consoleLedgerExport streams the entire append-only ledger as a CSV attachment for tax reporting
|
||||||
|
// and rail reconciliation. The snapshot column carries the raw purchase/refund JSON.
|
||||||
|
func (s *Server) consoleLedgerExport(c *gin.Context) {
|
||||||
|
rows, err := s.payments.LedgerExport(c.Request.Context())
|
||||||
|
if err != nil {
|
||||||
|
s.consoleError(c, err)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
c.Header("Content-Type", "text/csv; charset=utf-8")
|
||||||
|
c.Header("Content-Disposition", `attachment; filename="ledger.csv"`)
|
||||||
|
w := csv.NewWriter(c.Writer)
|
||||||
|
_ = w.Write([]string{
|
||||||
|
"created_at", "account_id", "kind", "source", "origin", "chips_delta",
|
||||||
|
"product_id", "order_id", "provider", "provider_payment_id", "snapshot",
|
||||||
|
})
|
||||||
|
for _, r := range rows {
|
||||||
|
_ = w.Write([]string{
|
||||||
|
r.CreatedAt.UTC().Format(time.RFC3339), r.AccountID, r.Kind, r.Source, r.Origin,
|
||||||
|
strconv.Itoa(r.ChipsDelta), r.ProductID, r.OrderID, r.Provider, r.ProviderPaymentID, r.Snapshot,
|
||||||
|
})
|
||||||
|
}
|
||||||
|
w.Flush()
|
||||||
|
}
|
||||||
@@ -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)
|
||||||
|
|||||||
@@ -112,6 +112,22 @@ func (s *Server) handleWalletCatalog(c *gin.Context) {
|
|||||||
c.JSON(http.StatusOK, catalogDTOFrom(view))
|
c.JSON(http.StatusOK, catalogDTOFrom(view))
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// handleOfferPricing serves the public-offer price list (§4.4) as markdown — the two catalog tables
|
||||||
|
// projected from the active products. The render sidecar fetches it and splices it into the offer
|
||||||
|
// markdown before rendering the /offer/ page. Internal, non-public: the /api/v1/internal group is
|
||||||
|
// off the edge allow-list, and the value is served from the payments cache (no per-request query in
|
||||||
|
// the steady state). Called by the renderer, not the gateway.
|
||||||
|
func (s *Server) handleOfferPricing(c *gin.Context) {
|
||||||
|
md, err := s.payments.OfferPricing(c.Request.Context())
|
||||||
|
if err != nil {
|
||||||
|
s.log.Error("offer pricing projection failed", zap.Error(err))
|
||||||
|
c.String(http.StatusInternalServerError, "offer pricing unavailable")
|
||||||
|
return
|
||||||
|
}
|
||||||
|
c.Header("Content-Type", "text/markdown; charset=utf-8")
|
||||||
|
c.String(http.StatusOK, md)
|
||||||
|
}
|
||||||
|
|
||||||
// handleWallet returns the caller's wallet — the segments and benefits visible in the current
|
// handleWallet returns the caller's wallet — the segments and benefits visible in the current
|
||||||
// trusted execution context, plus the rewarded-video payout available here (0 outside VK or when
|
// trusted execution context, plus the rewarded-video payout available here (0 outside VK or when
|
||||||
// unconfigured), which gates the client's "watch for chips" button.
|
// unconfigured), which gates the client's "watch for chips" button.
|
||||||
|
|||||||
@@ -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
|
||||||
|
|||||||
@@ -125,6 +125,17 @@ GATEWAY_VK_APP_SECRET=
|
|||||||
# come from VITE_VK_APP_ID / VITE_VK_ID_REDIRECT_URL above. All three empty disables link.vk.*.
|
# come from VITE_VK_APP_ID / VITE_VK_ID_REDIRECT_URL above. All three empty disables link.vk.*.
|
||||||
GATEWAY_VK_ID_CLIENT_SECRET=
|
GATEWAY_VK_ID_CLIENT_SECRET=
|
||||||
|
|
||||||
|
# --- Payments: Robokassa (direct RUB rail) ----------------------------------
|
||||||
|
# The shop's merchant login + the two pass phrases (Password1 signs the launch request,
|
||||||
|
# Password2 signs/verifies the Result callback). An empty login leaves the direct rail off.
|
||||||
|
# ROBOKASSA_TEST=1 runs test payments against the shop's TEST pass phrases (no real money);
|
||||||
|
# empty/0 is live. Mapped in compose to BACKEND_ROBOKASSA_*; Gitea TEST_/PROD_ secrets, with
|
||||||
|
# PROD_BACKEND_ROBOKASSA_TEST a variable so go-live is a flag flip, not a secret redeploy.
|
||||||
|
ROBOKASSA_MERCHANT_LOGIN=
|
||||||
|
ROBOKASSA_PASSWORD1=
|
||||||
|
ROBOKASSA_PASSWORD2=
|
||||||
|
ROBOKASSA_TEST=
|
||||||
|
|
||||||
# --- Gateway anti-abuse ------------------------------------------------------
|
# --- Gateway anti-abuse ------------------------------------------------------
|
||||||
# Planted honeytoken bearer value: any request presenting it is flagged — a 24h IP ban
|
# Planted honeytoken bearer value: any request presenting it is flagged — a 24h IP ban
|
||||||
# where the IP ban is on (prod), logs + a ban metric otherwise (test). Plant the value
|
# where the IP ban is on (prod), logs + a ban metric otherwise (test). Plant the value
|
||||||
|
|||||||
+10
-2
@@ -76,6 +76,9 @@ compose binds from this directory.
|
|||||||
| `GM_BASICAUTH_HASH` | secret | bcrypt hash gating `/_gm` (admin console + Grafana). Generate with `docker run --rm caddy:2-alpine caddy hash-password --plaintext '<pw>'`. |
|
| `GM_BASICAUTH_HASH` | secret | bcrypt hash gating `/_gm` (admin console + Grafana). Generate with `docker run --rm caddy:2-alpine caddy hash-password --plaintext '<pw>'`. |
|
||||||
| `TELEGRAM_MINIAPP_URL` | derived | The Mini App URL the bot hands out in deep links / buttons. The deploy derives `PUBLIC_BASE_URL + /telegram/`; set it directly only for a local run (compose still `:?`-requires it). |
|
| `TELEGRAM_MINIAPP_URL` | derived | The Mini App URL the bot hands out in deep links / buttons. The deploy derives `PUBLIC_BASE_URL + /telegram/`; set it directly only for a local run (compose still `:?`-requires it). |
|
||||||
| `EXPORT_SIGN_KEY` | secret | HMAC key signing the public finished-game export download URLs (`/dl/*`). Generate with `openssl rand -base64 32`. |
|
| `EXPORT_SIGN_KEY` | secret | HMAC key signing the public finished-game export download URLs (`/dl/*`). Generate with `openssl rand -base64 32`. |
|
||||||
|
| `BACKEND_ROBOKASSA_MERCHANT_LOGIN` | secret | Robokassa shop login for the direct RUB rail. Empty leaves the rail off. |
|
||||||
|
| `BACKEND_ROBOKASSA_PASSWORD1` / `…_PASSWORD2` | secret | Robokassa pass phrases: Password1 signs the launch request, Password2 signs/verifies the Result callback. Use the shop's **test** pair while `…_ROBOKASSA_TEST=1`, the **live** pair for real money. (Password3 — Robokassa's JWT-invoice API — is unused.) |
|
||||||
|
| `BACKEND_ROBOKASSA_TEST` | **variable** | `1` runs test payments against the test pass phrases (no real money); empty/`0` is live. A variable, not a secret, so go-live is a flag flip + redeploy, not a secret rotation. |
|
||||||
|
|
||||||
**Plus the bot token** — `TELEGRAM_BOT_TOKEN` (secret), shared by the validator (HMAC
|
**Plus the bot token** — `TELEGRAM_BOT_TOKEN` (secret), shared by the validator (HMAC
|
||||||
secret) and the bot (Bot API). It defaults to empty in compose, but both **fail at
|
secret) and the bot (Bot API). It defaults to empty in compose, but both **fail at
|
||||||
@@ -112,6 +115,9 @@ without it Docker's resolver handles `otelcol`, `gateway` and `api.telegram.org`
|
|||||||
| `GATEWAY_VK_APP_SECRET` | secret (shared) | _(empty)_ | The VK Mini App's protected key (`client_secret`): the gateway verifies the launch-parameter signature in-process under it (offline HMAC, no VK API call). Empty disables the VK auth path (`auth.vk`). One VK Mini App for all contours. |
|
| `GATEWAY_VK_APP_SECRET` | secret (shared) | _(empty)_ | The VK Mini App's protected key (`client_secret`): the gateway verifies the launch-parameter signature in-process under it (offline HMAC, no VK API call). Empty disables the VK auth path (`auth.vk`). One VK Mini App for all contours. |
|
||||||
| `GATEWAY_VK_ID_CLIENT_SECRET` | secret (shared) | _(empty)_ | The VK ID "Web" app's protected key (`client_secret`) for the gateway's server-side confidential code exchange. A SEPARATE VK app from `GATEWAY_VK_APP_SECRET` (the Mini App). One VK ID "Web" app for all contours. |
|
| `GATEWAY_VK_ID_CLIENT_SECRET` | secret (shared) | _(empty)_ | The VK ID "Web" app's protected key (`client_secret`) for the gateway's server-side confidential code exchange. A SEPARATE VK app from `GATEWAY_VK_APP_SECRET` (the Mini App). One VK ID "Web" app for all contours. |
|
||||||
| `GATEWAY_HONEYTOKEN` | secret | _(empty)_ | Planted honeytoken bearer value: presenting it earns a 24h IP ban + a high-severity alarm where the IP ban is on (prod), or logs + a `gateway_abuse_banned_total{reason="honeytoken"}` metric (test, ban off). Plant the value somewhere an attacker would find it; empty disables the trap. Per-contour `TEST_`/`PROD_GATEWAY_HONEYTOKEN`. |
|
| `GATEWAY_HONEYTOKEN` | secret | _(empty)_ | Planted honeytoken bearer value: presenting it earns a 24h IP ban + a high-severity alarm where the IP ban is on (prod), or logs + a `gateway_abuse_banned_total{reason="honeytoken"}` metric (test, ban off). Plant the value somewhere an attacker would find it; empty disables the trap. Per-contour `TEST_`/`PROD_GATEWAY_HONEYTOKEN`. |
|
||||||
|
| `GATEWAY_BLOCKLIST_ENABLED` | variable | `false` | Enable the community IP blocklist at the edge (prod-only, same real-client-IP reason as the ban). Requires `GATEWAY_BLOCKLIST_URL`. Opt-in via `PROD_GATEWAY_BLOCKLIST_ENABLED` after verifying the feed. |
|
||||||
|
| `GATEWAY_BLOCKLIST_URL` | variable | _(empty)_ | The curated CIDR feed to fetch (Spamhaus DROP). Required when enabled; refreshed every few hours and dropped fail-open once stale (48h). `PROD_GATEWAY_BLOCKLIST_URL`. |
|
||||||
|
| `GATEWAY_BLOCKLIST_ALLOW` | variable | _(empty)_ | Comma-separated never-block set (CIDRs / bare IPs — own infra, monitoring) the feed can never block. `PROD_GATEWAY_BLOCKLIST_ALLOW`. |
|
||||||
| `VITE_GATEWAY_URL` | variable | _(empty)_ | UI build-arg: gateway origin; empty = same-origin (the usual single-origin deploy). |
|
| `VITE_GATEWAY_URL` | variable | _(empty)_ | UI build-arg: gateway origin; empty = same-origin (the usual single-origin deploy). |
|
||||||
| `SMTP_RELAY_HOST` | variable (shared) | _(empty)_ | Selectel SMTP relay host for confirm-code email. Empty leaves the backend on the log mailer (email disabled) — the contour still boots. One relay for every contour (limit 100 msgs / 5 min). |
|
| `SMTP_RELAY_HOST` | variable (shared) | _(empty)_ | Selectel SMTP relay host for confirm-code email. Empty leaves the backend on the log mailer (email disabled) — the contour still boots. One relay for every contour (limit 100 msgs / 5 min). |
|
||||||
| `SMTP_RELAY_PORT` | variable (shared) | `465` | Relay port. No client certificate is needed (the server cert is validated against the system roots). |
|
| `SMTP_RELAY_PORT` | variable (shared) | `465` | Relay port. No client certificate is needed (the server cert is validated against the system roots). |
|
||||||
@@ -252,8 +258,10 @@ shipping or redeploying this stack does **not** start archiving — the artifact
|
|||||||
armed, which is why an un-armed prod deploy can never pile WAL onto the disk. The base-backup
|
armed, which is why an un-armed prod deploy can never pile WAL onto the disk. The base-backup
|
||||||
timer is provisioned by the Ansible `main` role behind `pitr_enabled` (also default off). Two
|
timer is provisioned by the Ansible `main` role behind `pitr_enabled` (also default off). Two
|
||||||
Grafana alerts watch health: `WAL archiving failing` (`pg_stat_archiver_failed_count` rising)
|
Grafana alerts watch health: `WAL archiving failing` (`pg_stat_archiver_failed_count` rising)
|
||||||
and `WAL archiving stalled` (`pg_stat_archiver_last_archive_age` over 30 min) — both
|
and `WAL archiving stalled` (last archive over 30 min old **while `pg_wal_size_bytes` is growing**
|
||||||
absent/NaN-safe, so they stay quiet until archiving is armed.
|
— the pg_wal-growth guard keeps an idle database, which archives nothing because it writes nothing,
|
||||||
|
from false-triggering during quiet hours) — both absent/NaN-safe, so they stay quiet until
|
||||||
|
archiving is armed.
|
||||||
|
|
||||||
**Assessment (owner-reviewed; the gate before the first real money).** Measured on prod
|
**Assessment (owner-reviewed; the gate before the first real money).** Measured on prod
|
||||||
`pg_stat_wal`: WAL is generated at **~0.77 MB/day** and the database is **~9.6 MB**. At
|
`pg_stat_wal`: WAL is generated at **~0.77 MB/day** and the database is **~9.6 MB**. At
|
||||||
|
|||||||
@@ -213,6 +213,20 @@
|
|||||||
loop:
|
loop:
|
||||||
- ""
|
- ""
|
||||||
- config
|
- config
|
||||||
- certs
|
|
||||||
- dumps
|
- dumps
|
||||||
- images
|
- images
|
||||||
|
|
||||||
|
# The certs dir holds the reverse-mTLS bot-link keypair, bind-mounted into the gateway (and
|
||||||
|
# backend) which run as the distroless nonroot UID 65532 — not the deploy user. The dir must be
|
||||||
|
# traversable by "other" (0755) or the nonroot process cannot open the 0644 keypair and crash-loops
|
||||||
|
# at startup ("mtls: load server keypair: ... permission denied") — a latent failure that only bites
|
||||||
|
# on a container restart (e.g. a host reboot), not while a long-running container holds the keypair
|
||||||
|
# in memory. The keys themselves are 0644 by design (see the gateway compose); the host is
|
||||||
|
# single-tenant and SSH-access-controlled, so a traversable certs dir adds no meaningful exposure.
|
||||||
|
- name: Create the scrabble certs directory (traversable by the nonroot gateway UID)
|
||||||
|
ansible.builtin.file:
|
||||||
|
path: "{{ scrabble_base_dir }}/certs"
|
||||||
|
state: directory
|
||||||
|
owner: "{{ deploy_user }}"
|
||||||
|
group: "{{ deploy_user }}"
|
||||||
|
mode: "0755"
|
||||||
|
|||||||
@@ -107,6 +107,15 @@
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
# The public offer page is rendered by the render sidecar: it splices the live catalog price
|
||||||
|
# list (backend, internal) into the committed ui/legal/offer_ru.md and returns the HTML. Only
|
||||||
|
# /offer/ is exposed here — the sidecar's /render stays off this allow-list, internal-only. Kept
|
||||||
|
# disjoint from the landing/app paths so the catch-all below never shadows it.
|
||||||
|
@offer path /offer /offer/*
|
||||||
|
handle @offer {
|
||||||
|
reverse_proxy renderer:8090
|
||||||
|
}
|
||||||
|
|
||||||
# Everything else — the public landing at / and any stray path — is static.
|
# Everything else — the public landing at / and any stray path — is static.
|
||||||
handle {
|
handle {
|
||||||
reverse_proxy landing:80
|
reverse_proxy landing:80
|
||||||
|
|||||||
@@ -3,8 +3,10 @@
|
|||||||
# docker compose -f docker-compose.bot.yml up -d
|
# docker compose -f docker-compose.bot.yml up -d
|
||||||
#
|
#
|
||||||
# The bot egresses to the Bot API directly (no VPN sidecar) and dials the main host's
|
# The bot egresses to the Bot API directly (no VPN sidecar) and dials the main host's
|
||||||
# published bot-link :9443 over mTLS. It exports no telemetry — otelcol lives on the
|
# published bot-link :9443 over mTLS. It exports no OTLP telemetry — otelcol lives on the
|
||||||
# main host and is unreachable from here — so observe it via `docker logs` on this host.
|
# main host and is unreachable from here — but it reports its Bot API health up the bot-link,
|
||||||
|
# which the gateway turns into metrics + alerts on the main host (docs/ARCHITECTURE.md); `docker
|
||||||
|
# logs` on this host is the local detail view.
|
||||||
# Values come from the prod-deploy workflow (PROD_ secrets/variables); BOT_IMAGE is the
|
# Values come from the prod-deploy workflow (PROD_ secrets/variables); BOT_IMAGE is the
|
||||||
# pushed registry tag and BOTLINK_GATEWAY_ADDR is the main host's <ip>:9443.
|
# pushed registry tag and BOTLINK_GATEWAY_ADDR is the main host's <ip>:9443.
|
||||||
name: scrabble-bot
|
name: scrabble-bot
|
||||||
@@ -50,7 +52,8 @@ services:
|
|||||||
TELEGRAM_BOTLINK_TLS_CA: /certs/ca.crt
|
TELEGRAM_BOTLINK_TLS_CA: /certs/ca.crt
|
||||||
TELEGRAM_LOG_LEVEL: ${LOG_LEVEL:-info}
|
TELEGRAM_LOG_LEVEL: ${LOG_LEVEL:-info}
|
||||||
TELEGRAM_SERVICE_NAME: scrabble-telegram-bot
|
TELEGRAM_SERVICE_NAME: scrabble-telegram-bot
|
||||||
# No telemetry export: otelcol is on the main host, unreachable from here.
|
# No OTLP export: otelcol is on the main host, unreachable from here. Bot API health rides the
|
||||||
|
# bot-link instead (the gateway exposes it as metrics); see docs/ARCHITECTURE.md.
|
||||||
TELEGRAM_OTEL_TRACES_EXPORTER: none
|
TELEGRAM_OTEL_TRACES_EXPORTER: none
|
||||||
TELEGRAM_OTEL_METRICS_EXPORTER: none
|
TELEGRAM_OTEL_METRICS_EXPORTER: none
|
||||||
GOMAXPROCS: "1"
|
GOMAXPROCS: "1"
|
||||||
|
|||||||
@@ -84,6 +84,9 @@ services:
|
|||||||
logging: *default-logging
|
logging: *default-logging
|
||||||
environment:
|
environment:
|
||||||
RENDERER_PORT: "8090"
|
RENDERER_PORT: "8090"
|
||||||
|
# The offer page (GET /offer/) fetches the live catalog price list from the backend's internal
|
||||||
|
# endpoint and splices it into the committed offer markdown. Backend down ⇒ /offer/ returns 502.
|
||||||
|
RENDERER_BACKEND_URL: http://backend:8080
|
||||||
healthcheck:
|
healthcheck:
|
||||||
test: ["CMD", "node", "-e", "fetch('http://127.0.0.1:8090/healthz').then((r)=>process.exit(r.ok?0:1)).catch(()=>process.exit(1))"]
|
test: ["CMD", "node", "-e", "fetch('http://127.0.0.1:8090/healthz').then((r)=>process.exit(r.ok?0:1)).catch(()=>process.exit(1))"]
|
||||||
interval: 10s
|
interval: 10s
|
||||||
@@ -251,6 +254,12 @@ services:
|
|||||||
# secret; empty (unset secret) leaves the trap off.
|
# secret; empty (unset secret) leaves the trap off.
|
||||||
GATEWAY_ABUSE_BAN_ENABLED: ${GATEWAY_ABUSE_BAN_ENABLED:-false}
|
GATEWAY_ABUSE_BAN_ENABLED: ${GATEWAY_ABUSE_BAN_ENABLED:-false}
|
||||||
GATEWAY_HONEYTOKEN: ${GATEWAY_HONEYTOKEN:-}
|
GATEWAY_HONEYTOKEN: ${GATEWAY_HONEYTOKEN:-}
|
||||||
|
# Community IP blocklist (Spamhaus DROP): prod-only, off unless the real client IP is visible
|
||||||
|
# (the shared-NAT test contour would self-block). Enabled + fed the feed URL + allowlist by the
|
||||||
|
# prod deploy (write-prod-env.sh); the refresh/staleness windows use the built-in defaults.
|
||||||
|
GATEWAY_BLOCKLIST_ENABLED: ${GATEWAY_BLOCKLIST_ENABLED:-false}
|
||||||
|
GATEWAY_BLOCKLIST_URL: ${GATEWAY_BLOCKLIST_URL:-}
|
||||||
|
GATEWAY_BLOCKLIST_ALLOW: ${GATEWAY_BLOCKLIST_ALLOW:-}
|
||||||
GATEWAY_LOG_LEVEL: ${LOG_LEVEL:-info}
|
GATEWAY_LOG_LEVEL: ${LOG_LEVEL:-info}
|
||||||
GATEWAY_SERVICE_NAME: scrabble-gateway
|
GATEWAY_SERVICE_NAME: scrabble-gateway
|
||||||
GATEWAY_OTEL_TRACES_EXPORTER: otlp
|
GATEWAY_OTEL_TRACES_EXPORTER: otlp
|
||||||
|
|||||||
@@ -0,0 +1,45 @@
|
|||||||
|
{
|
||||||
|
"uid": "scrabble-bot",
|
||||||
|
"title": "Scrabble — Telegram bot",
|
||||||
|
"tags": ["scrabble"],
|
||||||
|
"timezone": "",
|
||||||
|
"schemaVersion": 39,
|
||||||
|
"version": 1,
|
||||||
|
"refresh": "30s",
|
||||||
|
"time": { "from": "now-6h", "to": "now" },
|
||||||
|
"panels": [
|
||||||
|
{
|
||||||
|
"type": "stat",
|
||||||
|
"title": "Bot connected",
|
||||||
|
"description": "botlink_connected_bots: bots currently holding the gateway bot-link (1 = healthy).",
|
||||||
|
"gridPos": { "h": 5, "w": 6, "x": 0, "y": 0 },
|
||||||
|
"datasource": { "type": "prometheus", "uid": "prometheus" },
|
||||||
|
"targets": [{ "refId": "A", "expr": "max(botlink_connected_bots)" }]
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"type": "stat",
|
||||||
|
"title": "Last Bot API OK (age)",
|
||||||
|
"description": "Seconds since the bot's most recent successful Bot API call. Grows unbounded if the bot wedges.",
|
||||||
|
"gridPos": { "h": 5, "w": 6, "x": 6, "y": 0 },
|
||||||
|
"fieldConfig": { "defaults": { "unit": "s" }, "overrides": [] },
|
||||||
|
"datasource": { "type": "prometheus", "uid": "prometheus" },
|
||||||
|
"targets": [{ "refId": "A", "expr": "time() - max(bot_tg_last_ok_unix)" }]
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"type": "timeseries",
|
||||||
|
"title": "Bot API errors/s by kind",
|
||||||
|
"description": "bot_tg_errors_total by kind: connect (getUpdates), api (other sends), rate_limited (429). 429 should stay ~0.",
|
||||||
|
"gridPos": { "h": 8, "w": 12, "x": 12, "y": 0 },
|
||||||
|
"datasource": { "type": "prometheus", "uid": "prometheus" },
|
||||||
|
"targets": [{ "refId": "A", "expr": "sum by (kind) (rate(bot_tg_errors_total[5m]))", "legendFormat": "{{kind}}" }]
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"type": "timeseries",
|
||||||
|
"title": "Bot-link commands/s by result",
|
||||||
|
"description": "botlink_commands_total by result: delivered / not_delivered / dropped / error. Sustained not_delivered or error means gateway sends are failing to reach Telegram.",
|
||||||
|
"gridPos": { "h": 8, "w": 12, "x": 0, "y": 5 },
|
||||||
|
"datasource": { "type": "prometheus", "uid": "prometheus" },
|
||||||
|
"targets": [{ "refId": "A", "expr": "sum by (result) (rate(botlink_commands_total[5m]))", "legendFormat": "{{result}}" }]
|
||||||
|
}
|
||||||
|
]
|
||||||
|
}
|
||||||
@@ -53,6 +53,31 @@
|
|||||||
"fieldConfig": { "defaults": { "unit": "bytes" }, "overrides": [] },
|
"fieldConfig": { "defaults": { "unit": "bytes" }, "overrides": [] },
|
||||||
"datasource": { "type": "prometheus", "uid": "prometheus" },
|
"datasource": { "type": "prometheus", "uid": "prometheus" },
|
||||||
"targets": [{ "refId": "A", "expr": "sum(go_memory_used) by (service_name)", "legendFormat": "{{service_name}}" }]
|
"targets": [{ "refId": "A", "expr": "sum(go_memory_used) by (service_name)", "legendFormat": "{{service_name}}" }]
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"type": "stat",
|
||||||
|
"title": "Blocklist entries",
|
||||||
|
"description": "CIDR ranges in the active community IP blocklist feed (0 when disabled or not yet fetched).",
|
||||||
|
"gridPos": { "h": 5, "w": 6, "x": 0, "y": 13 },
|
||||||
|
"datasource": { "type": "prometheus", "uid": "prometheus" },
|
||||||
|
"targets": [{ "refId": "A", "expr": "max(gateway_blocklist_entries)" }]
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"type": "stat",
|
||||||
|
"title": "Blocklist feed age",
|
||||||
|
"description": "Seconds since the community IP blocklist feed was last successfully fetched. Grows if the fetch is failing; the feed is dropped fail-open once stale.",
|
||||||
|
"gridPos": { "h": 5, "w": 6, "x": 6, "y": 13 },
|
||||||
|
"fieldConfig": { "defaults": { "unit": "s" }, "overrides": [] },
|
||||||
|
"datasource": { "type": "prometheus", "uid": "prometheus" },
|
||||||
|
"targets": [{ "refId": "A", "expr": "max(gateway_blocklist_age_seconds)" }]
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"type": "timeseries",
|
||||||
|
"title": "Blocklist blocks/s",
|
||||||
|
"description": "Requests refused at the edge by the community IP blocklist (gateway_blocklist_blocked_total).",
|
||||||
|
"gridPos": { "h": 8, "w": 12, "x": 12, "y": 13 },
|
||||||
|
"datasource": { "type": "prometheus", "uid": "prometheus" },
|
||||||
|
"targets": [{ "refId": "A", "expr": "sum(rate(gateway_blocklist_blocked_total[5m]))" }]
|
||||||
}
|
}
|
||||||
]
|
]
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -108,6 +108,32 @@ groups:
|
|||||||
labels: { severity: critical }
|
labels: { severity: critical }
|
||||||
annotations: { summary: 'Edge TLS cert has under 20 days left — Caddy ACME renewal may have failed.' }
|
annotations: { summary: 'Edge TLS cert has under 20 days left — Caddy ACME renewal may have failed.' }
|
||||||
|
|
||||||
|
# The community IP blocklist feed has not refreshed in over a day (the gateway re-fetches every
|
||||||
|
# few hours). Absent/NaN-safe: the age gauge appears only once a feed has loaded, so a disabled
|
||||||
|
# or never-fetched blocklist stays quiet. The feed itself is dropped (fail-open) at its
|
||||||
|
# max-staleness window; this warns well before that so the operator can fix the fetch.
|
||||||
|
- uid: blocklist_stale
|
||||||
|
title: IP blocklist feed not refreshing
|
||||||
|
condition: C
|
||||||
|
for: 15m
|
||||||
|
noDataState: OK
|
||||||
|
execErrState: OK
|
||||||
|
data:
|
||||||
|
- refId: A
|
||||||
|
relativeTimeRange: { from: 300, to: 0 }
|
||||||
|
datasourceUid: prometheus
|
||||||
|
model: { refId: A, expr: gateway_blocklist_age_seconds, instant: true }
|
||||||
|
- refId: C
|
||||||
|
datasourceUid: __expr__
|
||||||
|
model:
|
||||||
|
refId: C
|
||||||
|
type: threshold
|
||||||
|
expression: A
|
||||||
|
conditions:
|
||||||
|
- evaluator: { type: gt, params: [86400] }
|
||||||
|
labels: { severity: warning }
|
||||||
|
annotations: { summary: 'The community IP blocklist feed has not refreshed in over 24h — the fetch is failing; it will be dropped (fail-open) once stale. Check the gateway blocklist refresher logs and the feed URL.' }
|
||||||
|
|
||||||
- orgId: 1
|
- orgId: 1
|
||||||
name: scrabble-host
|
name: scrabble-host
|
||||||
folder: Alerts
|
folder: Alerts
|
||||||
@@ -245,8 +271,14 @@ groups:
|
|||||||
conditions:
|
conditions:
|
||||||
- evaluator: { type: gt, params: [0] }
|
- evaluator: { type: gt, params: [0] }
|
||||||
labels: { severity: critical }
|
labels: { severity: critical }
|
||||||
annotations: { summary: 'pgBackRest archive_command is failing — PITR is degrading and pg_wal can fill the disk. Run `docker exec scrabble-postgres pgbackrest --stanza=scrabble check`.' }
|
annotations: { summary: 'pgBackRest archive_command is failing — PITR is degrading and pg_wal can fill the disk. Run `docker exec scrabble-postgres pgbackrest --stanza=scrabble --pg1-user=scrabble check`.' }
|
||||||
|
|
||||||
|
# Fires only when the last archive is >30 min old AND pg_wal is actually growing (WAL is being
|
||||||
|
# produced but not archived). A genuinely idle database archives nothing — Postgres does not
|
||||||
|
# force-switch an empty segment on archive_timeout — so `last_archive_age` alone false-triggers
|
||||||
|
# during quiet hours; the pg_wal-growth guard removes that. `and on()` bridges the differing
|
||||||
|
# label sets (last_archive_age has a server label, the pg_wal gauge does not); delta() (not
|
||||||
|
# increase) suits the pg_wal_size_bytes gauge. Real archive failures are caught by pg_archive_failing.
|
||||||
- uid: pg_archive_stalled
|
- uid: pg_archive_stalled
|
||||||
title: WAL archiving stalled
|
title: WAL archiving stalled
|
||||||
condition: C
|
condition: C
|
||||||
@@ -255,11 +287,11 @@ groups:
|
|||||||
execErrState: OK
|
execErrState: OK
|
||||||
data:
|
data:
|
||||||
- refId: A
|
- refId: A
|
||||||
relativeTimeRange: { from: 300, to: 0 }
|
relativeTimeRange: { from: 2100, to: 0 }
|
||||||
datasourceUid: prometheus
|
datasourceUid: prometheus
|
||||||
model:
|
model:
|
||||||
refId: A
|
refId: A
|
||||||
expr: pg_stat_archiver_last_archive_age
|
expr: (pg_stat_archiver_last_archive_age > 1800) and on() (delta(pg_wal_size_bytes[35m]) > 16777216)
|
||||||
instant: true
|
instant: true
|
||||||
- refId: C
|
- refId: C
|
||||||
datasourceUid: __expr__
|
datasourceUid: __expr__
|
||||||
@@ -270,4 +302,92 @@ groups:
|
|||||||
conditions:
|
conditions:
|
||||||
- evaluator: { type: gt, params: [1800] }
|
- evaluator: { type: gt, params: [1800] }
|
||||||
labels: { severity: critical }
|
labels: { severity: critical }
|
||||||
annotations: { summary: 'No WAL segment archived for over 30 minutes (archive_timeout is 5m) — archiving is stalled; the PITR recovery point is frozen and pg_wal may grow.' }
|
annotations: { summary: 'No WAL archived for over 30 minutes while pg_wal keeps growing — archiving is genuinely stalled; the PITR recovery point is frozen and pg_wal will fill the disk. Run `docker exec scrabble-postgres pgbackrest --stanza=scrabble --pg1-user=scrabble check`.' }
|
||||||
|
|
||||||
|
# Remote Telegram bot health. The bot runs on its own host and exports no telemetry of its own; the
|
||||||
|
# gateway observes it through the bot-link (botlink_connected_bots) and the health it reports over
|
||||||
|
# that stream (bot_tg_*). All absent/NaN-safe: before a bot ever connects the metrics are absent, so
|
||||||
|
# noDataState OK keeps them quiet on a fresh contour. The alert email does NOT go through the bot, so
|
||||||
|
# a bot-down alert is deliverable.
|
||||||
|
- orgId: 1
|
||||||
|
name: scrabble-bot
|
||||||
|
folder: Alerts
|
||||||
|
interval: 1m
|
||||||
|
rules:
|
||||||
|
- uid: bot_disconnected
|
||||||
|
title: Telegram bot disconnected
|
||||||
|
condition: C
|
||||||
|
for: 5m
|
||||||
|
noDataState: OK
|
||||||
|
execErrState: OK
|
||||||
|
data:
|
||||||
|
- refId: A
|
||||||
|
relativeTimeRange: { from: 300, to: 0 }
|
||||||
|
datasourceUid: prometheus
|
||||||
|
model: { refId: A, expr: botlink_connected_bots, instant: true }
|
||||||
|
- refId: C
|
||||||
|
datasourceUid: __expr__
|
||||||
|
model:
|
||||||
|
refId: C
|
||||||
|
type: threshold
|
||||||
|
expression: A
|
||||||
|
conditions:
|
||||||
|
- evaluator: { type: lt, params: [1] }
|
||||||
|
labels: { severity: critical }
|
||||||
|
annotations: { summary: 'No Telegram bot is connected to the gateway bot-link — out-of-app push and admin sends are down. Check the bot host.' }
|
||||||
|
|
||||||
|
# Positive liveness: a bot is connected but its last successful Bot API call is over 5 minutes
|
||||||
|
# old — the getUpdates long-poll returns every ~minute even when idle, so a stale stamp means the
|
||||||
|
# bot is wedged (no errors, no traffic). Guarded by "and connected" so a mere disconnect (owned by
|
||||||
|
# bot_disconnected) does not double-fire.
|
||||||
|
- uid: bot_tg_stale
|
||||||
|
title: Telegram bot not reaching the Bot API
|
||||||
|
condition: C
|
||||||
|
for: 5m
|
||||||
|
noDataState: OK
|
||||||
|
execErrState: OK
|
||||||
|
data:
|
||||||
|
- refId: A
|
||||||
|
relativeTimeRange: { from: 600, to: 0 }
|
||||||
|
datasourceUid: prometheus
|
||||||
|
model:
|
||||||
|
refId: A
|
||||||
|
expr: (time() - bot_tg_last_ok_unix) and on() (botlink_connected_bots >= 1)
|
||||||
|
instant: true
|
||||||
|
- refId: C
|
||||||
|
datasourceUid: __expr__
|
||||||
|
model:
|
||||||
|
refId: C
|
||||||
|
type: threshold
|
||||||
|
expression: A
|
||||||
|
conditions:
|
||||||
|
- evaluator: { type: gt, params: [300] }
|
||||||
|
labels: { severity: critical }
|
||||||
|
annotations: { summary: 'A connected Telegram bot has not reached the Bot API in over 5 minutes — the update poll is wedged. Check the bot host and Telegram reachability.' }
|
||||||
|
|
||||||
|
# 429s should be ~never once the bot honours Retry-After; any sustained rate-limiting is a symptom
|
||||||
|
# (a send loop, a misbehaving path) worth investigating.
|
||||||
|
- uid: bot_tg_rate_limited
|
||||||
|
title: Telegram bot rate-limited (429)
|
||||||
|
condition: C
|
||||||
|
for: 5m
|
||||||
|
noDataState: OK
|
||||||
|
execErrState: OK
|
||||||
|
data:
|
||||||
|
- refId: A
|
||||||
|
relativeTimeRange: { from: 900, to: 0 }
|
||||||
|
datasourceUid: prometheus
|
||||||
|
model:
|
||||||
|
refId: A
|
||||||
|
expr: increase(bot_tg_errors_total{kind="rate_limited"}[15m])
|
||||||
|
instant: true
|
||||||
|
- refId: C
|
||||||
|
datasourceUid: __expr__
|
||||||
|
model:
|
||||||
|
refId: C
|
||||||
|
type: threshold
|
||||||
|
expression: A
|
||||||
|
conditions:
|
||||||
|
- evaluator: { type: gt, params: [0] }
|
||||||
|
labels: { severity: warning }
|
||||||
|
annotations: { summary: 'The Telegram bot is being rate-limited (HTTP 429). It should honour Retry-After, so sustained 429s point to a send loop or a hot path.' }
|
||||||
|
|||||||
@@ -18,18 +18,9 @@
|
|||||||
@shell not path /assets/*
|
@shell not path /assets/*
|
||||||
header @shell Cache-Control "no-cache"
|
header @shell Cache-Control "no-cache"
|
||||||
|
|
||||||
# The static public offer page, rendered from ui/legal/offer_ru.md at build
|
# The public offer page (/offer/) is no longer served here: the contour caddy routes it to the
|
||||||
# time into dist/offer/index.html (vite emit-offer plugin). Served with its own
|
# render sidecar, which splices the live catalog price list into ui/legal/offer_ru.md. This
|
||||||
# index so /offer/ resolves to /srv/offer/index.html rather than falling to the
|
# container never sees /offer/, so it carries no offer assets (the vite emit-offer plugin is gone).
|
||||||
# landing shell below (whose index is landing.html). A bare /offer redirects in.
|
|
||||||
handle /offer {
|
|
||||||
redir * /offer/ permanent
|
|
||||||
}
|
|
||||||
handle /offer/* {
|
|
||||||
file_server {
|
|
||||||
index index.html
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
# An unknown path falls back to the landing shell (the gateway's old "/"
|
# An unknown path falls back to the landing shell (the gateway's old "/"
|
||||||
# behaviour); "/" itself resolves through the index below.
|
# behaviour); "/" itself resolves through the index below.
|
||||||
|
|||||||
@@ -52,6 +52,10 @@ export APP_VERSION='$APP_VERSION'
|
|||||||
export TELEGRAM_BOT_TOKEN='$TELEGRAM_BOT_TOKEN'
|
export TELEGRAM_BOT_TOKEN='$TELEGRAM_BOT_TOKEN'
|
||||||
export TELEGRAM_MINIAPP_URL='$TELEGRAM_MINIAPP_URL'
|
export TELEGRAM_MINIAPP_URL='$TELEGRAM_MINIAPP_URL'
|
||||||
export GATEWAY_VK_APP_SECRET='$GATEWAY_VK_APP_SECRET'
|
export GATEWAY_VK_APP_SECRET='$GATEWAY_VK_APP_SECRET'
|
||||||
|
export ROBOKASSA_MERCHANT_LOGIN='$ROBOKASSA_MERCHANT_LOGIN'
|
||||||
|
export ROBOKASSA_PASSWORD1='$ROBOKASSA_PASSWORD1'
|
||||||
|
export ROBOKASSA_PASSWORD2='$ROBOKASSA_PASSWORD2'
|
||||||
|
export ROBOKASSA_TEST='$ROBOKASSA_TEST'
|
||||||
export VITE_VK_APP_ID='$VITE_VK_APP_ID'
|
export VITE_VK_APP_ID='$VITE_VK_APP_ID'
|
||||||
export VITE_VK_ID_REDIRECT_URL='$VITE_VK_ID_REDIRECT_URL'
|
export VITE_VK_ID_REDIRECT_URL='$VITE_VK_ID_REDIRECT_URL'
|
||||||
export GATEWAY_VK_ID_CLIENT_SECRET='$GATEWAY_VK_ID_CLIENT_SECRET'
|
export GATEWAY_VK_ID_CLIENT_SECRET='$GATEWAY_VK_ID_CLIENT_SECRET'
|
||||||
@@ -73,6 +77,11 @@ export GF_SMTP_ENABLED='$GF_SMTP_ENABLED'
|
|||||||
export PUBLIC_BASE_URL='$PUBLIC_BASE_URL'
|
export PUBLIC_BASE_URL='$PUBLIC_BASE_URL'
|
||||||
export GATEWAY_HONEYTOKEN='$GATEWAY_HONEYTOKEN'
|
export GATEWAY_HONEYTOKEN='$GATEWAY_HONEYTOKEN'
|
||||||
export GATEWAY_ABUSE_BAN_ENABLED='true'
|
export GATEWAY_ABUSE_BAN_ENABLED='true'
|
||||||
|
# Community IP blocklist (Spamhaus DROP): opt-in — the operator enables it and sets the feed URL +
|
||||||
|
# allowlist via PROD_GATEWAY_BLOCKLIST_* vars once the feed is verified. Unset ⇒ off (safe).
|
||||||
|
export GATEWAY_BLOCKLIST_ENABLED='${GATEWAY_BLOCKLIST_ENABLED:-false}'
|
||||||
|
export GATEWAY_BLOCKLIST_URL='$GATEWAY_BLOCKLIST_URL'
|
||||||
|
export GATEWAY_BLOCKLIST_ALLOW='$GATEWAY_BLOCKLIST_ALLOW'
|
||||||
# Continuous WAL archiving (pgBackRest -> S3) for point-in-time recovery. The artifact
|
# Continuous WAL archiving (pgBackRest -> S3) for point-in-time recovery. The artifact
|
||||||
# ships disarmed: PGBACKREST_ARCHIVE_MODE defaults off, so archiving stays inert until the
|
# ships disarmed: PGBACKREST_ARCHIVE_MODE defaults off, so archiving stays inert until the
|
||||||
# operator sets the S3 repository values + secrets, creates the stanza and flips the switch
|
# operator sets the S3 repository values + secrets, creates the stanza and flips the switch
|
||||||
|
|||||||
+78
-17
@@ -435,7 +435,11 @@ Key points:
|
|||||||
through a **session-gated `GET /dict/{variant}/{version}`** edge route
|
through a **session-gated `GET /dict/{variant}/{version}`** edge route
|
||||||
(immutable; cached in IndexedDB best-effort) and reused across sessions; any
|
(immutable; cached in IndexedDB best-effort) and reused across sessions; any
|
||||||
miss, storage eviction or a bad-connection breaker falls back to the network
|
miss, storage eviction or a bad-connection breaker falls back to the network
|
||||||
`evaluate`. The warm-up overlay is `docs/UI_DESIGN.md`.
|
`evaluate`. The warm-up overlay is `docs/UI_DESIGN.md`. The **on-board rendering** of a staged
|
||||||
|
play — the legality tint, the cells a formed word covers, and where the score badge anchors — is a
|
||||||
|
separate **pure geometry helper** (`ui/src/lib/formed.ts`) derived from the board and the staged
|
||||||
|
tiles, so it works regardless of which eval path produced the preview (the badge's number still
|
||||||
|
comes from the preview's score): legality and score stay with the eval, geometry with the board.
|
||||||
|
|
||||||
## 6. Game rules
|
## 6. Game rules
|
||||||
|
|
||||||
@@ -650,18 +654,30 @@ 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 client counts its lobby games per kind against the per-tier caps it reads on
|
||||||
|
the profile (`Profile.game_limits`, carrying `GameView.kind`), and locks a capped kind's
|
||||||
|
New-Game start — a 🔒 outline button that opens a prompt instead of a game: a sign-in funnel
|
||||||
|
for a guest, a "finish a current game first" notice for a durable account (native inside
|
||||||
|
Telegram, an in-app modal elsewhere), lifting when the profile is re-fetched after a
|
||||||
|
guest→durable upgrade. The `games.list` `at_game_limit` boolean (now the random-kind cap) is
|
||||||
|
still carried but no longer drives the UI — the per-kind start lock superseded the old
|
||||||
|
New-Game-tab disable.
|
||||||
- **Friends**: two add paths over one `friendships` table. A **one-time
|
- **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
|
||||||
@@ -893,6 +909,22 @@ finished journal on each GET. The only degraded platform is a legacy Telegram cl
|
|||||||
predating `downloadFile`, where the GCG falls back to the old clipboard copy and the
|
predating `downloadFile`, where the GCG falls back to the old clipboard copy and the
|
||||||
image option is not offered.
|
image option is not offered.
|
||||||
|
|
||||||
|
The same sidecar also serves the **public offer page** at `/offer/` — the one edge-exposed
|
||||||
|
route on it (caddy routes `/offer/` here; its `/render` stays internal). It reuses the shared
|
||||||
|
`ui/src/lib/offer.ts` renderer (again one renderer, no drift) over the owner-edited
|
||||||
|
`ui/legal/offer_ru.md`, baked into the image, splicing in the **live price list** (§4.4): it
|
||||||
|
fetches the two catalog tables as markdown from the backend's internal
|
||||||
|
`/api/v1/internal/offer/pricing` at the `<#pricing_template#>` marker, then renders the page. The
|
||||||
|
backend projects the tables from the active catalog through `payments.Money` (no float reaches the
|
||||||
|
page) and caches them in memory — built at boot, reprojected on any catalog edit — so a served
|
||||||
|
render issues no query in the steady state and the page always reflects the current catalog without
|
||||||
|
a redeploy. A backend outage degrades `/offer/` to a 502 rather than a stale price list. Packs are
|
||||||
|
ordered by ascending rouble price; values are grouped by what they grant — hints only, then no-ads
|
||||||
|
only, then no-ads + hints, then (reserved, empty today) products carrying the **tournament** atom,
|
||||||
|
which becomes a fourth group once the tournament economy makes them sellable — and ordered by
|
||||||
|
ascending chip price within each group. Product titles are admin input, so the projection escapes
|
||||||
|
them (HTML entities + markdown metacharacters) before they reach the un-sanitised renderer.
|
||||||
|
|
||||||
The alphabet-on-the-wire transport does **not** touch this invariant: the live edge
|
The alphabet-on-the-wire transport does **not** touch this invariant: the live edge
|
||||||
exchanges alphabet indices, but the persisted journal (and everything derived from it —
|
exchanges alphabet indices, but the persisted journal (and everything derived from it —
|
||||||
replay, history, GCG) keeps the decoded concrete letters described above, so an archived
|
replay, history, GCG) keeps the decoded concrete letters described above, so an archived
|
||||||
@@ -983,6 +1015,20 @@ answering `/start` with a URL button into the **main** bot's Mini App (`?startap
|
|||||||
button would sign initData with the promo token); it is self-contained — no bot-link, no gateway.
|
button would sign initData with the promo token); it is self-contained — no bot-link, no gateway.
|
||||||
Session-revocation events and cursor-based stream resume stay deferred (single-instance MVP).
|
Session-revocation events and cursor-based stream resume stay deferred (single-instance MVP).
|
||||||
|
|
||||||
|
Because the bot **exports no telemetry of its own** (the OTel collector is on the main host,
|
||||||
|
unreachable from the bot host), it reports its **Bot API health** up the same stream: a periodic
|
||||||
|
`Health` message (`platform/telegram/internal/health`) carries delta counts of connect failures (the
|
||||||
|
getUpdates long-poll), other API errors and 429s, plus the wall-clock second of its last successful
|
||||||
|
Bot API call. The bot observes these **centrally by wrapping the Bot API HTTP client** — one place,
|
||||||
|
no per-call-site instrumentation — and there also honours a 429's `Retry-After` (bounded) so it backs
|
||||||
|
off rather than hammering (a 429 should therefore stay ~0). The gateway folds each report into its
|
||||||
|
own metrics (`bot_tg_errors_total{kind}`, the `bot_tg_last_ok_unix` liveness gauge). The remote bot
|
||||||
|
is thus monitored from the main host's Grafana with three layered signals: the **connection gauge**
|
||||||
|
(`botlink_connected_bots`) catches a link/host/process outage, the **liveness gauge** catches a
|
||||||
|
silently wedged bot (its stamp stays fresh even when idle, since getUpdates returns every poll), and
|
||||||
|
**`botlink_commands_total{result}`** catches gateway sends failing to reach Telegram. Alerts route to
|
||||||
|
the operator email, which does not depend on the bot.
|
||||||
|
|
||||||
A separate **advertising-banner** channel feeds the client's one-line strip (UI_DESIGN.md),
|
A separate **advertising-banner** channel feeds the client's one-line strip (UI_DESIGN.md),
|
||||||
server-driven by `internal/ads`. An operator manages **campaigns** (each one placement order) in
|
server-driven by `internal/ads`. An operator manages **campaigns** (each one placement order) in
|
||||||
the admin console (`/_gm/banners`): a campaign has a show **weight** (integer percent 1..100), an
|
the admin console (`/_gm/banners`): a campaign has a show **weight** (integer percent 1..100), an
|
||||||
@@ -1041,7 +1087,9 @@ link — misses the event; while an add-email confirmation is pending the client
|
|||||||
`OTEL_EXPORTER_OTLP_*` environment) exports to a collector. The Postgres pool is
|
`OTEL_EXPORTER_OTLP_*` environment) exports to a collector. The Postgres pool is
|
||||||
instrumented with otelsql and `otelgrpc` traces the backend↔gateway push stream
|
instrumented with otelsql and `otelgrpc` traces the backend↔gateway push stream
|
||||||
and the gateway↔validator and bot-link calls; the gateway also exports
|
and the gateway↔validator and bot-link calls; the gateway also exports
|
||||||
`botlink_connected_bots` and `botlink_commands_total` (by result) for the bot-link. The OTLP **Collector** (OTLP/gRPC → Prometheus
|
`botlink_connected_bots` and `botlink_commands_total` (by result) for the bot-link, plus the remote
|
||||||
|
bot's own Bot API health it relays over the stream — `bot_tg_errors_total` (by kind) and the
|
||||||
|
`bot_tg_last_ok_unix` liveness gauge (see the bot-link section). The OTLP **Collector** (OTLP/gRPC → Prometheus
|
||||||
metrics + Tempo traces), **Prometheus** (15d), **Tempo** (72h) and **Grafana**
|
metrics + Tempo traces), **Prometheus** (15d), **Tempo** (72h) and **Grafana**
|
||||||
(provisioned datasources + dashboards, behind the caddy `/_gm/grafana` Basic-Auth)
|
(provisioned datasources + dashboards, behind the caddy `/_gm/grafana` Basic-Auth)
|
||||||
are stood up with the deploy (`deploy/`); the default exporter stays
|
are stood up with the deploy (`deploy/`); the default exporter stays
|
||||||
@@ -1158,6 +1206,18 @@ link — misses the event; while an add-email confirmation is pending the client
|
|||||||
(`POST /api/v1/internal/bans/sync`, network-trusted like the rejection report) and applies
|
(`POST /api/v1/internal/bans/sync`, network-trusted like the rejection report) and applies
|
||||||
the operator unbans the response returns, so a manual unban takes effect within the sync
|
the operator unbans the response returns, so a manual unban takes effect within the sync
|
||||||
interval.
|
interval.
|
||||||
|
- **Community IP blocklist (prod-only):** with `GATEWAY_BLOCKLIST_ENABLED` set, the gateway also
|
||||||
|
refuses a client whose IP is in a curated CIDR feed (Spamhaus DROP, `GATEWAY_BLOCKLIST_URL`) with
|
||||||
|
**403** in the same `abuseGuard`, before the fail2ban list. A background refresher re-fetches the
|
||||||
|
feed every few hours (bounded fetch + size cap) into a sorted-range matcher (`ratelimit.Blocklist`,
|
||||||
|
binary search, IPv4 only — an IPv6 client is never blocked here). It is **fault-tolerant and
|
||||||
|
fail-open**: a failed fetch keeps the last-good feed, and once the feed is older than
|
||||||
|
`GATEWAY_BLOCKLIST_MAX_STALENESS` it is **dropped** rather than block a legitimate client on a
|
||||||
|
frozen list; a `GATEWAY_BLOCKLIST_ALLOW` allowlist (own infra, monitoring) is never blocked. Off by
|
||||||
|
default and prod-only for the same real-client-IP reason as the ban. It is a **separate** static
|
||||||
|
CIDR set, not the per-IP fail2ban store (a bulk CIDR feed cannot expand into per-IP entries).
|
||||||
|
Observability: `gateway_blocklist_blocked_total`, the `gateway_blocklist_entries` size gauge and the
|
||||||
|
`gateway_blocklist_age_seconds` staleness gauge (a Grafana alert warns before the feed is dropped).
|
||||||
- Unauthenticated `GET /healthz` (liveness) and `GET /readyz` (readiness — the
|
- Unauthenticated `GET /healthz` (liveness) and `GET /readyz` (readiness — the
|
||||||
database answers a bounded ping and the session cache is warmed).
|
database answers a bounded ping and the session cache is warmed).
|
||||||
- The backend serves a **second listener** — a gRPC server
|
- The backend serves a **second listener** — a gRPC server
|
||||||
@@ -1333,9 +1393,10 @@ in-process (the distroless image has no `/etc/mime.types`). Hash-named `/assets/
|
|||||||
in-compose **caddy** is the contour's edge: it owns a single `/_gm` Basic-Auth and
|
in-compose **caddy** is the contour's edge: it owns a single `/_gm` Basic-Auth and
|
||||||
routes `/_gm/grafana/*` to **Grafana** (anonymous-admin, so the one shared login gates
|
routes `/_gm/grafana/*` to **Grafana** (anonymous-admin, so the one shared login gates
|
||||||
it with no per-user Grafana accounts) and the rest of `/_gm/*` to the backend-rendered
|
it with no per-user Grafana accounts) and the rest of `/_gm/*` to the backend-rendered
|
||||||
**admin console**; `/app/`, `/telegram/`, `/vk/` and the Connect path go to the gateway; the
|
**admin console**; `/app/`, `/telegram/`, `/vk/` and the Connect path go to the gateway; `/offer/`
|
||||||
catch-all — notably the landing at `/`, plus the static public offer at `/offer/`
|
(the public offer, rendered by the `renderer` sidecar with the live catalog price list spliced in)
|
||||||
(rendered from `ui/legal/offer_ru.md` at build time) — goes to the landing container. The
|
goes to the render sidecar; and the catch-all — notably the landing at `/` — goes to the landing
|
||||||
|
container. The
|
||||||
**Telegram validator** runs as a separate container with **no public ingress**,
|
**Telegram validator** runs as a separate container with **no public ingress**,
|
||||||
answering only internal gRPC (HMAC, no Telegram egress). The **Telegram bot** holds
|
answering only internal gRPC (HMAC, no Telegram egress). The **Telegram bot** holds
|
||||||
no inbound port either: it dials the gateway's **bot-link** (mTLS) and egresses to
|
no inbound port either: it dials the gateway's **bot-link** (mTLS) and egresses to
|
||||||
|
|||||||
+47
-26
@@ -30,7 +30,11 @@ render with arbitrary languages, so detection would make the indexed content
|
|||||||
nondeterministic); a saved 🌐 choice still wins. The page carries a static Russian SEO head
|
nondeterministic); a saved 🌐 choice still wins. The page carries a static Russian SEO head
|
||||||
(title/description, the Open Graph card Telegram/VK link previews use, a canonical link
|
(title/description, the Open Graph card Telegram/VK link previews use, a canonical link
|
||||||
pinned to the production origin, JSON-LD, the favicon set and `robots.txt`), while the SPA
|
pinned to the production origin, JSON-LD, the favicon set and `robots.txt`), while the SPA
|
||||||
shell is `noindex` — the landing is the only indexable page.
|
shell is `noindex` — the landing is the only indexable page. The footer links to the **public
|
||||||
|
offer** (`/offer/`) and, beside it, **feedback** — the Telegram bot the offer names as the seller's
|
||||||
|
contact. The offer page (the legal document a purchase accepts) is rendered on demand from
|
||||||
|
`ui/legal/offer_ru.md` with its **price list** (§4.4) generated from the live product catalog, so
|
||||||
|
the published prices always match what is currently on sale — no redeploy to update them.
|
||||||
|
|
||||||
On the plain web the client is an **installable PWA**: a logged-out player sees an install
|
On the plain web the client is an **installable PWA**: a logged-out player sees an install
|
||||||
call-to-action under the login form (and at the bottom of Settings) that installs the app to the
|
call-to-action under the login form (and at the bottom of Settings) that installs the app to the
|
||||||
@@ -190,24 +194,31 @@ Mini App, the link only opens the app and the recipient enters the copied code b
|
|||||||
settings and the game starts once every invitee has accepted — any decline cancels it, and an unanswered invitation
|
settings and the game starts once every invitee has accepted — any decline cancels it, and an unanswered invitation
|
||||||
expires after seven days.
|
expires after seven days.
|
||||||
|
|
||||||
**Simultaneous-game cap.** A player may hold at most **10 active quick games** at once —
|
**Active-game caps (per kind).** A player's simultaneous unfinished games are capped **per kind** —
|
||||||
counting both in-progress and still-searching auto-match/AI games, but **not** friend games
|
against the AI, in a random match, and by friend invitation — with separate limits for a **guest**
|
||||||
created by invitation. At the cap the **New Game** button is disabled (greyed) and a plain,
|
and a signed-in (durable) account, tuned by the operator without a release. A **guest** may hold **1**
|
||||||
low-emphasis line under the lists reads *"You've reached the simultaneous games limit."*; both
|
game against the AI and **1** random match at once and cannot start friend games at all; a signed-in
|
||||||
clear automatically once an active game finishes and the count drops below ten. The cap blocks
|
account holds up to **10** of each kind. Only in-progress and still-searching games count; a finished
|
||||||
**starting** any game (a quick game or a friend invitation, which share the New Game entry), but
|
game frees its slot, and games already in progress are never interrupted. On the **New Game** screen a
|
||||||
**accepting an incoming invitation is never blocked** — so friend games are capped from the other
|
start whose kind is at its cap shows a **🔒** and, when tapped, opens a short prompt instead of a game:
|
||||||
end (you cannot initiate one while at the cap) while you can always join a game you are invited to.
|
a **guest** is invited to sign in or create an account (to play several games at once), a signed-in
|
||||||
|
player is told to finish a current game first. **Accepting an incoming invitation is never blocked** —
|
||||||
|
friend games are capped only when you start one. The server enforces every cap regardless of the
|
||||||
|
client, and refuses a guest's friend request, friend code or invitation outright.
|
||||||
|
|
||||||
### Playing a game
|
### Playing a game
|
||||||
Place tiles, pass, exchange, or resign. Pass and exchange share one control —
|
Place tiles, pass, exchange, or resign. Pass and exchange share one control —
|
||||||
choosing no tiles passes the turn, choosing tiles exchanges them. Tiles are laid
|
choosing no tiles passes the turn, choosing tiles exchanges them. The tiles left in the
|
||||||
|
bag are shown as a count on that control and at the foot of the move table. Tiles are laid
|
||||||
without choosing a direction — the game infers the play's orientation, so a single
|
without choosing a direction — the game infers the play's orientation, so a single
|
||||||
tile that extends
|
tile that extends
|
||||||
an existing word (down a column or across a row) is accepted. A play is validated
|
an existing word (down a column or across a row) is accepted. A play is validated
|
||||||
against the game's dictionary at submit time and scored; an unlimited preview
|
against the game's dictionary at submit time and scored; an unlimited preview shows
|
||||||
reports the word(s) a tentative move would form and its score, or that it is not
|
**on the board itself** whether the tentative move is legal — the staged tiles turn a light
|
||||||
legal, and the move is offered for submission only once it is confirmed legal. The preview is
|
green once they form a word, a shade darker on the board tiles the word runs through (in a
|
||||||
|
one-word-per-turn game only the main word), or a calm pink when they form none — and an
|
||||||
|
**orange badge** on the main word carries the move's
|
||||||
|
score. The confirm control is offered only once the move is legal. The preview is
|
||||||
computed **on-device** for an instant response once the game's dictionary has loaded — a
|
computed **on-device** for an instant response once the game's dictionary has loaded — a
|
||||||
brief warm-up shows on the first open otherwise — and falls back to the server whenever the
|
brief warm-up shows on the first open otherwise — and falls back to the server whenever the
|
||||||
local dictionary is unavailable. The dictionary check tool is
|
local dictionary is unavailable. The dictionary check tool is
|
||||||
@@ -369,7 +380,9 @@ an email or Telegram and merging accounts are covered under "Accounts, linking &
|
|||||||
merge". Inside the Telegram Mini App, Telegram's own ⋮ menu also offers a **Settings**
|
merge". Inside the Telegram Mini App, Telegram's own ⋮ menu also offers a **Settings**
|
||||||
entry that opens this screen, and your display preferences (theme, board-label style and
|
entry that opens this screen, and your display preferences (theme, board-label style and
|
||||||
reduce-motion — not the interface language, which follows your account) sync across your
|
reduce-motion — not the interface language, which follows your account) sync across your
|
||||||
Telegram devices.
|
Telegram devices. On a touch device the settings also offer a **Zoom the board** toggle (on by
|
||||||
|
default): with it off, dropping a tile no longer auto-magnifies the board toward it. It is a
|
||||||
|
per-device preference and is hidden on desktop, where the board already fits and never auto-zooms.
|
||||||
|
|
||||||
**Preferences (which variants you can be matched into).** A profile setting picks the game
|
**Preferences (which variants you can be matched into).** A profile setting picks the game
|
||||||
variants — Erudite, Russian Scrabble and English Scrabble, shown **Erudite-first** — you allow
|
variants — Erudite, Russian Scrabble and English Scrabble, shown **Erudite-first** — you allow
|
||||||
@@ -525,21 +538,29 @@ shown defensively (text escaped, attachments downloaded rather than rendered).
|
|||||||
### Wallet
|
### Wallet
|
||||||
|
|
||||||
Durable players have a **Wallet** — a tab in the Settings hub, between Friends and About; guests have
|
Durable players have a **Wallet** — a tab in the Settings hub, between Friends and About; guests have
|
||||||
none. It shows their **chips** (the in-game currency, split by where they were funded — VK, Telegram
|
none. A compact **balance** leads the screen: the running context's own **chips** (the in-game
|
||||||
or the web), their **active benefits** (ads off until a date or forever, and the available hint
|
currency) first, then any linked other-platform chips behind that platform's logo — VK, Telegram or
|
||||||
count), and a **store**. What is visible and spendable depends on **where the app runs**, by the
|
the web, split by where they were funded. What is visible and spendable depends on **where the app
|
||||||
store-compliance rules in [`PAYMENTS.md`](PAYMENTS.md): inside a store only that store's chips are
|
runs**, by the store-compliance rules in [`PAYMENTS.md`](PAYMENTS.md): inside a store only that
|
||||||
usable; on the open web all attached chips are, drawn web → VK → Telegram; on VK-iOS the balance is
|
store's chips are usable; on the open web all attached chips are, drawn web → VK → Telegram; on
|
||||||
shown but frozen for spending.
|
VK-iOS the balance is shown but frozen for spending.
|
||||||
|
|
||||||
|
Below the balance an **Active** line lists what the player currently holds — the remaining hint count
|
||||||
|
and, if bought, the ad-free end date (or "forever"); it is hidden when nothing is active. The
|
||||||
|
**store** then splits, by a two-way toggle, into **Buy chips** and **Spend chips**:
|
||||||
|
|
||||||
|
- **Buy chips** — the **chip packs** bought with real money, priced in the running context's currency
|
||||||
|
(VK Votes, Telegram Stars, or roubles on the web), plus, at the top, a **watch-an-ad-for-chips**
|
||||||
|
option where it is offered. Paying opens the provider's payment page (accepting the public offer).
|
||||||
|
- **Spend chips** — the **values** bought with chips (extra hints, days without ads) at a fixed chip
|
||||||
|
price. Their action reads **Exchange**, and a value the balance cannot cover is disabled. Tapping
|
||||||
|
Exchange asks the player to **confirm** (the spend is instant, with no provider window to stand in
|
||||||
|
for a confirmation); that same dialog also carries the store-compliance **warning** when a web
|
||||||
|
spend would draw VK/Telegram chips — the benefit then works only on the web and in the app.
|
||||||
|
|
||||||
The **store** lists **values** bought with chips (extra hints, days without ads) at a fixed chip
|
|
||||||
price shown in every context, and **chip packs** bought with real money, priced in the running
|
|
||||||
context's currency (VK Votes, Telegram Stars, or roubles on the web). Buying a value applies its
|
|
||||||
benefit at once. Before a web purchase that would draw VK/Telegram chips, the app **warns** that the
|
|
||||||
benefit will then work only on the web and in the app — a store rule — and asks the player to confirm.
|
|
||||||
On the **Google Play** build the money purchases are hidden behind a note pointing to the RuStore
|
On the **Google Play** build the money purchases are hidden behind a note pointing to the RuStore
|
||||||
build (Google's in-app-currency rule); spending already-earned chips still works. The wallet keeps
|
build (Google's in-app-currency rule); spending already-earned chips still works. The wallet keeps
|
||||||
**no purchase history** — only the current balances, benefits and store.
|
**no purchase history** — only the current balances, active benefits and store.
|
||||||
|
|
||||||
### Advertising banner
|
### Advertising banner
|
||||||
|
|
||||||
|
|||||||
+53
-29
@@ -32,7 +32,12 @@ top-1 подсказку, безлимитную проверку слова с
|
|||||||
главнее. Страница несёт статическую русскую SEO-«шапку» (title/description, карточка
|
главнее. Страница несёт статическую русскую SEO-«шапку» (title/description, карточка
|
||||||
Open Graph, которую используют превью ссылок в Telegram/VK, канонический адрес
|
Open Graph, которую используют превью ссылок в Telegram/VK, канонический адрес
|
||||||
продакшен-домена, JSON-LD, набор favicon и `robots.txt`), а оболочка SPA помечена
|
продакшен-домена, JSON-LD, набор favicon и `robots.txt`), а оболочка SPA помечена
|
||||||
`noindex` — индексируется только посадочная страница.
|
`noindex` — индексируется только посадочная страница. В подвале — ссылки на **публичную
|
||||||
|
оферту** (`/offer/`) и рядом на **обратную связь** (тот самый Telegram-бот, который оферта
|
||||||
|
указывает как контакт Продавца). Страница оферты (юридический документ, который принимается при
|
||||||
|
покупке) рендерится по запросу из `ui/legal/offer_ru.md`, а её **перечень стоимости** (§4.4)
|
||||||
|
формируется из живого каталога товаров, поэтому опубликованные цены всегда совпадают с тем, что
|
||||||
|
сейчас в продаже — без передеплоя.
|
||||||
|
|
||||||
В обычном вебе клиент — **устанавливаемое PWA**: незалогиненный игрок видит призыв к установке
|
В обычном вебе клиент — **устанавливаемое PWA**: незалогиненный игрок видит призыв к установке
|
||||||
под формой входа (и внизу «Настроек»), который в один тап ставит приложение на рабочий стол
|
под формой входа (и внизу «Настроек»), который в один тап ставит приложение на рабочий стол
|
||||||
@@ -197,25 +202,32 @@ e-mail) либо ввод фразы. Активные игры форфейтя
|
|||||||
выбирает настройки, и партия стартует, когда приняли все приглашённые — любой отказ отменяет приглашение, а без
|
выбирает настройки, и партия стартует, когда приняли все приглашённые — любой отказ отменяет приглашение, а без
|
||||||
ответа приглашение протухает через семь дней.
|
ответа приглашение протухает через семь дней.
|
||||||
|
|
||||||
**Лимит одновременных партий.** Игрок может держать одновременно не более **10 активных
|
**Лимит одновременных партий (по видам).** Число одновременных незавершённых партий игрока
|
||||||
быстрых партий** — считаются и идущие, и ещё ищущие соперника авто-подбор/AI-партии, но **не**
|
ограничено **по видам** — против AI, в случайном подборе и по приглашению друзей — с раздельными
|
||||||
игры с друзьями, созданные приглашением. На лимите кнопка **«Новая игра»** становится неактивной
|
лимитами для **гостя** и для вошедшего (постоянного) аккаунта; оператор настраивает их без релиза.
|
||||||
(серой), а под списками появляется ненавязчивая строка *«Вы достигли лимита одновременных
|
**Гость** может держать **1** партию против AI и **1** случайную одновременно и вовсе не может
|
||||||
партий»*; и кнопка, и надпись снимаются автоматически, как только активная партия завершается и
|
создавать игры с друзьями; вошедший аккаунт — до **10** каждого вида. Считаются только идущие и ещё
|
||||||
счётчик опускается ниже десяти. Лимит запрещает **создавать** любую партию (быструю или
|
ищущие соперника партии; завершённая освобождает слот, а уже идущие партии никогда не прерываются.
|
||||||
приглашение в игру с друзьями — у них общий вход «Новая игра»), но **принимать входящее
|
На экране **«Новая игра»** старт того вида, что достиг лимита, показывает **🔒** и по нажатию
|
||||||
приглашение никогда не запрещено** — так игры с друзьями ограничиваются «с другого конца» (на
|
открывает короткое сообщение вместо партии: **гостю** предлагают войти или создать учётную запись
|
||||||
лимите их нельзя инициировать), а присоединиться к партии по приглашению можно всегда.
|
(чтобы играть несколько партий сразу), вошедшему — сначала завершить текущую. **Принимать входящее
|
||||||
|
приглашение никогда не запрещено** — игры с друзьями ограничиваются только при создании. Сервер
|
||||||
|
применяет все лимиты независимо от клиента и напрямую отклоняет заявку в друзья, код друга или
|
||||||
|
приглашение от гостя.
|
||||||
|
|
||||||
### Игровой процесс
|
### Игровой процесс
|
||||||
Выкладывание фишек, пас, обмен или сдача. Пас и обмен — один элемент управления:
|
Выкладывание фишек, пас, обмен или сдача. Пас и обмен — один элемент управления:
|
||||||
без выбранных фишек — пас, с выбранными — обмен. Фишки кладутся без выбора
|
без выбранных фишек — пас, с выбранными — обмен. Число оставшихся в мешке фишек
|
||||||
|
показано счётчиком на этом элементе и внизу таблицы ходов. Фишки кладутся без выбора
|
||||||
направления — игра сама определяет ориентацию хода, поэтому одна фишка, продолжающая
|
направления — игра сама определяет ориентацию хода, поэтому одна фишка, продолжающая
|
||||||
уже лежащее
|
уже лежащее
|
||||||
слово (по столбцу или по строке), принимается. Ход проверяется по словарю партии при
|
слово (по столбцу или по строке), принимается. Ход проверяется по словарю партии при
|
||||||
сдаче и считается; безлимитный предпросмотр показывает слово (или слова), которое
|
сдаче и считается; безлимитный предпросмотр показывает **прямо на доске**, допустим ли
|
||||||
образует предполагаемый ход, и его очки — либо что ход недопустим, — и ход можно
|
предполагаемый ход: выложенные фишки становятся светло-зелёными, когда образуют слово
|
||||||
отправить только после подтверждения, что он допустим. Предпросмотр считается **на устройстве**
|
(и чуть темнее — фишки доски, через которые проходит слово; в партии «одно слово за ход»
|
||||||
|
подсвечивается только основное слово), или спокойно-розовыми, когда слова нет, — а
|
||||||
|
**оранжевый бейдж** на основном слове несёт очки хода. Кнопка
|
||||||
|
подтверждения появляется только когда ход допустим. Предпросмотр считается **на устройстве**
|
||||||
и отвечает мгновенно, как только словарь партии загружен — иначе при первом открытии показывается
|
и отвечает мгновенно, как только словарь партии загружен — иначе при первом открытии показывается
|
||||||
короткий прогрев, — и уходит на сервер, если локальный словарь недоступен. Инструмент проверки слова безлимитный и
|
короткий прогрев, — и уходит на сервер, если локальный словарь недоступен. Инструмент проверки слова безлимитный и
|
||||||
предлагает пожаловаться на любой результат; для найденного слова он также даёт ссылку на
|
предлагает пожаловаться на любой результат; для найденного слова он также даёт ссылку на
|
||||||
@@ -378,7 +390,10 @@ UTC; при создании аккаунта она подставляется
|
|||||||
Mini App пункт **Settings** в системном меню «⋮» Telegram также открывает этот экран, а
|
Mini App пункт **Settings** в системном меню «⋮» Telegram также открывает этот экран, а
|
||||||
ваши настройки отображения (тема, стиль подписей клеток и reduce-motion — кроме языка
|
ваши настройки отображения (тема, стиль подписей клеток и reduce-motion — кроме языка
|
||||||
интерфейса, который следует за аккаунтом) синхронизируются между вашими устройствами в
|
интерфейса, который следует за аккаунтом) синхронизируются между вашими устройствами в
|
||||||
Telegram.
|
Telegram. На сенсорном устройстве в настройках также есть переключатель **«Приближать
|
||||||
|
доску»** (по умолчанию включён): если выключить, при кидании фишки доска больше не
|
||||||
|
приближается к ней автоматически. Это настройка на устройство, и она скрыта на десктопе,
|
||||||
|
где доска и так помещается целиком и авто-зума нет.
|
||||||
|
|
||||||
**Предпочтения (в какие варианты тебя можно подбирать).** Настройка профиля задаёт варианты
|
**Предпочтения (в какие варианты тебя можно подбирать).** Настройка профиля задаёт варианты
|
||||||
игры — Эрудит, русский Scrabble и английский Scrabble, показанные **сначала Эрудит**, — в
|
игры — Эрудит, русский Scrabble и английский Scrabble, показанные **сначала Эрудит**, — в
|
||||||
@@ -538,21 +553,30 @@ high-rate флага. С карточки пользователя операт
|
|||||||
### Кошелёк
|
### Кошелёк
|
||||||
|
|
||||||
У постоянных (не гостевых) игроков есть **Кошелёк** — вкладка в разделе настроек, между «Друзьями» и
|
У постоянных (не гостевых) игроков есть **Кошелёк** — вкладка в разделе настроек, между «Друзьями» и
|
||||||
«О программе»; у гостей его нет. Он показывает **фишки** (внутриигровую валюту, разделённую по тому,
|
«О программе»; у гостей его нет. Экран открывает компактный **баланс**: сначала собственные **фишки**
|
||||||
где она была пополнена — VK, Telegram или веб), **активные блага** (реклама выключена до даты или
|
(внутриигровая валюта) текущего контекста, затем привязанные фишки других платформ за их логотипом —
|
||||||
навсегда, и число доступных подсказок) и **магазин**. Что видно и что можно потратить, зависит от
|
VK, Telegram или веб, разделённые по тому, где они были пополнены. Что видно и что можно потратить,
|
||||||
того, **где запущено приложение**, по правилам соответствия магазинам из [`PAYMENTS.md`](PAYMENTS.md):
|
зависит от того, **где запущено приложение**, по правилам соответствия магазинам из
|
||||||
внутри магазина доступны только его фишки; в открытом вебе — все привязанные, списываются в порядке
|
[`PAYMENTS.md`](PAYMENTS.md): внутри магазина доступны только его фишки; в открытом вебе — все
|
||||||
веб → VK → Telegram; на VK-iOS баланс показан, но трата заморожена.
|
привязанные, списываются в порядке веб → VK → Telegram; на VK-iOS баланс показан, но трата заморожена.
|
||||||
|
|
||||||
**Магазин** перечисляет **ценности**, покупаемые за фишки (дополнительные подсказки, дни без рекламы),
|
Под балансом строка **«Активные»** перечисляет, что у игрока есть сейчас — остаток подсказок и, если
|
||||||
по фиксированной цене в фишках, одинаковой во всех контекстах, и **наборы фишек**, покупаемые за
|
куплено, дату окончания «без рекламы» (или «навсегда»); она скрыта, когда активного ничего нет. Далее
|
||||||
настоящие деньги, в валюте текущего контекста (голоса VK, звёзды Telegram или рубли в вебе). Покупка
|
**магазин** двухпозиционным переключателем делится на **«Купить фишки»** и **«Потратить фишки»**:
|
||||||
ценности сразу применяет её благо. Перед покупкой в вебе, которая списала бы фишки VK/Telegram,
|
|
||||||
приложение **предупреждает**, что благо будет работать только в вебе и в приложении — это правило
|
- **Купить фишки** — **наборы фишек** за настоящие деньги, в валюте текущего контекста (голоса VK,
|
||||||
магазинов — и просит подтверждения. В сборке для **Google Play** покупки за деньги скрыты за подсказкой
|
звёзды Telegram или рубли в вебе), а сверху — вариант **«ролик за фишки»**, где он предлагается.
|
||||||
установить сборку из RuStore (правило Google о внутриигровой валюте); трата уже заработанных фишек
|
Оплата открывает страницу провайдера (с принятием публичной оферты).
|
||||||
по-прежнему работает. Кошелёк **не хранит историю покупок** — только текущие балансы, блага и магазин.
|
- **Потратить фишки** — **ценности** за фишки (дополнительные подсказки, дни без рекламы) по
|
||||||
|
фиксированной цене в фишках. Их действие называется **«Обмен»**, а ценность, на которую не хватает
|
||||||
|
баланса, недоступна. Тап по «Обмену» просит **подтверждения** (трата мгновенна, окна провайдера,
|
||||||
|
которое сошло бы за подтверждение, нет); это же окно несёт и **предупреждение** соответствия
|
||||||
|
магазинам, когда трата в вебе списала бы фишки VK/Telegram — благо тогда работает только в вебе и в
|
||||||
|
приложении.
|
||||||
|
|
||||||
|
В сборке для **Google Play** покупки за деньги скрыты за подсказкой установить сборку из RuStore
|
||||||
|
(правило Google о внутриигровой валюте); трата уже заработанных фишек по-прежнему работает. Кошелёк
|
||||||
|
**не хранит историю покупок** — только текущие балансы, активные блага и магазин.
|
||||||
|
|
||||||
### Рекламный баннер
|
### Рекламный баннер
|
||||||
|
|
||||||
|
|||||||
+4
-2
@@ -319,10 +319,12 @@ cache. Identity-presence (which segments are awake, §6) is supplied by the call
|
|||||||
here, so unlink/re-link takes effect immediately.
|
here, so unlink/re-link takes effect immediately.
|
||||||
|
|
||||||
**Admin rewards.** An admin grants **concrete values only** (no-ads / hints) — **never
|
**Admin rewards.** An admin grants **concrete values only** (no-ads / hints) — **never
|
||||||
chips** (a gifted currency balance = a store cash-desk bypass). The admin **picks the
|
chips** (a gifted currency balance = a store cash-desk bypass). It grants either raw atoms or a
|
||||||
|
**defined value product** (a reward bundle, which may be archived — hidden from the store but
|
||||||
|
grantable); both refuse a `chips` or `tournament` atom. The admin **picks the
|
||||||
origin** at grant time (compliance is on them: `origin=vk` point-wise/low-volume = low risk,
|
origin** at grant time (compliance is on them: `origin=vk` point-wise/low-volume = low risk,
|
||||||
`origin=direct` = safe). A grant is a ledger transaction of type `admin_grant`, price 0
|
`origin=direct` = safe). A grant is a ledger transaction of type `admin_grant`, price 0
|
||||||
chips — full audit of rewards.
|
chips (the by-product grant records the source `product_id` + snapshot) — full audit of rewards.
|
||||||
|
|
||||||
**Per-user financial report** in the admin console `/_gm` — segment balances, payments,
|
**Per-user financial report** in the admin console `/_gm` — segment balances, payments,
|
||||||
spends, grants, refunds, full history — as an extension of the existing user card
|
spends, grants, refunds, full history — as an extension of the existing user card
|
||||||
|
|||||||
+5
-2
@@ -318,10 +318,13 @@ in-process кэш сегментов и бенефитов по ключу-ак
|
|||||||
вызывающий, здесь не кэшируется, поэтому отвязка/повторная привязка действует сразу.
|
вызывающий, здесь не кэшируется, поэтому отвязка/повторная привязка действует сразу.
|
||||||
|
|
||||||
**Награждение админом.** Админ начисляет **только конкретные ценности** (без рекламы /
|
**Награждение админом.** Админ начисляет **только конкретные ценности** (без рекламы /
|
||||||
подсказки) — **никогда не Фишки** (подаренный баланс валюты = обход кассы стора). Админ
|
подсказки) — **никогда не Фишки** (подаренный баланс валюты = обход кассы стора). Выдаёт либо
|
||||||
|
сырыми атомами, либо **готовым продуктом-ценностью** (набор-награда, возможно архивный — скрыт
|
||||||
|
из магазина, но выдаётся); оба отказывают на атоме `chips` или `tournament`. Админ
|
||||||
**выбирает origin** при выдаче (ответственность за комплаенс на нём: `origin=vk`
|
**выбирает origin** при выдаче (ответственность за комплаенс на нём: `origin=vk`
|
||||||
точечно/малый объём = низкий риск, `origin=direct` = безопасно). Грант — транзакция журнала
|
точечно/малый объём = низкий риск, `origin=direct` = безопасно). Грант — транзакция журнала
|
||||||
типа `admin_grant`, цена 0 Фишек — полный аудит наград.
|
типа `admin_grant`, цена 0 Фишек (грант по продукту пишет исходный `product_id` + снапшот) —
|
||||||
|
полный аудит наград.
|
||||||
|
|
||||||
**Финансовый отчёт по пользователю** в админке `/_gm` — балансы сегментов, платежи, траты,
|
**Финансовый отчёт по пользователю** в админке `/_gm` — балансы сегментов, платежи, траты,
|
||||||
гранты, возвраты, полная история — расширение существующей карточки (`UserDetailView`,
|
гранты, возвраты, полная история — расширение существующей карточки (`UserDetailView`,
|
||||||
|
|||||||
+42
-26
@@ -179,16 +179,16 @@ e2e and the screenshots.
|
|||||||
back near the edges. It **recentres only on a zoom-in** — placing a 2nd+ tile or
|
back near the edges. It **recentres only on a zoom-in** — placing a 2nd+ tile or
|
||||||
hovering a dragged tile never jumps the board. On touch the first tile placement auto-zooms
|
hovering a dragged tile never jumps the board. On touch the first tile placement auto-zooms
|
||||||
in centred on the target, and **holding a dragged tile over a cell ~1 s** auto-zooms there
|
in centred on the target, and **holding a dragged tile over a cell ~1 s** auto-zooms there
|
||||||
the first time. A **swipe down on the zoom-out board** opens the history, but only when the
|
the first time — both gated by the **Zoom the board** setting (on by default; the toggle is
|
||||||
|
shown only on a touch device). A **swipe down on the zoom-out board** opens the history, but only when the
|
||||||
board is scrolled to its top so it never fights the stage's own vertical scroll (the conflict
|
board is scrolled to its top so it never fights the stage's own vertical scroll (the conflict
|
||||||
that once retired this gesture) — and it is suppressed on the zoomed board, where the
|
that once retired this gesture) — and it is suppressed on the zoomed board, where the
|
||||||
one-finger drag pans (and on desktop / the landscape iframe, where a mouse cannot drag-scroll the
|
one-finger drag pans (and on desktop / the landscape iframe, where a mouse cannot drag-scroll the
|
||||||
viewport natively, a **drag-to-pan** handler moves the zoomed board instead — active only while
|
viewport natively, a **drag-to-pan** handler moves the zoomed board instead — active only while
|
||||||
zoomed, off pending tiles, past a small threshold, swallowing the trailing click). History also
|
zoomed, off pending tiles, past a small threshold, swallowing the trailing click). History also
|
||||||
opens on a **tap of the score bar** and closes on a tap or
|
opens on a **tap of the score bar** and closes on a tap or
|
||||||
an **upward swipe** of the then-inert board (below). A **hint** auto-zooms centred on the
|
an **upward swipe** of the then-inert board (below). Taking a **hint** while zoomed in **zooms
|
||||||
hint's placement, not
|
out** to the whole board, so the hint's word — highlighted (below) — is never left off-screen.
|
||||||
the top-left.
|
|
||||||
- **Placing & recall** (`Game.svelte`): a rack tile is placed by tap-then-tap or by
|
- **Placing & recall** (`Game.svelte`): a rack tile is placed by tap-then-tap or by
|
||||||
dragging it onto a cell; while a dragged tile is carried over the board, the aimed-at empty
|
dragging it onto a cell; while a dragged tile is carried over the board, the aimed-at empty
|
||||||
cell is **highlighted as a drop target** (an accent ring). A pending tile is taken back by a
|
cell is **highlighted as a drop target** (an accent ring). A pending tile is taken back by a
|
||||||
@@ -224,7 +224,7 @@ e2e and the screenshots.
|
|||||||
are pinned to the card's edges.
|
are pinned to the card's edges.
|
||||||
Unread chat is also badged on the score bar itself, so it shows with the history closed.
|
Unread chat is also badged on the score bar itself, so it shows with the history closed.
|
||||||
- **Vertical fit & keyboard**: when the game does not fit the viewport, only the
|
- **Vertical fit & keyboard**: when the game does not fit the viewport, only the
|
||||||
board area scrolls vertically (`Screen` `column` mode; the score bar, status, rack and tab
|
board area scrolls vertically (`Screen` `column` mode; the turn strip, score bar, rack and tab
|
||||||
bar stay fixed), while zoom keeps its own scroll. The check-word dialog opens in
|
bar stay fixed), while zoom keeps its own scroll. The check-word dialog opens in
|
||||||
`Modal` keyboard-overlay mode — the small sheet is top-anchored and the soft keyboard
|
`Modal` keyboard-overlay mode — the small sheet is top-anchored and the soft keyboard
|
||||||
overlays the empty area below, so the layout doesn't resize/jank; other modals stay
|
overlays the empty area below, so the layout doesn't resize/jank; other modals stay
|
||||||
@@ -235,23 +235,32 @@ e2e and the screenshots.
|
|||||||
(`min(100cqw, 100cqh)`, height-driven in `Board.svelte`'s `.scaler.land`) inside a
|
(`min(100cqw, 100cqh)`, height-driven in `Board.svelte`'s `.scaler.land`) inside a
|
||||||
`container-type: size` pane, shrinking by width when the column is narrow — the board has the
|
`container-type: size` pane, shrinking by width when the column is narrow — the board has the
|
||||||
**lowest priority**, so the left panel is never squeezed. The **left panel** stacks, top to
|
**lowest priority**, so the left panel is never squeezed. The **left panel** stacks, top to
|
||||||
bottom: the score plaques, the **always-open** history (docked and scrolling with its header
|
bottom: the **turn/result strip**, the score plaques, the **always-open** history (docked and
|
||||||
sticky — no slide-down drawer, no score-bar toggle), the status line (bag · turn · score
|
scrolling with its header sticky and the **bag count** pinned at its foot — no slide-down drawer,
|
||||||
preview), the rack (+ the ✅ make control) and, pinned at the bottom, the controls tab bar. Board
|
no score-bar toggle), the rack (with the ✅ confirm control in its fixed 7th slot) and, pinned at
|
||||||
|
the bottom, the controls tab bar. Board
|
||||||
**zoom works as in portrait** (double-tap / pinch / placement auto-zoom), but the viewport is the
|
**zoom works as in portrait** (double-tap / pinch / placement auto-zoom), but the viewport is the
|
||||||
full pane: zoom-out shows the height-fitted square centred, and zoom-in magnifies the board past
|
full pane: zoom-out shows the height-fitted square centred, and zoom-in magnifies the board past
|
||||||
the pane and pans within it, occupying the full width up to the left panel (the focus-centred
|
the pane and pans within it, occupying the full width up to the left panel (the focus-centred
|
||||||
scroll is set directly, without the portrait width-progress tween). Only the history open/close
|
scroll is set directly, without the portrait width-progress tween). Only the history open/close
|
||||||
swipes are dropped (it is always open) and the nav bar does not grow (`growNav` off). The
|
swipes are dropped (it is always open) and the nav bar does not grow (`growNav` off). The
|
||||||
portrait layout is byte-for-byte unchanged; both layouts render from the same snippets, so the
|
portrait layout is byte-for-byte unchanged; both layouts render from the same snippets, so the
|
||||||
behaviour and markup stay single-sourced. The rack tiles size to the (fixed-width) panel rather
|
behaviour and markup stay single-sourced. The rack is a **seven-column grid** filling the tray
|
||||||
than the viewport width so seven tiles never overflow the column.
|
width in both layouts, so the tiles are square and as large as the row allows (the confirm ✅ takes
|
||||||
- **Highlights**: pending tiles use a slightly darker tile background (no outline). The
|
the fixed 7th slot while a play is staged).
|
||||||
|
- **Highlights**: while composing a play the staged tiles are **tinted by legality** — a light
|
||||||
|
green once they form a word (a shade darker on the committed board tiles the word runs through —
|
||||||
|
in a **single-word game** only the main word lights up, since the engine ignores cross words),
|
||||||
|
a calm pink when they form none — and an **orange score badge** sits on a corner of the main
|
||||||
|
word's tile, its digit sized like a tile's point value (so it scales with the zoom) and clamped
|
||||||
|
to stay on the board. Off-turn or with no preview the staged tiles keep the plain pending fill.
|
||||||
|
The colours are theme tokens in `app.css` (`--tile-pending-legal` / `-illegal`, `--tile-formed`,
|
||||||
|
`--score-badge`); the geometry — which cells a word covers and where the badge anchors — is a
|
||||||
|
pure client-side helper (`lib/formed.ts`), independent of the move evaluator. The
|
||||||
last completed word keeps the normal tile background; instead its letters — not the point
|
last completed word keeps the normal tile background; instead its letters — not the point
|
||||||
values — are drawn in the recent-move colour, in both themes. It is static while it is the
|
values — are drawn in the recent-move colour, in both themes. It is static while it is the
|
||||||
opponent's turn (our word), and a 1 s flash (the letter pulses between its normal colour
|
opponent's turn (our word), and a 1 s flash (the letter pulses between its normal colour
|
||||||
and the recent-move colour) when it is our turn (their word). While placing, only the
|
and the recent-move colour) when it is our turn (their word).
|
||||||
pending tiles are highlighted.
|
|
||||||
- **Bonus-square labels** — a Settings choice (`boardlabels.ts`): `beginner` shows a
|
- **Bonus-square labels** — a Settings choice (`boardlabels.ts`): `beginner` shows a
|
||||||
split `3×` / `word` (localized слово/буква), `classic` a single `3W` / `3С`, `none`
|
split `3×` / `word` (localized слово/буква), `classic` a single `3W` / `3С`, `none`
|
||||||
nothing. Default **beginner**.
|
nothing. Default **beginner**.
|
||||||
@@ -267,18 +276,22 @@ e2e and the screenshots.
|
|||||||
otherwise it reverts. Used by the **Hint** tab (the icon morphs to ✅, no label — replacing
|
otherwise it reverts. Used by the **Hint** tab (the icon morphs to ✅, no label — replacing
|
||||||
the old press-and-hold popover) and the in-game **add-friend 🤝** and **block ✖️** card
|
the old press-and-hold popover) and the in-game **add-friend 🤝** and **block ✖️** card
|
||||||
controls. The **Drop game** action keeps its `Modal` confirmation (a destructive, less-frequent action).
|
controls. The **Drop game** action keeps its `Modal` confirmation (a destructive, less-frequent action).
|
||||||
- **MakeMove / Reset**: when ≥1 tile is pending the rack collapses its used slots
|
- **Confirm / Reset**: while ≥1 tile is pending a **borderless ✅ icon button** (styled like a
|
||||||
and shifts left, a **borderless ✅ icon button** (styled like a tab, not a filled accent
|
tab, not a filled accent button) takes the rack's **fixed 7th slot** and commits the move — no
|
||||||
button) beside the rack commits the move — no popover, and disabled while the pending word
|
popover, and disabled while the pending word is known illegal; the 🔀 Shuffle tab is replaced by
|
||||||
is known illegal; the 🔀 Shuffle tab is replaced by a **↩️ Reset** tab.
|
a **↩️ Reset** tab.
|
||||||
- **Game tab bar**: 🔄 Exchange/Pass (opens a dialog — pick tiles to **Exchange N**, or pick
|
- **Game tab bar**: 🔄 Exchange/Pass (a **bag-count badge** on its icon while the bag is non-empty;
|
||||||
|
opens a dialog — pick tiles to **Exchange N**, or pick
|
||||||
none to **Pass without exchanging**; pass is always available on your turn, exchange only
|
none to **Pass without exchanging**; pass is always available on your turn, exchange only
|
||||||
while the bag still holds a full rack, below which the tiles disable and only the pass
|
while the bag still holds a full rack, below which the tiles disable and only the pass
|
||||||
remains), 🛟 Hint (with a remaining-count badge, disabled at zero); 🔀 Shuffle (no label,
|
remains), 🛟 Hint (with a remaining-count badge, disabled at zero); 🔀 Shuffle (no label,
|
||||||
no confirm), which
|
no confirm), which
|
||||||
**animates** — tiles hop along a low parabola to their new slots (duration scaled by the
|
**animates** — tiles hop along a low parabola to their new slots (duration scaled by the
|
||||||
distance, the longest ≤ 0.3 s; off under reduce-motion) with a short haptic shake. The
|
distance, the longest ≤ 0.3 s; off under reduce-motion) with a short haptic shake. A **thin strip
|
||||||
under-board slot shows the **Scores: N** preview. The screen **title** is the variant's
|
above the score plaques** reads, while composing a legal play, the formed word(s) and the move
|
||||||
|
score ("WORD+WORD = N"); otherwise whose turn it is, or the viewer's result once the game ends. The
|
||||||
|
move score also rides the board badge (above), never an under-board caption.
|
||||||
|
The screen **title** is the variant's
|
||||||
display name (Scrabble / Скрэббл / Erudite / Эрудит), not a constant "Scrabble".
|
display name (Scrabble / Скрэббл / Erudite / Эрудит), not a constant "Scrabble".
|
||||||
|
|
||||||
## Advertising banner (`components/AdBanner.svelte` in `components/Header.svelte`, `lib/banner.ts` + `lib/bannerEngine.ts`)
|
## Advertising banner (`components/AdBanner.svelte` in `components/Header.svelte`, `lib/banner.ts` + `lib/bannerEngine.ts`)
|
||||||
@@ -347,12 +360,15 @@ its entrance (`components/Toast.svelte`, re-keyed on a per-message sequence). An
|
|||||||
lives ~2 s, drifting up by about the tab-bar height as it fades (a plain fade, no travel, under
|
lives ~2 s, drifting up by about the tab-bar height as it fades (a plain fade, no travel, under
|
||||||
reduce-motion); an **error** toast dwells longer (~4 s). Either dismisses instantly on tap.
|
reduce-motion); an **error** toast dwells longer (~4 s). Either dismisses instantly on tap.
|
||||||
|
|
||||||
**Simultaneous-game cap.** When the player is at the active-quick-game cap (`games.list`
|
**Active-game caps.** A player's simultaneous unfinished games are capped per kind (vs AI / random /
|
||||||
`at_game_limit`), the lobby's **New Game** tab is **disabled** (greyed via the shared
|
friends) against the caller's per-tier limits (`Profile.game_limits`), counted client-side from the
|
||||||
`.tab:disabled` opacity) and a plain, low-emphasis line — muted, centred, no accent — sits
|
lobby games. The lobby's **New Game** tab is **always enabled** — the lock lives on the per-kind
|
||||||
under the lists with the localized "limit reached" notice. Both follow the flag carried in the
|
start, not the tab. On the **New Game** screen a start whose kind is at its cap renders as an
|
||||||
cached lobby snapshot, so they render in their last-known state on entry (the button stays
|
**outline** button with a **🔒** (not the filled accent CTA), so it reads as gated rather than ready;
|
||||||
enabled on the first, uncached load) and flip in place when an event refreshes the lobby.
|
tapped, it opens a short prompt instead of a game — inside Telegram a **native popup**, elsewhere the
|
||||||
|
in-app **Modal**. A **guest** sees a sign-in funnel (Cancel / Sign in → the account screen); a
|
||||||
|
signed-in account at its cap sees a plain "finish a current game first" notice (OK). The lock lifts in
|
||||||
|
place when the profile is re-fetched after a guest→durable upgrade.
|
||||||
|
|
||||||
## Social, account & history surfaces
|
## Social, account & history surfaces
|
||||||
|
|
||||||
|
|||||||
@@ -0,0 +1,86 @@
|
|||||||
|
package main
|
||||||
|
|
||||||
|
import (
|
||||||
|
"context"
|
||||||
|
"fmt"
|
||||||
|
"io"
|
||||||
|
"net"
|
||||||
|
"net/http"
|
||||||
|
"strings"
|
||||||
|
"time"
|
||||||
|
|
||||||
|
"go.uber.org/zap"
|
||||||
|
|
||||||
|
"scrabble/gateway/internal/config"
|
||||||
|
"scrabble/gateway/internal/ratelimit"
|
||||||
|
)
|
||||||
|
|
||||||
|
const (
|
||||||
|
// blocklistFetchTimeout bounds one feed fetch.
|
||||||
|
blocklistFetchTimeout = 30 * time.Second
|
||||||
|
// maxBlocklistBytes caps the fetched feed (Spamhaus DROP is well under 1 MiB; the cap stops a
|
||||||
|
// hostile or misconfigured URL from streaming unbounded data into memory).
|
||||||
|
maxBlocklistBytes = 8 << 20
|
||||||
|
)
|
||||||
|
|
||||||
|
// parseAllowlist parses the never-block entries (CIDRs or bare IPs, a bare IP read as a /32) into
|
||||||
|
// networks. A malformed entry is a fatal config error — the operator must fix it, not silently lose
|
||||||
|
// a protected range.
|
||||||
|
func parseAllowlist(entries []string) ([]*net.IPNet, error) {
|
||||||
|
out := make([]*net.IPNet, 0, len(entries))
|
||||||
|
for _, e := range entries {
|
||||||
|
s := strings.TrimSpace(e)
|
||||||
|
if s == "" {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
if !strings.Contains(s, "/") {
|
||||||
|
s += "/32"
|
||||||
|
}
|
||||||
|
_, n, err := net.ParseCIDR(s)
|
||||||
|
if err != nil {
|
||||||
|
return nil, fmt.Errorf("gateway: GATEWAY_BLOCKLIST_ALLOW: %q: %w", e, err)
|
||||||
|
}
|
||||||
|
out = append(out, n)
|
||||||
|
}
|
||||||
|
return out, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// runBlocklistRefresher keeps the community IP blocklist feed current: it fetches immediately, then
|
||||||
|
// every cfg.Refresh, and applies each outcome through ratelimit.ApplyRefresh (keep-last-good on a
|
||||||
|
// transient failure; drop the feed once it goes stale, fail-open). It returns when ctx is cancelled.
|
||||||
|
func runBlocklistRefresher(ctx context.Context, bl *ratelimit.Blocklist, cfg config.BlocklistConfig, log *zap.Logger) {
|
||||||
|
client := &http.Client{Timeout: blocklistFetchTimeout}
|
||||||
|
for {
|
||||||
|
cidrs, err := fetchBlocklist(ctx, client, cfg.URL)
|
||||||
|
switch ratelimit.ApplyRefresh(bl, cidrs, err, time.Now(), cfg.MaxStaleness) {
|
||||||
|
case ratelimit.RefreshUpdated:
|
||||||
|
log.Info("blocklist refreshed", zap.String("url", cfg.URL), zap.Int("entries", bl.Len()))
|
||||||
|
case ratelimit.RefreshKept:
|
||||||
|
log.Warn("blocklist refresh failed; keeping the last-good feed", zap.Error(err))
|
||||||
|
case ratelimit.RefreshDropped:
|
||||||
|
log.Warn("blocklist refresh failed and the feed is stale; dropped it (fail-open)", zap.Error(err))
|
||||||
|
}
|
||||||
|
select {
|
||||||
|
case <-ctx.Done():
|
||||||
|
return
|
||||||
|
case <-time.After(cfg.Refresh):
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// fetchBlocklist GETs the feed and parses it, bounded by the fetch timeout and the size cap.
|
||||||
|
func fetchBlocklist(ctx context.Context, client *http.Client, url string) ([]*net.IPNet, error) {
|
||||||
|
req, err := http.NewRequestWithContext(ctx, http.MethodGet, url, nil)
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
resp, err := client.Do(req)
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
defer func() { _ = resp.Body.Close() }()
|
||||||
|
if resp.StatusCode != http.StatusOK {
|
||||||
|
return nil, fmt.Errorf("gateway: blocklist fetch %s: status %d", url, resp.StatusCode)
|
||||||
|
}
|
||||||
|
return ratelimit.ParseDROP(io.LimitReader(resp.Body, maxBlocklistBytes))
|
||||||
|
}
|
||||||
@@ -129,6 +129,11 @@ func run(ctx context.Context, cfg config.Config, logger *zap.Logger) error {
|
|||||||
Window: cfg.Abuse.BanWindow,
|
Window: cfg.Abuse.BanWindow,
|
||||||
Duration: cfg.Abuse.BanDuration,
|
Duration: cfg.Abuse.BanDuration,
|
||||||
})
|
})
|
||||||
|
allow, err := parseAllowlist(cfg.Blocklist.Allow)
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
blocklist := ratelimit.NewBlocklist(cfg.Blocklist.Enabled, allow)
|
||||||
hub := push.NewHub(0)
|
hub := push.NewHub(0)
|
||||||
|
|
||||||
var validator transcode.TelegramValidator
|
var validator transcode.TelegramValidator
|
||||||
@@ -222,6 +227,7 @@ func run(ctx context.Context, cfg config.Config, logger *zap.Logger) error {
|
|||||||
Limiter: limiter,
|
Limiter: limiter,
|
||||||
Tracker: tracker,
|
Tracker: tracker,
|
||||||
Banlist: banlist,
|
Banlist: banlist,
|
||||||
|
Blocklist: blocklist,
|
||||||
Honeytoken: cfg.Abuse.Honeytoken,
|
Honeytoken: cfg.Abuse.Honeytoken,
|
||||||
VKAppSecret: cfg.VKAppSecret,
|
VKAppSecret: cfg.VKAppSecret,
|
||||||
Hub: hub,
|
Hub: hub,
|
||||||
@@ -243,6 +249,10 @@ func run(ctx context.Context, cfg config.Config, logger *zap.Logger) error {
|
|||||||
if cfg.Abuse.BanEnabled {
|
if cfg.Abuse.BanEnabled {
|
||||||
go runBanSync(ctx, banlist, backend, logger)
|
go runBanSync(ctx, banlist, backend, logger)
|
||||||
}
|
}
|
||||||
|
// When the edge blocklist is enabled (prod), keep the community feed refreshed.
|
||||||
|
if cfg.Blocklist.Enabled {
|
||||||
|
go runBlocklistRefresher(ctx, blocklist, cfg.Blocklist, logger)
|
||||||
|
}
|
||||||
|
|
||||||
public := &http.Server{Addr: cfg.HTTPAddr, Handler: edge.HTTPHandler(), ReadHeaderTimeout: readHeaderTimeout}
|
public := &http.Server{Addr: cfg.HTTPAddr, Handler: edge.HTTPHandler(), ReadHeaderTimeout: readHeaderTimeout}
|
||||||
servers := []*namedServer{{name: "public", srv: public}}
|
servers := []*namedServer{{name: "public", srv: public}}
|
||||||
|
|||||||
@@ -47,6 +47,16 @@ type ProfileResp struct {
|
|||||||
// DictVersions is the current dictionary version per game variant, forwarded verbatim
|
// DictVersions is the current dictionary version per game variant, forwarded verbatim
|
||||||
// into the Profile payload so an offline-capable client preloads the matching dawg.
|
// into the Profile payload so an offline-capable client preloads the matching dawg.
|
||||||
DictVersions []DictVersion `json:"dict_versions,omitempty"`
|
DictVersions []DictVersion `json:"dict_versions,omitempty"`
|
||||||
|
// GameLimits is the caller's tier active-game caps per kind (-1 = unlimited), forwarded verbatim
|
||||||
|
// into the Profile payload for the client's per-kind New-Game lock.
|
||||||
|
GameLimits *GameLimitsResp `json:"game_limits,omitempty"`
|
||||||
|
}
|
||||||
|
|
||||||
|
// GameLimitsResp is the caller's tier active-game caps per kind (-1 = unlimited) in the profile.
|
||||||
|
type GameLimitsResp struct {
|
||||||
|
VsAI int `json:"vs_ai"`
|
||||||
|
Random int `json:"random"`
|
||||||
|
Friends int `json:"friends"`
|
||||||
}
|
}
|
||||||
|
|
||||||
// AdsResp is the post-move interstitial config in the profile: the client-mirrored cooldowns
|
// AdsResp is the post-move interstitial config in the profile: the client-mirrored cooldowns
|
||||||
@@ -170,6 +180,7 @@ type GameResp struct {
|
|||||||
Seats []SeatResp `json:"seats"`
|
Seats []SeatResp `json:"seats"`
|
||||||
UnreadChat bool `json:"unread_chat"`
|
UnreadChat bool `json:"unread_chat"`
|
||||||
UnreadMessages bool `json:"unread_messages"`
|
UnreadMessages bool `json:"unread_messages"`
|
||||||
|
Kind int `json:"kind"`
|
||||||
}
|
}
|
||||||
|
|
||||||
// MoveResultResp is the outcome of a committed move. Rack carries the actor's refilled rack as
|
// MoveResultResp is the outcome of a committed move. Rack carries the actor's refilled rack as
|
||||||
@@ -197,7 +208,6 @@ type StateResp struct {
|
|||||||
Rack []int `json:"rack"`
|
Rack []int `json:"rack"`
|
||||||
BagLen int `json:"bag_len"`
|
BagLen int `json:"bag_len"`
|
||||||
HintsRemaining int `json:"hints_remaining"`
|
HintsRemaining int `json:"hints_remaining"`
|
||||||
WalletBalance int `json:"wallet_balance"`
|
|
||||||
HintUnlockLeftSeconds int `json:"hint_unlock_left_seconds"`
|
HintUnlockLeftSeconds int `json:"hint_unlock_left_seconds"`
|
||||||
Alphabet []AlphabetEntryJSON `json:"alphabet,omitempty"`
|
Alphabet []AlphabetEntryJSON `json:"alphabet,omitempty"`
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -76,6 +76,11 @@ type Hub struct {
|
|||||||
|
|
||||||
connected metric.Int64UpDownCounter
|
connected metric.Int64UpDownCounter
|
||||||
commands metric.Int64Counter
|
commands metric.Int64Counter
|
||||||
|
// tgErrors counts the remote bot's Bot API failures it reports over the stream, by kind
|
||||||
|
// (connect / api / rate_limited). lastOK holds the wall-clock second of the bot's most recent
|
||||||
|
// successful Bot API call, read by the bot_tg_last_ok_unix observable gauge for a staleness alert.
|
||||||
|
tgErrors metric.Int64Counter
|
||||||
|
lastOK atomic.Int64
|
||||||
}
|
}
|
||||||
|
|
||||||
// link is one connected bot's outbound queue and identity.
|
// link is one connected bot's outbound queue and identity.
|
||||||
@@ -103,6 +108,19 @@ func NewHub(log *zap.Logger, meter metric.Meter, resolve EligibilityResolver) *H
|
|||||||
metric.WithDescription("Number of Telegram bots currently connected to the gateway bot-link."))
|
metric.WithDescription("Number of Telegram bots currently connected to the gateway bot-link."))
|
||||||
h.commands, _ = meter.Int64Counter("botlink_commands_total",
|
h.commands, _ = meter.Int64Counter("botlink_commands_total",
|
||||||
metric.WithDescription("Bot-link send commands by result (delivered, not_delivered, dropped, error)."))
|
metric.WithDescription("Bot-link send commands by result (delivered, not_delivered, dropped, error)."))
|
||||||
|
h.tgErrors, _ = meter.Int64Counter("bot_tg_errors_total",
|
||||||
|
metric.WithDescription("Telegram Bot API failures the remote bot reports over the bot-link, by kind (connect, api, rate_limited)."))
|
||||||
|
// The bot's last successful Bot API second, reported over the stream. An observable gauge
|
||||||
|
// reads the atomic on each collection; it observes nothing until a bot has reported, so a
|
||||||
|
// never-connected gateway shows no data (the connected-bots alert covers that case).
|
||||||
|
_, _ = meter.Int64ObservableGauge("bot_tg_last_ok_unix",
|
||||||
|
metric.WithDescription("Unix second of the remote bot's most recent successful Bot API call (0/absent until reported)."),
|
||||||
|
metric.WithInt64Callback(func(_ context.Context, o metric.Int64Observer) error {
|
||||||
|
if v := h.lastOK.Load(); v > 0 {
|
||||||
|
o.Observe(v)
|
||||||
|
}
|
||||||
|
return nil
|
||||||
|
}))
|
||||||
}
|
}
|
||||||
return h
|
return h
|
||||||
}
|
}
|
||||||
@@ -149,6 +167,36 @@ func (h *Hub) Link(stream grpc.BidiStreamingServer[botlinkv1.FromBot, botlinkv1.
|
|||||||
if ack := msg.GetAck(); ack != nil {
|
if ack := msg.GetAck(); ack != nil {
|
||||||
h.resolve(ack)
|
h.resolve(ack)
|
||||||
}
|
}
|
||||||
|
if hb := msg.GetHealth(); hb != nil {
|
||||||
|
h.recordHealth(hb)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// recordHealth folds one bot Health report into the gateway's cumulative Bot API metrics: the three
|
||||||
|
// delta counters are added under their kind label, and the last-ok stamp (an absolute wall-clock
|
||||||
|
// second) advances the liveness gauge (monotonically — a stale reordered report cannot rewind it).
|
||||||
|
func (h *Hub) recordHealth(hb *botlinkv1.Health) {
|
||||||
|
if h.tgErrors != nil {
|
||||||
|
ctx := context.Background()
|
||||||
|
if v := hb.GetConnectFailures(); v > 0 {
|
||||||
|
h.tgErrors.Add(ctx, int64(v), metric.WithAttributes(attribute.String("kind", "connect")))
|
||||||
|
}
|
||||||
|
if v := hb.GetApiErrors(); v > 0 {
|
||||||
|
h.tgErrors.Add(ctx, int64(v), metric.WithAttributes(attribute.String("kind", "api")))
|
||||||
|
}
|
||||||
|
if v := hb.GetRateLimited(); v > 0 {
|
||||||
|
h.tgErrors.Add(ctx, int64(v), metric.WithAttributes(attribute.String("kind", "rate_limited")))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
for {
|
||||||
|
cur := h.lastOK.Load()
|
||||||
|
if hb.GetLastOkUnix() <= cur {
|
||||||
|
break
|
||||||
|
}
|
||||||
|
if h.lastOK.CompareAndSwap(cur, hb.GetLastOkUnix()) {
|
||||||
|
break
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -233,3 +233,21 @@ func TestRelayServerNoBot(t *testing.T) {
|
|||||||
t.Fatal("expected an error with no bot connected")
|
t.Fatal("expected an error with no bot connected")
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// TestRecordHealthLastOKMonotonic checks a bot Health report advances the last-ok liveness stamp but
|
||||||
|
// a stale or reordered report (an earlier second) never rewinds it.
|
||||||
|
func TestRecordHealthLastOKMonotonic(t *testing.T) {
|
||||||
|
hub := NewHub(nil, nil, nil)
|
||||||
|
hub.recordHealth(&botlinkv1.Health{LastOkUnix: 100})
|
||||||
|
if got := hub.lastOK.Load(); got != 100 {
|
||||||
|
t.Fatalf("lastOK = %d, want 100", got)
|
||||||
|
}
|
||||||
|
hub.recordHealth(&botlinkv1.Health{LastOkUnix: 90}) // reordered/stale — must not rewind
|
||||||
|
if got := hub.lastOK.Load(); got != 100 {
|
||||||
|
t.Errorf("lastOK rewound to %d, want 100", got)
|
||||||
|
}
|
||||||
|
hub.recordHealth(&botlinkv1.Health{LastOkUnix: 150})
|
||||||
|
if got := hub.lastOK.Load(); got != 150 {
|
||||||
|
t.Errorf("lastOK = %d, want 150", got)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|||||||
@@ -6,6 +6,7 @@ import (
|
|||||||
"fmt"
|
"fmt"
|
||||||
"os"
|
"os"
|
||||||
"strconv"
|
"strconv"
|
||||||
|
"strings"
|
||||||
"time"
|
"time"
|
||||||
|
|
||||||
pkgtel "scrabble/pkg/telemetry"
|
pkgtel "scrabble/pkg/telemetry"
|
||||||
@@ -57,6 +58,8 @@ type Config struct {
|
|||||||
RateLimit RateLimitConfig
|
RateLimit RateLimitConfig
|
||||||
// Abuse configures the temporary IP ban and the honeytoken (prod-only).
|
// Abuse configures the temporary IP ban and the honeytoken (prod-only).
|
||||||
Abuse AbuseConfig
|
Abuse AbuseConfig
|
||||||
|
// Blocklist configures the community IP blocklist (Spamhaus DROP) enforced at the edge (prod-only).
|
||||||
|
Blocklist BlocklistConfig
|
||||||
// Telemetry configures the OpenTelemetry providers (shared bootstrap).
|
// Telemetry configures the OpenTelemetry providers (shared bootstrap).
|
||||||
Telemetry pkgtel.Config
|
Telemetry pkgtel.Config
|
||||||
}
|
}
|
||||||
@@ -166,6 +169,42 @@ func DefaultAbuse() AbuseConfig {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// BlocklistConfig configures the community IP blocklist (a curated CIDR feed such as Spamhaus DROP)
|
||||||
|
// enforced at the edge alongside the fail2ban ban. Disabled by default and prod-only, for the same
|
||||||
|
// reason as the ban: it is only safe where the real client IP is visible (not behind the shared-NAT
|
||||||
|
// test contour). Only IPv4 is matched.
|
||||||
|
type BlocklistConfig struct {
|
||||||
|
// Enabled turns edge blocklisting on. Off by default (prod-only).
|
||||||
|
Enabled bool
|
||||||
|
// URL is the CIDR feed to fetch (e.g. the Spamhaus DROP list). Required when Enabled; the
|
||||||
|
// operator sets it explicitly so no feed URL is assumed.
|
||||||
|
URL string
|
||||||
|
// Refresh is how often the feed is re-fetched.
|
||||||
|
Refresh time.Duration
|
||||||
|
// MaxStaleness is how long a stale feed (no successful refresh) is tolerated before it is dropped
|
||||||
|
// (fail-open — a legitimate client is never blocked on a frozen feed).
|
||||||
|
MaxStaleness time.Duration
|
||||||
|
// Allow is the never-block set: CIDRs or bare IPs (own infrastructure, monitoring, known-good) that
|
||||||
|
// the feed can never block. Parsed at startup.
|
||||||
|
Allow []string
|
||||||
|
}
|
||||||
|
|
||||||
|
// Blocklist defaults; the feed URL has no default (the operator sets it).
|
||||||
|
const (
|
||||||
|
defaultBlocklistRefresh = 6 * time.Hour
|
||||||
|
defaultBlocklistMaxStaleness = 48 * time.Hour
|
||||||
|
)
|
||||||
|
|
||||||
|
// DefaultBlocklist returns the built-in blocklist settings: disabled (prod-only), no feed URL, and
|
||||||
|
// the agreed refresh / staleness windows.
|
||||||
|
func DefaultBlocklist() BlocklistConfig {
|
||||||
|
return BlocklistConfig{
|
||||||
|
Enabled: false,
|
||||||
|
Refresh: defaultBlocklistRefresh,
|
||||||
|
MaxStaleness: defaultBlocklistMaxStaleness,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
// VKIDConfig holds the VK ID web-login credentials for the confidential
|
// VKIDConfig holds the VK ID web-login credentials for the confidential
|
||||||
// authorization-code exchange. AppID is the VK "Web" app's client id; ClientSecret is
|
// authorization-code exchange. AppID is the VK "Web" app's client id; ClientSecret is
|
||||||
// its protected key; RedirectURI must exactly match the trusted redirect URL registered
|
// its protected key; RedirectURI must exactly match the trusted redirect URL registered
|
||||||
@@ -203,6 +242,7 @@ func Load() (Config, error) {
|
|||||||
SessionCacheMax: defaultSessionCacheMax,
|
SessionCacheMax: defaultSessionCacheMax,
|
||||||
RateLimit: DefaultRateLimit(),
|
RateLimit: DefaultRateLimit(),
|
||||||
Abuse: DefaultAbuse(),
|
Abuse: DefaultAbuse(),
|
||||||
|
Blocklist: DefaultBlocklist(),
|
||||||
BotLink: BotLinkConfig{
|
BotLink: BotLinkConfig{
|
||||||
Addr: os.Getenv("GATEWAY_BOTLINK_ADDR"),
|
Addr: os.Getenv("GATEWAY_BOTLINK_ADDR"),
|
||||||
RelayAddr: os.Getenv("GATEWAY_BOTLINK_RELAY_ADDR"),
|
RelayAddr: os.Getenv("GATEWAY_BOTLINK_RELAY_ADDR"),
|
||||||
@@ -244,6 +284,17 @@ func Load() (Config, error) {
|
|||||||
if c.Abuse.BanDuration, err = envDuration("GATEWAY_ABUSE_BAN_DURATION", c.Abuse.BanDuration); err != nil {
|
if c.Abuse.BanDuration, err = envDuration("GATEWAY_ABUSE_BAN_DURATION", c.Abuse.BanDuration); err != nil {
|
||||||
return Config{}, err
|
return Config{}, err
|
||||||
}
|
}
|
||||||
|
if c.Blocklist.Enabled, err = envBool("GATEWAY_BLOCKLIST_ENABLED", c.Blocklist.Enabled); err != nil {
|
||||||
|
return Config{}, err
|
||||||
|
}
|
||||||
|
c.Blocklist.URL = os.Getenv("GATEWAY_BLOCKLIST_URL")
|
||||||
|
if c.Blocklist.Refresh, err = envDuration("GATEWAY_BLOCKLIST_REFRESH", c.Blocklist.Refresh); err != nil {
|
||||||
|
return Config{}, err
|
||||||
|
}
|
||||||
|
if c.Blocklist.MaxStaleness, err = envDuration("GATEWAY_BLOCKLIST_MAX_STALENESS", c.Blocklist.MaxStaleness); err != nil {
|
||||||
|
return Config{}, err
|
||||||
|
}
|
||||||
|
c.Blocklist.Allow = splitList(os.Getenv("GATEWAY_BLOCKLIST_ALLOW"))
|
||||||
if c.BotLink.SendTimeout, err = envDuration("GATEWAY_BOTLINK_SEND_TIMEOUT", defaultBotLinkSendTimeout); err != nil {
|
if c.BotLink.SendTimeout, err = envDuration("GATEWAY_BOTLINK_SEND_TIMEOUT", defaultBotLinkSendTimeout); err != nil {
|
||||||
return Config{}, err
|
return Config{}, err
|
||||||
}
|
}
|
||||||
@@ -284,6 +335,9 @@ func (c Config) validate() error {
|
|||||||
if c.Abuse.BanEnabled && (c.Abuse.BanThreshold <= 0 || c.Abuse.BanWindow <= 0 || c.Abuse.BanDuration <= 0) {
|
if c.Abuse.BanEnabled && (c.Abuse.BanThreshold <= 0 || c.Abuse.BanWindow <= 0 || c.Abuse.BanDuration <= 0) {
|
||||||
return fmt.Errorf("config: GATEWAY_ABUSE_BAN_THRESHOLD/_WINDOW/_DURATION must be positive when GATEWAY_ABUSE_BAN_ENABLED")
|
return fmt.Errorf("config: GATEWAY_ABUSE_BAN_THRESHOLD/_WINDOW/_DURATION must be positive when GATEWAY_ABUSE_BAN_ENABLED")
|
||||||
}
|
}
|
||||||
|
if c.Blocklist.Enabled && (c.Blocklist.URL == "" || c.Blocklist.Refresh <= 0 || c.Blocklist.MaxStaleness <= 0) {
|
||||||
|
return fmt.Errorf("config: GATEWAY_BLOCKLIST_URL must be set and _REFRESH/_MAX_STALENESS positive when GATEWAY_BLOCKLIST_ENABLED")
|
||||||
|
}
|
||||||
if c.BotLink.Addr != "" {
|
if c.BotLink.Addr != "" {
|
||||||
if c.BotLink.CertFile == "" || c.BotLink.KeyFile == "" || c.BotLink.CAFile == "" {
|
if c.BotLink.CertFile == "" || c.BotLink.KeyFile == "" || c.BotLink.CAFile == "" {
|
||||||
return fmt.Errorf("config: GATEWAY_BOTLINK_ADDR requires GATEWAY_BOTLINK_TLS_CERT, _KEY and _CA")
|
return fmt.Errorf("config: GATEWAY_BOTLINK_ADDR requires GATEWAY_BOTLINK_TLS_CERT, _KEY and _CA")
|
||||||
@@ -304,6 +358,21 @@ func envOr(key, fallback string) string {
|
|||||||
return fallback
|
return fallback
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// splitList splits a comma-separated environment value into trimmed, non-empty items.
|
||||||
|
func splitList(v string) []string {
|
||||||
|
if v == "" {
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
parts := strings.Split(v, ",")
|
||||||
|
out := make([]string, 0, len(parts))
|
||||||
|
for _, p := range parts {
|
||||||
|
if p = strings.TrimSpace(p); p != "" {
|
||||||
|
out = append(out, p)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return out
|
||||||
|
}
|
||||||
|
|
||||||
// envBool parses the environment variable named key as a bool, returning fallback
|
// envBool parses the environment variable named key as a bool, returning fallback
|
||||||
// when it is unset and an error when it is set but malformed.
|
// when it is unset and an error when it is set but malformed.
|
||||||
func envBool(key string, fallback bool) (bool, error) {
|
func envBool(key string, fallback bool) (bool, error) {
|
||||||
|
|||||||
@@ -2,6 +2,7 @@ package connectsrv_test
|
|||||||
|
|
||||||
import (
|
import (
|
||||||
"context"
|
"context"
|
||||||
|
"net"
|
||||||
"net/http"
|
"net/http"
|
||||||
"net/http/httptest"
|
"net/http/httptest"
|
||||||
"testing"
|
"testing"
|
||||||
@@ -24,7 +25,7 @@ const honeypotHeader = "X-Scrabble-Honeypot"
|
|||||||
|
|
||||||
// guardedEdge wires an edge with an explicit banlist and honeytoken over a fake
|
// guardedEdge wires an edge with an explicit banlist and honeytoken over a fake
|
||||||
// backend, returning the front URL, a Connect client and a cleanup func.
|
// backend, returning the front URL, a Connect client and a cleanup func.
|
||||||
func guardedEdge(t *testing.T, bl *ratelimit.Banlist, honeytoken string, limits config.RateLimitConfig, backendHandler http.HandlerFunc) (string, edgev1connect.GatewayClient, func()) {
|
func guardedEdge(t *testing.T, bl *ratelimit.Banlist, block *ratelimit.Blocklist, honeytoken string, limits config.RateLimitConfig, backendHandler http.HandlerFunc) (string, edgev1connect.GatewayClient, func()) {
|
||||||
t.Helper()
|
t.Helper()
|
||||||
backendSrv := httptest.NewServer(backendHandler)
|
backendSrv := httptest.NewServer(backendHandler)
|
||||||
backend, err := backendclient.New(backendSrv.URL, "localhost:9090", 2*time.Second)
|
backend, err := backendclient.New(backendSrv.URL, "localhost:9090", 2*time.Second)
|
||||||
@@ -36,6 +37,7 @@ func guardedEdge(t *testing.T, bl *ratelimit.Banlist, honeytoken string, limits
|
|||||||
Sessions: session.NewCache(backend, time.Minute, 100),
|
Sessions: session.NewCache(backend, time.Minute, 100),
|
||||||
Limiter: ratelimit.New(),
|
Limiter: ratelimit.New(),
|
||||||
Banlist: bl,
|
Banlist: bl,
|
||||||
|
Blocklist: block,
|
||||||
Honeytoken: honeytoken,
|
Honeytoken: honeytoken,
|
||||||
Hub: push.NewHub(0),
|
Hub: push.NewHub(0),
|
||||||
RateLimit: limits,
|
RateLimit: limits,
|
||||||
@@ -69,7 +71,7 @@ func enabledBanlist(threshold int) *ratelimit.Banlist {
|
|||||||
func TestAbuseGuardBlocksBannedIP(t *testing.T) {
|
func TestAbuseGuardBlocksBannedIP(t *testing.T) {
|
||||||
bl := enabledBanlist(100)
|
bl := enabledBanlist(100)
|
||||||
bl.BanNow("127.0.0.1", ratelimit.ReasonTripwire)
|
bl.BanNow("127.0.0.1", ratelimit.ReasonTripwire)
|
||||||
url, _, cleanup := guardedEdge(t, bl, "", config.DefaultRateLimit(), func(w http.ResponseWriter, r *http.Request) {})
|
url, _, cleanup := guardedEdge(t, bl, nil, "", config.DefaultRateLimit(), func(w http.ResponseWriter, r *http.Request) {})
|
||||||
defer cleanup()
|
defer cleanup()
|
||||||
|
|
||||||
resp, err := noRedirect().Get(url + "/")
|
resp, err := noRedirect().Get(url + "/")
|
||||||
@@ -86,7 +88,7 @@ func TestAbuseGuardBlocksBannedIP(t *testing.T) {
|
|||||||
// and bans the client IP, so the next request is blocked.
|
// and bans the client IP, so the next request is blocked.
|
||||||
func TestHoneypotHeaderTrips(t *testing.T) {
|
func TestHoneypotHeaderTrips(t *testing.T) {
|
||||||
bl := enabledBanlist(100)
|
bl := enabledBanlist(100)
|
||||||
url, _, cleanup := guardedEdge(t, bl, "", config.DefaultRateLimit(), func(w http.ResponseWriter, r *http.Request) {
|
url, _, cleanup := guardedEdge(t, bl, nil, "", config.DefaultRateLimit(), func(w http.ResponseWriter, r *http.Request) {
|
||||||
t.Error("backend must not be called for a honeypot hit")
|
t.Error("backend must not be called for a honeypot hit")
|
||||||
})
|
})
|
||||||
defer cleanup()
|
defer cleanup()
|
||||||
@@ -118,7 +120,7 @@ func TestHoneypotHeaderTrips(t *testing.T) {
|
|||||||
// banlist still 404s the decoy (detection/logging) but bans nothing.
|
// banlist still 404s the decoy (detection/logging) but bans nothing.
|
||||||
func TestHoneypotDetectsWithoutBanWhenDisabled(t *testing.T) {
|
func TestHoneypotDetectsWithoutBanWhenDisabled(t *testing.T) {
|
||||||
bl := ratelimit.NewBanlist(ratelimit.BanConfig{}) // disabled
|
bl := ratelimit.NewBanlist(ratelimit.BanConfig{}) // disabled
|
||||||
url, _, cleanup := guardedEdge(t, bl, "", config.DefaultRateLimit(), func(w http.ResponseWriter, r *http.Request) {})
|
url, _, cleanup := guardedEdge(t, bl, nil, "", config.DefaultRateLimit(), func(w http.ResponseWriter, r *http.Request) {})
|
||||||
defer cleanup()
|
defer cleanup()
|
||||||
|
|
||||||
req, _ := http.NewRequest(http.MethodGet, url+"/.env", nil)
|
req, _ := http.NewRequest(http.MethodGet, url+"/.env", nil)
|
||||||
@@ -150,7 +152,7 @@ func TestPublicRejectionStrikesBan(t *testing.T) {
|
|||||||
bl := enabledBanlist(1)
|
bl := enabledBanlist(1)
|
||||||
limits := config.DefaultRateLimit()
|
limits := config.DefaultRateLimit()
|
||||||
limits.PublicPerMinute, limits.PublicBurst = 1, 1
|
limits.PublicPerMinute, limits.PublicBurst = 1, 1
|
||||||
_, client, cleanup := guardedEdge(t, bl, "", limits, func(w http.ResponseWriter, r *http.Request) {
|
_, client, cleanup := guardedEdge(t, bl, nil, "", limits, func(w http.ResponseWriter, r *http.Request) {
|
||||||
_, _ = w.Write([]byte(`{"token":"tok","user_id":"u-1","is_guest":true,"display_name":"Guest"}`))
|
_, _ = w.Write([]byte(`{"token":"tok","user_id":"u-1","is_guest":true,"display_name":"Guest"}`))
|
||||||
})
|
})
|
||||||
defer cleanup()
|
defer cleanup()
|
||||||
@@ -173,7 +175,7 @@ func TestUserRejectionDoesNotBan(t *testing.T) {
|
|||||||
bl := enabledBanlist(1)
|
bl := enabledBanlist(1)
|
||||||
limits := config.DefaultRateLimit()
|
limits := config.DefaultRateLimit()
|
||||||
limits.UserPerMinute, limits.UserBurst = 1, 1
|
limits.UserPerMinute, limits.UserBurst = 1, 1
|
||||||
_, client, cleanup := guardedEdge(t, bl, "", limits, func(w http.ResponseWriter, r *http.Request) {
|
_, client, cleanup := guardedEdge(t, bl, nil, "", limits, func(w http.ResponseWriter, r *http.Request) {
|
||||||
switch r.URL.Path {
|
switch r.URL.Path {
|
||||||
case "/api/v1/internal/sessions/resolve":
|
case "/api/v1/internal/sessions/resolve":
|
||||||
_, _ = w.Write([]byte(`{"user_id":"u-1","is_guest":false}`))
|
_, _ = w.Write([]byte(`{"user_id":"u-1","is_guest":false}`))
|
||||||
@@ -202,7 +204,7 @@ func TestUserRejectionDoesNotBan(t *testing.T) {
|
|||||||
// caller and returns the ordinary invalid-session error without a backend call.
|
// caller and returns the ordinary invalid-session error without a backend call.
|
||||||
func TestHoneytokenBansAndRejects(t *testing.T) {
|
func TestHoneytokenBansAndRejects(t *testing.T) {
|
||||||
bl := enabledBanlist(100)
|
bl := enabledBanlist(100)
|
||||||
_, client, cleanup := guardedEdge(t, bl, "s3cr3t-trap", config.DefaultRateLimit(), func(w http.ResponseWriter, r *http.Request) {
|
_, client, cleanup := guardedEdge(t, bl, nil, "s3cr3t-trap", config.DefaultRateLimit(), func(w http.ResponseWriter, r *http.Request) {
|
||||||
t.Error("backend must not be called for the honeytoken")
|
t.Error("backend must not be called for the honeytoken")
|
||||||
})
|
})
|
||||||
defer cleanup()
|
defer cleanup()
|
||||||
@@ -217,3 +219,25 @@ func TestHoneytokenBansAndRejects(t *testing.T) {
|
|||||||
t.Fatal("the honeytoken must ban the caller")
|
t.Fatal("the honeytoken must ban the caller")
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// TestAbuseGuardBlocksBlocklistedIP verifies a client IP in the community blocklist feed is refused
|
||||||
|
// with 403 at the HTTP layer, before any handler runs.
|
||||||
|
func TestAbuseGuardBlocksBlocklistedIP(t *testing.T) {
|
||||||
|
block := ratelimit.NewBlocklist(true, nil)
|
||||||
|
_, feed, err := net.ParseCIDR("127.0.0.0/8")
|
||||||
|
if err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
block.SetCIDRs([]*net.IPNet{feed}, time.Now())
|
||||||
|
url, _, cleanup := guardedEdge(t, nil, block, "", config.DefaultRateLimit(), func(w http.ResponseWriter, r *http.Request) {})
|
||||||
|
defer cleanup()
|
||||||
|
|
||||||
|
resp, err := noRedirect().Get(url + "/")
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("get: %v", err)
|
||||||
|
}
|
||||||
|
_ = resp.Body.Close()
|
||||||
|
if resp.StatusCode != http.StatusForbidden {
|
||||||
|
t.Fatalf("blocklisted GET / = %d, want 403", resp.StatusCode)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|||||||
@@ -7,6 +7,8 @@ import (
|
|||||||
"go.opentelemetry.io/otel/attribute"
|
"go.opentelemetry.io/otel/attribute"
|
||||||
"go.opentelemetry.io/otel/metric"
|
"go.opentelemetry.io/otel/metric"
|
||||||
"go.opentelemetry.io/otel/metric/noop"
|
"go.opentelemetry.io/otel/metric/noop"
|
||||||
|
|
||||||
|
"scrabble/gateway/internal/ratelimit"
|
||||||
)
|
)
|
||||||
|
|
||||||
// meterName scopes the gateway edge's OpenTelemetry instruments.
|
// meterName scopes the gateway edge's OpenTelemetry instruments.
|
||||||
@@ -27,6 +29,7 @@ type serverMetrics struct {
|
|||||||
edge metric.Float64Histogram
|
edge metric.Float64Histogram
|
||||||
rateLimited metric.Int64Counter
|
rateLimited metric.Int64Counter
|
||||||
banned metric.Int64Counter
|
banned metric.Int64Counter
|
||||||
|
blocklisted metric.Int64Counter
|
||||||
active *activeUsers
|
active *activeUsers
|
||||||
// Client-reported local move-preview adoption (see localEvalMetricsHandler).
|
// Client-reported local move-preview adoption (see localEvalMetricsHandler).
|
||||||
localColdStart metric.Int64Counter
|
localColdStart metric.Int64Counter
|
||||||
@@ -40,7 +43,7 @@ type serverMetrics struct {
|
|||||||
// falling back to a no-op histogram on the (rare) construction error. The
|
// falling back to a no-op histogram on the (rare) construction error. The
|
||||||
// active_users gauge is registered as an observable callback over the in-memory
|
// active_users gauge is registered as an observable callback over the in-memory
|
||||||
// tracker.
|
// tracker.
|
||||||
func newServerMetrics(meter metric.Meter) *serverMetrics {
|
func newServerMetrics(meter metric.Meter, bl *ratelimit.Blocklist) *serverMetrics {
|
||||||
if meter == nil {
|
if meter == nil {
|
||||||
meter = noop.NewMeterProvider().Meter(meterName)
|
meter = noop.NewMeterProvider().Meter(meterName)
|
||||||
}
|
}
|
||||||
@@ -68,6 +71,8 @@ func newServerMetrics(meter metric.Meter) *serverMetrics {
|
|||||||
}
|
}
|
||||||
m := &serverMetrics{
|
m := &serverMetrics{
|
||||||
edge: h, rateLimited: c, banned: b, active: newActiveUsers(),
|
edge: h, rateLimited: c, banned: b, active: newActiveUsers(),
|
||||||
|
blocklisted: counterOf(meter, "gateway_blocklist_blocked_total",
|
||||||
|
"Requests refused at the edge by the community IP blocklist (Spamhaus DROP)."),
|
||||||
localColdStart: counterOf(meter, "local_eval_cold_start_total", "App cold starts reported by clients — the denominator for local-move-preview adoption."),
|
localColdStart: counterOf(meter, "local_eval_cold_start_total", "App cold starts reported by clients — the denominator for local-move-preview adoption."),
|
||||||
localDictLoad: counterOf(meter, "local_eval_dict_load_total", "Client dictionary loads for the local move preview, by result (fetched, cache_hit or miss)."),
|
localDictLoad: counterOf(meter, "local_eval_dict_load_total", "Client dictionary loads for the local move preview, by result (fetched, cache_hit or miss)."),
|
||||||
localPreview: counterOf(meter, "local_eval_preview_total", "Client move previews, by path (local on-device, or network fallback)."),
|
localPreview: counterOf(meter, "local_eval_preview_total", "Client move previews, by path (local on-device, or network fallback)."),
|
||||||
@@ -90,6 +95,25 @@ func newServerMetrics(meter metric.Meter) *serverMetrics {
|
|||||||
return nil
|
return nil
|
||||||
}, gauge)
|
}, gauge)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Community blocklist status: the feed size and how stale it is, for the dashboard and the
|
||||||
|
// "feed not refreshing" alert. Age is observed only once a feed has loaded, so a disabled or
|
||||||
|
// never-fetched blocklist reports no age (and entries 0).
|
||||||
|
if bl != nil {
|
||||||
|
entries, e1 := meter.Int64ObservableGauge("gateway_blocklist_entries",
|
||||||
|
metric.WithDescription("CIDR ranges in the active community IP blocklist feed."))
|
||||||
|
age, e2 := meter.Float64ObservableGauge("gateway_blocklist_age_seconds",
|
||||||
|
metric.WithDescription("Seconds since the community IP blocklist feed was last successfully fetched (absent until the first fetch)."))
|
||||||
|
if e1 == nil && e2 == nil {
|
||||||
|
_, _ = meter.RegisterCallback(func(_ context.Context, o metric.Observer) error {
|
||||||
|
o.ObserveInt64(entries, int64(bl.Len()))
|
||||||
|
if last := bl.LastFetch(); !last.IsZero() {
|
||||||
|
o.ObserveFloat64(age, time.Since(last).Seconds())
|
||||||
|
}
|
||||||
|
return nil
|
||||||
|
}, entries, age)
|
||||||
|
}
|
||||||
|
}
|
||||||
return m
|
return m
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -118,6 +142,11 @@ func (m *serverMetrics) recordBan(ctx context.Context, reason string) {
|
|||||||
m.banned.Add(ctx, 1, metric.WithAttributes(attribute.String("reason", reason)))
|
m.banned.Add(ctx, 1, metric.WithAttributes(attribute.String("reason", reason)))
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// recordBlocklistBlock counts one request refused by the community IP blocklist.
|
||||||
|
func (m *serverMetrics) recordBlocklistBlock(ctx context.Context) {
|
||||||
|
m.blocklisted.Add(ctx, 1)
|
||||||
|
}
|
||||||
|
|
||||||
// recordUnsupportedEngine counts one client turned away by the unsupported-engine boot screen,
|
// recordUnsupportedEngine counts one client turned away by the unsupported-engine boot screen,
|
||||||
// labelled by reason and Chromium major. The caller passes both already reduced to bounded label
|
// labelled by reason and Chromium major. The caller passes both already reduced to bounded label
|
||||||
// sets (see normalizeUnsupported) so a spoofed beacon cannot explode the metric cardinality.
|
// sets (see normalizeUnsupported) so a spoofed beacon cannot explode the metric cardinality.
|
||||||
|
|||||||
@@ -16,7 +16,7 @@ func TestEdgeMetric(t *testing.T) {
|
|||||||
ctx := context.Background()
|
ctx := context.Background()
|
||||||
reader := sdkmetric.NewManualReader()
|
reader := sdkmetric.NewManualReader()
|
||||||
meter := sdkmetric.NewMeterProvider(sdkmetric.WithReader(reader)).Meter("test")
|
meter := sdkmetric.NewMeterProvider(sdkmetric.WithReader(reader)).Meter("test")
|
||||||
m := newServerMetrics(meter)
|
m := newServerMetrics(meter, nil)
|
||||||
|
|
||||||
m.recordEdge(ctx, "game.submit_play", "ok", time.Now().Add(-time.Millisecond))
|
m.recordEdge(ctx, "game.submit_play", "ok", time.Now().Add(-time.Millisecond))
|
||||||
m.recordEdge(ctx, "game.submit_play", "ok", time.Now().Add(-time.Millisecond))
|
m.recordEdge(ctx, "game.submit_play", "ok", time.Now().Add(-time.Millisecond))
|
||||||
@@ -74,7 +74,7 @@ func TestRateLimitedMetric(t *testing.T) {
|
|||||||
ctx := context.Background()
|
ctx := context.Background()
|
||||||
reader := sdkmetric.NewManualReader()
|
reader := sdkmetric.NewManualReader()
|
||||||
meter := sdkmetric.NewMeterProvider(sdkmetric.WithReader(reader)).Meter("test")
|
meter := sdkmetric.NewMeterProvider(sdkmetric.WithReader(reader)).Meter("test")
|
||||||
m := newServerMetrics(meter)
|
m := newServerMetrics(meter, nil)
|
||||||
|
|
||||||
m.recordRateLimited(ctx, "user")
|
m.recordRateLimited(ctx, "user")
|
||||||
m.recordRateLimited(ctx, "user")
|
m.recordRateLimited(ctx, "user")
|
||||||
@@ -112,7 +112,7 @@ func TestBannedMetric(t *testing.T) {
|
|||||||
ctx := context.Background()
|
ctx := context.Background()
|
||||||
reader := sdkmetric.NewManualReader()
|
reader := sdkmetric.NewManualReader()
|
||||||
meter := sdkmetric.NewMeterProvider(sdkmetric.WithReader(reader)).Meter("test")
|
meter := sdkmetric.NewMeterProvider(sdkmetric.WithReader(reader)).Meter("test")
|
||||||
m := newServerMetrics(meter)
|
m := newServerMetrics(meter, nil)
|
||||||
|
|
||||||
m.recordBan(ctx, "tripwire")
|
m.recordBan(ctx, "tripwire")
|
||||||
m.recordBan(ctx, "tripwire")
|
m.recordBan(ctx, "tripwire")
|
||||||
@@ -150,7 +150,7 @@ func TestUnsupportedEngineMetric(t *testing.T) {
|
|||||||
ctx := context.Background()
|
ctx := context.Background()
|
||||||
reader := sdkmetric.NewManualReader()
|
reader := sdkmetric.NewManualReader()
|
||||||
meter := sdkmetric.NewMeterProvider(sdkmetric.WithReader(reader)).Meter("test")
|
meter := sdkmetric.NewMeterProvider(sdkmetric.WithReader(reader)).Meter("test")
|
||||||
m := newServerMetrics(meter)
|
m := newServerMetrics(meter, nil)
|
||||||
|
|
||||||
m.recordUnsupportedEngine(ctx, "no_bigint", "66")
|
m.recordUnsupportedEngine(ctx, "no_bigint", "66")
|
||||||
m.recordUnsupportedEngine(ctx, "no_bigint", "66")
|
m.recordUnsupportedEngine(ctx, "no_bigint", "66")
|
||||||
|
|||||||
@@ -77,6 +77,7 @@ type Server struct {
|
|||||||
limiter *ratelimit.Limiter
|
limiter *ratelimit.Limiter
|
||||||
tracker *ratelimit.Tracker
|
tracker *ratelimit.Tracker
|
||||||
banlist *ratelimit.Banlist
|
banlist *ratelimit.Banlist
|
||||||
|
blocklist *ratelimit.Blocklist
|
||||||
honeytoken string
|
honeytoken string
|
||||||
vkAppSecret string
|
vkAppSecret string
|
||||||
hub *push.Hub
|
hub *push.Hub
|
||||||
@@ -109,6 +110,9 @@ type Deps struct {
|
|||||||
// Banlist enforces temporary IP bans on the hot path; nil selects a disabled
|
// Banlist enforces temporary IP bans on the hot path; nil selects a disabled
|
||||||
// (inert) banlist.
|
// (inert) banlist.
|
||||||
Banlist *ratelimit.Banlist
|
Banlist *ratelimit.Banlist
|
||||||
|
// Blocklist enforces the community IP blocklist on the hot path; nil selects a
|
||||||
|
// disabled (inert) blocklist.
|
||||||
|
Blocklist *ratelimit.Blocklist
|
||||||
// Honeytoken, when non-empty, is the planted bearer value whose presentation
|
// Honeytoken, when non-empty, is the planted bearer value whose presentation
|
||||||
// bans the caller and raises a high-severity alarm.
|
// bans the caller and raises a high-severity alarm.
|
||||||
Honeytoken string
|
Honeytoken string
|
||||||
@@ -148,6 +152,10 @@ func NewServer(d Deps) *Server {
|
|||||||
if banlist == nil {
|
if banlist == nil {
|
||||||
banlist = ratelimit.NewBanlist(ratelimit.BanConfig{})
|
banlist = ratelimit.NewBanlist(ratelimit.BanConfig{})
|
||||||
}
|
}
|
||||||
|
blocklist := d.Blocklist
|
||||||
|
if blocklist == nil {
|
||||||
|
blocklist = ratelimit.NewBlocklist(false, nil)
|
||||||
|
}
|
||||||
rl := d.RateLimit
|
rl := d.RateLimit
|
||||||
if rl == (config.RateLimitConfig{}) {
|
if rl == (config.RateLimitConfig{}) {
|
||||||
rl = config.DefaultRateLimit()
|
rl = config.DefaultRateLimit()
|
||||||
@@ -160,12 +168,13 @@ func NewServer(d Deps) *Server {
|
|||||||
limiter: limiter,
|
limiter: limiter,
|
||||||
tracker: tracker,
|
tracker: tracker,
|
||||||
banlist: banlist,
|
banlist: banlist,
|
||||||
|
blocklist: blocklist,
|
||||||
honeytoken: d.Honeytoken,
|
honeytoken: d.Honeytoken,
|
||||||
hub: d.Hub,
|
hub: d.Hub,
|
||||||
heartbeat: d.Heartbeat,
|
heartbeat: d.Heartbeat,
|
||||||
log: log,
|
log: log,
|
||||||
adminProxy: d.AdminProxy,
|
adminProxy: d.AdminProxy,
|
||||||
metrics: newServerMetrics(d.Meter),
|
metrics: newServerMetrics(d.Meter, blocklist),
|
||||||
maxBodyBytes: maxBody,
|
maxBodyBytes: maxBody,
|
||||||
publicPolicy: ratelimit.PerMinute(rl.PublicPerMinute, rl.PublicBurst),
|
publicPolicy: ratelimit.PerMinute(rl.PublicPerMinute, rl.PublicBurst),
|
||||||
userPolicy: ratelimit.PerMinute(rl.UserPerMinute, rl.UserBurst),
|
userPolicy: ratelimit.PerMinute(rl.UserPerMinute, rl.UserBurst),
|
||||||
@@ -255,6 +264,13 @@ func (s *Server) abuseGuard(next http.Handler) http.Handler {
|
|||||||
http.Error(w, "banned", http.StatusTooManyRequests)
|
http.Error(w, "banned", http.StatusTooManyRequests)
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
// The community IP blocklist (Spamhaus DROP): a known-bad source is refused before any work.
|
||||||
|
// Counted, not logged per request (a blocked scanner hammers). Inert on a disabled blocklist.
|
||||||
|
if s.blocklist.Blocked(ip) {
|
||||||
|
s.metrics.recordBlocklistBlock(r.Context())
|
||||||
|
http.Error(w, "forbidden", http.StatusForbidden)
|
||||||
|
return
|
||||||
|
}
|
||||||
if r.Header.Get(honeypotHeader) != "" {
|
if r.Header.Get(honeypotHeader) != "" {
|
||||||
s.log.Warn("honeypot tripwire",
|
s.log.Warn("honeypot tripwire",
|
||||||
zap.String("path", r.URL.Path),
|
zap.String("path", r.URL.Path),
|
||||||
|
|||||||
@@ -0,0 +1,188 @@
|
|||||||
|
package ratelimit
|
||||||
|
|
||||||
|
import (
|
||||||
|
"bufio"
|
||||||
|
"encoding/binary"
|
||||||
|
"fmt"
|
||||||
|
"io"
|
||||||
|
"net"
|
||||||
|
"sort"
|
||||||
|
"strings"
|
||||||
|
"sync"
|
||||||
|
"time"
|
||||||
|
)
|
||||||
|
|
||||||
|
// ipRange is an inclusive IPv4 range [lo, hi] as uint32 — the form the blocklist matches against.
|
||||||
|
type ipRange struct{ lo, hi uint32 }
|
||||||
|
|
||||||
|
// Blocklist is a static IPv4 CIDR blocklist (a curated community feed such as Spamhaus DROP) enforced
|
||||||
|
// on the hot path alongside the fail2ban [Banlist]. It is refreshed periodically ([ApplyRefresh]); an
|
||||||
|
// allowlist (never blocked) protects known-good infrastructure and is checked first. Only IPv4 is
|
||||||
|
// matched — an IPv6 client is never blocked here (the fail2ban list and the honeypot still cover it).
|
||||||
|
// Disabled by default (prod-only, like the ban): while disabled, Blocked is always false.
|
||||||
|
type Blocklist struct {
|
||||||
|
enabled bool
|
||||||
|
allow []ipRange // sorted, non-overlapping; from config, immutable after construction
|
||||||
|
|
||||||
|
mu sync.RWMutex
|
||||||
|
ranges []ipRange // sorted, non-overlapping; the current feed
|
||||||
|
fetchedAt time.Time // last successful SetCIDRs; zero = never loaded
|
||||||
|
}
|
||||||
|
|
||||||
|
// NewBlocklist builds a Blocklist. enabled gates the whole mechanism; allow is the never-block set
|
||||||
|
// (CIDRs / bare IPs already parsed) — a client in it is never blocked even if the feed lists it.
|
||||||
|
func NewBlocklist(enabled bool, allow []*net.IPNet) *Blocklist {
|
||||||
|
return &Blocklist{enabled: enabled, allow: toRanges(allow)}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Blocked reports whether ip (a textual address) is in the current feed and not allowlisted. It is
|
||||||
|
// false on a disabled or empty blocklist, and false for any non-IPv4 address.
|
||||||
|
func (b *Blocklist) Blocked(ip string) bool {
|
||||||
|
if !b.enabled {
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
v, ok := ipv4ToUint32(ip)
|
||||||
|
if !ok {
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
b.mu.RLock()
|
||||||
|
defer b.mu.RUnlock()
|
||||||
|
if len(b.ranges) == 0 || rangesContain(b.allow, v) {
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
return rangesContain(b.ranges, v)
|
||||||
|
}
|
||||||
|
|
||||||
|
// SetCIDRs swaps in a freshly fetched feed, recording the fetch time. Non-IPv4 CIDRs are ignored.
|
||||||
|
func (b *Blocklist) SetCIDRs(cidrs []*net.IPNet, at time.Time) {
|
||||||
|
r := toRanges(cidrs)
|
||||||
|
b.mu.Lock()
|
||||||
|
b.ranges = r
|
||||||
|
b.fetchedAt = at
|
||||||
|
b.mu.Unlock()
|
||||||
|
}
|
||||||
|
|
||||||
|
// Clear drops the current feed (fail-open) but keeps the last-fetch time, so a staleness gauge keeps
|
||||||
|
// climbing. Blocked then returns false until a fresh feed loads.
|
||||||
|
func (b *Blocklist) Clear() {
|
||||||
|
b.mu.Lock()
|
||||||
|
b.ranges = nil
|
||||||
|
b.mu.Unlock()
|
||||||
|
}
|
||||||
|
|
||||||
|
// Len returns the number of ranges currently enforced.
|
||||||
|
func (b *Blocklist) Len() int {
|
||||||
|
b.mu.RLock()
|
||||||
|
defer b.mu.RUnlock()
|
||||||
|
return len(b.ranges)
|
||||||
|
}
|
||||||
|
|
||||||
|
// LastFetch returns the time of the last successful feed load (zero if never).
|
||||||
|
func (b *Blocklist) LastFetch() time.Time {
|
||||||
|
b.mu.RLock()
|
||||||
|
defer b.mu.RUnlock()
|
||||||
|
return b.fetchedAt
|
||||||
|
}
|
||||||
|
|
||||||
|
// RefreshOutcome is the result of one refresh attempt, for the caller's logging and metrics.
|
||||||
|
type RefreshOutcome int
|
||||||
|
|
||||||
|
const (
|
||||||
|
// RefreshUpdated: a new feed was fetched and applied.
|
||||||
|
RefreshUpdated RefreshOutcome = iota
|
||||||
|
// RefreshKept: the fetch failed but the last-good feed is still fresh, so it was kept.
|
||||||
|
RefreshKept
|
||||||
|
// RefreshDropped: the fetch failed and the feed went stale, so it was dropped (fail-open).
|
||||||
|
RefreshDropped
|
||||||
|
)
|
||||||
|
|
||||||
|
// ApplyRefresh updates bl from one fetch outcome: on success it applies the new feed; on failure it
|
||||||
|
// keeps the last-good feed unless it is older than maxStaleness, in which case it drops it (fail-open
|
||||||
|
// — better to under-block than to block a legitimate client on a frozen feed). now is the wall clock.
|
||||||
|
func ApplyRefresh(bl *Blocklist, cidrs []*net.IPNet, fetchErr error, now time.Time, maxStaleness time.Duration) RefreshOutcome {
|
||||||
|
if fetchErr == nil {
|
||||||
|
bl.SetCIDRs(cidrs, now)
|
||||||
|
return RefreshUpdated
|
||||||
|
}
|
||||||
|
last := bl.LastFetch()
|
||||||
|
if last.IsZero() || now.Sub(last) > maxStaleness {
|
||||||
|
bl.Clear()
|
||||||
|
return RefreshDropped
|
||||||
|
}
|
||||||
|
return RefreshKept
|
||||||
|
}
|
||||||
|
|
||||||
|
// ParseDROP parses a Spamhaus DROP-style feed: one CIDR per line with an optional "; comment" tail,
|
||||||
|
// plus blank and comment lines. It returns the IPv4 networks; a bare IP is read as a /32, and
|
||||||
|
// non-IPv4 or malformed entries are skipped so one bad line never fails the whole feed.
|
||||||
|
func ParseDROP(r io.Reader) ([]*net.IPNet, error) {
|
||||||
|
var out []*net.IPNet
|
||||||
|
sc := bufio.NewScanner(r)
|
||||||
|
sc.Buffer(make([]byte, 0, 64*1024), 1<<20)
|
||||||
|
for sc.Scan() {
|
||||||
|
line := sc.Text()
|
||||||
|
if i := strings.IndexByte(line, ';'); i >= 0 {
|
||||||
|
line = line[:i]
|
||||||
|
}
|
||||||
|
line = strings.TrimSpace(line)
|
||||||
|
if line == "" {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
if !strings.Contains(line, "/") {
|
||||||
|
line += "/32"
|
||||||
|
}
|
||||||
|
_, ipnet, err := net.ParseCIDR(line)
|
||||||
|
if err != nil || ipnet.IP.To4() == nil {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
out = append(out, ipnet)
|
||||||
|
}
|
||||||
|
if err := sc.Err(); err != nil {
|
||||||
|
return nil, fmt.Errorf("ratelimit: read blocklist: %w", err)
|
||||||
|
}
|
||||||
|
return out, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// toRanges converts IPv4 CIDRs to sorted, uint32 inclusive ranges (the match form); non-IPv4 CIDRs
|
||||||
|
// are dropped. Sorting by lo lets [rangesContain] binary-search.
|
||||||
|
func toRanges(cidrs []*net.IPNet) []ipRange {
|
||||||
|
out := make([]ipRange, 0, len(cidrs))
|
||||||
|
for _, n := range cidrs {
|
||||||
|
if n == nil {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
ip4 := n.IP.To4()
|
||||||
|
if ip4 == nil {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
ones, bits := n.Mask.Size()
|
||||||
|
if bits != 32 {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
lo := binary.BigEndian.Uint32(ip4)
|
||||||
|
hi := lo | (uint32(0xffffffff) >> uint(ones))
|
||||||
|
out = append(out, ipRange{lo: lo, hi: hi})
|
||||||
|
}
|
||||||
|
sort.Slice(out, func(i, j int) bool { return out[i].lo < out[j].lo })
|
||||||
|
return out
|
||||||
|
}
|
||||||
|
|
||||||
|
// rangesContain reports whether v falls in any range of the sorted, non-overlapping slice, via a
|
||||||
|
// binary search for the last range whose lo is at most v.
|
||||||
|
func rangesContain(ranges []ipRange, v uint32) bool {
|
||||||
|
i := sort.Search(len(ranges), func(i int) bool { return ranges[i].lo > v })
|
||||||
|
return i > 0 && ranges[i-1].hi >= v
|
||||||
|
}
|
||||||
|
|
||||||
|
// ipv4ToUint32 parses a textual address to a big-endian uint32, reporting whether it was IPv4.
|
||||||
|
func ipv4ToUint32(s string) (uint32, bool) {
|
||||||
|
ip := net.ParseIP(s)
|
||||||
|
if ip == nil {
|
||||||
|
return 0, false
|
||||||
|
}
|
||||||
|
ip4 := ip.To4()
|
||||||
|
if ip4 == nil {
|
||||||
|
return 0, false
|
||||||
|
}
|
||||||
|
return binary.BigEndian.Uint32(ip4), true
|
||||||
|
}
|
||||||
@@ -0,0 +1,106 @@
|
|||||||
|
package ratelimit
|
||||||
|
|
||||||
|
import (
|
||||||
|
"errors"
|
||||||
|
"net"
|
||||||
|
"strings"
|
||||||
|
"testing"
|
||||||
|
"time"
|
||||||
|
)
|
||||||
|
|
||||||
|
// cidrs parses CIDR strings into networks for a test fixture.
|
||||||
|
func cidrs(t *testing.T, ss ...string) []*net.IPNet {
|
||||||
|
t.Helper()
|
||||||
|
out := make([]*net.IPNet, 0, len(ss))
|
||||||
|
for _, s := range ss {
|
||||||
|
_, n, err := net.ParseCIDR(s)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("bad cidr %q: %v", s, err)
|
||||||
|
}
|
||||||
|
out = append(out, n)
|
||||||
|
}
|
||||||
|
return out
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestBlocklistBlocked(t *testing.T) {
|
||||||
|
bl := NewBlocklist(true, cidrs(t, "10.20.30.0/24")) // allowlist
|
||||||
|
bl.SetCIDRs(cidrs(t, "1.2.3.0/24", "203.0.113.5/32", "198.51.100.0/28", "10.20.30.0/24"), time.Now())
|
||||||
|
cases := map[string]bool{
|
||||||
|
"1.2.3.0": true, // range start
|
||||||
|
"1.2.3.255": true, // range end
|
||||||
|
"1.2.4.0": false, // just past the /24
|
||||||
|
"203.0.113.5": true, // /32 exact
|
||||||
|
"203.0.113.6": false,
|
||||||
|
"198.51.100.15": true, // within /28
|
||||||
|
"198.51.100.16": false, // past the /28
|
||||||
|
"9.9.9.9": false, // not listed
|
||||||
|
"10.20.30.99": false, // listed BUT allowlisted — never blocked
|
||||||
|
"::1": false, // IPv6 — never matched here
|
||||||
|
"not-an-ip": false,
|
||||||
|
}
|
||||||
|
for ip, want := range cases {
|
||||||
|
if got := bl.Blocked(ip); got != want {
|
||||||
|
t.Errorf("Blocked(%q) = %v, want %v", ip, got, want)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestBlocklistDisabledOrEmpty(t *testing.T) {
|
||||||
|
off := NewBlocklist(false, nil)
|
||||||
|
off.SetCIDRs(cidrs(t, "1.2.3.0/24"), time.Now())
|
||||||
|
if off.Blocked("1.2.3.4") {
|
||||||
|
t.Error("a disabled blocklist must not block")
|
||||||
|
}
|
||||||
|
empty := NewBlocklist(true, nil)
|
||||||
|
if empty.Blocked("1.2.3.4") {
|
||||||
|
t.Error("an empty (never-loaded) blocklist must not block")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestParseDROP(t *testing.T) {
|
||||||
|
in := "; Spamhaus DROP\n" +
|
||||||
|
"1.2.3.0/24 ; SBL1\n" +
|
||||||
|
" 198.51.100.0/28 ; SBL2\n" +
|
||||||
|
"\n" +
|
||||||
|
"203.0.113.5 ; a bare IP -> /32\n" +
|
||||||
|
"2001:db8::/32 ; IPv6 skipped\n" +
|
||||||
|
"garbage line\n"
|
||||||
|
nets, err := ParseDROP(strings.NewReader(in))
|
||||||
|
if err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
if len(nets) != 3 {
|
||||||
|
t.Fatalf("got %d networks, want 3: %v", len(nets), nets)
|
||||||
|
}
|
||||||
|
bl := NewBlocklist(true, nil)
|
||||||
|
bl.SetCIDRs(nets, time.Now())
|
||||||
|
for ip, want := range map[string]bool{"1.2.3.9": true, "198.51.100.1": true, "203.0.113.5": true, "203.0.113.6": false, "2001:db8::1": false} {
|
||||||
|
if got := bl.Blocked(ip); got != want {
|
||||||
|
t.Errorf("Blocked(%s) = %v, want %v", ip, got, want)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestApplyRefresh(t *testing.T) {
|
||||||
|
bl := NewBlocklist(true, nil)
|
||||||
|
now := time.Now()
|
||||||
|
|
||||||
|
if o := ApplyRefresh(bl, cidrs(t, "1.2.3.0/24"), nil, now, time.Hour); o != RefreshUpdated {
|
||||||
|
t.Fatalf("success: want RefreshUpdated, got %v", o)
|
||||||
|
}
|
||||||
|
if !bl.Blocked("1.2.3.4") {
|
||||||
|
t.Fatal("a successful refresh must apply the feed")
|
||||||
|
}
|
||||||
|
if o := ApplyRefresh(bl, nil, errors.New("net"), now.Add(30*time.Minute), time.Hour); o != RefreshKept {
|
||||||
|
t.Fatalf("fresh failure: want RefreshKept, got %v", o)
|
||||||
|
}
|
||||||
|
if !bl.Blocked("1.2.3.4") {
|
||||||
|
t.Error("a kept feed must still block")
|
||||||
|
}
|
||||||
|
if o := ApplyRefresh(bl, nil, errors.New("net"), now.Add(2*time.Hour), time.Hour); o != RefreshDropped {
|
||||||
|
t.Fatalf("stale failure: want RefreshDropped, got %v", o)
|
||||||
|
}
|
||||||
|
if bl.Blocked("1.2.3.4") {
|
||||||
|
t.Error("a stale feed must be dropped (fail-open)")
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -194,6 +194,15 @@ func encodeProfile(p backendclient.ProfileResp) []byte {
|
|||||||
fb.AdsInfoAddSuppressed(b, p.Ads.Suppressed)
|
fb.AdsInfoAddSuppressed(b, p.Ads.Suppressed)
|
||||||
ads = fb.AdsInfoEnd(b)
|
ads = fb.AdsInfoEnd(b)
|
||||||
}
|
}
|
||||||
|
// The active-game limits table (scalars only), built before Profile is opened.
|
||||||
|
var gameLimits flatbuffers.UOffsetT
|
||||||
|
if p.GameLimits != nil {
|
||||||
|
fb.GameLimitsStart(b)
|
||||||
|
fb.GameLimitsAddVsAi(b, int32(p.GameLimits.VsAI))
|
||||||
|
fb.GameLimitsAddRandom(b, int32(p.GameLimits.Random))
|
||||||
|
fb.GameLimitsAddFriends(b, int32(p.GameLimits.Friends))
|
||||||
|
gameLimits = fb.GameLimitsEnd(b)
|
||||||
|
}
|
||||||
fb.ProfileStart(b)
|
fb.ProfileStart(b)
|
||||||
fb.ProfileAddUserId(b, uid)
|
fb.ProfileAddUserId(b, uid)
|
||||||
fb.ProfileAddDisplayName(b, name)
|
fb.ProfileAddDisplayName(b, name)
|
||||||
@@ -219,6 +228,9 @@ func encodeProfile(p backendclient.ProfileResp) []byte {
|
|||||||
if p.Ads != nil {
|
if p.Ads != nil {
|
||||||
fb.ProfileAddAds(b, ads)
|
fb.ProfileAddAds(b, ads)
|
||||||
}
|
}
|
||||||
|
if p.GameLimits != nil {
|
||||||
|
fb.ProfileAddGameLimits(b, gameLimits)
|
||||||
|
}
|
||||||
b.Finish(fb.ProfileEnd(b))
|
b.Finish(fb.ProfileEnd(b))
|
||||||
return b.FinishedBytes()
|
return b.FinishedBytes()
|
||||||
}
|
}
|
||||||
@@ -405,7 +417,6 @@ func toWireState(s backendclient.StateResp) wire.StateView {
|
|||||||
Rack: s.Rack,
|
Rack: s.Rack,
|
||||||
BagLen: s.BagLen,
|
BagLen: s.BagLen,
|
||||||
HintsRemaining: s.HintsRemaining,
|
HintsRemaining: s.HintsRemaining,
|
||||||
WalletBalance: s.WalletBalance,
|
|
||||||
HintUnlockLeftSeconds: s.HintUnlockLeftSeconds,
|
HintUnlockLeftSeconds: s.HintUnlockLeftSeconds,
|
||||||
Alphabet: alphabet,
|
Alphabet: alphabet,
|
||||||
}
|
}
|
||||||
@@ -625,6 +636,7 @@ func toWireGame(g backendclient.GameResp) wire.GameView {
|
|||||||
LastActivityUnix: g.LastActivityUnix,
|
LastActivityUnix: g.LastActivityUnix,
|
||||||
UnreadChat: g.UnreadChat,
|
UnreadChat: g.UnreadChat,
|
||||||
UnreadMessages: g.UnreadMessages,
|
UnreadMessages: g.UnreadMessages,
|
||||||
|
Kind: g.Kind,
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -0,0 +1,61 @@
|
|||||||
|
package transcode_test
|
||||||
|
|
||||||
|
import (
|
||||||
|
"context"
|
||||||
|
"net/http"
|
||||||
|
"testing"
|
||||||
|
|
||||||
|
"scrabble/gateway/internal/transcode"
|
||||||
|
fb "scrabble/pkg/fbs/scrabblefb"
|
||||||
|
)
|
||||||
|
|
||||||
|
// TestProfileGetEncodesGameLimits verifies the gateway forwards the backend's per-kind active-game
|
||||||
|
// caps into the Profile payload — the caps the client's New-Game lock reads. The encode is not
|
||||||
|
// exercised by the mock e2e (it bypasses the codec), so a dropped field would only surface here.
|
||||||
|
func TestProfileGetEncodesGameLimits(t *testing.T) {
|
||||||
|
backend, cleanup := fakeBackend(t, func(w http.ResponseWriter, r *http.Request) {
|
||||||
|
if r.Method != http.MethodGet || r.URL.Path != "/api/v1/user/profile" {
|
||||||
|
t.Errorf("unexpected %s %q", r.Method, r.URL.Path)
|
||||||
|
}
|
||||||
|
_, _ = w.Write([]byte(`{"user_id":"u-1","display_name":"Kaya","preferred_language":"en",` +
|
||||||
|
`"game_limits":{"vs_ai":1,"random":1,"friends":0}}`))
|
||||||
|
})
|
||||||
|
defer cleanup()
|
||||||
|
|
||||||
|
reg := transcode.NewRegistry(backend, nil)
|
||||||
|
op, ok := reg.Lookup(transcode.MsgProfileGet)
|
||||||
|
if !ok {
|
||||||
|
t.Fatal("profile.get not registered")
|
||||||
|
}
|
||||||
|
payload, err := op.Handler(context.Background(), transcode.Request{UserID: "u-1"})
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("handler: %v", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
gl := fb.GetRootAsProfile(payload, 0).GameLimits(nil)
|
||||||
|
if gl == nil {
|
||||||
|
t.Fatal("profile carries no game_limits block")
|
||||||
|
}
|
||||||
|
if gl.VsAi() != 1 || gl.Random() != 1 || gl.Friends() != 0 {
|
||||||
|
t.Errorf("game limits = %d/%d/%d, want 1/1/0", gl.VsAi(), gl.Random(), gl.Friends())
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// TestProfileGetNoGameLimits verifies a profile without a game_limits block encodes none (the
|
||||||
|
// backend omits it only when the limits config is unwired; normally the block is present).
|
||||||
|
func TestProfileGetNoGameLimits(t *testing.T) {
|
||||||
|
backend, cleanup := fakeBackend(t, func(w http.ResponseWriter, r *http.Request) {
|
||||||
|
_, _ = w.Write([]byte(`{"user_id":"u-1","display_name":"Kaya","preferred_language":"en"}`))
|
||||||
|
})
|
||||||
|
defer cleanup()
|
||||||
|
|
||||||
|
reg := transcode.NewRegistry(backend, nil)
|
||||||
|
op, _ := reg.Lookup(transcode.MsgProfileGet)
|
||||||
|
payload, err := op.Handler(context.Background(), transcode.Request{UserID: "u-1"})
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("handler: %v", err)
|
||||||
|
}
|
||||||
|
if p := fb.GetRootAsProfile(payload, 0); p.GameLimits(nil) != nil {
|
||||||
|
t.Error("profile without a game_limits block unexpectedly carries one")
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -59,7 +59,7 @@ func TestGameStateRoundTripForwardsUserID(t *testing.T) {
|
|||||||
if r.URL.Path != "/api/v1/user/games/g-1/state" {
|
if r.URL.Path != "/api/v1/user/games/g-1/state" {
|
||||||
t.Errorf("unexpected path %q", r.URL.Path)
|
t.Errorf("unexpected path %q", r.URL.Path)
|
||||||
}
|
}
|
||||||
_, _ = w.Write([]byte(`{"game":{"id":"g-1","variant":"scrabble_en","status":"active","players":2,"to_move":1,"seats":[{"seat":0,"account_id":"u-7","score":5}]},"seat":0,"rack":[0,1],"bag_len":80,"hints_remaining":4,"wallet_balance":3,"hint_unlock_left_seconds":1200}`))
|
_, _ = w.Write([]byte(`{"game":{"id":"g-1","variant":"scrabble_en","status":"active","players":2,"to_move":1,"seats":[{"seat":0,"account_id":"u-7","score":5}]},"seat":0,"rack":[0,1],"bag_len":80,"hints_remaining":4,"hint_unlock_left_seconds":1200}`))
|
||||||
})
|
})
|
||||||
defer cleanup()
|
defer cleanup()
|
||||||
|
|
||||||
@@ -77,8 +77,8 @@ func TestGameStateRoundTripForwardsUserID(t *testing.T) {
|
|||||||
t.Fatalf("handler: %v", err)
|
t.Fatalf("handler: %v", err)
|
||||||
}
|
}
|
||||||
st := fb.GetRootAsStateView(payload, 0)
|
st := fb.GetRootAsStateView(payload, 0)
|
||||||
if st.BagLen() != 80 || st.RackLength() != 2 || st.HintsRemaining() != 4 || st.WalletBalance() != 3 || st.HintUnlockLeftSeconds() != 1200 {
|
if st.BagLen() != 80 || st.RackLength() != 2 || st.HintsRemaining() != 4 || st.HintUnlockLeftSeconds() != 1200 {
|
||||||
t.Fatalf("state decoded wrong: bag=%d rack=%d hints=%d wallet=%d unlockLeft=%d", st.BagLen(), st.RackLength(), st.HintsRemaining(), st.WalletBalance(), st.HintUnlockLeftSeconds())
|
t.Fatalf("state decoded wrong: bag=%d rack=%d hints=%d unlockLeft=%d", st.BagLen(), st.RackLength(), st.HintsRemaining(), st.HintUnlockLeftSeconds())
|
||||||
}
|
}
|
||||||
game := st.Game(nil)
|
game := st.Game(nil)
|
||||||
if game == nil || string(game.Id()) != "g-1" || string(game.Variant()) != "scrabble_en" || game.ToMove() != 1 {
|
if game == nil || string(game.Id()) != "g-1" || string(game.Variant()) != "scrabble_en" || game.ToMove() != 1 {
|
||||||
|
|||||||
+26
-10
@@ -80,6 +80,11 @@ table GameView {
|
|||||||
// message (not just a nudge). With unread_chat it colours the badge — both set is the regular
|
// message (not just a nudge). With unread_chat it colours the badge — both set is the regular
|
||||||
// badge, unread_chat alone is the softer nudge-only badge.
|
// badge, unread_chat alone is the softer nudge-only badge.
|
||||||
unread_messages:bool;
|
unread_messages:bool;
|
||||||
|
// kind is the game's origin for the active-game limits: 0 unknown (a pre-existing game, never
|
||||||
|
// gated), 1 vs_ai, 2 random, 3 friends. The lobby counts active games per kind against the
|
||||||
|
// caller's per-kind limits (Profile.game_limits) to lock a capped New-Game start (added
|
||||||
|
// trailing — backward-compatible).
|
||||||
|
kind:int;
|
||||||
}
|
}
|
||||||
|
|
||||||
// MoveRecord is one decoded move (a committed play, or a hint preview).
|
// MoveRecord is one decoded move (a committed play, or a hint preview).
|
||||||
@@ -267,6 +272,10 @@ table Profile {
|
|||||||
// ads carries the post-move interstitial config (cooldowns + suppressed) for the client-mirrored
|
// ads carries the post-move interstitial config (cooldowns + suppressed) for the client-mirrored
|
||||||
// gate (added trailing — backward-compatible).
|
// gate (added trailing — backward-compatible).
|
||||||
ads:AdsInfo;
|
ads:AdsInfo;
|
||||||
|
// game_limits carries the caller's tier active-game caps per kind (-1 = unlimited); the client
|
||||||
|
// counts its active games per kind from the lobby and locks a capped New-Game start (added
|
||||||
|
// trailing — backward-compatible).
|
||||||
|
game_limits:GameLimits;
|
||||||
}
|
}
|
||||||
|
|
||||||
// AdsInfo is the post-move interstitial config: the client-mirrored cooldowns (seconds) and whether
|
// AdsInfo is the post-move interstitial config: the client-mirrored cooldowns (seconds) and whether
|
||||||
@@ -278,6 +287,15 @@ table AdsInfo {
|
|||||||
suppressed:bool;
|
suppressed:bool;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// GameLimits is the caller's tier active-game caps per kind (-1 = unlimited), resolved to the
|
||||||
|
// guest or durable tier server-side. It rides Profile.game_limits for the client's per-kind
|
||||||
|
// New-Game lock.
|
||||||
|
table GameLimits {
|
||||||
|
vs_ai:int;
|
||||||
|
random:int;
|
||||||
|
friends:int;
|
||||||
|
}
|
||||||
|
|
||||||
// BlockStatus reports the caller's current manual block. The UI fetches it after any operation
|
// BlockStatus reports the caller's current manual block. The UI fetches it after any operation
|
||||||
// returns the "account_blocked" result code, then replaces every screen with the terminal blocked
|
// returns the "account_blocked" result code, then replaces every screen with the terminal blocked
|
||||||
// screen and stops all push/poll. permanent is false for a temporary block; until is an RFC3339
|
// screen and stops all push/poll. permanent is false for a temporary block; until is an RFC3339
|
||||||
@@ -330,12 +348,11 @@ table StateView {
|
|||||||
bag_len:int;
|
bag_len:int;
|
||||||
hints_remaining:int;
|
hints_remaining:int;
|
||||||
alphabet:[AlphabetEntry];
|
alphabet:[AlphabetEntry];
|
||||||
// wallet_balance is the requesting player's global hint-wallet balance, sent apart from
|
// wallet_balance is deprecated (D31): the purchasable hint wallet moved to the profile (payments),
|
||||||
// hints_remaining (which folds the wallet in with the per-game allowance) so the client can
|
// and hints_remaining now carries the per-game allowance alone. The field is tombstoned rather than
|
||||||
// separate the two: the per-game allowance remaining is hints_remaining - wallet_balance, and
|
// deleted so the vtable slots of the fields after it stay stable across a rolling deploy (an old
|
||||||
// the wallet is a single global figure the client keeps live across games (added trailing —
|
// SPA served before the deploy must keep reading a new gateway correctly). No accessor is generated.
|
||||||
// backward-compatible).
|
wallet_balance:int (deprecated);
|
||||||
wallet_balance:int;
|
|
||||||
// hint_unlock_left_seconds is, for a vs_ai game, how many seconds until the idle hint unlocks
|
// hint_unlock_left_seconds is, for a vs_ai game, how many seconds until the idle hint unlocks
|
||||||
// (the robot's last move plus the idle window, computed from the SERVER clock online / the device
|
// (the robot's last move plus the idle window, computed from the SERVER clock online / the device
|
||||||
// clock offline, capped at the window and floored at 0); 0 for a human's first move (no robot move
|
// clock offline, capped at the window and floored at 0); 0 for a human's first move (no robot move
|
||||||
@@ -393,10 +410,9 @@ table ComplaintRequest {
|
|||||||
note:string;
|
note:string;
|
||||||
}
|
}
|
||||||
|
|
||||||
// HintResult is the top-ranked move plus the remaining hint budget. wallet_balance is the
|
// HintResult is the top-ranked move plus hints_remaining (the per-game allowance alone) and
|
||||||
// global hint-wallet balance after spending (see StateView.wallet_balance), so the client
|
// wallet_balance (the purchasable hint wallet after spending, from payments). The client adopts
|
||||||
// refreshes its live wallet and re-derives the per-game allowance (added trailing —
|
// wallet_balance into the profile so the badge stays live across games (see lib/hints).
|
||||||
// backward-compatible).
|
|
||||||
table HintResult {
|
table HintResult {
|
||||||
move:MoveRecord;
|
move:MoveRecord;
|
||||||
hints_remaining:int;
|
hints_remaining:int;
|
||||||
|
|||||||
@@ -0,0 +1,94 @@
|
|||||||
|
// Code generated by the FlatBuffers compiler. DO NOT EDIT.
|
||||||
|
|
||||||
|
package scrabblefb
|
||||||
|
|
||||||
|
import (
|
||||||
|
flatbuffers "github.com/google/flatbuffers/go"
|
||||||
|
)
|
||||||
|
|
||||||
|
type GameLimits struct {
|
||||||
|
_tab flatbuffers.Table
|
||||||
|
}
|
||||||
|
|
||||||
|
func GetRootAsGameLimits(buf []byte, offset flatbuffers.UOffsetT) *GameLimits {
|
||||||
|
n := flatbuffers.GetUOffsetT(buf[offset:])
|
||||||
|
x := &GameLimits{}
|
||||||
|
x.Init(buf, n+offset)
|
||||||
|
return x
|
||||||
|
}
|
||||||
|
|
||||||
|
func FinishGameLimitsBuffer(builder *flatbuffers.Builder, offset flatbuffers.UOffsetT) {
|
||||||
|
builder.Finish(offset)
|
||||||
|
}
|
||||||
|
|
||||||
|
func GetSizePrefixedRootAsGameLimits(buf []byte, offset flatbuffers.UOffsetT) *GameLimits {
|
||||||
|
n := flatbuffers.GetUOffsetT(buf[offset+flatbuffers.SizeUint32:])
|
||||||
|
x := &GameLimits{}
|
||||||
|
x.Init(buf, n+offset+flatbuffers.SizeUint32)
|
||||||
|
return x
|
||||||
|
}
|
||||||
|
|
||||||
|
func FinishSizePrefixedGameLimitsBuffer(builder *flatbuffers.Builder, offset flatbuffers.UOffsetT) {
|
||||||
|
builder.FinishSizePrefixed(offset)
|
||||||
|
}
|
||||||
|
|
||||||
|
func (rcv *GameLimits) Init(buf []byte, i flatbuffers.UOffsetT) {
|
||||||
|
rcv._tab.Bytes = buf
|
||||||
|
rcv._tab.Pos = i
|
||||||
|
}
|
||||||
|
|
||||||
|
func (rcv *GameLimits) Table() flatbuffers.Table {
|
||||||
|
return rcv._tab
|
||||||
|
}
|
||||||
|
|
||||||
|
func (rcv *GameLimits) VsAi() int32 {
|
||||||
|
o := flatbuffers.UOffsetT(rcv._tab.Offset(4))
|
||||||
|
if o != 0 {
|
||||||
|
return rcv._tab.GetInt32(o + rcv._tab.Pos)
|
||||||
|
}
|
||||||
|
return 0
|
||||||
|
}
|
||||||
|
|
||||||
|
func (rcv *GameLimits) MutateVsAi(n int32) bool {
|
||||||
|
return rcv._tab.MutateInt32Slot(4, n)
|
||||||
|
}
|
||||||
|
|
||||||
|
func (rcv *GameLimits) Random() int32 {
|
||||||
|
o := flatbuffers.UOffsetT(rcv._tab.Offset(6))
|
||||||
|
if o != 0 {
|
||||||
|
return rcv._tab.GetInt32(o + rcv._tab.Pos)
|
||||||
|
}
|
||||||
|
return 0
|
||||||
|
}
|
||||||
|
|
||||||
|
func (rcv *GameLimits) MutateRandom(n int32) bool {
|
||||||
|
return rcv._tab.MutateInt32Slot(6, n)
|
||||||
|
}
|
||||||
|
|
||||||
|
func (rcv *GameLimits) Friends() int32 {
|
||||||
|
o := flatbuffers.UOffsetT(rcv._tab.Offset(8))
|
||||||
|
if o != 0 {
|
||||||
|
return rcv._tab.GetInt32(o + rcv._tab.Pos)
|
||||||
|
}
|
||||||
|
return 0
|
||||||
|
}
|
||||||
|
|
||||||
|
func (rcv *GameLimits) MutateFriends(n int32) bool {
|
||||||
|
return rcv._tab.MutateInt32Slot(8, n)
|
||||||
|
}
|
||||||
|
|
||||||
|
func GameLimitsStart(builder *flatbuffers.Builder) {
|
||||||
|
builder.StartObject(3)
|
||||||
|
}
|
||||||
|
func GameLimitsAddVsAi(builder *flatbuffers.Builder, vsAi int32) {
|
||||||
|
builder.PrependInt32Slot(0, vsAi, 0)
|
||||||
|
}
|
||||||
|
func GameLimitsAddRandom(builder *flatbuffers.Builder, random int32) {
|
||||||
|
builder.PrependInt32Slot(1, random, 0)
|
||||||
|
}
|
||||||
|
func GameLimitsAddFriends(builder *flatbuffers.Builder, friends int32) {
|
||||||
|
builder.PrependInt32Slot(2, friends, 0)
|
||||||
|
}
|
||||||
|
func GameLimitsEnd(builder *flatbuffers.Builder) flatbuffers.UOffsetT {
|
||||||
|
return builder.EndObject()
|
||||||
|
}
|
||||||
@@ -209,8 +209,20 @@ func (rcv *GameView) MutateUnreadMessages(n bool) bool {
|
|||||||
return rcv._tab.MutateBoolSlot(32, n)
|
return rcv._tab.MutateBoolSlot(32, n)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func (rcv *GameView) Kind() int32 {
|
||||||
|
o := flatbuffers.UOffsetT(rcv._tab.Offset(34))
|
||||||
|
if o != 0 {
|
||||||
|
return rcv._tab.GetInt32(o + rcv._tab.Pos)
|
||||||
|
}
|
||||||
|
return 0
|
||||||
|
}
|
||||||
|
|
||||||
|
func (rcv *GameView) MutateKind(n int32) bool {
|
||||||
|
return rcv._tab.MutateInt32Slot(34, n)
|
||||||
|
}
|
||||||
|
|
||||||
func GameViewStart(builder *flatbuffers.Builder) {
|
func GameViewStart(builder *flatbuffers.Builder) {
|
||||||
builder.StartObject(15)
|
builder.StartObject(16)
|
||||||
}
|
}
|
||||||
func GameViewAddId(builder *flatbuffers.Builder, id flatbuffers.UOffsetT) {
|
func GameViewAddId(builder *flatbuffers.Builder, id flatbuffers.UOffsetT) {
|
||||||
builder.PrependUOffsetTSlot(0, flatbuffers.UOffsetT(id), 0)
|
builder.PrependUOffsetTSlot(0, flatbuffers.UOffsetT(id), 0)
|
||||||
@@ -260,6 +272,9 @@ func GameViewAddUnreadChat(builder *flatbuffers.Builder, unreadChat bool) {
|
|||||||
func GameViewAddUnreadMessages(builder *flatbuffers.Builder, unreadMessages bool) {
|
func GameViewAddUnreadMessages(builder *flatbuffers.Builder, unreadMessages bool) {
|
||||||
builder.PrependBoolSlot(14, unreadMessages, false)
|
builder.PrependBoolSlot(14, unreadMessages, false)
|
||||||
}
|
}
|
||||||
|
func GameViewAddKind(builder *flatbuffers.Builder, kind int32) {
|
||||||
|
builder.PrependInt32Slot(15, kind, 0)
|
||||||
|
}
|
||||||
func GameViewEnd(builder *flatbuffers.Builder) flatbuffers.UOffsetT {
|
func GameViewEnd(builder *flatbuffers.Builder) flatbuffers.UOffsetT {
|
||||||
return builder.EndObject()
|
return builder.EndObject()
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -244,8 +244,21 @@ func (rcv *Profile) Ads(obj *AdsInfo) *AdsInfo {
|
|||||||
return nil
|
return nil
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func (rcv *Profile) GameLimits(obj *GameLimits) *GameLimits {
|
||||||
|
o := flatbuffers.UOffsetT(rcv._tab.Offset(40))
|
||||||
|
if o != 0 {
|
||||||
|
x := rcv._tab.Indirect(o + rcv._tab.Pos)
|
||||||
|
if obj == nil {
|
||||||
|
obj = new(GameLimits)
|
||||||
|
}
|
||||||
|
obj.Init(rcv._tab.Bytes, x)
|
||||||
|
return obj
|
||||||
|
}
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
func ProfileStart(builder *flatbuffers.Builder) {
|
func ProfileStart(builder *flatbuffers.Builder) {
|
||||||
builder.StartObject(18)
|
builder.StartObject(19)
|
||||||
}
|
}
|
||||||
func ProfileAddUserId(builder *flatbuffers.Builder, userId flatbuffers.UOffsetT) {
|
func ProfileAddUserId(builder *flatbuffers.Builder, userId flatbuffers.UOffsetT) {
|
||||||
builder.PrependUOffsetTSlot(0, flatbuffers.UOffsetT(userId), 0)
|
builder.PrependUOffsetTSlot(0, flatbuffers.UOffsetT(userId), 0)
|
||||||
@@ -307,6 +320,9 @@ func ProfileStartDictVersionsVector(builder *flatbuffers.Builder, numElems int)
|
|||||||
func ProfileAddAds(builder *flatbuffers.Builder, ads flatbuffers.UOffsetT) {
|
func ProfileAddAds(builder *flatbuffers.Builder, ads flatbuffers.UOffsetT) {
|
||||||
builder.PrependUOffsetTSlot(17, flatbuffers.UOffsetT(ads), 0)
|
builder.PrependUOffsetTSlot(17, flatbuffers.UOffsetT(ads), 0)
|
||||||
}
|
}
|
||||||
|
func ProfileAddGameLimits(builder *flatbuffers.Builder, gameLimits flatbuffers.UOffsetT) {
|
||||||
|
builder.PrependUOffsetTSlot(18, flatbuffers.UOffsetT(gameLimits), 0)
|
||||||
|
}
|
||||||
func ProfileEnd(builder *flatbuffers.Builder) flatbuffers.UOffsetT {
|
func ProfileEnd(builder *flatbuffers.Builder) flatbuffers.UOffsetT {
|
||||||
return builder.EndObject()
|
return builder.EndObject()
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -144,18 +144,6 @@ func (rcv *StateView) AlphabetLength() int {
|
|||||||
return 0
|
return 0
|
||||||
}
|
}
|
||||||
|
|
||||||
func (rcv *StateView) WalletBalance() int32 {
|
|
||||||
o := flatbuffers.UOffsetT(rcv._tab.Offset(16))
|
|
||||||
if o != 0 {
|
|
||||||
return rcv._tab.GetInt32(o + rcv._tab.Pos)
|
|
||||||
}
|
|
||||||
return 0
|
|
||||||
}
|
|
||||||
|
|
||||||
func (rcv *StateView) MutateWalletBalance(n int32) bool {
|
|
||||||
return rcv._tab.MutateInt32Slot(16, n)
|
|
||||||
}
|
|
||||||
|
|
||||||
func (rcv *StateView) HintUnlockLeftSeconds() int32 {
|
func (rcv *StateView) HintUnlockLeftSeconds() int32 {
|
||||||
o := flatbuffers.UOffsetT(rcv._tab.Offset(18))
|
o := flatbuffers.UOffsetT(rcv._tab.Offset(18))
|
||||||
if o != 0 {
|
if o != 0 {
|
||||||
@@ -195,9 +183,6 @@ func StateViewAddAlphabet(builder *flatbuffers.Builder, alphabet flatbuffers.UOf
|
|||||||
func StateViewStartAlphabetVector(builder *flatbuffers.Builder, numElems int) flatbuffers.UOffsetT {
|
func StateViewStartAlphabetVector(builder *flatbuffers.Builder, numElems int) flatbuffers.UOffsetT {
|
||||||
return builder.StartVector(4, numElems, 4)
|
return builder.StartVector(4, numElems, 4)
|
||||||
}
|
}
|
||||||
func StateViewAddWalletBalance(builder *flatbuffers.Builder, walletBalance int32) {
|
|
||||||
builder.PrependInt32Slot(6, walletBalance, 0)
|
|
||||||
}
|
|
||||||
func StateViewAddHintUnlockLeftSeconds(builder *flatbuffers.Builder, hintUnlockLeftSeconds int32) {
|
func StateViewAddHintUnlockLeftSeconds(builder *flatbuffers.Builder, hintUnlockLeftSeconds int32) {
|
||||||
builder.PrependInt32Slot(7, hintUnlockLeftSeconds, 0)
|
builder.PrependInt32Slot(7, hintUnlockLeftSeconds, 0)
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -30,13 +30,14 @@ const (
|
|||||||
)
|
)
|
||||||
|
|
||||||
// FromBot is a message the bot sends to the gateway: the opening Hello, then one
|
// FromBot is a message the bot sends to the gateway: the opening Hello, then one
|
||||||
// Ack per Command.
|
// Ack per Command and a periodic Health report.
|
||||||
type FromBot struct {
|
type FromBot struct {
|
||||||
state protoimpl.MessageState `protogen:"open.v1"`
|
state protoimpl.MessageState `protogen:"open.v1"`
|
||||||
// Types that are valid to be assigned to Msg:
|
// Types that are valid to be assigned to Msg:
|
||||||
//
|
//
|
||||||
// *FromBot_Hello
|
// *FromBot_Hello
|
||||||
// *FromBot_Ack
|
// *FromBot_Ack
|
||||||
|
// *FromBot_Health
|
||||||
Msg isFromBot_Msg `protobuf_oneof:"msg"`
|
Msg isFromBot_Msg `protobuf_oneof:"msg"`
|
||||||
unknownFields protoimpl.UnknownFields
|
unknownFields protoimpl.UnknownFields
|
||||||
sizeCache protoimpl.SizeCache
|
sizeCache protoimpl.SizeCache
|
||||||
@@ -97,6 +98,15 @@ func (x *FromBot) GetAck() *Ack {
|
|||||||
return nil
|
return nil
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func (x *FromBot) GetHealth() *Health {
|
||||||
|
if x != nil {
|
||||||
|
if x, ok := x.Msg.(*FromBot_Health); ok {
|
||||||
|
return x.Health
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
type isFromBot_Msg interface {
|
type isFromBot_Msg interface {
|
||||||
isFromBot_Msg()
|
isFromBot_Msg()
|
||||||
}
|
}
|
||||||
@@ -109,10 +119,92 @@ type FromBot_Ack struct {
|
|||||||
Ack *Ack `protobuf:"bytes,2,opt,name=ack,proto3,oneof"`
|
Ack *Ack `protobuf:"bytes,2,opt,name=ack,proto3,oneof"`
|
||||||
}
|
}
|
||||||
|
|
||||||
|
type FromBot_Health struct {
|
||||||
|
Health *Health `protobuf:"bytes,3,opt,name=health,proto3,oneof"`
|
||||||
|
}
|
||||||
|
|
||||||
func (*FromBot_Hello) isFromBot_Msg() {}
|
func (*FromBot_Hello) isFromBot_Msg() {}
|
||||||
|
|
||||||
func (*FromBot_Ack) isFromBot_Msg() {}
|
func (*FromBot_Ack) isFromBot_Msg() {}
|
||||||
|
|
||||||
|
func (*FromBot_Health) isFromBot_Msg() {}
|
||||||
|
|
||||||
|
// Health is a periodic report the bot pushes up the stream so the gateway can expose the remote
|
||||||
|
// bot's Bot-API health as its own metrics — the bot exports no telemetry of its own (otelcol lives
|
||||||
|
// on the main host, unreachable from the bot), so the bot-link is its only channel out. The three
|
||||||
|
// counters are DELTAS since the previous Health: the gateway adds them to cumulative counters, so a
|
||||||
|
// report lost across a reconnect only undercounts, never corrupts. last_ok_unix is the wall-clock
|
||||||
|
// second of the bot's most recent successful Bot API response (any call, including the getUpdates
|
||||||
|
// long-poll that returns every poll cycle even when idle) — an absolute liveness stamp the gateway
|
||||||
|
// surfaces as a gauge, so a silently stalled bot (no errors, no traffic) is still caught.
|
||||||
|
type Health struct {
|
||||||
|
state protoimpl.MessageState `protogen:"open.v1"`
|
||||||
|
ConnectFailures uint64 `protobuf:"varint,1,opt,name=connect_failures,json=connectFailures,proto3" json:"connect_failures,omitempty"` // getUpdates long-poll failures (transport / 5xx) since the last report
|
||||||
|
ApiErrors uint64 `protobuf:"varint,2,opt,name=api_errors,json=apiErrors,proto3" json:"api_errors,omitempty"` // other Bot API failures (transport / 5xx) since the last report
|
||||||
|
RateLimited uint64 `protobuf:"varint,3,opt,name=rate_limited,json=rateLimited,proto3" json:"rate_limited,omitempty"` // Bot API 429 responses since the last report (should stay ~0)
|
||||||
|
LastOkUnix int64 `protobuf:"varint,4,opt,name=last_ok_unix,json=lastOkUnix,proto3" json:"last_ok_unix,omitempty"` // unix seconds of the last successful Bot API response (0 = none yet)
|
||||||
|
unknownFields protoimpl.UnknownFields
|
||||||
|
sizeCache protoimpl.SizeCache
|
||||||
|
}
|
||||||
|
|
||||||
|
func (x *Health) Reset() {
|
||||||
|
*x = Health{}
|
||||||
|
mi := &file_botlink_v1_botlink_proto_msgTypes[1]
|
||||||
|
ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x))
|
||||||
|
ms.StoreMessageInfo(mi)
|
||||||
|
}
|
||||||
|
|
||||||
|
func (x *Health) String() string {
|
||||||
|
return protoimpl.X.MessageStringOf(x)
|
||||||
|
}
|
||||||
|
|
||||||
|
func (*Health) ProtoMessage() {}
|
||||||
|
|
||||||
|
func (x *Health) ProtoReflect() protoreflect.Message {
|
||||||
|
mi := &file_botlink_v1_botlink_proto_msgTypes[1]
|
||||||
|
if x != nil {
|
||||||
|
ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x))
|
||||||
|
if ms.LoadMessageInfo() == nil {
|
||||||
|
ms.StoreMessageInfo(mi)
|
||||||
|
}
|
||||||
|
return ms
|
||||||
|
}
|
||||||
|
return mi.MessageOf(x)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Deprecated: Use Health.ProtoReflect.Descriptor instead.
|
||||||
|
func (*Health) Descriptor() ([]byte, []int) {
|
||||||
|
return file_botlink_v1_botlink_proto_rawDescGZIP(), []int{1}
|
||||||
|
}
|
||||||
|
|
||||||
|
func (x *Health) GetConnectFailures() uint64 {
|
||||||
|
if x != nil {
|
||||||
|
return x.ConnectFailures
|
||||||
|
}
|
||||||
|
return 0
|
||||||
|
}
|
||||||
|
|
||||||
|
func (x *Health) GetApiErrors() uint64 {
|
||||||
|
if x != nil {
|
||||||
|
return x.ApiErrors
|
||||||
|
}
|
||||||
|
return 0
|
||||||
|
}
|
||||||
|
|
||||||
|
func (x *Health) GetRateLimited() uint64 {
|
||||||
|
if x != nil {
|
||||||
|
return x.RateLimited
|
||||||
|
}
|
||||||
|
return 0
|
||||||
|
}
|
||||||
|
|
||||||
|
func (x *Health) GetLastOkUnix() int64 {
|
||||||
|
if x != nil {
|
||||||
|
return x.LastOkUnix
|
||||||
|
}
|
||||||
|
return 0
|
||||||
|
}
|
||||||
|
|
||||||
// ToBot is a message the gateway sends to the bot. Only Command is carried today.
|
// ToBot is a message the gateway sends to the bot. Only Command is carried today.
|
||||||
type ToBot struct {
|
type ToBot struct {
|
||||||
state protoimpl.MessageState `protogen:"open.v1"`
|
state protoimpl.MessageState `protogen:"open.v1"`
|
||||||
@@ -123,7 +215,7 @@ type ToBot struct {
|
|||||||
|
|
||||||
func (x *ToBot) Reset() {
|
func (x *ToBot) Reset() {
|
||||||
*x = ToBot{}
|
*x = ToBot{}
|
||||||
mi := &file_botlink_v1_botlink_proto_msgTypes[1]
|
mi := &file_botlink_v1_botlink_proto_msgTypes[2]
|
||||||
ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x))
|
ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x))
|
||||||
ms.StoreMessageInfo(mi)
|
ms.StoreMessageInfo(mi)
|
||||||
}
|
}
|
||||||
@@ -135,7 +227,7 @@ func (x *ToBot) String() string {
|
|||||||
func (*ToBot) ProtoMessage() {}
|
func (*ToBot) ProtoMessage() {}
|
||||||
|
|
||||||
func (x *ToBot) ProtoReflect() protoreflect.Message {
|
func (x *ToBot) ProtoReflect() protoreflect.Message {
|
||||||
mi := &file_botlink_v1_botlink_proto_msgTypes[1]
|
mi := &file_botlink_v1_botlink_proto_msgTypes[2]
|
||||||
if x != nil {
|
if x != nil {
|
||||||
ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x))
|
ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x))
|
||||||
if ms.LoadMessageInfo() == nil {
|
if ms.LoadMessageInfo() == nil {
|
||||||
@@ -148,7 +240,7 @@ func (x *ToBot) ProtoReflect() protoreflect.Message {
|
|||||||
|
|
||||||
// Deprecated: Use ToBot.ProtoReflect.Descriptor instead.
|
// Deprecated: Use ToBot.ProtoReflect.Descriptor instead.
|
||||||
func (*ToBot) Descriptor() ([]byte, []int) {
|
func (*ToBot) Descriptor() ([]byte, []int) {
|
||||||
return file_botlink_v1_botlink_proto_rawDescGZIP(), []int{1}
|
return file_botlink_v1_botlink_proto_rawDescGZIP(), []int{2}
|
||||||
}
|
}
|
||||||
|
|
||||||
func (x *ToBot) GetCommand() *Command {
|
func (x *ToBot) GetCommand() *Command {
|
||||||
@@ -171,7 +263,7 @@ type Hello struct {
|
|||||||
|
|
||||||
func (x *Hello) Reset() {
|
func (x *Hello) Reset() {
|
||||||
*x = Hello{}
|
*x = Hello{}
|
||||||
mi := &file_botlink_v1_botlink_proto_msgTypes[2]
|
mi := &file_botlink_v1_botlink_proto_msgTypes[3]
|
||||||
ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x))
|
ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x))
|
||||||
ms.StoreMessageInfo(mi)
|
ms.StoreMessageInfo(mi)
|
||||||
}
|
}
|
||||||
@@ -183,7 +275,7 @@ func (x *Hello) String() string {
|
|||||||
func (*Hello) ProtoMessage() {}
|
func (*Hello) ProtoMessage() {}
|
||||||
|
|
||||||
func (x *Hello) ProtoReflect() protoreflect.Message {
|
func (x *Hello) ProtoReflect() protoreflect.Message {
|
||||||
mi := &file_botlink_v1_botlink_proto_msgTypes[2]
|
mi := &file_botlink_v1_botlink_proto_msgTypes[3]
|
||||||
if x != nil {
|
if x != nil {
|
||||||
ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x))
|
ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x))
|
||||||
if ms.LoadMessageInfo() == nil {
|
if ms.LoadMessageInfo() == nil {
|
||||||
@@ -196,7 +288,7 @@ func (x *Hello) ProtoReflect() protoreflect.Message {
|
|||||||
|
|
||||||
// Deprecated: Use Hello.ProtoReflect.Descriptor instead.
|
// Deprecated: Use Hello.ProtoReflect.Descriptor instead.
|
||||||
func (*Hello) Descriptor() ([]byte, []int) {
|
func (*Hello) Descriptor() ([]byte, []int) {
|
||||||
return file_botlink_v1_botlink_proto_rawDescGZIP(), []int{2}
|
return file_botlink_v1_botlink_proto_rawDescGZIP(), []int{3}
|
||||||
}
|
}
|
||||||
|
|
||||||
func (x *Hello) GetInstanceId() string {
|
func (x *Hello) GetInstanceId() string {
|
||||||
@@ -233,7 +325,7 @@ type Command struct {
|
|||||||
|
|
||||||
func (x *Command) Reset() {
|
func (x *Command) Reset() {
|
||||||
*x = Command{}
|
*x = Command{}
|
||||||
mi := &file_botlink_v1_botlink_proto_msgTypes[3]
|
mi := &file_botlink_v1_botlink_proto_msgTypes[4]
|
||||||
ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x))
|
ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x))
|
||||||
ms.StoreMessageInfo(mi)
|
ms.StoreMessageInfo(mi)
|
||||||
}
|
}
|
||||||
@@ -245,7 +337,7 @@ func (x *Command) String() string {
|
|||||||
func (*Command) ProtoMessage() {}
|
func (*Command) ProtoMessage() {}
|
||||||
|
|
||||||
func (x *Command) ProtoReflect() protoreflect.Message {
|
func (x *Command) ProtoReflect() protoreflect.Message {
|
||||||
mi := &file_botlink_v1_botlink_proto_msgTypes[3]
|
mi := &file_botlink_v1_botlink_proto_msgTypes[4]
|
||||||
if x != nil {
|
if x != nil {
|
||||||
ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x))
|
ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x))
|
||||||
if ms.LoadMessageInfo() == nil {
|
if ms.LoadMessageInfo() == nil {
|
||||||
@@ -258,7 +350,7 @@ func (x *Command) ProtoReflect() protoreflect.Message {
|
|||||||
|
|
||||||
// Deprecated: Use Command.ProtoReflect.Descriptor instead.
|
// Deprecated: Use Command.ProtoReflect.Descriptor instead.
|
||||||
func (*Command) Descriptor() ([]byte, []int) {
|
func (*Command) Descriptor() ([]byte, []int) {
|
||||||
return file_botlink_v1_botlink_proto_rawDescGZIP(), []int{3}
|
return file_botlink_v1_botlink_proto_rawDescGZIP(), []int{4}
|
||||||
}
|
}
|
||||||
|
|
||||||
func (x *Command) GetCommandId() string {
|
func (x *Command) GetCommandId() string {
|
||||||
@@ -372,7 +464,7 @@ type Ack struct {
|
|||||||
|
|
||||||
func (x *Ack) Reset() {
|
func (x *Ack) Reset() {
|
||||||
*x = Ack{}
|
*x = Ack{}
|
||||||
mi := &file_botlink_v1_botlink_proto_msgTypes[4]
|
mi := &file_botlink_v1_botlink_proto_msgTypes[5]
|
||||||
ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x))
|
ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x))
|
||||||
ms.StoreMessageInfo(mi)
|
ms.StoreMessageInfo(mi)
|
||||||
}
|
}
|
||||||
@@ -384,7 +476,7 @@ func (x *Ack) String() string {
|
|||||||
func (*Ack) ProtoMessage() {}
|
func (*Ack) ProtoMessage() {}
|
||||||
|
|
||||||
func (x *Ack) ProtoReflect() protoreflect.Message {
|
func (x *Ack) ProtoReflect() protoreflect.Message {
|
||||||
mi := &file_botlink_v1_botlink_proto_msgTypes[4]
|
mi := &file_botlink_v1_botlink_proto_msgTypes[5]
|
||||||
if x != nil {
|
if x != nil {
|
||||||
ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x))
|
ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x))
|
||||||
if ms.LoadMessageInfo() == nil {
|
if ms.LoadMessageInfo() == nil {
|
||||||
@@ -397,7 +489,7 @@ func (x *Ack) ProtoReflect() protoreflect.Message {
|
|||||||
|
|
||||||
// Deprecated: Use Ack.ProtoReflect.Descriptor instead.
|
// Deprecated: Use Ack.ProtoReflect.Descriptor instead.
|
||||||
func (*Ack) Descriptor() ([]byte, []int) {
|
func (*Ack) Descriptor() ([]byte, []int) {
|
||||||
return file_botlink_v1_botlink_proto_rawDescGZIP(), []int{4}
|
return file_botlink_v1_botlink_proto_rawDescGZIP(), []int{5}
|
||||||
}
|
}
|
||||||
|
|
||||||
func (x *Ack) GetCommandId() string {
|
func (x *Ack) GetCommandId() string {
|
||||||
@@ -445,7 +537,7 @@ type ChatGateCommand struct {
|
|||||||
|
|
||||||
func (x *ChatGateCommand) Reset() {
|
func (x *ChatGateCommand) Reset() {
|
||||||
*x = ChatGateCommand{}
|
*x = ChatGateCommand{}
|
||||||
mi := &file_botlink_v1_botlink_proto_msgTypes[5]
|
mi := &file_botlink_v1_botlink_proto_msgTypes[6]
|
||||||
ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x))
|
ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x))
|
||||||
ms.StoreMessageInfo(mi)
|
ms.StoreMessageInfo(mi)
|
||||||
}
|
}
|
||||||
@@ -457,7 +549,7 @@ func (x *ChatGateCommand) String() string {
|
|||||||
func (*ChatGateCommand) ProtoMessage() {}
|
func (*ChatGateCommand) ProtoMessage() {}
|
||||||
|
|
||||||
func (x *ChatGateCommand) ProtoReflect() protoreflect.Message {
|
func (x *ChatGateCommand) ProtoReflect() protoreflect.Message {
|
||||||
mi := &file_botlink_v1_botlink_proto_msgTypes[5]
|
mi := &file_botlink_v1_botlink_proto_msgTypes[6]
|
||||||
if x != nil {
|
if x != nil {
|
||||||
ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x))
|
ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x))
|
||||||
if ms.LoadMessageInfo() == nil {
|
if ms.LoadMessageInfo() == nil {
|
||||||
@@ -470,7 +562,7 @@ func (x *ChatGateCommand) ProtoReflect() protoreflect.Message {
|
|||||||
|
|
||||||
// Deprecated: Use ChatGateCommand.ProtoReflect.Descriptor instead.
|
// Deprecated: Use ChatGateCommand.ProtoReflect.Descriptor instead.
|
||||||
func (*ChatGateCommand) Descriptor() ([]byte, []int) {
|
func (*ChatGateCommand) Descriptor() ([]byte, []int) {
|
||||||
return file_botlink_v1_botlink_proto_rawDescGZIP(), []int{5}
|
return file_botlink_v1_botlink_proto_rawDescGZIP(), []int{6}
|
||||||
}
|
}
|
||||||
|
|
||||||
func (x *ChatGateCommand) GetExternalId() string {
|
func (x *ChatGateCommand) GetExternalId() string {
|
||||||
@@ -498,7 +590,7 @@ type ChatEligibilityRequest struct {
|
|||||||
|
|
||||||
func (x *ChatEligibilityRequest) Reset() {
|
func (x *ChatEligibilityRequest) Reset() {
|
||||||
*x = ChatEligibilityRequest{}
|
*x = ChatEligibilityRequest{}
|
||||||
mi := &file_botlink_v1_botlink_proto_msgTypes[6]
|
mi := &file_botlink_v1_botlink_proto_msgTypes[7]
|
||||||
ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x))
|
ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x))
|
||||||
ms.StoreMessageInfo(mi)
|
ms.StoreMessageInfo(mi)
|
||||||
}
|
}
|
||||||
@@ -510,7 +602,7 @@ func (x *ChatEligibilityRequest) String() string {
|
|||||||
func (*ChatEligibilityRequest) ProtoMessage() {}
|
func (*ChatEligibilityRequest) ProtoMessage() {}
|
||||||
|
|
||||||
func (x *ChatEligibilityRequest) ProtoReflect() protoreflect.Message {
|
func (x *ChatEligibilityRequest) ProtoReflect() protoreflect.Message {
|
||||||
mi := &file_botlink_v1_botlink_proto_msgTypes[6]
|
mi := &file_botlink_v1_botlink_proto_msgTypes[7]
|
||||||
if x != nil {
|
if x != nil {
|
||||||
ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x))
|
ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x))
|
||||||
if ms.LoadMessageInfo() == nil {
|
if ms.LoadMessageInfo() == nil {
|
||||||
@@ -523,7 +615,7 @@ func (x *ChatEligibilityRequest) ProtoReflect() protoreflect.Message {
|
|||||||
|
|
||||||
// Deprecated: Use ChatEligibilityRequest.ProtoReflect.Descriptor instead.
|
// Deprecated: Use ChatEligibilityRequest.ProtoReflect.Descriptor instead.
|
||||||
func (*ChatEligibilityRequest) Descriptor() ([]byte, []int) {
|
func (*ChatEligibilityRequest) Descriptor() ([]byte, []int) {
|
||||||
return file_botlink_v1_botlink_proto_rawDescGZIP(), []int{6}
|
return file_botlink_v1_botlink_proto_rawDescGZIP(), []int{7}
|
||||||
}
|
}
|
||||||
|
|
||||||
func (x *ChatEligibilityRequest) GetExternalId() string {
|
func (x *ChatEligibilityRequest) GetExternalId() string {
|
||||||
@@ -546,7 +638,7 @@ type ChatEligibilityResponse struct {
|
|||||||
|
|
||||||
func (x *ChatEligibilityResponse) Reset() {
|
func (x *ChatEligibilityResponse) Reset() {
|
||||||
*x = ChatEligibilityResponse{}
|
*x = ChatEligibilityResponse{}
|
||||||
mi := &file_botlink_v1_botlink_proto_msgTypes[7]
|
mi := &file_botlink_v1_botlink_proto_msgTypes[8]
|
||||||
ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x))
|
ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x))
|
||||||
ms.StoreMessageInfo(mi)
|
ms.StoreMessageInfo(mi)
|
||||||
}
|
}
|
||||||
@@ -558,7 +650,7 @@ func (x *ChatEligibilityResponse) String() string {
|
|||||||
func (*ChatEligibilityResponse) ProtoMessage() {}
|
func (*ChatEligibilityResponse) ProtoMessage() {}
|
||||||
|
|
||||||
func (x *ChatEligibilityResponse) ProtoReflect() protoreflect.Message {
|
func (x *ChatEligibilityResponse) ProtoReflect() protoreflect.Message {
|
||||||
mi := &file_botlink_v1_botlink_proto_msgTypes[7]
|
mi := &file_botlink_v1_botlink_proto_msgTypes[8]
|
||||||
if x != nil {
|
if x != nil {
|
||||||
ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x))
|
ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x))
|
||||||
if ms.LoadMessageInfo() == nil {
|
if ms.LoadMessageInfo() == nil {
|
||||||
@@ -571,7 +663,7 @@ func (x *ChatEligibilityResponse) ProtoReflect() protoreflect.Message {
|
|||||||
|
|
||||||
// Deprecated: Use ChatEligibilityResponse.ProtoReflect.Descriptor instead.
|
// Deprecated: Use ChatEligibilityResponse.ProtoReflect.Descriptor instead.
|
||||||
func (*ChatEligibilityResponse) Descriptor() ([]byte, []int) {
|
func (*ChatEligibilityResponse) Descriptor() ([]byte, []int) {
|
||||||
return file_botlink_v1_botlink_proto_rawDescGZIP(), []int{7}
|
return file_botlink_v1_botlink_proto_rawDescGZIP(), []int{8}
|
||||||
}
|
}
|
||||||
|
|
||||||
func (x *ChatEligibilityResponse) GetRegistered() bool {
|
func (x *ChatEligibilityResponse) GetRegistered() bool {
|
||||||
@@ -605,7 +697,7 @@ type CreateInvoiceCommand struct {
|
|||||||
|
|
||||||
func (x *CreateInvoiceCommand) Reset() {
|
func (x *CreateInvoiceCommand) Reset() {
|
||||||
*x = CreateInvoiceCommand{}
|
*x = CreateInvoiceCommand{}
|
||||||
mi := &file_botlink_v1_botlink_proto_msgTypes[8]
|
mi := &file_botlink_v1_botlink_proto_msgTypes[9]
|
||||||
ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x))
|
ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x))
|
||||||
ms.StoreMessageInfo(mi)
|
ms.StoreMessageInfo(mi)
|
||||||
}
|
}
|
||||||
@@ -617,7 +709,7 @@ func (x *CreateInvoiceCommand) String() string {
|
|||||||
func (*CreateInvoiceCommand) ProtoMessage() {}
|
func (*CreateInvoiceCommand) ProtoMessage() {}
|
||||||
|
|
||||||
func (x *CreateInvoiceCommand) ProtoReflect() protoreflect.Message {
|
func (x *CreateInvoiceCommand) ProtoReflect() protoreflect.Message {
|
||||||
mi := &file_botlink_v1_botlink_proto_msgTypes[8]
|
mi := &file_botlink_v1_botlink_proto_msgTypes[9]
|
||||||
if x != nil {
|
if x != nil {
|
||||||
ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x))
|
ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x))
|
||||||
if ms.LoadMessageInfo() == nil {
|
if ms.LoadMessageInfo() == nil {
|
||||||
@@ -630,7 +722,7 @@ func (x *CreateInvoiceCommand) ProtoReflect() protoreflect.Message {
|
|||||||
|
|
||||||
// Deprecated: Use CreateInvoiceCommand.ProtoReflect.Descriptor instead.
|
// Deprecated: Use CreateInvoiceCommand.ProtoReflect.Descriptor instead.
|
||||||
func (*CreateInvoiceCommand) Descriptor() ([]byte, []int) {
|
func (*CreateInvoiceCommand) Descriptor() ([]byte, []int) {
|
||||||
return file_botlink_v1_botlink_proto_rawDescGZIP(), []int{8}
|
return file_botlink_v1_botlink_proto_rawDescGZIP(), []int{9}
|
||||||
}
|
}
|
||||||
|
|
||||||
func (x *CreateInvoiceCommand) GetTitle() string {
|
func (x *CreateInvoiceCommand) GetTitle() string {
|
||||||
@@ -674,7 +766,7 @@ type PreCheckoutRequest struct {
|
|||||||
|
|
||||||
func (x *PreCheckoutRequest) Reset() {
|
func (x *PreCheckoutRequest) Reset() {
|
||||||
*x = PreCheckoutRequest{}
|
*x = PreCheckoutRequest{}
|
||||||
mi := &file_botlink_v1_botlink_proto_msgTypes[9]
|
mi := &file_botlink_v1_botlink_proto_msgTypes[10]
|
||||||
ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x))
|
ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x))
|
||||||
ms.StoreMessageInfo(mi)
|
ms.StoreMessageInfo(mi)
|
||||||
}
|
}
|
||||||
@@ -686,7 +778,7 @@ func (x *PreCheckoutRequest) String() string {
|
|||||||
func (*PreCheckoutRequest) ProtoMessage() {}
|
func (*PreCheckoutRequest) ProtoMessage() {}
|
||||||
|
|
||||||
func (x *PreCheckoutRequest) ProtoReflect() protoreflect.Message {
|
func (x *PreCheckoutRequest) ProtoReflect() protoreflect.Message {
|
||||||
mi := &file_botlink_v1_botlink_proto_msgTypes[9]
|
mi := &file_botlink_v1_botlink_proto_msgTypes[10]
|
||||||
if x != nil {
|
if x != nil {
|
||||||
ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x))
|
ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x))
|
||||||
if ms.LoadMessageInfo() == nil {
|
if ms.LoadMessageInfo() == nil {
|
||||||
@@ -699,7 +791,7 @@ func (x *PreCheckoutRequest) ProtoReflect() protoreflect.Message {
|
|||||||
|
|
||||||
// Deprecated: Use PreCheckoutRequest.ProtoReflect.Descriptor instead.
|
// Deprecated: Use PreCheckoutRequest.ProtoReflect.Descriptor instead.
|
||||||
func (*PreCheckoutRequest) Descriptor() ([]byte, []int) {
|
func (*PreCheckoutRequest) Descriptor() ([]byte, []int) {
|
||||||
return file_botlink_v1_botlink_proto_rawDescGZIP(), []int{9}
|
return file_botlink_v1_botlink_proto_rawDescGZIP(), []int{10}
|
||||||
}
|
}
|
||||||
|
|
||||||
func (x *PreCheckoutRequest) GetOrderId() string {
|
func (x *PreCheckoutRequest) GetOrderId() string {
|
||||||
@@ -735,7 +827,7 @@ type PreCheckoutResponse struct {
|
|||||||
|
|
||||||
func (x *PreCheckoutResponse) Reset() {
|
func (x *PreCheckoutResponse) Reset() {
|
||||||
*x = PreCheckoutResponse{}
|
*x = PreCheckoutResponse{}
|
||||||
mi := &file_botlink_v1_botlink_proto_msgTypes[10]
|
mi := &file_botlink_v1_botlink_proto_msgTypes[11]
|
||||||
ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x))
|
ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x))
|
||||||
ms.StoreMessageInfo(mi)
|
ms.StoreMessageInfo(mi)
|
||||||
}
|
}
|
||||||
@@ -747,7 +839,7 @@ func (x *PreCheckoutResponse) String() string {
|
|||||||
func (*PreCheckoutResponse) ProtoMessage() {}
|
func (*PreCheckoutResponse) ProtoMessage() {}
|
||||||
|
|
||||||
func (x *PreCheckoutResponse) ProtoReflect() protoreflect.Message {
|
func (x *PreCheckoutResponse) ProtoReflect() protoreflect.Message {
|
||||||
mi := &file_botlink_v1_botlink_proto_msgTypes[10]
|
mi := &file_botlink_v1_botlink_proto_msgTypes[11]
|
||||||
if x != nil {
|
if x != nil {
|
||||||
ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x))
|
ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x))
|
||||||
if ms.LoadMessageInfo() == nil {
|
if ms.LoadMessageInfo() == nil {
|
||||||
@@ -760,7 +852,7 @@ func (x *PreCheckoutResponse) ProtoReflect() protoreflect.Message {
|
|||||||
|
|
||||||
// Deprecated: Use PreCheckoutResponse.ProtoReflect.Descriptor instead.
|
// Deprecated: Use PreCheckoutResponse.ProtoReflect.Descriptor instead.
|
||||||
func (*PreCheckoutResponse) Descriptor() ([]byte, []int) {
|
func (*PreCheckoutResponse) Descriptor() ([]byte, []int) {
|
||||||
return file_botlink_v1_botlink_proto_rawDescGZIP(), []int{10}
|
return file_botlink_v1_botlink_proto_rawDescGZIP(), []int{11}
|
||||||
}
|
}
|
||||||
|
|
||||||
func (x *PreCheckoutResponse) GetOk() bool {
|
func (x *PreCheckoutResponse) GetOk() bool {
|
||||||
@@ -792,7 +884,7 @@ type ForwardPaymentRequest struct {
|
|||||||
|
|
||||||
func (x *ForwardPaymentRequest) Reset() {
|
func (x *ForwardPaymentRequest) Reset() {
|
||||||
*x = ForwardPaymentRequest{}
|
*x = ForwardPaymentRequest{}
|
||||||
mi := &file_botlink_v1_botlink_proto_msgTypes[11]
|
mi := &file_botlink_v1_botlink_proto_msgTypes[12]
|
||||||
ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x))
|
ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x))
|
||||||
ms.StoreMessageInfo(mi)
|
ms.StoreMessageInfo(mi)
|
||||||
}
|
}
|
||||||
@@ -804,7 +896,7 @@ func (x *ForwardPaymentRequest) String() string {
|
|||||||
func (*ForwardPaymentRequest) ProtoMessage() {}
|
func (*ForwardPaymentRequest) ProtoMessage() {}
|
||||||
|
|
||||||
func (x *ForwardPaymentRequest) ProtoReflect() protoreflect.Message {
|
func (x *ForwardPaymentRequest) ProtoReflect() protoreflect.Message {
|
||||||
mi := &file_botlink_v1_botlink_proto_msgTypes[11]
|
mi := &file_botlink_v1_botlink_proto_msgTypes[12]
|
||||||
if x != nil {
|
if x != nil {
|
||||||
ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x))
|
ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x))
|
||||||
if ms.LoadMessageInfo() == nil {
|
if ms.LoadMessageInfo() == nil {
|
||||||
@@ -817,7 +909,7 @@ func (x *ForwardPaymentRequest) ProtoReflect() protoreflect.Message {
|
|||||||
|
|
||||||
// Deprecated: Use ForwardPaymentRequest.ProtoReflect.Descriptor instead.
|
// Deprecated: Use ForwardPaymentRequest.ProtoReflect.Descriptor instead.
|
||||||
func (*ForwardPaymentRequest) Descriptor() ([]byte, []int) {
|
func (*ForwardPaymentRequest) Descriptor() ([]byte, []int) {
|
||||||
return file_botlink_v1_botlink_proto_rawDescGZIP(), []int{11}
|
return file_botlink_v1_botlink_proto_rawDescGZIP(), []int{12}
|
||||||
}
|
}
|
||||||
|
|
||||||
func (x *ForwardPaymentRequest) GetOrderId() string {
|
func (x *ForwardPaymentRequest) GetOrderId() string {
|
||||||
@@ -861,7 +953,7 @@ type ForwardPaymentResponse struct {
|
|||||||
|
|
||||||
func (x *ForwardPaymentResponse) Reset() {
|
func (x *ForwardPaymentResponse) Reset() {
|
||||||
*x = ForwardPaymentResponse{}
|
*x = ForwardPaymentResponse{}
|
||||||
mi := &file_botlink_v1_botlink_proto_msgTypes[12]
|
mi := &file_botlink_v1_botlink_proto_msgTypes[13]
|
||||||
ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x))
|
ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x))
|
||||||
ms.StoreMessageInfo(mi)
|
ms.StoreMessageInfo(mi)
|
||||||
}
|
}
|
||||||
@@ -873,7 +965,7 @@ func (x *ForwardPaymentResponse) String() string {
|
|||||||
func (*ForwardPaymentResponse) ProtoMessage() {}
|
func (*ForwardPaymentResponse) ProtoMessage() {}
|
||||||
|
|
||||||
func (x *ForwardPaymentResponse) ProtoReflect() protoreflect.Message {
|
func (x *ForwardPaymentResponse) ProtoReflect() protoreflect.Message {
|
||||||
mi := &file_botlink_v1_botlink_proto_msgTypes[12]
|
mi := &file_botlink_v1_botlink_proto_msgTypes[13]
|
||||||
if x != nil {
|
if x != nil {
|
||||||
ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x))
|
ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x))
|
||||||
if ms.LoadMessageInfo() == nil {
|
if ms.LoadMessageInfo() == nil {
|
||||||
@@ -886,7 +978,7 @@ func (x *ForwardPaymentResponse) ProtoReflect() protoreflect.Message {
|
|||||||
|
|
||||||
// Deprecated: Use ForwardPaymentResponse.ProtoReflect.Descriptor instead.
|
// Deprecated: Use ForwardPaymentResponse.ProtoReflect.Descriptor instead.
|
||||||
func (*ForwardPaymentResponse) Descriptor() ([]byte, []int) {
|
func (*ForwardPaymentResponse) Descriptor() ([]byte, []int) {
|
||||||
return file_botlink_v1_botlink_proto_rawDescGZIP(), []int{12}
|
return file_botlink_v1_botlink_proto_rawDescGZIP(), []int{13}
|
||||||
}
|
}
|
||||||
|
|
||||||
func (x *ForwardPaymentResponse) GetCredited() bool {
|
func (x *ForwardPaymentResponse) GetCredited() bool {
|
||||||
@@ -900,11 +992,19 @@ var File_botlink_v1_botlink_proto protoreflect.FileDescriptor
|
|||||||
|
|
||||||
const file_botlink_v1_botlink_proto_rawDesc = "" +
|
const file_botlink_v1_botlink_proto_rawDesc = "" +
|
||||||
"\n" +
|
"\n" +
|
||||||
"\x18botlink/v1/botlink.proto\x12\x13scrabble.botlink.v1\x1a\x1atelegram/v1/telegram.proto\"r\n" +
|
"\x18botlink/v1/botlink.proto\x12\x13scrabble.botlink.v1\x1a\x1atelegram/v1/telegram.proto\"\xa9\x01\n" +
|
||||||
"\aFromBot\x122\n" +
|
"\aFromBot\x122\n" +
|
||||||
"\x05hello\x18\x01 \x01(\v2\x1a.scrabble.botlink.v1.HelloH\x00R\x05hello\x12,\n" +
|
"\x05hello\x18\x01 \x01(\v2\x1a.scrabble.botlink.v1.HelloH\x00R\x05hello\x12,\n" +
|
||||||
"\x03ack\x18\x02 \x01(\v2\x18.scrabble.botlink.v1.AckH\x00R\x03ackB\x05\n" +
|
"\x03ack\x18\x02 \x01(\v2\x18.scrabble.botlink.v1.AckH\x00R\x03ack\x125\n" +
|
||||||
"\x03msg\"?\n" +
|
"\x06health\x18\x03 \x01(\v2\x1b.scrabble.botlink.v1.HealthH\x00R\x06healthB\x05\n" +
|
||||||
|
"\x03msg\"\x97\x01\n" +
|
||||||
|
"\x06Health\x12)\n" +
|
||||||
|
"\x10connect_failures\x18\x01 \x01(\x04R\x0fconnectFailures\x12\x1d\n" +
|
||||||
|
"\n" +
|
||||||
|
"api_errors\x18\x02 \x01(\x04R\tapiErrors\x12!\n" +
|
||||||
|
"\frate_limited\x18\x03 \x01(\x04R\vrateLimited\x12 \n" +
|
||||||
|
"\flast_ok_unix\x18\x04 \x01(\x03R\n" +
|
||||||
|
"lastOkUnix\"?\n" +
|
||||||
"\x05ToBot\x126\n" +
|
"\x05ToBot\x126\n" +
|
||||||
"\acommand\x18\x01 \x01(\v2\x1c.scrabble.botlink.v1.CommandR\acommand\"K\n" +
|
"\acommand\x18\x01 \x01(\v2\x1c.scrabble.botlink.v1.CommandR\acommand\"K\n" +
|
||||||
"\x05Hello\x12\x1f\n" +
|
"\x05Hello\x12\x1f\n" +
|
||||||
@@ -976,47 +1076,49 @@ func file_botlink_v1_botlink_proto_rawDescGZIP() []byte {
|
|||||||
return file_botlink_v1_botlink_proto_rawDescData
|
return file_botlink_v1_botlink_proto_rawDescData
|
||||||
}
|
}
|
||||||
|
|
||||||
var file_botlink_v1_botlink_proto_msgTypes = make([]protoimpl.MessageInfo, 13)
|
var file_botlink_v1_botlink_proto_msgTypes = make([]protoimpl.MessageInfo, 14)
|
||||||
var file_botlink_v1_botlink_proto_goTypes = []any{
|
var file_botlink_v1_botlink_proto_goTypes = []any{
|
||||||
(*FromBot)(nil), // 0: scrabble.botlink.v1.FromBot
|
(*FromBot)(nil), // 0: scrabble.botlink.v1.FromBot
|
||||||
(*ToBot)(nil), // 1: scrabble.botlink.v1.ToBot
|
(*Health)(nil), // 1: scrabble.botlink.v1.Health
|
||||||
(*Hello)(nil), // 2: scrabble.botlink.v1.Hello
|
(*ToBot)(nil), // 2: scrabble.botlink.v1.ToBot
|
||||||
(*Command)(nil), // 3: scrabble.botlink.v1.Command
|
(*Hello)(nil), // 3: scrabble.botlink.v1.Hello
|
||||||
(*Ack)(nil), // 4: scrabble.botlink.v1.Ack
|
(*Command)(nil), // 4: scrabble.botlink.v1.Command
|
||||||
(*ChatGateCommand)(nil), // 5: scrabble.botlink.v1.ChatGateCommand
|
(*Ack)(nil), // 5: scrabble.botlink.v1.Ack
|
||||||
(*ChatEligibilityRequest)(nil), // 6: scrabble.botlink.v1.ChatEligibilityRequest
|
(*ChatGateCommand)(nil), // 6: scrabble.botlink.v1.ChatGateCommand
|
||||||
(*ChatEligibilityResponse)(nil), // 7: scrabble.botlink.v1.ChatEligibilityResponse
|
(*ChatEligibilityRequest)(nil), // 7: scrabble.botlink.v1.ChatEligibilityRequest
|
||||||
(*CreateInvoiceCommand)(nil), // 8: scrabble.botlink.v1.CreateInvoiceCommand
|
(*ChatEligibilityResponse)(nil), // 8: scrabble.botlink.v1.ChatEligibilityResponse
|
||||||
(*PreCheckoutRequest)(nil), // 9: scrabble.botlink.v1.PreCheckoutRequest
|
(*CreateInvoiceCommand)(nil), // 9: scrabble.botlink.v1.CreateInvoiceCommand
|
||||||
(*PreCheckoutResponse)(nil), // 10: scrabble.botlink.v1.PreCheckoutResponse
|
(*PreCheckoutRequest)(nil), // 10: scrabble.botlink.v1.PreCheckoutRequest
|
||||||
(*ForwardPaymentRequest)(nil), // 11: scrabble.botlink.v1.ForwardPaymentRequest
|
(*PreCheckoutResponse)(nil), // 11: scrabble.botlink.v1.PreCheckoutResponse
|
||||||
(*ForwardPaymentResponse)(nil), // 12: scrabble.botlink.v1.ForwardPaymentResponse
|
(*ForwardPaymentRequest)(nil), // 12: scrabble.botlink.v1.ForwardPaymentRequest
|
||||||
(*v1.NotifyRequest)(nil), // 13: scrabble.telegram.v1.NotifyRequest
|
(*ForwardPaymentResponse)(nil), // 13: scrabble.botlink.v1.ForwardPaymentResponse
|
||||||
(*v1.SendToUserRequest)(nil), // 14: scrabble.telegram.v1.SendToUserRequest
|
(*v1.NotifyRequest)(nil), // 14: scrabble.telegram.v1.NotifyRequest
|
||||||
(*v1.SendToGameChannelRequest)(nil), // 15: scrabble.telegram.v1.SendToGameChannelRequest
|
(*v1.SendToUserRequest)(nil), // 15: scrabble.telegram.v1.SendToUserRequest
|
||||||
|
(*v1.SendToGameChannelRequest)(nil), // 16: scrabble.telegram.v1.SendToGameChannelRequest
|
||||||
}
|
}
|
||||||
var file_botlink_v1_botlink_proto_depIdxs = []int32{
|
var file_botlink_v1_botlink_proto_depIdxs = []int32{
|
||||||
2, // 0: scrabble.botlink.v1.FromBot.hello:type_name -> scrabble.botlink.v1.Hello
|
3, // 0: scrabble.botlink.v1.FromBot.hello:type_name -> scrabble.botlink.v1.Hello
|
||||||
4, // 1: scrabble.botlink.v1.FromBot.ack:type_name -> scrabble.botlink.v1.Ack
|
5, // 1: scrabble.botlink.v1.FromBot.ack:type_name -> scrabble.botlink.v1.Ack
|
||||||
3, // 2: scrabble.botlink.v1.ToBot.command:type_name -> scrabble.botlink.v1.Command
|
1, // 2: scrabble.botlink.v1.FromBot.health:type_name -> scrabble.botlink.v1.Health
|
||||||
13, // 3: scrabble.botlink.v1.Command.notify:type_name -> scrabble.telegram.v1.NotifyRequest
|
4, // 3: scrabble.botlink.v1.ToBot.command:type_name -> scrabble.botlink.v1.Command
|
||||||
14, // 4: scrabble.botlink.v1.Command.send_to_user:type_name -> scrabble.telegram.v1.SendToUserRequest
|
14, // 4: scrabble.botlink.v1.Command.notify:type_name -> scrabble.telegram.v1.NotifyRequest
|
||||||
15, // 5: scrabble.botlink.v1.Command.send_to_channel:type_name -> scrabble.telegram.v1.SendToGameChannelRequest
|
15, // 5: scrabble.botlink.v1.Command.send_to_user:type_name -> scrabble.telegram.v1.SendToUserRequest
|
||||||
5, // 6: scrabble.botlink.v1.Command.chat_gate:type_name -> scrabble.botlink.v1.ChatGateCommand
|
16, // 6: scrabble.botlink.v1.Command.send_to_channel:type_name -> scrabble.telegram.v1.SendToGameChannelRequest
|
||||||
8, // 7: scrabble.botlink.v1.Command.create_invoice:type_name -> scrabble.botlink.v1.CreateInvoiceCommand
|
6, // 7: scrabble.botlink.v1.Command.chat_gate:type_name -> scrabble.botlink.v1.ChatGateCommand
|
||||||
0, // 8: scrabble.botlink.v1.BotLink.Link:input_type -> scrabble.botlink.v1.FromBot
|
9, // 8: scrabble.botlink.v1.Command.create_invoice:type_name -> scrabble.botlink.v1.CreateInvoiceCommand
|
||||||
6, // 9: scrabble.botlink.v1.BotLink.ResolveChatEligibility:input_type -> scrabble.botlink.v1.ChatEligibilityRequest
|
0, // 9: scrabble.botlink.v1.BotLink.Link:input_type -> scrabble.botlink.v1.FromBot
|
||||||
9, // 10: scrabble.botlink.v1.BotLink.ValidatePreCheckout:input_type -> scrabble.botlink.v1.PreCheckoutRequest
|
7, // 10: scrabble.botlink.v1.BotLink.ResolveChatEligibility:input_type -> scrabble.botlink.v1.ChatEligibilityRequest
|
||||||
11, // 11: scrabble.botlink.v1.BotLink.ForwardPayment:input_type -> scrabble.botlink.v1.ForwardPaymentRequest
|
10, // 11: scrabble.botlink.v1.BotLink.ValidatePreCheckout:input_type -> scrabble.botlink.v1.PreCheckoutRequest
|
||||||
1, // 12: scrabble.botlink.v1.BotLink.Link:output_type -> scrabble.botlink.v1.ToBot
|
12, // 12: scrabble.botlink.v1.BotLink.ForwardPayment:input_type -> scrabble.botlink.v1.ForwardPaymentRequest
|
||||||
7, // 13: scrabble.botlink.v1.BotLink.ResolveChatEligibility:output_type -> scrabble.botlink.v1.ChatEligibilityResponse
|
2, // 13: scrabble.botlink.v1.BotLink.Link:output_type -> scrabble.botlink.v1.ToBot
|
||||||
10, // 14: scrabble.botlink.v1.BotLink.ValidatePreCheckout:output_type -> scrabble.botlink.v1.PreCheckoutResponse
|
8, // 14: scrabble.botlink.v1.BotLink.ResolveChatEligibility:output_type -> scrabble.botlink.v1.ChatEligibilityResponse
|
||||||
12, // 15: scrabble.botlink.v1.BotLink.ForwardPayment:output_type -> scrabble.botlink.v1.ForwardPaymentResponse
|
11, // 15: scrabble.botlink.v1.BotLink.ValidatePreCheckout:output_type -> scrabble.botlink.v1.PreCheckoutResponse
|
||||||
12, // [12:16] is the sub-list for method output_type
|
13, // 16: scrabble.botlink.v1.BotLink.ForwardPayment:output_type -> scrabble.botlink.v1.ForwardPaymentResponse
|
||||||
8, // [8:12] is the sub-list for method input_type
|
13, // [13:17] is the sub-list for method output_type
|
||||||
8, // [8:8] is the sub-list for extension type_name
|
9, // [9:13] is the sub-list for method input_type
|
||||||
8, // [8:8] is the sub-list for extension extendee
|
9, // [9:9] is the sub-list for extension type_name
|
||||||
0, // [0:8] is the sub-list for field type_name
|
9, // [9:9] is the sub-list for extension extendee
|
||||||
|
0, // [0:9] is the sub-list for field type_name
|
||||||
}
|
}
|
||||||
|
|
||||||
func init() { file_botlink_v1_botlink_proto_init() }
|
func init() { file_botlink_v1_botlink_proto_init() }
|
||||||
@@ -1027,8 +1129,9 @@ func file_botlink_v1_botlink_proto_init() {
|
|||||||
file_botlink_v1_botlink_proto_msgTypes[0].OneofWrappers = []any{
|
file_botlink_v1_botlink_proto_msgTypes[0].OneofWrappers = []any{
|
||||||
(*FromBot_Hello)(nil),
|
(*FromBot_Hello)(nil),
|
||||||
(*FromBot_Ack)(nil),
|
(*FromBot_Ack)(nil),
|
||||||
|
(*FromBot_Health)(nil),
|
||||||
}
|
}
|
||||||
file_botlink_v1_botlink_proto_msgTypes[3].OneofWrappers = []any{
|
file_botlink_v1_botlink_proto_msgTypes[4].OneofWrappers = []any{
|
||||||
(*Command_Notify)(nil),
|
(*Command_Notify)(nil),
|
||||||
(*Command_SendToUser)(nil),
|
(*Command_SendToUser)(nil),
|
||||||
(*Command_SendToChannel)(nil),
|
(*Command_SendToChannel)(nil),
|
||||||
@@ -1041,7 +1144,7 @@ func file_botlink_v1_botlink_proto_init() {
|
|||||||
GoPackagePath: reflect.TypeOf(x{}).PkgPath(),
|
GoPackagePath: reflect.TypeOf(x{}).PkgPath(),
|
||||||
RawDescriptor: unsafe.Slice(unsafe.StringData(file_botlink_v1_botlink_proto_rawDesc), len(file_botlink_v1_botlink_proto_rawDesc)),
|
RawDescriptor: unsafe.Slice(unsafe.StringData(file_botlink_v1_botlink_proto_rawDesc), len(file_botlink_v1_botlink_proto_rawDesc)),
|
||||||
NumEnums: 0,
|
NumEnums: 0,
|
||||||
NumMessages: 13,
|
NumMessages: 14,
|
||||||
NumExtensions: 0,
|
NumExtensions: 0,
|
||||||
NumServices: 1,
|
NumServices: 1,
|
||||||
},
|
},
|
||||||
|
|||||||
@@ -48,14 +48,30 @@ service BotLink {
|
|||||||
}
|
}
|
||||||
|
|
||||||
// FromBot is a message the bot sends to the gateway: the opening Hello, then one
|
// FromBot is a message the bot sends to the gateway: the opening Hello, then one
|
||||||
// Ack per Command.
|
// Ack per Command and a periodic Health report.
|
||||||
message FromBot {
|
message FromBot {
|
||||||
oneof msg {
|
oneof msg {
|
||||||
Hello hello = 1;
|
Hello hello = 1;
|
||||||
Ack ack = 2;
|
Ack ack = 2;
|
||||||
|
Health health = 3;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Health is a periodic report the bot pushes up the stream so the gateway can expose the remote
|
||||||
|
// bot's Bot-API health as its own metrics — the bot exports no telemetry of its own (otelcol lives
|
||||||
|
// on the main host, unreachable from the bot), so the bot-link is its only channel out. The three
|
||||||
|
// counters are DELTAS since the previous Health: the gateway adds them to cumulative counters, so a
|
||||||
|
// report lost across a reconnect only undercounts, never corrupts. last_ok_unix is the wall-clock
|
||||||
|
// second of the bot's most recent successful Bot API response (any call, including the getUpdates
|
||||||
|
// long-poll that returns every poll cycle even when idle) — an absolute liveness stamp the gateway
|
||||||
|
// surfaces as a gauge, so a silently stalled bot (no errors, no traffic) is still caught.
|
||||||
|
message Health {
|
||||||
|
uint64 connect_failures = 1; // getUpdates long-poll failures (transport / 5xx) since the last report
|
||||||
|
uint64 api_errors = 2; // other Bot API failures (transport / 5xx) since the last report
|
||||||
|
uint64 rate_limited = 3; // Bot API 429 responses since the last report (should stay ~0)
|
||||||
|
int64 last_ok_unix = 4; // unix seconds of the last successful Bot API response (0 = none yet)
|
||||||
|
}
|
||||||
|
|
||||||
// ToBot is a message the gateway sends to the bot. Only Command is carried today.
|
// ToBot is a message the gateway sends to the bot. Only Command is carried today.
|
||||||
message ToBot {
|
message ToBot {
|
||||||
Command command = 1;
|
Command command = 1;
|
||||||
|
|||||||
+4
-2
@@ -49,6 +49,9 @@ type GameView struct {
|
|||||||
// UnreadMessages is a per-viewer flag: at least one unread entry is a real message (not just
|
// UnreadMessages is a per-viewer flag: at least one unread entry is a real message (not just
|
||||||
// a nudge), so the badge can be coloured apart from the nudge-only case.
|
// a nudge), so the badge can be coloured apart from the nudge-only case.
|
||||||
UnreadMessages bool
|
UnreadMessages bool
|
||||||
|
// Kind is the game's origin for the active-game limits: 0 unknown (a pre-existing game), 1 vs_ai,
|
||||||
|
// 2 random, 3 friends. The lobby counts active games per kind to lock a capped New-Game start.
|
||||||
|
Kind int
|
||||||
}
|
}
|
||||||
|
|
||||||
// TileRecord is one tile in a decoded MoveRecord (the concrete letter, "?" for a blank
|
// TileRecord is one tile in a decoded MoveRecord (the concrete letter, "?" for a blank
|
||||||
@@ -92,7 +95,6 @@ type StateView struct {
|
|||||||
Rack []int
|
Rack []int
|
||||||
BagLen int
|
BagLen int
|
||||||
HintsRemaining int
|
HintsRemaining int
|
||||||
WalletBalance int
|
|
||||||
HintUnlockLeftSeconds int
|
HintUnlockLeftSeconds int
|
||||||
Alphabet []AlphabetEntry
|
Alphabet []AlphabetEntry
|
||||||
}
|
}
|
||||||
@@ -170,6 +172,7 @@ func BuildGameView(b *flatbuffers.Builder, g GameView) flatbuffers.UOffsetT {
|
|||||||
fb.GameViewAddVsAi(b, g.VsAI)
|
fb.GameViewAddVsAi(b, g.VsAI)
|
||||||
fb.GameViewAddUnreadChat(b, g.UnreadChat)
|
fb.GameViewAddUnreadChat(b, g.UnreadChat)
|
||||||
fb.GameViewAddUnreadMessages(b, g.UnreadMessages)
|
fb.GameViewAddUnreadMessages(b, g.UnreadMessages)
|
||||||
|
fb.GameViewAddKind(b, int32(g.Kind))
|
||||||
return fb.GameViewEnd(b)
|
return fb.GameViewEnd(b)
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -256,7 +259,6 @@ func BuildStateView(b *flatbuffers.Builder, s StateView) flatbuffers.UOffsetT {
|
|||||||
fb.StateViewAddRack(b, rack)
|
fb.StateViewAddRack(b, rack)
|
||||||
fb.StateViewAddBagLen(b, int32(s.BagLen))
|
fb.StateViewAddBagLen(b, int32(s.BagLen))
|
||||||
fb.StateViewAddHintsRemaining(b, int32(s.HintsRemaining))
|
fb.StateViewAddHintsRemaining(b, int32(s.HintsRemaining))
|
||||||
fb.StateViewAddWalletBalance(b, int32(s.WalletBalance))
|
|
||||||
fb.StateViewAddHintUnlockLeftSeconds(b, int32(s.HintUnlockLeftSeconds))
|
fb.StateViewAddHintUnlockLeftSeconds(b, int32(s.HintUnlockLeftSeconds))
|
||||||
if hasAlphabet {
|
if hasAlphabet {
|
||||||
fb.StateViewAddAlphabet(b, alphabet)
|
fb.StateViewAddAlphabet(b, alphabet)
|
||||||
|
|||||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user