Compare commits

...

58 Commits

Author SHA1 Message Date
developer 516ffbe5f0 Merge pull request 'release: v1.16.0 — offer live pricing, bot health telemetry, edge blocklist' (#247) from development into master 2026-07-11 11:56:09 +00:00
developer 3ce460f72a Merge pull request 'feat(edge): community IP blocklist (Spamhaus DROP) at the gateway' (#246) from feature/edge-blocklist into development
CI / changes (push) Successful in 2s
CI / unit (push) Successful in 12s
CI / integration (push) Successful in 24s
CI / ui (push) Successful in 1m11s
CI / conformance (push) Successful in 10s
CI / gate (push) Successful in 0s
CI / deploy (push) Successful in 1m53s
CI / changes (pull_request) Successful in 2s
CI / unit (pull_request) Successful in 11s
CI / integration (pull_request) Successful in 25s
CI / ui (pull_request) Successful in 1m11s
CI / deploy (pull_request) Has been skipped
CI / conformance (pull_request) Successful in 10s
CI / gate (pull_request) Successful in 0s
2026-07-11 11:39:47 +00:00
Ilia Denisov 4ba9da6721 feat(edge): community IP blocklist (Spamhaus DROP) at the gateway
CI / changes (pull_request) Successful in 2s
CI / unit (pull_request) Successful in 10s
CI / integration (pull_request) Successful in 24s
CI / ui (pull_request) Successful in 1m11s
CI / conformance (pull_request) Successful in 10s
CI / gate (pull_request) Successful in 0s
CI / deploy (pull_request) Successful in 1m49s
Refuse a client whose IP is in a curated CIDR feed with 403 in the same
abuseGuard, before the fail2ban ban. Prod-only (keys by real client IP), off by
default.

- ratelimit.Blocklist: a sorted-range IPv4 matcher (binary search) with an
  allowlist checked first; ParseDROP reads the feed; ApplyRefresh keeps the
  last-good feed on a transient fetch failure and drops it fail-open once stale
  (better to under-block than block a legitimate client on a frozen feed). A
  separate static CIDR set, not the per-IP fail2ban store.
- gateway: a refresher goroutine re-fetches every few hours (bounded fetch + size
  cap); config GATEWAY_BLOCKLIST_{ENABLED,URL,ALLOW,REFRESH,MAX_STALENESS}.
  IPv6 is not matched (a v6 client is still covered by fail2ban / the honeypot).
- observability: gateway_blocklist_blocked_total + entries/age gauges; a Grafana
  alert warns before the feed is dropped; service-overview panels.
- deploy: compose env + write-prod-env.sh + prod-deploy/rollback wiring (opt-in
  via PROD_ vars).
- docs (ARCHITECTURE, deploy/README); unit tests (match, allowlist, IPv6 skip,
  parser, fault-tolerance) + a 403 abuse-guard integration test.
2026-07-11 13:33:28 +02:00
developer ad1cc361e9 Merge pull request 'feat(bot): Telegram bot Bot API health over the bot-link (metrics + alerts)' (#245) from feature/bot-health-telemetry into development
CI / changes (push) Successful in 1s
CI / unit (push) Successful in 11s
CI / integration (push) Successful in 23s
CI / ui (push) Successful in 1m10s
CI / conformance (push) Successful in 10s
CI / gate (push) Successful in 0s
CI / deploy (push) Successful in 1m55s
2026-07-11 10:58:01 +00:00
Ilia Denisov 2c2316fb5e chore(catalog): order the admin catalog list like the public offer
CI / changes (pull_request) Successful in 2s
CI / unit (pull_request) Successful in 11s
CI / integration (pull_request) Successful in 26s
CI / ui (pull_request) Successful in 1m11s
CI / conformance (pull_request) Successful in 9s
CI / gate (pull_request) Successful in 0s
CI / deploy (pull_request) Successful in 1m50s
Sales (chip packs) first, ascending by rouble price; then the chip-exchange
values, grouped and price-sorted the same way projectOfferPricing lists them, so
the /catalog console mirrors what a buyer sees. Internal cosmetics only — no
product behaviour change. The value-group order moves to a shared helper
(valueGroup) so the offer and the admin list cannot drift.
2026-07-11 12:53:37 +02:00
Ilia Denisov bb71e7b1c7 feat(bot): report Telegram bot Bot API health to the gateway over the bot-link
CI / changes (pull_request) Successful in 2s
CI / unit (pull_request) Successful in 12s
CI / integration (pull_request) Successful in 26s
CI / ui (pull_request) Successful in 1m11s
CI / conformance (pull_request) Successful in 10s
CI / gate (pull_request) Successful in 0s
CI / deploy (pull_request) Successful in 1m56s
The bot runs on its own host and exports no telemetry (otelcol is unreachable
from there), so it was a monitoring blind spot. Observe its Bot API health
centrally by wrapping the HTTP client — one place, no per-call-site
instrumentation — and relay it up the existing bot-link as a periodic Health
message the gateway turns into its own metrics.

- proto: add Health (delta connect / api / 429 counters + a last-ok stamp) to
  the FromBot oneof (additive, backward-compatible).
- bot: platform/telegram/internal/health wraps the Bot API HTTP client — it
  stamps liveness on any 2xx (so the getUpdates long-poll keeps it fresh even
  when idle), classifies transport/5xx failures (getUpdates vs other) and 429s,
  and honours a 429's Retry-After (bounded) so the bot backs off; a 4xx other
  than 429 is a normal per-request outcome and is not counted.
- bot-link client: flush the reporter as a Health message every 30s over a
  single-sender loop (a gRPC stream forbids concurrent Send).
- gateway: fold each report into bot_tg_errors_total{kind} and the
  bot_tg_last_ok_unix liveness gauge.
- grafana: alerts (bot disconnected, bot not reaching the Bot API, sustained
  429s) routed to the operator email — which does not go through the bot — plus
  a Telegram-bot dashboard.
- docs (ARCHITECTURE, compose comments); unit tests (observer classification,
  Retry-After, snapshot/commit, hub last-ok monotonicity).
2026-07-11 12:48:06 +02:00
developer 5c1f64c7d1 Merge pull request 'feat(offer): live catalog price list in the public offer (render sidecar)' (#244) from feature/offer-pricing-live into development
CI / changes (push) Successful in 2s
CI / unit (push) Successful in 12s
CI / integration (push) Successful in 19s
CI / ui (push) Successful in 1m11s
CI / conformance (push) Successful in 10s
CI / gate (push) Successful in 0s
CI / deploy (push) Successful in 1m48s
2026-07-11 09:51:48 +00:00
Ilia Denisov b3cf024e9e chore(offer): fix typo
CI / changes (pull_request) Successful in 2s
CI / unit (pull_request) Successful in 11s
CI / integration (pull_request) Successful in 19s
CI / ui (pull_request) Successful in 1m11s
CI / conformance (pull_request) Successful in 10s
CI / gate (pull_request) Successful in 0s
CI / deploy (pull_request) Successful in 1m54s
2026-07-11 11:45:45 +02:00
Ilia Denisov 6e120bdaa7 docs(offer): update phrasing
CI / changes (pull_request) Successful in 3s
CI / unit (pull_request) Successful in 10s
CI / integration (pull_request) Successful in 20s
CI / ui (pull_request) Successful in 1m10s
CI / conformance (pull_request) Successful in 9s
CI / gate (pull_request) Successful in 0s
CI / deploy (pull_request) Successful in 2m24s
2026-07-11 11:39:43 +02:00
Ilia Denisov 40acbcccdd feat(offer): sort and align the §4.4 price tables, style them for both themes
CI / changes (pull_request) Successful in 2s
CI / unit (pull_request) Successful in 12s
CI / integration (pull_request) Successful in 20s
CI / ui (pull_request) Successful in 1m10s
CI / conformance (pull_request) Successful in 9s
CI / gate (pull_request) Successful in 0s
CI / deploy (pull_request) Successful in 1m47s
- packs sorted by ascending rouble price;
- values grouped hints-only -> no-ads-only -> no-ads+hints -> tournament
  (the tournament group is empty until such products become sellable),
  ascending chip price within each group;
- price columns right-aligned (GFM "---:" separators);
- tables span the full content width; the name column shrinks to its content
  and never wraps;
- muted-but-visible cell borders on the dark theme, where the section rule
  colour blends into the background.
2026-07-11 11:29:41 +02:00
Ilia Denisov b54371845f harden(offer): escape admin product titles against HTML/markdown injection
CI / changes (pull_request) Successful in 2s
CI / unit (pull_request) Successful in 11s
CI / integration (pull_request) Successful in 21s
CI / ui (pull_request) Successful in 1m11s
CI / conformance (pull_request) Successful in 11s
CI / gate (pull_request) Successful in 0s
CI / deploy (pull_request) Successful in 1m21s
The offer price list projects the admin-entered product title into a markdown
table cell that is rendered, unsanitised, into the public /offer/ page. Escape
the title at the projection boundary so it renders as literal text: HTML
metacharacters become entities and the markdown table pipe and link brackets are
escaped, so a title can neither inject markup nor form a javascript: link. The
committed offer prose keeps its trusted-content treatment (code-reviewed, not
runtime input).
2026-07-10 20:30:36 +02:00
Ilia Denisov b6c2598710 feat(offer): live catalog price list in the public offer, served by the render sidecar
CI / changes (pull_request) Successful in 2s
CI / unit (pull_request) Successful in 11s
CI / integration (pull_request) Successful in 26s
CI / ui (pull_request) Successful in 1m13s
CI / conformance (pull_request) Successful in 10s
CI / gate (pull_request) Successful in 0s
CI / deploy (pull_request) Failing after 16m19s
Robokassa moderation requires the public offer to list every digital good with
its price. Move /offer/ off the static landing container to the render sidecar:
it splices the live catalog price list (§4.4) into the owner-edited
ui/legal/offer_ru.md and renders it with the shared ui/src/lib/offer.ts — one
renderer, no drift, always matching the current catalog with no redeploy.

- backend: /api/v1/internal/offer/pricing (internal, off the edge allow-list)
  projects the active catalog into two markdown tables — chip packs priced per
  rail (roubles / VK votes / Telegram Stars) and chip-priced values — through
  payments.Money so no float reaches the page. Cached in memory: warmed at boot,
  marked stale on every catalog mutation, so a served render issues no query.
- renderer: GET /offer/ fetches the tables and substitutes them at the
  <#pricing_template#> marker, then renders; offer_ru.md is baked into the image
  and marked is bundled from ui. GET /offer -> 301. Only /offer/ is edge-exposed.
- caddy: route /offer/ to the sidecar; drop the now-dead landing /offer/
  handlers and the vite emit-offer plugin.
- offer: fill §4.3 (the chip-payment wording) and drop the in-page back link.
- landing footer: a feedback link (the offer's Telegram contact) beside the
  offer link.
- docs (ARCHITECTURE, FUNCTIONAL +_ru, renderer README), CI /offer/ probe,
  unit + integration + node tests.
2026-07-10 20:25:41 +02:00
developer 6badc20078 Merge pull request 'Release v1.15.0 — in-game UX + wallet redesign + WAL-alert fix' (#243) from development into master 2026-07-10 16:15:20 +00:00
developer a241e43d79 Merge pull request 'Wallet redesign: split buy/spend, compact balance, exchange confirm + insufficient-chips fix' (#242) from feature/wallet-redesign into development
CI / changes (push) Successful in 2s
CI / deploy (push) Successful in 1m56s
CI / conformance (pull_request) Successful in 10s
CI / gate (pull_request) Successful in 0s
CI / unit (push) Has been skipped
CI / integration (push) Has been skipped
CI / ui (push) Successful in 1m10s
CI / conformance (push) Successful in 10s
CI / gate (push) Successful in 0s
CI / changes (pull_request) Successful in 2s
CI / unit (pull_request) Successful in 11s
CI / integration (pull_request) Successful in 19s
CI / ui (pull_request) Successful in 1m12s
CI / deploy (pull_request) Has been skipped
2026-07-10 16:00:54 +00:00
Ilia Denisov 8ce986922a feat(ui): wallet storefront redesign — split buy/spend, compact balance, exchange confirm
CI / changes (pull_request) Successful in 2s
CI / unit (pull_request) Has been skipped
CI / integration (pull_request) Has been skipped
CI / ui (pull_request) Successful in 1m11s
CI / conformance (pull_request) Successful in 10s
CI / gate (pull_request) Successful in 0s
CI / deploy (pull_request) Successful in 1m55s
Reworks the Wallet screen to be more compact and to separate the two flows, and fixes a
reported bug.

Bug: buying a value with too few chips showed the generic "Something went wrong" toast — the
backend's `insufficient_chips` (409) code was propagated correctly but had no i18n mapping, so
`errorKey` fell back to `error.generic`. Add `error.insufficient_chips` / `error.product_not_found`.
Also disable a value's Exchange button up front when the spendable balance cannot cover it (the
server stays authoritative).

Redesign:
- a compact **balance** row leads the screen — the context's own chips (🪙 N) then any linked
  other-platform chips behind that platform's logo (monospaced digits); inside a VK/TG store only
  that store's own segment shows;
- the benefits section is renamed **Active**, moved above the store, shows one inline line
  (hints + ad-free) and hides when nothing is active;
- the **store** splits by a two-way toggle into **Buy chips** (money packs + the watch-an-ad row at
  the top) and **Spend chips** (values, exchanged for chips);
- a value's action reads **Exchange** and opens a single confirmation dialog (chip spends are
  instant, with no provider window to confirm them); the existing cross-platform store-compliance
  warning folds into that same dialog when the spend would draw VK/TG chips.

No payments-model or wire change; the money→chips→values model is unchanged (verified: money buys
only chip packs, values are chips-only — "no-ads for money" is structurally impossible).

Tests: wallet e2e updated for the new layout (chromium + webkit green). Docs: FUNCTIONAL (+_ru)
wallet story rewritten. App bundle budget 126 → 127 KB (always-loaded settings hub).
2026-07-10 17:46:22 +02:00
developer 3469278260 Merge pull request 'fix(monitoring): don't false-fire the WAL-stalled alert on an idle database' (#241) from fix/pg-archive-stalled-idle-falsepositive into development
CI / changes (push) Successful in 2s
CI / unit (push) Successful in 11s
CI / integration (push) Successful in 25s
CI / ui (push) Successful in 1m12s
CI / conformance (push) Successful in 10s
CI / gate (push) Successful in 0s
CI / deploy (push) Successful in 2m38s
2026-07-10 14:31:57 +00:00
Ilia Denisov c66bf1eceb fix(monitoring): pg_archive_stalled must not false-fire on an idle database
CI / changes (pull_request) Successful in 2s
CI / unit (pull_request) Successful in 11s
CI / integration (pull_request) Successful in 24s
CI / ui (pull_request) Successful in 1m11s
CI / conformance (pull_request) Successful in 9s
CI / gate (pull_request) Successful in 0s
CI / deploy (pull_request) Successful in 1m55s
The "WAL archiving stalled" alert fired on prod during a quiet period. An idle Postgres
archives nothing — it never force-switches an empty WAL segment on archive_timeout — so
pg_stat_archiver_last_archive_age grows past 30 min even though archiving is perfectly
healthy (failed_count 0, no .ready segments, pg_wal flat, backups current). A false
positive: no data and no disk at risk (nothing was written, so the frozen recovery point
equals the live state).

Gate the age condition on pg_wal actually growing:

  (pg_stat_archiver_last_archive_age > 1800) and on() (delta(pg_wal_size_bytes[35m]) > 16MB)

so it fires only when WAL is being produced but not archived (the real "pg_wal fills the
disk" danger; genuine archive_command failures are already caught by pg_archive_failing).
`and on()` bridges the two metrics' differing label sets (last_archive_age carries a server
label, the pg_wal gauge does not); delta() (not increase) suits the gauge. pg_stat_wal is
not exported by this postgres_exporter, so pg_wal size growth is the available signal.
Validated on prod with promtool: the expression is empty while idle.

Also fix the runbook command in both archive alerts' annotations: `pgbackrest check` needs
`--pg1-user=scrabble`, or it connects as role "root" (which does not exist) and aborts with
"no database found".
2026-07-10 16:25:01 +02:00
developer 7b4d2421e2 Merge pull request 'In-game UX: board highlight + score badge, full-width rack, bag badge, zoom setting' (#240) from feature/ingame-ux-board-highlight into development
CI / changes (push) Successful in 2s
CI / unit (push) Successful in 11s
CI / integration (push) Successful in 21s
CI / ui (push) Successful in 1m11s
CI / conformance (push) Successful in 10s
CI / gate (push) Successful in 0s
CI / deploy (push) Successful in 2m49s
2026-07-10 14:06:20 +00:00
Ilia Denisov bf46b9492d fix(ui): one-word games must not highlight phantom cross words; review polish
CI / changes (pull_request) Successful in 3s
CI / unit (pull_request) Successful in 11s
CI / integration (pull_request) Successful in 21s
CI / ui (pull_request) Successful in 1m25s
CI / conformance (pull_request) Successful in 11s
CI / gate (pull_request) Successful in 0s
CI / deploy (pull_request) Successful in 2m2s
Board-highlight bug (reported on the contour): formedGeometry walked cross words
unconditionally, so in a single-word (one-word-per-turn) game a staged tile sitting
next to a committed tile lit up a green "cross word" the engine ignores — and which
need not even be a real word (the reported "БО lights up green in a one-word ПОПА
play"). Gate the cross-word walk on the game's multipleWordsPerTurn flag. The score
(8) was already correct — a premium square under the main word.

Also, from review:
- the turn strip reads the staged play's "WORD+WORD = N" while composing a legal move,
  reverting to the turn / result text otherwise;
- the Exchange/Pass dialog shows the bag count ("In the bag: N" / "Bag is empty")
  right-aligned in the title row, via a new optional Modal `titleAside`;
- cosmetics: half the turn strip's bottom padding (the plaques below carry their own
  top pad); a top gap above the landscape rack (it sat flush under the docked history);
  more horizontal padding on tab count badges so a 2-3 digit bag count clears the pill
  ends;
- admin console: the game Summary now shows the single-word / multiple-words rule.

Tests: formed single-word case added; full unit (584) + e2e (chromium + webkit, 113
each) green; backend build + adminconsole templates parse. Docs (FUNCTIONAL +_ru,
UI_DESIGN) updated.
2026-07-10 15:58:05 +02:00
Ilia Denisov 1e1117c28e chore(ui): raise the app bundle budget to 126 KB for the in-game board UX
CI / changes (pull_request) Successful in 2s
CI / unit (pull_request) Has been skipped
CI / integration (pull_request) Has been skipped
CI / ui (pull_request) Successful in 1m10s
CI / conformance (pull_request) Successful in 10s
CI / gate (pull_request) Successful in 0s
CI / deploy (pull_request) Successful in 1m59s
2026-07-10 15:03:30 +02:00
Ilia Denisov 77a690fcf6 feat(ui): move in-game status to the board — highlight, score badge, full-width rack
CI / changes (pull_request) Successful in 2s
CI / unit (pull_request) Has been skipped
CI / integration (pull_request) Has been skipped
CI / ui (pull_request) Failing after 13s
CI / conformance (pull_request) Successful in 9s
CI / gate (pull_request) Failing after 0s
CI / deploy (pull_request) Has been skipped
Remove the under-board status strip and relocate its signals:
- bag count -> a badge on the exchange/pass control + the foot of the move table
- whose-turn / win-lose -> a thin strip above the score plaques
- the tentative-move caption -> the board itself: staged tiles tint green (legal) or
  pink (illegal), the board tiles a formed word runs through go a shade darker, and an
  orange score badge sits on the main word (digit sized like a tile value, clamped on-board)

The word geometry (covered cells + badge anchor) is a new pure client-side helper
(ui/src/lib/formed.ts), independent of the move evaluator, so it works on the local or
network preview path alike; the badge's number still comes from the preview score.

Rack: a seven-column grid filling the tray width in both layouts — square, full-width
tiles — with the confirm control in the fixed 7th slot.

Settings: a touch-only "Zoom the board" toggle (default on, device-local) gates the
tile-placement auto-zoom; taking a hint while zoomed in now zooms out so the highlighted
hint word is never left off-screen.

Docs (FUNCTIONAL +_ru, UI_DESIGN, ARCHITECTURE) and e2e/unit tests updated.
2026-07-10 14:58:32 +02:00
developer 0ca01133b5 Merge pull request 'Release v1.14.1 — ansible certs-dir fix + Robokassa go-live' (#239) from development into master 2026-07-10 10:58:38 +00:00
developer 2683103fc1 Merge pull request 'fix(deploy): provision the certs dir traversable by the nonroot gateway' (#238) from fix/ansible-certs-dir-traversable into development
CI / changes (push) Successful in 2s
CI / unit (push) Successful in 11s
CI / integration (push) Successful in 19s
CI / ui (push) Successful in 1m10s
CI / conformance (push) Successful in 11s
CI / gate (push) Successful in 0s
CI / deploy (push) Successful in 1m57s
CI / changes (pull_request) Successful in 2s
CI / unit (pull_request) Successful in 10s
CI / integration (pull_request) Successful in 19s
CI / ui (pull_request) Successful in 1m11s
CI / conformance (pull_request) Successful in 10s
CI / gate (pull_request) Successful in 0s
CI / deploy (pull_request) Has been skipped
2026-07-10 10:39:06 +00:00
Ilia Denisov 2d2dd2bc47 fix(deploy): provision the certs dir traversable by the nonroot gateway
CI / changes (pull_request) Successful in 2s
CI / unit (pull_request) Successful in 11s
CI / integration (pull_request) Successful in 19s
CI / ui (pull_request) Successful in 1m11s
CI / conformance (pull_request) Successful in 9s
CI / gate (pull_request) Successful in 0s
CI / deploy (pull_request) Successful in 1m54s
The Ansible base-directory loop created /opt/scrabble/certs at mode 0750
(deploy:deploy), like config/dumps/images. The gateway (and backend) run as
the distroless nonroot UID 65532 — not the deploy user — so the container
cannot traverse a 0750 certs dir and fails at startup with
"mtls: load server keypair: ... permission denied", crash-looping.

This is latent: a long-running container holds the keypair in memory and
never re-reads the file, so the misconfig only bites when a container
restarts (a host reboot / redeploy). A hoster maintenance reboot exposed it
on prod — the gateway came back crash-looping while the deploy could not SSH
in mid-reboot.

Split certs out of the 0750 loop and create it 0755 (traversable). The keys
stay 0644 by design (the gateway compose relies on it); the host is
single-tenant + SSH-access-controlled, so a traversable certs dir adds no
meaningful exposure. The live prod host was already chmod-fixed by hand; this
keeps the next provisioning run from re-tightening it.
2026-07-10 12:34:21 +02:00
developer 45f0b34881 Merge pull request 'Release v1.14.0 — monetization launch (E5-E8)' (#237) from development into master 2026-07-10 10:11:02 +00:00
developer df9eace09f Merge pull request 'fix(deploy): wire the Robokassa direct rail into the prod deploy + rollback' (#236) from fix/prod-robokassa-wiring into development
CI / changes (push) Successful in 2s
CI / integration (push) Successful in 20s
CI / conformance (push) Successful in 10s
CI / deploy (push) Successful in 1m53s
CI / changes (pull_request) Successful in 2s
CI / unit (push) Successful in 11s
CI / ui (push) Successful in 1m10s
CI / gate (push) Successful in 0s
CI / ui (pull_request) Successful in 1m10s
CI / unit (pull_request) Successful in 11s
CI / integration (pull_request) Successful in 21s
CI / conformance (pull_request) Successful in 10s
CI / gate (pull_request) Successful in 0s
CI / deploy (pull_request) Has been skipped
2026-07-10 10:04:03 +00:00
Ilia Denisov 0a0a9e5a8d fix(deploy): wire the Robokassa direct rail into the prod deploy + rollback
CI / changes (pull_request) Successful in 2s
CI / unit (pull_request) Successful in 11s
CI / integration (pull_request) Successful in 27s
CI / ui (pull_request) Successful in 1m11s
CI / conformance (pull_request) Successful in 10s
CI / gate (pull_request) Successful in 0s
CI / deploy (pull_request) Successful in 1m54s
The Robokassa credentials reached only the test contour (ci.yaml → TEST_
secrets). The prod-deploy / prod-rollback workflows and write-prod-env.sh
never rendered BACKEND_ROBOKASSA_*, so on prod the shop login was empty and
the direct RUB rail stayed disabled — the PROD_BACKEND_ROBOKASSA_* secrets
went nowhere.

Export the shop login + Password1/Password2 (secrets) and a
BACKEND_ROBOKASSA_TEST flag (a variable, so go-live is a flag flip not a
secret rotation) from both prod workflows, and emit the four ROBOKASSA_*
vars from write-prod-env.sh (the shared deploy/rollback env renderer) so a
rollback keeps the rail up. The compose already maps ROBOKASSA_* →
BACKEND_ROBOKASSA_*. Document the new secrets/variable in the deploy README
and .env.example. Password3 (Robokassa's JWT-invoice API) is unused.
2026-07-10 11:59:20 +02:00
developer 0c9678c42b Merge pull request 'feat(ui): per-kind active-game limit lock on the New Game screen' (#235) from feature/e8-guest-limits-client into development
CI / changes (push) Successful in 2s
CI / unit (push) Successful in 12s
CI / integration (push) Successful in 28s
CI / ui (push) Successful in 1m11s
CI / conformance (push) Successful in 10s
CI / gate (push) Successful in 0s
CI / deploy (push) Successful in 1m53s
2026-07-10 09:11:40 +00:00
Ilia Denisov e40adfb0c7 fix(games): carry game kind on live events + fix the New Game lock funnel
CI / changes (pull_request) Successful in 2s
CI / unit (pull_request) Successful in 11s
CI / integration (pull_request) Successful in 21s
CI / ui (pull_request) Successful in 1m10s
CI / conformance (pull_request) Successful in 10s
CI / gate (pull_request) Successful in 0s
CI / deploy (pull_request) Successful in 1m56s
Address review of the active-game limit lock:

- Live-event GameViews (opponent_moved, match_found, game_started, ...) did
  not carry game_kind, so a lobby patch from an event zeroed a game's kind
  and the client's per-kind count under-counted — a capped kind read as
  free (a disabled start instead of the lock, and a wrong per-kind result).
  Thread Kind through notify.GameSummary → the event GameView.

- New Game screen refreshes the lobby games on mount so the per-kind count
  reflects the current set, not a stale cached snapshot.

- The guest funnel's login button routes to the profile screen (the account
  controls), not settings.

- Copy: the guest prompt is "sign in to use all the game's features"; the
  durable notice is "finish your active games to start a new one".

Regression tests: the notify opponent-moved payload carries kind; the client
lock locks only the reached kind (vs_ai at cap leaves random open).
2026-07-10 10:55:48 +02:00
Ilia Denisov 3306a016a0 feat(ui): per-kind active-game limit lock on the New Game screen
CI / changes (pull_request) Successful in 2s
CI / unit (pull_request) Successful in 12s
CI / integration (pull_request) Successful in 28s
CI / ui (pull_request) Successful in 1m11s
CI / conformance (pull_request) Successful in 10s
CI / gate (pull_request) Successful in 0s
CI / deploy (pull_request) Successful in 1m46s
Carry the caller's per-tier active-game caps and each game's kind on the
wire (Profile.game_limits + GameView.kind, additive FBS + gateway transcode
+ client codec, committed regen). The New Game screen counts the player's
active games per kind from the lobby and locks a capped start: an outline
button with a lock that opens a funnel modal instead of a game -- a sign-in
prompt for a guest, a "finish a current game first" notice for a signed-in
account (native Telegram popup, in-app modal elsewhere). The lock lifts via
the existing profile refetch after a guest->durable upgrade.

Remove the lobby's old at_game_limit New-Game tab disable + notice: the flag
(now the random-kind cap) conflicted with the per-kind lock -- it hid the
screen where the lock lives and wrongly blocked an unfulfilled kind. The New
Game tab is always enabled; the per-kind start lock is the only gate. The
at_game_limit wire field stays (unused by the client) for a later cleanup.

Tests: client lock logic + codec kind/game_limits roundtrip + gateway
transcode encode + native popup builders (unit); a mock e2e for the lock
badge and the modal.
2026-07-10 10:09:45 +02:00
developer e45167041f Merge pull request 'feat(backend): per-tier, per-kind active-game limits with a guest funnel' (#234) from feature/e8-guest-limits into development
CI / changes (push) Successful in 2s
CI / unit (push) Successful in 12s
CI / integration (push) Successful in 22s
CI / ui (push) Has been skipped
CI / conformance (push) Successful in 10s
CI / gate (push) Successful in 1s
CI / deploy (push) Successful in 1m56s
2026-07-10 07:17:54 +00:00
Ilia Denisov ed53e25e57 feat(backend): per-tier, per-kind active-game limits with a guest funnel
CI / changes (pull_request) Successful in 5s
CI / unit (pull_request) Successful in 11s
CI / integration (pull_request) Successful in 20s
CI / ui (pull_request) Has been skipped
CI / conformance (pull_request) Successful in 11s
CI / gate (pull_request) Successful in 0s
CI / deploy (pull_request) Successful in 2m41s
Cap a player's simultaneous unfinished games per kind (vs_ai, random,
friends) with independent guest and durable-account tiers, held in a new
single-row backend.config table (-1 = unlimited) behind an in-memory cache
and editable live in the admin console (/_gm/limits). Each game is tagged
with games.game_kind on creation.

This replaces the earlier flat MaxActiveQuickGames=10 combined cap: the
per-tier/kind config is the single mechanism, enforced at the same handler
gate (ensureUnderGameLimit by kind on lobby/enqueue) plus the durable
friends cap in CreateInvitation. game.Service.AtGameLimit only resolves the
tier and counts; the limit policy stays at the request edge.

Guests are now refused friend requests, friend-code redemption,
befriend-in-game and invitation creation outright (403 guest_forbidden) --
previously only the UI hid these.

Admin: a kind column in both game lists and the config editor.

Defaults: guest 1 vs_ai / 1 random / 0 friends; durable 10 / 10 / 10.
2026-07-10 09:03:57 +02:00
developer 2e5136b22a Merge pull request 'feat(admin): manual full-order refund + ledger CSV export' (#233) from feature/e7-refund-export into development
CI / changes (push) Successful in 2s
CI / unit (push) Successful in 11s
CI / integration (push) Successful in 19s
CI / ui (push) Has been skipped
CI / conformance (push) Successful in 10s
CI / gate (push) Successful in 0s
CI / deploy (push) Successful in 2m5s
2026-07-10 04:52:49 +00:00
Ilia Denisov 1bf612a087 feat(admin): manual full-order refund + ledger CSV export
CI / changes (pull_request) Successful in 2s
CI / unit (pull_request) Successful in 11s
CI / integration (pull_request) Successful in 22s
CI / ui (pull_request) Has been skipped
CI / conformance (pull_request) Successful in 10s
CI / gate (pull_request) Successful in 0s
CI / deploy (pull_request) Successful in 1m53s
Closes the admin / reports / catalog work. Each fund row on the /_gm finance
panel gains 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 (never negative, D27), idempotent (a second refund reports
already-refunded). A ledger CSV export (/_gm/ledger.csv, payments.LedgerExport)
streams the whole append-only ledger for tax + reconciliation.

Tests: refund an order in full (chips revoked, a refund row), an idempotent
second refund, the CSV export shape; CSRF-guarded.
2026-07-10 06:46:18 +02:00
developer ec5c6afa23 Merge pull request 'feat(admin): admin grant — raw benefits and by-product reward bundles' (#232) from feature/e7-admin-grant into development
CI / changes (push) Successful in 3s
CI / unit (push) Successful in 12s
CI / integration (push) Successful in 19s
CI / ui (push) Successful in 1m10s
CI / conformance (push) Successful in 10s
CI / gate (push) Successful in 0s
CI / deploy (push) Successful in 1m55s
2026-07-10 04:37:43 +00:00
Ilia Denisov 82648a4398 fix(game): show granted/bought hints in-game — finish the D31 wire removal
CI / changes (pull_request) Successful in 2s
CI / unit (pull_request) Successful in 10s
CI / integration (pull_request) Successful in 19s
CI / ui (pull_request) Successful in 1m11s
CI / conformance (pull_request) Successful in 10s
CI / gate (pull_request) Successful in 0s
CI / deploy (pull_request) Successful in 1m58s
The in-game hint badge ignored the payments hint wallet: loading a game clobbered
app.profile.hintBalance with the deprecated StateView.wallet_balance (zeroed in the
D31 domain removal but left in the protocol as a dead 0, then synced over the real
balance at game load).

Finish the removal honestly. StateView carries the per-game allowance alone
(hints_remaining); the purchasable wallet lives solely on the profile and the client
adds it (lib/hints). Removed StateView.wallet_balance across every layer — backend
StateView/DTO, notify.PlayerState (live events), gateway StateResp + wire.StateView,
the client model/codec/mock/localgame — and dropped the now-unused wallet arg from
hintsRemaining. The FBS field is tombstoned `(deprecated)` (not deleted) so the vtable
slots after it stay stable across a rolling deploy; no accessor is generated. HintResult
keeps wallet_balance (the real post-spend payments balance the client adopts into the
profile). The StateView type no longer has walletBalance, so the clobber cannot return
without a compile error.

Tests: hintsRemaining (2-arg); hints.hintsLeft (allowance + live wallet, no strip); the
game state/hint integration (allowance-only HintsRemaining); gateway transcode; FBS regen.
2026-07-10 06:26:49 +02:00
Ilia Denisov d2d6955cbf feat(admin): admin grant — raw benefits and by-product reward bundles
CI / changes (pull_request) Successful in 2s
CI / unit (pull_request) Successful in 11s
CI / integration (pull_request) Successful in 26s
CI / ui (pull_request) Has been skipped
CI / conformance (pull_request) Successful in 10s
CI / gate (pull_request) Successful in 0s
CI / deploy (pull_request) Successful in 1m48s
The /_gm user card gains a 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; the by-product grant records the source
product_id + snapshot. Both refuse a chips atom (never grant currency) or a
tournament atom (no credit target yet); chips/tournament products are also
kept out of the by-product picker.

Tests: the console grant end to end (raw, by-product, refuse a chips pack,
CSRF-guarded).
2026-07-10 05:35:55 +02:00
developer 18785efc8c Merge pull request 'release v1.13.0: payments wallet mechanics + database point-in-time recovery' (#220) from development into master 2026-07-08 23:42:15 +00:00
developer 780ff68ec2 Merge pull request 'release: offline mode + local pass-and-play (hotseat) — proposed v1.12.0' (#212) from development into master 2026-07-07 14:40:43 +00:00
developer 57ff2d03f8 Merge pull request 'Release v1.11.0: PWA install + code-only PWA login + email/metrics fixes' (#187) from development into master 2026-07-05 21:02:08 +00:00
developer a9d0986e74 Merge pull request 'Release v1.10.0: banner colours + urgent, and 3 UI fixes' (#183) from development into master 2026-07-05 14:10:25 +00:00
developer 45957bdcd6 Merge pull request 'Release: promote development → master (old Android WebView support + unsupported-engine telemetry)' (#178) from development into master 2026-07-04 21:23:29 +00:00
developer 829e29a726 Merge pull request 'Release v1.8.0: prod build fix (re-promote)' (#175) from development into master 2026-07-03 21:48:13 +00:00
developer 399508f2f0 Merge pull request 'Release v1.8.0: promote development → master' (#173) from development into master 2026-07-03 21:31:25 +00:00
developer 3a18e683ca Merge pull request 'release: VK Mini App + landscape UI + dict v1.3.1 seed (development→master)' (#144) from development into master 2026-06-30 05:37:43 +00:00
developer 93d086a8a3 Merge pull request 'release: v1.7.0 — Telegram Mini App embedding enhancements' (#138) from development into master 2026-06-24 11:49:44 +00:00
developer 8fe1bdba6b Merge pull request 'release: v1.6.0 — promo deep-link seeds EN variant (+ UI nits)' (#135) from development into master 2026-06-23 21:02:19 +00:00
developer 7923b3cc09 Merge pull request 'release v1.5.1: support-relay card + topic-reopen fixes' (#133) from development into master 2026-06-23 16:54:01 +00:00
developer 4891216749 Merge pull request 'release v1.5.0: Telegram bot support relay' (#131) from development into master 2026-06-23 16:16:04 +00:00
developer f1b8769c89 Merge pull request 'release: v1.4.1 — Telegram nav (windowed, own back button, debug panel)' (#129) from development into master 2026-06-23 13:27:31 +00:00
developer b6f28a2423 Merge pull request 'release: v1.4.0 — Telegram launch diagnostic + dynamic SDK load' (#127) from development into master 2026-06-23 08:40:09 +00:00
developer e32ee9ce68 Merge pull request 'Release: development → master' (#125) from development into master 2026-06-22 22:36:42 +00:00
developer dc946a1faf Merge pull request 'release v1.2.2: edge HTTP/3 stall fix + db-size dashboard threshold' (#121) from development into master 2026-06-22 19:50:58 +00:00
developer 384bd143d0 Merge pull request 'Promote development → master: banner tip set + banner/push language fix' (#114) from development into master 2026-06-22 18:28:00 +00:00
developer c5d22fceca Merge pull request 'Promote development → master: Erudit blank star + dictionary v1.3.0 pin' (#111) from development into master 2026-06-22 13:12:01 +00:00
developer deaa7a29c5 Merge pull request 'Promote development → master (docs finalize + UI tweaks + Telegram name fallback)' (#108) from development into master 2026-06-22 07:27:40 +00:00
developer 24017bcb7f Merge pull request 'Promote development → master (deploy v2: versioning + visible jobs + rollback)' (#106) from development into master 2026-06-22 06:01:03 +00:00
developer 2c4f4b10dc Merge pull request 'Promote development → master (initial production release: pre-release line + Stage 18)' (#104) from development into master 2026-06-22 05:05:48 +00:00
171 changed files with 5633 additions and 1071 deletions
+12 -7
View File
@@ -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
+13
View File
@@ -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
+10
View File
@@ -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 }}
+84 -33
View File
@@ -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
View File
@@ -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 24 player game once every friend-game invitations (invite → accept, starting a 24 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`
+20
View File
@@ -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>
+40 -2
View File
@@ -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
}
+1 -1
View File
@@ -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)
+8 -8
View File
@@ -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)
} }
} }
} }
+45 -18
View File
@@ -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.
+20 -24
View File
@@ -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
} }
+11 -16
View File
@@ -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
+149
View File
@@ -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)
}
}
}
+186 -103
View File
@@ -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.
+7 -6
View File
@@ -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)
}
}
+16
View File
@@ -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
+9 -1
View File
@@ -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")
+2
View File
@@ -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
+1 -1
View File
@@ -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,
}) })
} }
+2 -2
View File
@@ -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))
} }
} }
+3 -1
View File
@@ -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
} }
+82 -4
View File
@@ -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
} }
+44
View File
@@ -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
}
+218
View File
@@ -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 "&" → "&amp;" does not
// double-escape the entities the other rules emit.
var offerCellReplacer = strings.NewReplacer(
"&", "&amp;",
"<", "&lt;",
">", "&gt;",
`"`, "&quot;",
"'", "&#39;",
"|", `\|`,
"[", `\[`,
"]", `\]`,
"\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)
}
+137
View File
@@ -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{"&lt;script&gt;", `\[x\]`, "&amp;", "&quot;q&quot;"} {
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)
}
}
+41
View File
@@ -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)
}
+60
View File
@@ -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 {
+24
View File
@@ -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;
+1
View File
@@ -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
+18 -3
View File
@@ -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 {
+5
View File
@@ -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()
}
+14 -22
View File
@@ -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)
+6 -3
View File
@@ -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.
+7
View File
@@ -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,
+6
View File
@@ -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
+7
View File
@@ -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
+6
View File
@@ -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
+3
View File
@@ -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
+11
View File
@@ -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
View File
@@ -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
+15 -1
View File
@@ -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"
+9
View File
@@ -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
+6 -3
View File
@@ -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"
+9
View File
@@ -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
+45
View File
@@ -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]))" }]
} }
] ]
} }
+124 -4
View File
@@ -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.' }
+3 -12
View File
@@ -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.
+9
View File
@@ -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
View File
@@ -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
View File
@@ -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
View File
@@ -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
View File
@@ -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
View File
@@ -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
View File
@@ -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
+86
View File
@@ -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))
}
+10
View File
@@ -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}}
+11 -1
View File
@@ -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"`
} }
+48
View File
@@ -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
}
} }
} }
+18
View File
@@ -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)
}
}
+69
View File
@@ -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) {
+31 -7
View File
@@ -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)
}
}
+30 -1
View File
@@ -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.
+4 -4
View File
@@ -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")
+17 -1
View File
@@ -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),
+188
View File
@@ -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)")
}
}
+13 -1
View File
@@ -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")
}
}
+3 -3
View File
@@ -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
View File
@@ -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;
+94
View File
@@ -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()
}
+16 -1
View File
@@ -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()
} }
+17 -1
View File
@@ -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()
} }
-15
View File
@@ -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)
} }
+182 -79
View File
@@ -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,
}, },
+17 -1
View File
@@ -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
View File
@@ -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