Compare commits

...

97 Commits

Author SHA1 Message Date
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 850884d077 Merge pull request 'feat(deploy): continuous WAL archiving to S3 for point-in-time recovery' (#219) from feature/postgres-pitr-archiving into development
CI / changes (push) Successful in 2s
CI / unit (push) Successful in 10s
CI / integration (push) Successful in 21s
CI / ui (push) Successful in 1m9s
CI / conformance (push) Successful in 10s
CI / changes (pull_request) Successful in 1s
CI / gate (push) Successful in 0s
CI / deploy (push) Successful in 1m42s
CI / unit (pull_request) Successful in 10s
CI / integration (pull_request) Successful in 18s
CI / ui (pull_request) Successful in 1m8s
CI / conformance (pull_request) Successful in 10s
CI / gate (pull_request) Successful in 0s
CI / deploy (pull_request) Has been skipped
2026-07-08 23:35:07 +00:00
Ilia Denisov a58f2ce0e4 fix(deploy): support a non-standard S3 port for the pgBackRest endpoint
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 1m8s
CI / conformance (pull_request) Successful in 10s
CI / gate (pull_request) Successful in 1s
CI / deploy (pull_request) Successful in 1m43s
The S3 endpoint is a host only; some providers publish a separate, non-443
port. Add an optional PGBACKREST_S3_PORT (default 443) alongside the endpoint
host, wired through the prod overlay, write-prod-env.sh and both prod workflows,
and clarify in the docs that the endpoint variable takes the host alone.
2026-07-09 01:14:05 +02:00
Ilia Denisov e922f5f3b9 feat(deploy): continuous WAL archiving to S3 for point-in-time recovery
CI / changes (pull_request) Successful in 4s
CI / unit (pull_request) Successful in 12s
CI / integration (pull_request) Successful in 24s
CI / ui (pull_request) Successful in 1m9s
CI / conformance (pull_request) Successful in 10s
CI / gate (pull_request) Successful in 0s
CI / deploy (pull_request) Successful in 1m41s
Add pgBackRest-based PITR for the production database, shipped disarmed
(archive_mode and the base-backup timer gated off) so it stays inert until the
operator arms it before real payments — an un-armed deploy cannot pile WAL onto
the disk.

- Postgres now builds from a thin image carrying pgBackRest (archive_command runs
  in-process); the prod overlay wires an encrypted (AES-256-CBC), path-style S3
  repository with 30-day retention and a daily full base-backup systemd timer.
- Repository config/secrets flow through the PROD_PGBACKREST_* Gitea set and
  write-prod-env.sh; prod-rollback re-renders the same env so a rollback cannot
  disarm archiving.
- Grafana alerts watch pg_stat_archiver (failing / stalled), absent-safe on the
  test contour, which never archives.
- Fix the pre-migration pg_dump to snapshot the whole database (was backend-only,
  silently excluding payments).
- Document the PITR runbook, the arming sequence and the restore drill in
  deploy/README.md; record the measured cost/perf assessment.
2026-07-09 00:58:04 +02:00
developer 04976a64e8 Merge pull request 'feat(payments): wallet screen with balances, benefits and storefront' (#218) from feature/payments-wallet-ui into development
CI / changes (push) Successful in 3s
CI / unit (push) Successful in 10s
CI / integration (push) Successful in 19s
CI / ui (push) Successful in 1m8s
CI / conformance (push) Successful in 9s
CI / gate (push) Successful in 0s
CI / deploy (push) Successful in 1m40s
2026-07-08 16:44:06 +00:00
Ilia Denisov 4aa6174968 feat(payments): wallet screen with balances, benefits and storefront
CI / changes (pull_request) Successful in 3s
CI / unit (pull_request) Successful in 11s
CI / integration (pull_request) Successful in 19s
CI / ui (pull_request) Successful in 1m8s
CI / conformance (pull_request) Successful in 9s
CI / gate (pull_request) Successful in 0s
CI / deploy (pull_request) Successful in 1m39s
Add the "Кошелёк" section to the settings hub: context-visible chip
balances, active benefits (no-ads term/forever, hints) and a storefront of
chip-priced values and money-priced chip packs. Guests have no wallet; the
Google Play build hides the money purchases behind a RuStore stub; a web
purchase that would draw VK/Telegram chips warns first.

Add the catalog read path the storefront needs — a context-projected
GET /api/v1/user/wallet/catalog (payments service + store, gateway op
wallet.catalog, FBS Catalog/CatalogProduct/CatalogAtom, client decode) —
plus the client leg for the existing wallet.get/buy ops. Value spends reuse
the existing spend path; the chip-pack purchase (money order flow) arrives
with payment intake, so its action is a disabled placeholder for now.

Covered by Go unit (catalog projection) + integration (/wallet/catalog over
Postgres), vitest (formatting, spendable selection, web-spend warning, GP
flag, codec + gateway encode round-trips) and Playwright mock e2e (render,
guest-hidden, GP stub, warning) on Chromium + WebKit.
2026-07-08 12:37:32 +02:00
developer 41f4fd3497 Merge pull request 'feat(payments): chip wallet, store-compliance gate and benefit application' (#217) from feature/payments-currency-benefit-core into development
CI / changes (push) Successful in 2s
CI / unit (push) Successful in 10s
CI / integration (push) Successful in 21s
CI / ui (push) Successful in 1m7s
CI / conformance (push) Successful in 9s
CI / gate (push) Successful in 0s
CI / deploy (push) Successful in 1m45s
2026-07-08 05:53:24 +00:00
Ilia Denisov 1c06d1d0d1 feat(payments): chip wallet, store-compliance gate and benefit application
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) Successful in 1m7s
CI / conformance (pull_request) Successful in 9s
CI / gate (pull_request) Successful in 0s
CI / deploy (pull_request) Successful in 1m41s
Stand up the internal chip/benefit mechanic behind the narrow payments interface:
context-aware balances and benefits, an atomic chip spend, admin grants as
zero-price value sales, the one-directional store-compliance gate (VK/TG same-
origin only, web draws direct→vk→tg, VK-iOS frozen, untrusted fail-closed), and
per-origin hint and no-ads application with term stacking. Reads are served from
an in-process, account-keyed write-through cache (mirroring the suspension gate),
so hot paths issue no query to the payments schema.

Flip the online-game hint wallet and the ad-banner suppression from the deprecated
accounts.hint_balance / paid_account columns to the payments benefit (a hint
balance no longer suppresses the banner — only a no-ads benefit does), and fold
chip segments and benefits by origin on account merge, inside the merge tx. Add
the GET/POST /api/v1/user/wallet edge chain (REST → Connect → FlatBuffers) plus
its codec unit test; no wallet UI yet.

Bring the frozen owner decisions log into the repo at
docs/PAYMENTS_DECISIONS_ru.md (it was untracked under .vscode) and reference it
from PLAN.md; record the read-cache design and the present-sources interface in
PLAN.md and docs/PAYMENTS.md (+ RU mirror).
2026-07-08 06:06:40 +02:00
developer 711fabe9cc Merge pull request 'feat(payments): trusted platform signal on the session' (#216) from feature/trusted-platform-signal into development
CI / changes (push) Successful in 1s
CI / unit (push) Successful in 10s
CI / integration (push) Successful in 21s
CI / ui (push) Successful in 1m7s
CI / conformance (push) Successful in 10s
CI / gate (push) Successful in 0s
CI / deploy (push) Successful in 1m44s
2026-07-08 01:37:39 +00:00
Ilia Denisov 92633f935e feat(payments): trusted platform signal on the session
CI / changes (pull_request) Successful in 2s
CI / unit (pull_request) Successful in 11s
CI / integration (pull_request) Successful in 18s
CI / ui (pull_request) Successful in 1m7s
CI / conformance (pull_request) Successful in 10s
CI / gate (pull_request) Successful in 0s
CI / deploy (pull_request) Successful in 1m42s
Record the execution platform (kind vk|telegram|direct + device subtype
ios|android|web) on each session, captured at creation and carried
gateway->backend as a trusted X-Platform header, so the upcoming
store-compliance gate has an unforgeable execution context.

- backend.sessions gains nullable platform_kind/platform_subtype columns
  (migration 00011, CHECK-constrained, jet regenerated); session.Platform
  captures them at mint, resolve returns them, middleware exposes platform(c).
  kind is derived from the establish endpoint, never a client field; the
  account-merge session mint inherits the caller's platform.
- gateway derives the platform (VK subtype from the signed vk_platform via
  vkauth, Telegram/direct best-effort from the client) and injects X-Platform
  on every authenticated backend call through the request context.
- ui submits a best-effort device subtype on the telegram/guest/email login
  requests (new FBS subtype field); VK is server-derived from the signed params.
- an unattributed session is untrusted (view-only); VK/TG self-heal on the next
  cold-start re-mint, direct/email on re-login.

Signal plumbing only, no user-visible change; X-Platform is inert until the
gate consumes it.
2026-07-08 03:31:51 +02:00
developer 07815c5a30 Merge pull request 'feat(payments): payments schema, currency domain and money type' (#215) from feature/payments-data-foundation into development
CI / changes (push) Successful in 2s
CI / unit (push) Successful in 10s
CI / integration (push) Successful in 18s
CI / ui (push) Has been skipped
CI / conformance (push) Successful in 9s
CI / gate (push) Successful in 0s
CI / deploy (push) Successful in 1m44s
2026-07-07 23:21:43 +00:00
Ilia Denisov bab57343e1 chore(jet): regenerate the backend jet to match the migrations
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) Has been skipped
CI / conformance (pull_request) Successful in 10s
CI / gate (pull_request) Successful in 0s
CI / deploy (pull_request) Successful in 1m43s
The committed go-jet code had drifted from the schema: robot_blocks and
robot_friend_requests (created in the baseline) had no generated tables, the
feedback_messages model was missing app_version/browser_tz (added in 00003 and
00004), and UseSchema omitted account_best_move and the two robot tables.
Regenerate so the jet layer mirrors the migrations again.

Additive only — two nullable columns appended to feedback_messages plus two new
tables; the full build, vet, unit and integration suites stay green.
2026-07-08 01:18:36 +02:00
Ilia Denisov ce8b5026c1 feat(payments): add the payments schema, currency domain and money type
CI / changes (pull_request) Successful in 2s
CI / unit (pull_request) Successful in 10s
CI / integration (pull_request) Successful in 20s
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 1m45s
Stand up the payments data foundation: a self-contained `payments` Postgres
schema for the in-game currency, wallets, benefits, catalog, orders and the
append-only operations ledger, behind a domain package — nothing wired to real
money yet.

- Migration 00010: the `payments` schema and a NOLOGIN confinement role (ALL on
  payments.*, nothing on backend); the ledger with a BEFORE UPDATE/DELETE
  append-only trigger and a partial idempotency index; the materialised
  balances/benefits; the catalog (atoms seeded) + products + per-method prices;
  the typed single-row config; orders and payment_events. There is no
  cross-schema foreign key — account_id is a plain uuid kept consistent in code,
  which keeps the domain extractable. Expand-contract and reversible.
- Money is a bigint in the currency's minor units carried by a `Money` value
  type (exact, math/big): no float ever touches an amount, and a whole-unit
  currency cannot hold a fraction.
- Extend jetgen to generate the payments schema; construct the service in the
  composition root behind a narrow interface with a boot reachability check.
- Tests: integration (role confinement via SET ROLE, the append-only trigger,
  CHECK constraints, the idempotency index, and a forward+backward migration),
  Money unit tests, and an import-boundary test keeping the payments jet code
  private to the domain.
- Docs: PLAN.md, docs/PAYMENTS.md (+ _ru mirror) updated to the built model.
2026-07-08 01:07:56 +02:00
developer d9bc7596c6 Merge pull request 'docs(payments): monetization spec and implementation plan' (#213) from feature/payments-docs into development
CI / changes (push) Successful in 1s
CI / unit (push) Has been skipped
CI / integration (push) Has been skipped
CI / ui (push) Has been skipped
CI / conformance (push) Has been skipped
CI / gate (push) Successful in 0s
CI / deploy (push) Has been skipped
2026-07-07 21:31:31 +00:00
developer 3ad3b1dd7f Merge pull request 'ci: skip the test-contour deploy on a no-code change' (#214) from feature/ci-skip-deploy-on-docs into development
CI / changes (push) Successful in 1s
CI / unit (push) Successful in 9s
CI / integration (push) Successful in 15s
CI / ui (push) Successful in 1m6s
CI / conformance (push) Successful in 10s
CI / gate (push) Successful in 0s
CI / deploy (push) Successful in 1m39s
2026-07-07 21:27:46 +00:00
Ilia Denisov 4d1f389cf1 ci: skip the test-contour deploy on a no-code change
CI / changes (pull_request) Successful in 1s
CI / unit (pull_request) Successful in 11s
CI / integration (pull_request) Successful in 15s
CI / ui (pull_request) Successful in 1m6s
CI / conformance (pull_request) Successful in 10s
CI / gate (pull_request) Successful in 0s
CI / deploy (pull_request) Successful in 1m44s
The deploy job gated only on the event/ref, so a docs-only PR into
development (where unit/integration/ui/conformance path-skip) still
redeployed the test contour for nothing. Gate it on the `changes` job
too: deploy only when the Go or UI side changed. `changes` still
defaults both true when the diff is uncomputable, and a workflow/deploy
edit forces both true, so ambiguous or infra changes still deploy.
2026-07-07 23:22:29 +02:00
Ilia Denisov 4c7bc7baa9 docs(payments): add monetization spec and implementation plan
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) Has been skipped
CI / conformance (pull_request) Has been skipped
CI / gate (pull_request) Successful in 0s
CI / deploy (pull_request) Successful in 1m45s
Add docs/PAYMENTS.md (+ docs/PAYMENTS_ru.md mirror) — the monetization
mechanics specification: two-tier «Фишка» currency, per-source wallet
segments, per-origin benefits with the one-directional store-compliance
gate, order-flow payment intake, VK ads, configurable catalog,
append-only ledger, taxes, and native-Android distribution.

Add PLAN.md — the staged technical implementation plan with per-step
touch-points, tests, and done-criteria.
2026-07-07 23:11:49 +02: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 ca48b3e18e Merge pull request 'feat(offline): local pass-and-play (hotseat) — 2-4 players, seat/host PINs' (#211) from feature/offline-hotseat into development
CI / changes (push) Successful in 2s
CI / unit (push) Has been skipped
CI / integration (push) Has been skipped
CI / ui (push) Successful in 1m6s
CI / conformance (push) Successful in 9s
CI / changes (pull_request) Successful in 2s
CI / gate (push) Successful in 0s
CI / deploy (push) Successful in 1m44s
CI / unit (pull_request) Successful in 10s
CI / integration (pull_request) Successful in 15s
CI / ui (pull_request) Successful in 1m7s
CI / conformance (pull_request) Successful in 9s
CI / gate (pull_request) Successful in 0s
CI / deploy (pull_request) Has been skipped
2026-07-07 14:27:57 +00:00
Ilia Denisov a7f483792f feat(offline): randomise the hotseat seating (random first mover)
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 1m7s
CI / conformance (pull_request) Successful in 10s
CI / gate (pull_request) Successful in 0s
CI / deploy (pull_request) Successful in 1m42s
The 2-4 hotseat players now start in a random seating order (so who goes
first, and the turn order, is random) — matching the online games. The
engine starts at seat 0, so shuffling the seats at creation picks the
starter; the shuffle is seed-driven (a distinct derivation from the tile
bag), so replay reproduces it. Scoped to hotseat — vs_ai stays human-first.
- lib/roster.ts: shuffleSeeded (unit-tested); NewGame shuffles buildSeats
  with the game seed before create.
- e2e: pin a seed that keeps the roster order so the lock assertions stay
  deterministic.
2026-07-07 16:20:53 +02:00
Ilia Denisov 80cc2a76e8 fix(ui): unify tie-for-lead result across lobby + game (shared victory, not a draw)
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 1m7s
CI / conformance (pull_request) Successful in 9s
CI / gate (pull_request) Successful in 0s
CI / deploy (pull_request) Successful in 1m41s
Bring the online lobby badge and the in-game 'you won/lost' text to the
same competition ranking the hotseat seat medals use: in a 3-4 player game
a TIE for the lead is a SHARED victory for the top scorers and a placed
finish for those below — not a full draw for everyone (winner() is -1 on
any tie, so isWinner alone conflated them). An aborted game and a genuine
all-level finish stay a draw; a win by resignation (winner at a not-higher
score) is unchanged.
- result.ts: resultBadge no-winner branch ranks by final score; placeBadge
  helper; resigned seats excluded from the ranking.
- Game.svelte: resultText aligned (shared lead = won, below = lost).
2026-07-07 15:47:34 +02:00
Ilia Denisov 1e087be90a fix(offline): hotseat finished-game medals by final score (fix all-last-place on a tie)
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 1m6s
CI / conformance (pull_request) Successful in 10s
CI / gate (pull_request) Successful in 0s
CI / deploy (pull_request) Successful in 1m42s
Reported: a hotseat game ended by 6 scoreless passes deducts each rack
(correct rule, same as online: -8/-14/-8 here), which tied two seats for
the lead. The engine's winner() returns -1 on a tie, so seatMedal read it
as a full draw and gave EVERY seat the last-place medal.

- result.ts: seatMedal now ranks by the FINAL score (competition ranking —
  a tie for the lead shares the trophy), not the single-winner flag. A
  resigned / host-excluded seat places last with no medal and does not push
  the others down.
- model.ts: Seat.resigned (offline-only); engine.resignedOf getter; the
  local source surfaces it on the game view.
- The scoreless rack deduction is unchanged (standard rules, owner-confirmed).
2026-07-07 15:37:25 +02:00
Ilia Denisov 84d0385c95 fix(offline): scope the medal treatment to hotseat only
CI / changes (pull_request) Successful in 1s
CI / unit (pull_request) Has been skipped
CI / integration (pull_request) Has been skipped
CI / ui (pull_request) Successful in 1m7s
CI / conformance (pull_request) Successful in 10s
CI / gate (pull_request) Successful in 0s
CI / deploy (pull_request) Successful in 1m40s
Per owner: a vs_ai game (one human) keeps its 'you won/lost' status text
AND its lobby medal — the hidden-status + per-seat-medal treatment applies
only to hotseat, where 2-4 local players make a single 'you' meaningless.
isLocalGameId -> game.hotseat at the three call sites (statusBlock, seat
plaque, lobby card).
2026-07-07 15:00:59 +02:00
Ilia Denisov 0ed34c7720 feat(offline): per-seat medals in local games; drop the you-won text + lobby medal
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 1m6s
CI / conformance (pull_request) Successful in 10s
CI / gate (pull_request) Successful in 0s
CI / deploy (pull_request) Successful in 1m42s
For a local (offline) game the outcome is not always about a single
'you' (hotseat is 2-4 players), so:
- Game.svelte: a finished local game no longer shows the viewer-centric
  'you won/lost/draw' status; instead each seat plaque shows a per-seat
  place medal, left of the name (result.seatMedal — trophy for the winner,
  then places by score; a draw medals everyone).
- Lobby.svelte: a local game shows no lobby medal (resultBadge is
  viewer-centric and no seat matches the account) — its result lives on
  the in-game plaques.
- result.ts: seatMedal(game, seat), unit-tested.
2026-07-07 14:54:44 +02:00
Ilia Denisov 52d0c559f9 fix(ui): local-game history (hide social, keep dictionary); Enter dismisses keyboard; email-code autosubmit
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 1m7s
CI / conformance (pull_request) Successful in 11s
CI / gate (pull_request) Successful in 0s
CI / deploy (pull_request) Successful in 1m49s
- History drawer in a local (offline) game: the seat plaques no longer
  show the add-friend/block controls — canAddFriend/canBlock now exclude a
  local game (hotseat seats have synthetic account ids that slipped past
  the vs_ai-only guard) — and the Dictionary entry is restored: an active
  hotseat game keeps the comms button, and CommsHub is Dictionary-only for
  a chatless vs_ai OR hotseat game (ChatScreen never mounts offline).
- Enter on any single-line <input> now dismisses the soft keyboard (blur);
  a <textarea> (feedback) keeps Enter for newlines. One global handler.
- Login email code: the friend-code spread-digit style (.codein), a
  6-char cap, auto-submit on the 6th digit, and Enter to submit.
- e2e: the local-game history has no social controls and keeps the
  dictionary entry.
2026-07-07 14:45:53 +02:00
Ilia Denisov beda6ccd3d fix(ui): iOS soft-keyboard shell alignment + hotseat relock on return
CI / changes (pull_request) Successful in 1s
CI / unit (pull_request) Has been skipped
CI / integration (pull_request) Has been skipped
CI / ui (pull_request) Successful in 1m6s
CI / conformance (pull_request) Successful in 10s
CI / gate (pull_request) Successful in 0s
CI / deploy (pull_request) Successful in 1m43s
iOS Safari/WKWebView does not shrink the layout viewport for the soft
keyboard (Android Chrome does): the visual viewport shrinks AND offsets
down toward the focused field, and the values do not cleanly revert. The
pinned shell tracked only the height (--vvh), not the offset, so its
top-anchored content misaligned on iOS — empty space below the form,
worst on a repeat focus. Fix (one place; covers NewGame, Chat, Feedback):
- app.svelte.ts syncViewport: also mirror visualViewport.offsetTop into
  --vv-top, and scroll the focused field into view on the keyboard-open
  transition (iOS does not reliably scroll a pinned document to it).
- app.css: the pinned app-shell body follows top=--vv-top + height=--vvh
  (was inset:0), so it stays on the visible area.
- e2e/viewport.spec.ts emulates the visual-viewport resize+offset (a fake
  window.visualViewport) to verify the shell follows, on Chromium+WebKit.

Hotseat: returning to the lobby now re-locks the current seat (its PIN is
re-prompted) — LocalSource.relock, cleared in Game.svelte onDestroy.
Root-caused a crash it exposed: a $props() value (id) reads back undefined
during Svelte teardown, so isLocalGameId(id) threw and aborted onDestroy
(breaking navigation) — read the id from the loaded view instead.
2026-07-07 14:10:57 +02:00
Ilia Denisov c1ac77bc6a fix(offline): unify NewGame layout to natural-flow + PinPad verdict pause
CI / changes (pull_request) Successful in 1s
CI / unit (pull_request) Has been skipped
CI / integration (pull_request) Has been skipped
CI / ui (pull_request) Successful in 1m6s
CI / conformance (pull_request) Successful in 9s
CI / gate (pull_request) Successful in 0s
CI / deploy (pull_request) Successful in 1m42s
- NewGame: all three forms (quick / friends / hotseat) now use the
  Profile sub-view's natural-flow .page (no forced height, no pinned-CTA
  .grow/.fg scroll), so the screen's single .content scroll + --vvh handle
  overflow and a focused name input scrolls into view cleanly — fixes the
  keyboard blank-space / deep-scroll regression. One layout rule, one place.
- PinPad: pause 250ms after the 4th digit before the verdict, so the fill
  (and the shake on a wrong PIN) reads as a deliberate response.
- e2e: typePin waits for empty dots before typing (robust to the pause).
2026-07-07 13:19:22 +02:00
Ilia Denisov cafe67e9c8 fix(offline): hotseat form — natural scroll + focus-into-view (keyboard)
CI / changes (pull_request) Successful in 1s
CI / unit (pull_request) Has been skipped
CI / integration (pull_request) Has been skipped
CI / ui (pull_request) Successful in 1m6s
CI / conformance (pull_request) Successful in 9s
CI / gate (pull_request) Successful in 0s
CI / deploy (pull_request) Successful in 2m38s
The nested scroll region regressed the keyboard behaviour (a double
scroll: the whole screen scrolled with a huge blank area under the Start
button, and a focused bottom row hid behind the keyboard). Drop it: the
hotseat form now flows naturally and scrolls with the screen's own scroll
(.page.scrollform: no forced full height, no pinned button), and a name
input scrolls itself into view (centred above the keyboard) on focus.
2026-07-07 12:54:57 +02:00
Ilia Denisov 3adc4e32c9 fix(offline): hotseat review fixes + PWA stale-shell (SW route order)
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 1m6s
CI / conformance (pull_request) Successful in 10s
CI / gate (pull_request) Successful in 0s
CI / deploy (pull_request) Successful in 1m44s
Owner review of the pass-and-play PR:
- Roster delete (bug): a correct host PIN now ARMS a delete cross next to
  the row (mirroring the lobby delete) instead of removing it silently;
  tapping the kebab again clears the host authorisation.
- Variant picker (UX): replace the dropdown with the Quick-Match variant
  plaques + the multiple-words toggle, one-to-one.
- Keyboard layout (UX): the roster sits in a scroll region with the Start
  button pinned (friends-form pattern), so a name input's soft keyboard
  shrinks the region instead of leaving a full-height blank spacer painted
  behind the keyboard.
- e2e: variant via plaque + a row-delete (arm then cross) regression step.

PWA stale-version (pre-existing, same PR):
- sw.ts: register the network-first NavigationRoute BEFORE precacheAndRoute.
  Workbox matches in registration order and the precache route (directoryIndex
  index.html) shadowed the shell navigation, serving it cache-first — the old
  build's __APP_VERSION__ until a second load. Fixes both the maintenance
  self-reload and a cold open after a deploy. Doc updated (ARCHITECTURE).
2026-07-07 12:35:33 +02:00
Ilia Denisov 903de7d41d fix(offline): unlock finished hotseat games + raise entry budget to 120
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 1m5s
CI / conformance (pull_request) Successful in 10s
CI / gate (pull_request) Successful in 0s
CI / deploy (pull_request) Successful in 2m47s
- source: a finished hotseat game is never locked (its final rack is
  shown for review, not an Unlock button); guard locked on !isOver.
- bundle-size: raise the app-entry budget 115 -> 120 KB for the hotseat
  UI (PIN pad, roster, host menu), which lives in the always-loaded New
  Game / Game / Lobby screens; the offline engine + PIN hashing stay lazy.
2026-07-07 12:04:46 +02:00
Ilia Denisov baa9961c3e docs(offline): document hotseat pass-and-play
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 12s
CI / conformance (pull_request) Successful in 10s
CI / gate (pull_request) Failing after 0s
CI / deploy (pull_request) Has been skipped
ARCHITECTURE (offline section) + FUNCTIONAL (+_ru mirror): the local
pass-and-play mode — host/referee master PIN, per-seat optional PIN
locks (social lock, salted SHA-256, client-only), the board-visible seat
unlock, host overrides (skip/exclude/terminate), no hints/chat, and the
master-PIN-gated lobby deletion.
2026-07-07 11:56:40 +02:00
Ilia Denisov 66278e34b7 test(offline): e2e hotseat flow + fix $state-proxy persist
Add the Playwright mock e2e for offline pass-and-play (Chromium+WebKit):
create a 2-seat hotseat with a PIN-locked seat, verify the rack is
withheld, unlock (wrong then right PIN), host-skip the current turn, and
terminate from the lobby behind the master PIN.

The e2e surfaced a persistence bug: the roster PINs are $state proxies,
which fail structured-clone on the IndexedDB put (silently — best-effort
store), so a created hotseat game vanished on reload. Snapshot the seats
+ host PIN to plain objects at the create() call site.
2026-07-07 11:53:50 +02:00
Ilia Denisov 452a686e07 feat(offline): hotseat lobby delete behind the master PIN
Lobby.svelte: hotseat games show the kebab / swipe delete in BOTH the
active and finished groups (not finished-only); deleting one opens a
master-PIN pad first — active = terminate (no result), finished = remove
— so the last mover cannot instantly wipe a game. Local games stay
deletable offline. Non-hotseat games are unchanged (finished-only, free).
2026-07-07 11:43:02 +02:00
Ilia Denisov 4ddc690c38 feat(offline): hotseat in-game seat lock + host menu
Game.svelte drives a hotseat game:
- the seat-to-move's rack is replaced by an Unlock button when the seat
  is PIN-locked (board stays visible); canMove gates the move controls
  on the unlock; PinPad(verify) -> source.unlockSeat reveals it.
- the hint slot becomes a host button (hints off in hotseat), always
  enabled while the game runs (acts on a locked seat too). Master-PIN ->
  action sheet: skip current / exclude a player / end the game, each with
  a confirm + a fading check; terminate deletes and returns to the lobby.
- per-turn advance re-fetches the next seat's (locked) state; self-resign
  and chat are hidden (hotseat resign is a host action, no comms).
- gamesource: expose unlockSeat / verifyHostPin / hostAction on the local
  proxy. i18n hotseat.* (en + ru).
2026-07-07 11:39:43 +02:00
Ilia Denisov 6216359c6f feat(offline): hotseat creation roster + host-participate flow
NewGame offline 'with friends' now builds a local pass-and-play game:
- lib/roster.ts: keep-last-valid name + roster->seats (unit-tested).
- NewGame.svelte: master host-PIN gate (rows disabled until set),
  'are you playing too?' prompt seating the host at row 0, 2-4 player
  rows with inline name validation + optional per-seat PIN, add/remove
  (remove behind the master PIN), variant + multiple-words, Start.
- PinPad: verification via a verify(pin) callback (UI-held lock at
  creation, source-held in-game) instead of a lock prop.
- i18n: hotseat.* (en + ru); the offline mode selector is now shown offline.
2026-07-07 11:28:17 +02:00
Ilia Denisov 8c67d679d9 feat(offline): hotseat record schema + source (locks, host actions)
Extend the local game to offline pass-and-play (hotseat):
- serialize.ts: Seat.pin, record hotseat + hostPin (all optional; old
  vs_ai records read hotseat as false).
- source.ts: create() takes hotseat/hostPin + per-seat pins; stateView
  reveals the seat-to-move's rack and withholds it (locked) when that
  seat is PIN-locked, until unlockSeat; ephemeral per-turn unlock
  re-locks on advance; new unlockSeat / verifyHostPin / hostAction
  (skip/resign/terminate); gameView sets vsAi=!hotseat + the hotseat flag.
- model.ts: GameView.hotseat, StateView.locked (offline-only, optional).

Tests: source.hotseat (lock/unlock/re-lock, host skip/resign/terminate,
master-PIN gate, natural-end persistence) + serialize hotseat shape.
2026-07-07 11:20:39 +02:00
Ilia Denisov 69673e7727 feat(offline): PIN lock primitives + Apple-style keypad
Groundwork for offline pass-and-play (hotseat) seat/host PIN locks:
- lib/pin.ts: salted SHA-256 PinLock (newSalt/hashPin/newLock/verifyPin);
  a social lock for a shared device (documented, not cryptography).
- components/PinPad.svelte: 4-digit keypad (set/verify/change), auto-submit
  on the 4th digit, shake on mismatch, hardware-keyboard support.
- i18n: pin.* keys (en + ru).

Engine/source/UI wiring follows.
2026-07-07 11:11:13 +02:00
developer d7f3d93c6c Merge pull request 'fix(router): sync route in navigate() — kill boot lobby-flash + offline e2e flake' (#210) from fix/boot-route-lag-e2e-flake into development
CI / changes (push) Successful in 1s
CI / unit (push) Has been skipped
CI / integration (push) Has been skipped
CI / ui (push) Successful in 1m4s
CI / conformance (push) Successful in 10s
CI / gate (push) Successful in 0s
CI / deploy (push) Successful in 1m39s
2026-07-06 23:51:21 +00:00
Ilia Denisov 16cd3d0411 fix(router): update route rune synchronously in navigate()
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 1m4s
CI / conformance (pull_request) Successful in 10s
CI / gate (pull_request) Successful in 0s
CI / deploy (pull_request) Successful in 1m42s
navigate() wrote location.hash and left router.route to the asynchronous
hashchange event. bootstrap flips app.ready in the same tick right after
navigate('/login') on an unauthenticated cold start, so for one frame the app
rendered with app.ready=true and the stale route ('lobby', from the empty hash)
— briefly mounting the lobby shell (tab bar + a doomed games.list) under the new
login screen before hashchange settled it.

Update router.route synchronously inside navigate(); the later hashchange
re-parses to the same value. This removes the boot lobby-flash (and its spurious
games.list 401) and, as the root cause, the offline e2e flake: enterLobby could
latch that transient lobby tab-bar instead of clicking through login, then the
Settings-tab click at line 44 fought the tab detaching into the login slide
(~7% of runs, both engines).

Also:
- offline.spec enterLobby: wait for the guest button and click it, instead of a
  point-in-time count() that a pre-login splash frame sampled as 0 (skipping the
  click and hanging / latching the transient).
- New e2e router.spec pins the synchronous-route property (RED on the old code,
  which returned the previous route; GREEN now) via a mock-only __router seam.

Verify: check 0 / unit 490 / build / bundle 114.3/115 / full e2e 200 / the
offline+router stress at 40x on both engines 160/160 (was 6/80 failing).
2026-07-07 01:44:16 +02:00
developer ef8a32bb82 Merge pull request 'feat(hint): unify the vs_ai idle hint online + offline (server-enforced, monotonic)' (#209) from feature/online-vsai-hint into development
CI / changes (push) Successful in 1s
CI / unit (push) Successful in 10s
CI / integration (push) Successful in 16s
CI / ui (push) Successful in 1m12s
CI / conformance (push) Successful in 10s
CI / gate (push) Successful in 0s
CI / deploy (push) Successful in 1m40s
2026-07-06 23:04:38 +00:00
Ilia Denisov 53311cbc95 fix(hint): arm the vs_ai idle-gate from the warm cache so the lock shows without a delay
CI / changes (pull_request) Successful in 2s
CI / unit (pull_request) Successful in 10s
CI / integration (pull_request) Successful in 17s
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 1m41s
Entering a vs_ai game from the lobby armed the idle-hint countdown only in load() (after
the gameState round-trip), so the lock popped in after a visible delay. Arm it on the
instant warm-cache render in onMount too — the preloadGames-warmed StateView carries
hint_unlock_left_seconds; load() then refreshes the snapshot. A first move (0 seconds
left) stays open.
2026-07-07 00:59:06 +02:00
Ilia Denisov 7fc1301b31 feat(hint): unify the vs_ai idle hint online + offline (server-enforced, monotonic)
CI / changes (pull_request) Successful in 2s
CI / unit (pull_request) Successful in 10s
CI / integration (pull_request) Successful in 15s
CI / ui (pull_request) Successful in 1m28s
CI / conformance (pull_request) Successful in 9s
CI / gate (pull_request) Successful in 0s
CI / deploy (pull_request) Successful in 1m43s
Online vs_ai hints were broken: #207 made the vs_ai hint button always-enabled and
wallet-free (assuming all vs_ai = the offline idle-gate), but the backend still served
online vs_ai from the allowance/wallet, so over-clicking hit ErrNoHintsLeft -> a generic
error toast. Now online vs_ai uses the SAME idle-gate model as offline.

Backend: Hint() for a vs_ai game skips the allowance/wallet and increments no hints_used
(owner: vs_ai counts toward no hint statistic), and is idle-gated from the SERVER clock --
it returns ErrHintLocked (code hint_locked) until the robot's last move + 30 min, else
serves the top move. GameState/StateView expose hint_unlock_left_seconds (server-computed
seconds remaining; 0 for a human game / first move / not-your-turn). Pure helper
hintUnlockLeftSeconds unit-tested.

Wire: StateView gains hint_unlock_left_seconds (FlatBuffers, additive); pkg/wire + gateway
transcode carry it (round-trip test).

Client: the gate counts down from a MONOTONIC clock (performance.now()) anchored to the
source's seconds-left when it lands (on load from the view; to the full window when the
robot moves), so a client clock change cannot skew it and a relaunch re-reads a fresh
value. The vs_ai hint button (online + offline) shows the lock + toast; doHint handles the
hint_locked backstop by re-syncing. Replaces #207's absolute hintUnlockAtMs on the
wire/model/delta (the offline record keeps the absolute for persistence; the view exposes
seconds-left).

Docs FUNCTIONAL(+_ru)/ARCHITECTURE updated. Verified: go build/vet + game/server/transcode
tests (+ new hintUnlockLeftSeconds); ui check 0 / unit 490 / e2e 198 (one pre-existing
webkit offline flake) / app entry 114.2/115.
2026-07-07 00:42:43 +02:00
developer ee7ff06403 Merge pull request 'feat(hint): idle-gated wallet-free vs_ai hint on a monotonic clock (B4)' (#207) from feature/offline-hint-gate into development
CI / changes (push) Successful in 2s
CI / unit (push) Has been skipped
CI / integration (push) Has been skipped
CI / ui (push) Successful in 1m11s
CI / conformance (push) Successful in 9s
CI / gate (push) Successful in 0s
CI / deploy (push) Successful in 1m39s
2026-07-06 21:46:18 +00:00
Ilia Denisov 537a265409 Merge branch 'development' into feature/offline-hint-gate
CI / changes (pull_request) Successful in 3s
CI / unit (pull_request) Has been skipped
CI / integration (pull_request) Has been skipped
CI / ui (pull_request) Successful in 1m28s
CI / conformance (pull_request) Successful in 10s
CI / gate (pull_request) Successful in 0s
CI / deploy (pull_request) Successful in 1m42s
# Conflicts:
#	ui/src/lib/localgame/source.ts
2026-07-06 23:23:12 +02:00
developer 8c991429c6 Merge pull request 'fix(offline): zero tile weights on cold boot + wrong-mode game in the lobby' (#208) from fix/offline-alphabet-and-lobby into development
CI / changes (push) Successful in 2s
CI / unit (push) Has been skipped
CI / integration (push) Has been skipped
CI / ui (push) Successful in 1m12s
CI / conformance (push) Successful in 10s
CI / gate (push) Successful in 0s
CI / deploy (push) Successful in 1m48s
2026-07-06 21:21:19 +00:00
Ilia Denisov 359af83c34 fix(offline): zero tile weights on cold boot + wrong-mode game in the lobby
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 1m29s
CI / conformance (pull_request) Successful in 10s
CI / gate (pull_request) Successful in 0s
CI / deploy (pull_request) Successful in 1m43s
Two pre-existing offline bugs, both exposed once offline mode became usable (cold boot +
toggling), owner-reported on the contour.

Bug 1 -- offline tiles render weight 0. The rack/board read tile values from the
lib/alphabet cache, populated only by the online wire codec (server-sent alphabet) or the
mock (so the e2e masked it). A local game never goes through the codec, so on a cold
offline boot with no prior online session the cache was empty and every value read 0
(scores stayed correct -- they come from the offline ruleset). Fix: the local source
seeds lib/alphabet from the static offline ruleset (letters + values) when it builds a
game view.

Bug 2 -- the lobby showed (and could open) the other mode's game after a toggle. Two
causes: (a) the lobby cache was not mode-tagged, so getLobby() instant-rendered the last
snapshot regardless of mode; (b) load() wrote the module list after an await with no
generation guard, so a slow online gamesList() finishing after a fast offline list() (or
vice versa) left the wrong mode's games on screen. Fix: tag the snapshot with offline and
gate getLobby() on it; add a load-sequence guard so only the latest load() writes.

- localgame/source.ts: ensureAlphabet from the ruleset in gameView (+ unit test).
- lobbycache.ts: LobbySnapshot.offline + getLobby(offline) mode check (+ tests).
- Lobby.svelte: setLobby tags the mode; load() carries a generation guard.

check 0 / unit 485 / e2e 198 / app entry 113.8/114.
2026-07-06 23:11:17 +02:00
Ilia Denisov ff486c80f8 feat(hint): persist the vs_ai idle-hint wait (wall-clock + read sanitiser)
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 1m28s
CI / conformance (pull_request) Successful in 11s
CI / gate (pull_request) Successful in 0s
CI / deploy (pull_request) Successful in 1m44s
Per the owner's call, the idle-hint gate now PERSISTS across leaving and reopening the
app, instead of the session-scoped monotonic clock: the unlock is a wall-clock instant
(hintUnlockAtMs) stamped from the robot's reply, stored on the local record, carried on
the game view + the opponent_moved move delta, and read through a sanitiser that caps it
at now + the window. So:
- the wait survives a relaunch (a stuck turn is not forgotten);
- a device clock set BACK cannot freeze the gate (the cap bounds the remaining to the
  window and self-heals on the next read);
- a clock set FORWARD just opens the hint early -- accepted as harmless for a solo game.

- lib/hints.ts hintGateRemainingMs now takes the unlock instant + clamps to the window.
- localgame: re-add hintUnlockAtMs to the record/meta; stamp it off the robot's reply;
  sanitise on read (a shared hintUnlock helper feeds stateView + the opponent_moved event).
- gamedelta + PushEvent: the move delta carries hintUnlockAtMs so the view stays fresh.
- Game.svelte: hintUnlockAt derives from the view; the tick + lock + toast unchanged.
- offline.spec.ts: also assert the lock survives a reload (the wait persisted).
- Docs FUNCTIONAL(+_ru)/ARCHITECTURE updated (persist + sanitiser, not monotonic-resets).
2026-07-06 22:34:50 +02:00
Ilia Denisov 42a6308261 feat(hint): idle-gated wallet-free vs_ai hint on a monotonic clock (B4)
CI / changes (pull_request) Successful in 3s
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 11s
CI / gate (pull_request) Successful in 0s
CI / deploy (pull_request) Successful in 1m42s
A vs_ai hint is now unlimited and wallet-free, but idle-gated as an anti-frustration
aid: it unlocks only after the player has been stuck ~30 min on a turn (timed from the
robot's last move; the human's first move, before the robot has played, is exempt).
While gated the hint button carries a small lock badge and a tap shows the remaining
minutes ('Available in N min.'); the lock lifts live at the mark.

The gate is enforced CLIENT-SIDE against a MONOTONIC clock (performance.now()), never a
wall-clock timestamp: a device clock the player controls (or an auto-sync) must not be
able to open or freeze it. The wait is therefore session-scoped -- a reload restarts it
(there is no tamper-proof way to carry idle time across a relaunch without a wall clock).
An online vs_ai game will gate the same way but from the server's clock (a follow-up).

- lib/hints.ts: HINT_GATE_MS + pure hintGateRemainingMs (monotonic) + hintLockMinutes;
  removed the wall-clock hintRemainingMs. Unit-tested red->green.
- Game.svelte: the monotonic gate (hintGateStart/monoNow, armed on the turn change), a
  vs_ai hint button (plain: no confirm, no count; lock badge + gated tap -> toast), a
  live 10s tick.
- localgame: removed the wall-clock hint gate and the now-dead robotLastMoveAtUnix field
  from source.ts/serialize.ts (the client is authoritative); hint() just serves the top
  move.
- i18n game.hintLockedIn.
- offline.spec.ts: assert the first move is un-gated and the lock arms after the robot moves.
- Docs FUNCTIONAL(+_ru) + ARCHITECTURE; bundle budget 114->115 (game-screen feature).
2026-07-06 22:03:27 +02:00
developer 08792dad3d Merge pull request 'test(offline): mock e2e for a device-local vs_ai game (C8)' (#206) from feature/offline-mock-e2e into development
CI / changes (push) Successful in 2s
CI / unit (push) Successful in 11s
CI / integration (push) Successful in 16s
CI / ui (push) Successful in 1m28s
CI / conformance (push) Successful in 9s
CI / gate (push) Successful in 0s
CI / deploy (push) Successful in 1m42s
2026-07-06 19:07:40 +00:00
Ilia Denisov e9f4cb0178 test(offline): mock e2e for a device-local vs_ai game (C8)
CI / changes (pull_request) Successful in 1s
CI / unit (pull_request) Successful in 10s
CI / integration (pull_request) Successful in 15s
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 1m43s
A Playwright spec drives the whole offline flow in the mock build: force the
installed-PWA display mode, enter offline via the Settings toggle (its readiness check
fetches the dawgs), assert the blue chrome + online-games-hidden + Stats-disabled, then
create and play a device-local English vs_ai game with a pinned bag seed (deterministic
rack NEWYMAO) -- play the opening WAY across the centre, watch the robot reply with a
real move, and reload to confirm the IndexedDB replay.

Enabling infra (all e2e-only; nothing enters the production build):
- mock/client.ts fetchDict serves the per-variant dawgs from the preview build's
  /e2edict/ (was: threw 'unsupported in mock').
- scripts/e2e-dict.mjs copies the real dawgs into dist-e2e from E2E_DICT_DIR (the ui CI
  job fetches the scrabble-dictionary release like the Go jobs; local default: the
  sibling scrabble-solver/dawg); playwright.config runs it between build and preview.
- localgame/id.ts setForcedSeed + gateway.ts window.__mock.setLocalSeed: a mock-only
  seam to pin a local game's bag seed (tree-shaken from prod).
- ci.yaml ui job: fetch the dawgs + pass E2E_DICT_DIR to the e2e step.
- docs/TESTING.md: the offline e2e + the mock-dawg wiring.

Verified: check 0 / unit 482 / e2e 198 (both engines) / app entry 113.8/114.
2026-07-06 20:54:03 +02:00
developer 8fbbb3c5ef Merge pull request 'feat(offline): gate the offline toggle on dictionary readiness' (#205) from feature/offline-toggle-readiness into development
CI / changes (push) Successful in 2s
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 / deploy (push) Successful in 1m41s
2026-07-06 18:12:01 +00:00
Ilia Denisov 30770a759b feat(offline): gate the offline toggle on dictionary readiness
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 9s
CI / gate (pull_request) Successful in 1s
CI / deploy (pull_request) Successful in 1m44s
Flipping the Settings toggle to offline now checks that every enabled variant's
dictionary is on the device before entering offline mode: it fetches missing ones
cache-first and waits up to ~5 s (raceOfflineReady + the lazy dict/offlineready),
greying the toggle meanwhile. If they cannot be readied in time it stays online and
shows a 'needs internet' note, while the fetch keeps warming the cache in the
background so a later flip is instant. Leaving offline is never gated.

Prevents entering a half-baked offline mode (no dawg -> cannot create/play a local
game) when the background preload has not finished (poor connection, or an immediate
flip right after install).

- offline.ts: raceOfflineReady (pure, injected sleep; unit-tested red->green)
- dict/offlineready.ts: ensureOfflineDicts (cache-first preloadDicts, lazy chunk)
- offline.svelte.ts: requestOffline + TOGGLE_READY_BUDGET_MS
- Settings.svelte: checking/needsData state, disabled toggle, inline note
- i18n: settings.offlineChecking / settings.offlineNeedsData (en+ru)
- docs: FUNCTIONAL(+_ru) offline story + ARCHITECTURE offline paragraph
2026-07-06 20:03:12 +02:00
developer 05c445e4da Merge pull request 'docs(offline): connectivity auto-detect + kill switch user story' (#204) from feature/offline-autodetect-docs into development
CI / changes (push) Successful in 2s
CI / unit (push) Has been skipped
CI / integration (push) Has been skipped
CI / ui (push) Has been skipped
CI / conformance (push) Has been skipped
CI / gate (push) Successful in 0s
CI / deploy (push) Successful in 1m44s
2026-07-06 17:47:55 +00:00
Ilia Denisov d43a740eb6 docs(offline): document connectivity auto-detect + transport kill switch
CI / changes (pull_request) Successful in 1s
CI / unit (pull_request) Has been skipped
CI / integration (pull_request) Has been skipped
CI / ui (pull_request) Has been skipped
CI / conformance (pull_request) Has been skipped
CI / gate (pull_request) Successful in 0s
CI / deploy (pull_request) Successful in 1m43s
The offline auto-detect shipped in #202 (cold-start no-connection dialog / auto
offline) and #203 (mid-session flight-mode reactivity) but neither PR baked the
docs. Add the user story to FUNCTIONAL.md (+ _ru mirror) and the mechanism to the
ARCHITECTURE offline section: the auto vs deliberate distinction, the cold-start
navigator.onLine / bounded reachability-probe decision, the mid-session window
online/offline events backed by a navigator.onLine poll (the events are unreliable
on some platforms, notably iOS PWAs), and the reachability probe being the one call
exempt from the offline kill switch.
2026-07-06 19:41:09 +02:00
developer 350013acd9 Merge pull request 'feat(offline): mid-session flight-mode reactivity [PR2]' (#203) from feature/offline-flightmode into development
CI / changes (push) Successful in 2s
CI / unit (push) Has been skipped
CI / integration (push) Has been skipped
CI / ui (push) Successful in 1m10s
CI / conformance (push) Successful in 9s
CI / gate (push) Successful in 0s
CI / deploy (push) Successful in 1m52s
2026-07-06 17:31:17 +00:00
Ilia Denisov 3ad66f49c7 fix(offline): set the auto flag in setOfflineMode; remove temp diagnostic
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 1m51s
The auto-offline -> online return was dead because setOfflineMode never set the
'auto' flag: I added the state + getter but forgot the assignment, so it stayed
false. So scheduleRecovery bailed immediately (no poll ran) and the online event's
'if (active && auto)' was false. The contour diagnostic confirmed offline.auto
stayed false after an auto-offline. Add 'auto = on && !persist'.

Also remove the temporary network diagnostic panel (netdiag + the lobby strip) --
it did its job of pinpointing this.
2026-07-06 19:20:59 +02:00
Ilia Denisov 09c5a5e72e chore(offline): TEMPORARY network diagnostic panel (remove before release)
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 9s
CI / gate (pull_request) Successful in 0s
CI / deploy (pull_request) Successful in 1m44s
A fixed strip in the lobby showing live navigator.onLine / offline.active /
offline.auto / connection.online plus a timestamped event log (offline/online
events, poll ticks, checkReachable results, mode changes) - to pinpoint where the
auto-offline -> online transition breaks on the contour. A 1s heartbeat logs
navigator.onLine flips even if the events never fire. Skipped in the mock; to be
reverted with the fix.
2026-07-06 19:03:33 +02:00
Ilia Denisov fffc6030ce fix(offline): poll navigator.onLine to return online (the online event is unreliable)
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 1m24s
CI / conformance (pull_request) Successful in 10s
CI / gate (pull_request) Successful in 0s
CI / deploy (pull_request) Successful in 1m41s
The auto-offline -> online return still did not fire: the retry hung on the
'online' event, which an installed PWA often does not deliver. Replace it with a
lightweight poll while in auto-offline that reads navigator.onLine (a reliable
live flag, unlike the event) and only hits the network for a reachability check
when the interface is actually up - so flight mode ON costs no radio, and flight
mode OFF is detected within ~4s and returns online. The 'online' event, when it
does fire, just kicks an immediate check. Runs only in auto-offline (deliberate
offline is the player's choice); wired for both the mid-session and cold-start
auto-offline paths.
2026-07-06 18:50:10 +02:00
Ilia Denisov fc8143758a fix(offline): return online after flight-mode off; gate online-game actions offline
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 9s
CI / gate (pull_request) Successful in 0s
CI / deploy (pull_request) Successful in 1m38s
Two mid-session issues found on the contour:
- Auto-offline did not return to online after the network came back: a single
  reachability check right after the 'online' event failed (the interface is up
  before the gateway is actually reachable again). Retry a few times with backoff
  (tryReturnOnline) — that is what actually gets the app back online.
- An online game viewed while offline (flight mode on) still enabled its network
  actions, so they hit the kill switch and raised 'something went wrong' toasts.
  Gate them: netReady = isLocalGame || (connection.online && !offlineMode.active)
  — a local game stays fully usable; an online game's make/exchange/hint/resign
  disable while offline and re-enable when back. Also suppress the 'offline' code
  in handleError (a blocked call in offline mode is expected, not a toast).
2026-07-06 18:35:52 +02:00
Ilia Denisov e0d28733ff feat(offline): mid-session flight-mode reactivity (auto-offline self-heals)
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 1m41s
React to the network changing while the app is open (e.g. the player toggling
flight mode), via passive online/offline events - no polling, no battery cost:
- interface lost -> enter offline mode for the session (auto);
- interface back -> if the offline was auto, verify the gateway is really
  reachable (an interface being up does not guarantee it) and return online; a
  deliberate offline (the toggle or the cold-start dialog) is left as-is.

- offline.svelte: track `auto` (auto-detected vs the player's deliberate choice).
- connection.svelte: checkReachable is now a pure one-shot (the caller decides);
  the reachability watcher never probes in offline mode (events drive recovery).
- transport.ts: the reachability probe is exempt from the kill switch - it IS
  the mechanism that decides whether to return online, fired only deliberately.
- app.svelte.ts: initNetworkReactivity wires the events (web-only, skipped in
  the mock); called from bootstrap.

Online unaffected (skipped in the mock e2e): e2e 196. Mid-session reactivity is
contour-verified.
2026-07-06 18:15:12 +02:00
developer ccd65f61b8 Merge pull request 'feat(offline): auto-detect no network at cold start + go-offline dialog [PR1]' (#202) from feature/offline-autodetect into development
CI / changes (push) Successful in 1s
CI / unit (push) Has been skipped
CI / integration (push) Has been skipped
CI / ui (push) Successful in 1m13s
CI / conformance (push) Successful in 9s
CI / gate (push) Successful in 0s
CI / deploy (push) Successful in 1m52s
2026-07-06 16:11:17 +00:00
Ilia Denisov 5bc2ad3b6d feat(offline): auto-detect no network at cold start + 'go offline?' dialog
CI / changes (pull_request) Successful in 1s
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 1m44s
A sticky-online cold start with no network hung the splash on adoptSession's
retrying profile fetch. Now, for an offline-capable web install with a cached
profile:
- No network interface (navigator.onLine === false) -> enter offline mode for
  the session (no dialog; the next launch re-evaluates).
- Interface up but the gateway is unreachable within 3s (a single-attempt
  reachability probe, not the 6-retry loop) -> a 'No connection. Enable offline
  mode?' dialog: Enable -> sticky offline; Keep trying -> the normal online
  adopt (retries, 'Connecting...').

- connection.svelte: checkReachable(timeout) - a bounded single probe.
- offline.svelte: setOfflineMode(on, persist) - auto-offline is session-only, a
  deliberate choice (dialog/toggle) is sticky.
- app.svelte.ts: the cold-start auto-detect in bootstrap + the dialog resolver;
  App.svelte renders the boot dialog. i18n en/ru.
- App-entry bundle budget 113->114 (the boot path cannot be lazy-loaded).

Online cold-start unaffected (auto-detect gated to isStandalone, off in the mock
e2e): e2e 196. The offline paths are contour-verified.

Next: PR2 - mid-session flight-mode reactivity (online/offline events).
2026-07-06 17:49:53 +02:00
developer 2a4b0fb25e Merge pull request 'fix(offline): delete a finished local game from the device, not the network' (#201) from feature/offline-delete-game into development
CI / changes (push) Successful in 1s
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 / deploy (push) Successful in 1m38s
2026-07-06 15:06:17 +00:00
developer 2a7c632840 Merge pull request 'fix(pwa): network-first navigation so a new deploy loads online immediately' (#200) from feature/sw-fresh-online into development
CI / changes (push) Successful in 2s
CI / unit (push) Successful in 10s
CI / integration (push) Successful in 14s
CI / ui (push) Successful in 1m24s
CI / conformance (push) Successful in 10s
CI / gate (push) Successful in 0s
CI / deploy (push) Successful in 1m43s
2026-07-06 15:05:37 +00:00
Ilia Denisov 3a72bc29ba fix(offline): delete a finished local game from the device, not the network
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 1m41s
The lobby's finished-game delete (hide) always called gateway.hideGame; for a
local (offline) game the transport kill switch refused it, so it toasted and the
game stayed. Route by id: a local game is removed from the device store + the
source cache (LocalSource.delete), an online game still hides on the backend.
2026-07-06 16:59:36 +02:00
Ilia Denisov 020742fad3 fix(pwa): network-first navigation so a new deploy loads online immediately
CI / changes (pull_request) Successful in 2s
CI / unit (pull_request) Successful in 10s
CI / integration (pull_request) Successful in 18s
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 1m43s
The C1 service worker served the precached shell for every navigation, even
online, so a new deploy only reached a client after the worker updated its
precache in the background (a one-launch lag): a cold online start showed the
old version until then, and only a reinstall forced it fresh.

- sw.ts: navigations are now network-first — fetch the fresh (no-cache) shell
  from the server on each online launch (it references the new hashed assets,
  fetched fresh), with a 3s timeout falling back to the precached shell when the
  network is unreachable or too slow. Only the tiny HTML is re-fetched; the
  immutable hashed assets stay cache-first and re-download only when their hash
  (the version) changes. Offline cold-launch still works via the fallback.
- webui.go: serve sw.js with Cache-Control: no-cache so the browser reliably
  detects a new deploy's worker (regression-tested).

Online launch is contour-verified (the mock e2e disables the SW); e2e 196.
No new deps — the network-first handler is hand-written (~12 lines).
2026-07-06 16:45:07 +02:00
developer fd225564a3 Merge pull request 'fix(offline): cold-boot offline from the persisted session + profile' (#199) from feature/offline-boot into development
CI / changes (push) Successful in 2s
CI / unit (push) Has been skipped
CI / integration (push) Has been skipped
CI / ui (push) Successful in 1m9s
CI / conformance (push) Successful in 10s
CI / gate (push) Successful in 0s
CI / deploy (push) Successful in 1m41s
2026-07-06 14:25:11 +00:00
Ilia Denisov fdf14d5897 fix(offline): skip the NewGame friend fetch offline
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 9s
CI / gate (pull_request) Successful in 0s
CI / deploy (pull_request) Successful in 1m40s
NewGame's onMount fetched the friend list (gateway.friendsList) for a non-guest;
offline the transport kill switch refuses it, so it toasted on entering the New
Game screen (the local game still created fine). The friends section is hidden
offline anyway — skip the fetch when offline.
2026-07-06 16:12:50 +02:00
Ilia Denisov 1a456c4847 feat(offline): transport kill switch + gate the network-requiring UI
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 1m43s
Offline mode was 'fiction': the UI only disabled the Stats tab, but Profile was
reachable and a profile save actually hit the server and reported 'saved'.

Two layers now:
- Transport kill switch (transport.ts): in offline mode every network op — each
  unary RPC (exec), the live stream (subscribe), the dict/metrics fetches and
  the reachability probe — is refused before it leaves the device. Offline
  truly means offline regardless of any UI that slips through. The local
  (device-only) game path never uses this transport, so it is unaffected.
- UI gating: the Profile and Friends tabs (SettingsHub) and the Feedback entry
  (About) are disabled offline, and the hub falls back to Settings (which holds
  the online/offline toggle); the lobby Stats tab was already disabled. In-game
  social/export are already hidden for a vs_ai game.

Online unaffected (offlineMode is off): e2e 196. Offline gating is
contour-verified (the mock e2e cannot enter offline).
2026-07-06 15:46:22 +02:00
Ilia Denisov 2a045a5b37 fix(offline): give the local human seat the real account id
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 1m41s
In a local game the human seat's account id was a synthetic 'local:human:0',
so seatName's `accountId === session.userId` check never matched: the game
header rendered BOTH seats as 🤖 (the vs_ai fallback), and the lobby's
groupGames could not find the viewer's seat, so a human's turn read as
'Their turn' with the hourglass. Carry the real account id on the human seat
(create -> record -> GameView); the robot keeps its synthetic id.

Local games created before this fix keep the old display (no migration).
2026-07-06 15:36:51 +02:00
Ilia Denisov 19e7ea5da0 fix(offline): persist a plain profile snapshot; keep the sticky offline flag
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 1m40s
The offline cold-boot never engaged: it persisted app.profile — a Svelte
$state proxy — which is not structured-cloneable, so the IndexedDB write threw
and fell back to a localStorage entry that loadProfile (IDB-only on a
successful-empty read) never reads. loadProfile returned null, the boot
short-circuit missed, and it even cleared the sticky offline flag — so offline
mode stopped persisting across relaunches (owner-observed on the contour).

- Persist $state.snapshot(app.profile) (a plain object) at both persist sites,
  so the IndexedDB write succeeds and loadProfile round-trips.
- Drop the setOfflineMode(false) fallback: keep the deliberate offline flag on a
  cache miss (the mode is the player's choice; an online boot re-persists the
  profile so the next launch goes offline). A truly offline launch with no
  cached profile is unreachable (enabling offline needs a prior online session).
2026-07-06 15:20:15 +02:00
Ilia Denisov 54d701fd8a fix(offline): cold-boot offline from the persisted session + profile
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 1m9s
CI / conformance (pull_request) Successful in 10s
CI / gate (pull_request) Successful in 0s
CI / deploy (pull_request) Successful in 1m39s
An installed PWA relaunched with the network off hung on the splash: C1's
precache served the shell, but bootstrap() adopted the session and fetched the
profile over the network, which hung. Persist the profile (on every online
adopt + refresh, cleared on logout) and short-circuit bootstrap when the
deliberate offline flag is sticky-on: with a cached session + profile, skip the
network and land straight in the offline lobby. Without a cached profile (never
online), drop the sticky flag and boot online — the first launch must be online.

- session.ts: saveProfile/loadProfile/clearProfile (mirror saveSession).
- offline.ts: shouldBootOffline decision (unit-tested).
- app.svelte.ts: persist in adoptSession + refreshProfile, clear on logout,
  the offline boot short-circuit in bootstrap.

Docs: ARCHITECTURE. Online cold-start unaffected (e2e 196); the offline boot is
contour-verified (the mock e2e cannot enter sticky-offline).
2026-07-06 14:54:09 +02:00
developer ec0c13bebc Merge pull request 'feat(offline): offline lobby — list + create + play local vs_ai games [C6]' (#198) from feature/offline-lobby into development
CI / changes (push) Successful in 2s
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 / deploy (push) Successful in 1m43s
2026-07-06 12:43:23 +00:00
Ilia Denisov 5643c8be10 fix(offline): AI comms hub opens straight to the Dictionary, not via Chat
CI / changes (pull_request) Successful in 1s
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 9s
CI / gate (pull_request) Successful in 0s
CI / deploy (pull_request) Successful in 1m41s
For an honest-AI (vs_ai) game the comms hub relied on a post-mount $effect to
switch from the Chat tab to the Dictionary, so ChatScreen mounted for a beat
and fired its chat/state fetch over the network. Online that was a wasted
call; offline it threw and raised a 'something went wrong' toast on entering
the word-check form (the check itself already worked). Start the comms hub on
the Dictionary tab immediately for a vs_ai game, so ChatScreen never mounts.
2026-07-06 14:22:01 +02:00
Ilia Denisov 2f70ef1b85 fix(offline): route word-check through the game source; reload lobby on offline flip
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 9s
CI / gate (pull_request) Successful in 0s
CI / deploy (pull_request) Successful in 1m40s
Two offline bugs found on the contour:
- The word-check screen (CheckScreen) called gateway.gameState/checkWord
  directly, so in an offline (local) game it hit the network and errored
  ("something went wrong"). Route both through gameSource(id) — the local
  source answers from the device dawg. The complaint control (online-only,
  no offline backend) is hidden for a local game.
- The lobby did not react to the offline-mode toggle (which lives in Settings,
  so the lobby can stay mounted): an online game lingered until the next
  reload. Reload on an offlineMode flip so entering offline immediately shows
  only device-local games.

Cold offline launch hanging on the splash (boot still fetches the profile over
the network) is the separate C2 offline-boot follow-up (needs a persisted
profile + a boot short-circuit).
2026-07-06 14:04:23 +02:00
Ilia Denisov ef832b823d feat(offline): offline lobby lists + creates local vs_ai games
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 1m39s
In offline mode the lobby now shows only the device-local games and its
New-vs-AI entry creates one through the in-browser engine — the visible
payoff of the offline mode.

- LocalSource.list() reconstructs a lobby GameView per stored local game by
  replay, exposed through the lazy gamesource proxy; unit-tested via an
  in-memory store.
- Lobby.load() branches on offlineMode: lists local games and skips every
  gateway call (no online games/invitations/incoming); the Stats tab is
  disabled offline.
- NewGame offline: find() creates a device-local vs_ai game via
  LocalSource.create using the profile's advertised dict version + a local
  seed; the friends flow and the random-opponent option are hidden, and the
  variant picker / Start are enabled offline (were gated on connection).
- id.ts: newLocalGameId + randomSeed (tested).

Docs: ARCHITECTURE + FUNCTIONAL(+ru) offline-mode section.

Deferred to fast-follow: the Settings Friends/Profile/Feedback affordance
gating, the flip-to-offline readiness wait, the offline mock e2e (needs
mock-dawg support), and the local-hint UI. The offline flow is verified on
the test contour — the mock e2e cannot enter offline mode (the toggle is
gated to an installed PWA).
2026-07-06 11:35:23 +02:00
developer 99f0a545be Merge pull request 'feat(offline): app-shell precache service worker (vite-plugin-pwa) [C1]' (#197) from feature/offline-pwa-precache into development
CI / changes (push) Successful in 2s
CI / unit (push) Successful in 10s
CI / integration (push) Successful in 15s
CI / ui (push) Successful in 1m9s
CI / conformance (push) Successful in 10s
CI / gate (push) Successful in 0s
CI / deploy (push) Successful in 1m40s
2026-07-06 09:13:57 +00:00
Ilia Denisov 1a95a5f2cb feat(offline): app-shell precache service worker (vite-plugin-pwa)
CI / changes (pull_request) Successful in 2s
CI / unit (pull_request) Successful in 10s
CI / integration (pull_request) Successful in 18s
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 1m43s
Migrate the former install-only public/sw.js to a custom (injectManifest)
service worker built from ui/src/sw.ts by vite-plugin-pwa. It precaches the
app shell + hashed assets (Workbox) so an installed web PWA cold-launches with
no network, and falls in-scope navigations back to the precached shell (the
hash router resolves the route client-side). This is the C1 prerequisite for a
usable offline mode: without it a cold offline launch cannot load the bundle.

- ui/src/sw.ts: skipWaiting + clientsClaim + cleanupOutdatedCaches +
  precacheAndRoute(__WB_MANIFEST) + a NavigationRoute fallback to index.html,
  deny-listing the RPC path and /_gm. The Connect stream and API POSTs are
  never precached nor intercepted.
- vite.config.ts: VitePWA(injectManifest); manifest:false (we ship our own),
  injectRegister:false (registration stays manual + web-only in pwa.svelte.ts),
  disabled in the mock build (Playwright unperturbed); landing + polyfills
  excluded from the precache.
- Removed public/sw.js; the registration path (dist/sw.js at /app/) is unchanged.
- New dev deps: vite-plugin-pwa + workbox-{core,precaching,routing}.

Docs: ARCHITECTURE + gateway/README. Offline cold-launch is contour-verified
(the mock e2e harness disables the SW).
2026-07-06 11:06:59 +02:00
developer 8abd8b2ae6 Merge pull request 'feat(offline): advertise dict versions + background dictionary preload' (#196) from feature/offline-dict-preload into development
CI / changes (push) Successful in 1s
CI / unit (push) Successful in 10s
CI / integration (push) Successful in 16s
CI / ui (push) Successful in 1m8s
CI / conformance (push) Successful in 9s
CI / gate (push) Successful in 1s
CI / deploy (push) Successful in 1m42s
2026-07-06 08:45:41 +00:00
Ilia Denisov 2f867b8e6c feat(offline): background-preload dictionaries for offline readiness
CI / changes (pull_request) Successful in 2s
CI / unit (pull_request) Successful in 10s
CI / integration (pull_request) Successful in 15s
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 1m41s
An installed PWA (standalone web + confirmed email) warms the dictionaries
for the player's enabled variants while online — on lobby entry and on a
variant-preference change — using the per-variant version the profile now
advertises, so a later switch to offline mode already has the data.

- dict/preload.ts: pure preloadDicts (retry + linear backoff, honours the
  session dictionary miss-breaker); node-tested.
- dict/preloadrun.ts: lazy browser orchestration (real getDawg), imported
  dynamically so the loader and move generator stay out of the main bundle.
- offline.svelte.ts: kickDictPreload, gated by the pure, tested
  offlinePreloadEligible, plus the reactive first-lobby preload warning.
- Header shows a poor-connection notice in the ad-banner slot on a
  first-lobby preload failure (offline.preloadWarning, en/ru).
- App-entry bundle budget 112->113 for the irreducible main-side wiring
  (documented in bundle-size.mjs); the heavy parts remain lazy chunks.

Docs: ARCHITECTURE offline-mode + dict-preload mechanism.
2026-07-06 10:39:55 +02:00
Ilia Denisov a692024b4e feat(profile): advertise per-variant dictionary versions for offline preload
The Profile now carries dict_versions (game variant -> current dictionary
version), populated from the dictionary registry at the profileResponse
choke point, so an installed PWA can preload the matching dawg per enabled
variant off the existing cold-start profile request instead of adding a
round-trip for a rare feature.

Wire path: FBS DictVersion table + Profile.dict_versions (additive,
backward-compatible trailing field) -> backend dto/registry -> gateway
ProfileResp + FBS encoder -> client codec decode into a per-variant map on
model.Profile. Empty in a degenerate no-dictionary deployment; the mock
serves v1.3.0 for all three variants. Codec decode covered by a
bite-tested round-trip unit test.
2026-07-06 10:39:29 +02:00
developer 8349e222fc Merge pull request 'feat(offline): offline-mode state, Settings toggle + blue chrome (Phase C)' (#195) from feature/offline-mode-state into development
CI / changes (push) Successful in 2s
CI / unit (push) Has been skipped
CI / integration (push) Has been skipped
CI / ui (push) Successful in 1m9s
CI / conformance (push) Successful in 10s
CI / gate (push) Successful in 0s
CI / deploy (push) Successful in 1m39s
2026-07-06 07:48:49 +00:00
Ilia Denisov 68ecd881d9 feat(offline): offline-mode state, Settings toggle + blue chrome (Phase C)
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 1m9s
CI / conformance (pull_request) Successful in 9s
CI / gate (pull_request) Successful in 0s
CI / deploy (pull_request) Successful in 1m39s
The deliberate offline mode is now visible: an installed web-PWA user with a confirmed
email can switch to offline in Settings, and the header turns blue with an "Offline"
chip. (Network gating, the dict preload, the offline lobby + local-game creation, and the
service-worker precache follow in later Phase C steps.)

- offline.ts / offline.svelte.ts: a sticky, device-scoped reactive offline flag
  (offlineMode), distinct from connection.svelte's transient reachability signal, with the
  pure persistence + readiness helpers (offlineReady / missingDicts) unit-tested.
- Settings.svelte: an Online / Offline toggle, shown only for an installed web PWA with a
  confirmed email (the SW-launch + durable-account preconditions).
- Header.svelte: a blue-tinted nav (color-mix from the accent, so it tracks light/dark and
  a Telegram theme override) + an "Offline" chip while offline; the deliberate mode
  suppresses the transient "Connecting…" indicator.

Behaviour-preserving online (the toggle is hidden and offlineMode is false there; e2e 196
green). App entry stays within its size budget (111.6 / 112 KB gzip).
2026-07-06 09:44:05 +02:00
developer d80d28a402 Merge pull request 'feat(offline): wire the local game source into the game screen (Phase B3.2)' (#194) from feature/offline-game-seam-wire into development
CI / changes (push) Successful in 2s
CI / unit (push) Has been skipped
CI / integration (push) Has been skipped
CI / ui (push) Successful in 1m8s
CI / conformance (push) Successful in 10s
CI / gate (push) Successful in 0s
CI / deploy (push) Successful in 1m39s
2026-07-06 07:31:05 +00:00
Ilia Denisov 1654131904 feat(offline): wire the local game source into the game screen (Phase B3.2)
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 1m9s
CI / conformance (pull_request) Successful in 9s
CI / gate (pull_request) Successful in 0s
CI / deploy (pull_request) Successful in 1m40s
The game screen now drives a local vs_ai game through the offline engine, dispatched by
game id — completing the playable local game (on top of the source, #193). Online play
is unchanged.

- gamesource.ts: gameSource(id) returns the local source for a `local:` id, else the
  gateway (the same game-loop interface). The offline engine stays OUT of the app entry
  bundle — it is dynamically imported on first use (a separate chunk), so online-only
  users never pay for it (the app entry stays within its size budget).
- localgame/id.ts: the tiny id helper (no engine imports) the dispatcher branches on.
- Game.svelte: the game-loop calls (state/history/submit/pass/exchange/resign/hint/
  evaluate/draft) go through gameSource(id) instead of the gateway directly; a local
  game's robot-reply events route through the same app event hub the network stream
  feeds, so the screen reacts to opponent_moved / game_over identically.

Behaviour-preserving for network games (gameSource returns the gateway for them). Local
verify green: check + test:unit + build + bundle-size gate + e2e (196 passed).
2026-07-06 09:24:55 +02:00
developer 9ecbe480db Merge pull request 'feat(offline): local game source (Phase B3.1)' (#193) from feature/offline-game-seam into development
CI / changes (push) Successful in 2s
CI / unit (push) Successful in 10s
CI / integration (push) Successful in 17s
CI / ui (push) Successful in 1m9s
CI / conformance (push) Successful in 10s
CI / gate (push) Successful in 0s
CI / deploy (push) Successful in 1m40s
2026-07-06 07:13:26 +00:00
Ilia Denisov 32298595f2 feat(offline): local game source (Phase B3.1)
CI / changes (pull_request) Successful in 2s
CI / unit (pull_request) Successful in 10s
CI / integration (pull_request) Successful in 17s
CI / ui (pull_request) Successful in 1m9s
CI / conformance (pull_request) Successful in 9s
CI / gate (pull_request) Successful in 0s
CI / deploy (pull_request) Successful in 1m39s
A GatewayClient-shaped facade over the offline engine, so the same game screen can drive
a local vs_ai game with no backend (the wiring into Game.svelte is B3.2).

- source.ts: LocalSource implements the game-loop subset (GameLoopSource) for a local game
  id — gameState/gameHistory via replay, submitPlay/pass/exchange/resign apply the human
  move then run the robot (decide(generateMoves)) synchronously, persisting both and
  delivering the robot's move through a per-game event emitter (no live stream). hint is
  gated to >30 min since the robot's last move; evaluate/checkWord are local. It translates
  the UI's glyph space to the engine's index space with the static letters table.
- ruleset.ts: add the static per-variant letters (glyphs), pinned to the Go alphabet —
  offline is now fully self-contained (no reliance on a warm server alphabet cache).
- engine.ts: submitPlay (infers the direction like the server SubmitPlay), evaluatePlay +
  dictionaryHas for the move preview / word check, and record the main-word coordinate +
  the words on a play (for the history MoveRecord).
- source.test.ts: create -> human pass -> synchronous robot reply via the event, the hint
  gate, decoded history, and a whole game driven to completion.

Pure additive library code; no runtime behavior change (bundle unchanged).
2026-07-06 09:07:40 +02:00
developer 2847412b5b Merge pull request 'feat(offline): local game persistence + replay (Phase B2)' (#192) from feature/offline-localgame-store into development
CI / changes (push) Successful in 2s
CI / unit (push) Has been skipped
CI / integration (push) Has been skipped
CI / ui (push) Successful in 1m8s
CI / conformance (push) Successful in 9s
CI / gate (push) Successful in 0s
CI / deploy (push) Successful in 1m40s
2026-07-06 06:34:57 +00:00
Ilia Denisov 4fa77bf82c feat(offline): local game persistence + replay (Phase B2)
CI / changes (pull_request) Successful in 1s
CI / unit (pull_request) Has been skipped
CI / integration (pull_request) Has been skipped
CI / ui (pull_request) Successful in 1m8s
CI / conformance (pull_request) Successful in 10s
CI / gate (pull_request) Successful in 0s
CI / deploy (pull_request) Successful in 1m41s
Store an offline game durably and reconstruct it — the offline counterpart of the
server's replay rehydration. Builds on the engine (#191); not wired into the UI yet
(Phase B3).

- serialize.ts: a LocalGameRecord (seed + rules + seat metadata + the alphabet-index
  move journal) and replayGame() — reconstruct a live LocalGame by seeding a fresh
  engine identically and replaying the journal. The board/bag/racks are not stored; they
  are deterministic from the seed and the replayed operations, so the record stays small.
  The journal is dictionary-independent (alphabet-index space, stable per variant).
- store.ts: an IndexedDB store for local games (save/get/list/delete), mirroring
  lib/dict/store.ts — its own database, best-effort, guarded when IndexedDB is absent.
- engine.ts: record the swapped tiles on an exchange (needed for exact replay) and expose
  the game's rule config.
- serialize.test.ts: a round-trip — reconstruct a mid-game and a finished game by replay
  and assert the state (board/racks/bag/scores/turn/log) is identical.

Pure additive library code; no runtime behavior change (bundle unchanged).
2026-07-06 08:22:47 +02:00
developer afa44d41b4 Merge pull request 'feat(offline): local game engine (Phase B1)' (#191) from feature/offline-localgame-engine into development
CI / changes (push) Successful in 2s
CI / unit (push) Successful in 9s
CI / integration (push) Successful in 14s
CI / ui (push) Successful in 1m8s
CI / conformance (push) Successful in 10s
CI / gate (push) Successful in 0s
CI / deploy (push) Successful in 1m39s
2026-07-06 06:15:56 +00:00
Ilia Denisov e4cf143e9f feat(offline): local game engine (Phase B1)
CI / changes (pull_request) Successful in 1s
CI / unit (pull_request) Successful in 10s
CI / integration (pull_request) Successful in 15s
CI / ui (pull_request) Successful in 1m9s
CI / conformance (pull_request) Successful in 9s
CI / gate (pull_request) Successful in 0s
CI / deploy (pull_request) Successful in 1m39s
The offline vs_ai game engine — a faithful TS port of backend/internal/engine that
drives a whole local game with no backend. Composes with the move generator (#188) and
robot strategy (#189) from Phase A; not yet wired into the UI (Phase B2/B3).

- ui/src/lib/localgame/ruleset.ts: static per-variant tile values / bag counts / blank
  count, mirrored from rules.go (offline scoring is self-contained; online uses the
  server alphabet). Pinned by ruleset.parity.test.ts against a Go fixture.
- bag.ts: the tile bag (fill from counts/blanks, draw-from-end, return+reshuffle) on a
  deterministic in-house PRNG — a game replays from its seed, not bit-identical to a
  server game (per plan).
- board.ts: the mutable board, satisfying the validator/generator read view + set().
- engine.ts: LocalGame — deal / play (reusing validate.ts) / pass / exchange / resign,
  scoreless(6) & out-of-tiles end detection, end-of-game rack penalties, winner; mirrors
  game.go. The end-game math is exported as pure functions, pinned against the Go engine
  (engine.parity.test.ts, 9 constructed positions).
- engine.test.ts: a full-loop smoke — two robots play a whole vs_ai game to completion
  via generateMoves + decide, and it is reproducible from the seed.
- backend: movegen now dumps the per-variant rulesets; a new in-package engine emitter
  (endfixture_test.go, env-gated) produces the end-game golden.

Pure additive library code; no runtime behavior change (unused at runtime, bundle unchanged).
2026-07-06 08:02:05 +02:00
developer 543cbe56b9 Merge pull request 'test(offline): real-dictionary move-generator conformance in CI' (#190) from feature/offline-realdict-conformance into development
CI / changes (push) Successful in 2s
CI / unit (push) Successful in 10s
CI / integration (push) Successful in 21s
CI / ui (push) Successful in 1m9s
CI / conformance (push) Successful in 10s
CI / gate (push) Successful in 0s
CI / deploy (push) Successful in 1m36s
2026-07-06 00:20:57 +00:00
Ilia Denisov a9c8f1ecfe test(offline): real-dictionary move-generator conformance in CI
CI / changes (pull_request) Successful in 1s
CI / unit (pull_request) Successful in 10s
CI / integration (pull_request) Successful in 15s
CI / ui (pull_request) Successful in 1m8s
CI / conformance (pull_request) Successful in 9s
CI / gate (pull_request) Successful in 0s
CI / deploy (pull_request) Successful in 1m38s
Phase A (A4): prove the ported move generator (#188) against the FULL shipped
dictionaries, not just the tiny samples — the deep graphs and complete 26/33-letter
alphabets the samples cannot reach.

- backend/cmd/movegen: add a -dawg-dir mode that emits per-variant golden move-gen
  vectors from the real dawgs (a bounded first-move + a blank case + a deep 7-tile
  mid-game position). Regenerated in CI to /tmp, never committed (like the
  dictgen/validategen vectors), so no dictionary version is pinned into the repo.
- ui/src/lib/dict/generate.realparity.test.ts: env-gated (DICT_DAWG_DIR +
  DICT_MOVEGEN_DIR) parity against that golden — 9 positions across scrabble_en /
  scrabble_ru / erudit_ru match the Go solver exactly. Skips cleanly when unset.
- .gitea/workflows/ci.yaml: the conformance job now generates the movegen golden
  and points the gated vitest at it (DICT_MOVEGEN_DIR).
2026-07-06 02:16:50 +02:00
developer d694ead7b6 Merge pull request 'feat(offline): robot move-choice strategy in TS (parity-pinned)' (#189) from feature/offline-robot-strategy into development
CI / changes (push) Successful in 1s
CI / unit (push) Successful in 10s
CI / integration (push) Successful in 15s
CI / ui (push) Successful in 1m8s
CI / conformance (push) Successful in 10s
CI / gate (push) Successful in 0s
CI / deploy (push) Successful in 1m42s
2026-07-05 23:59:06 +00:00
Ilia Denisov 8c5995c076 feat(offline): port robot move-choice strategy to TS (parity-pinned)
CI / changes (pull_request) Successful in 2s
CI / unit (pull_request) Successful in 10s
CI / integration (pull_request) Successful in 15s
CI / ui (pull_request) Successful in 1m9s
CI / conformance (pull_request) Successful in 9s
CI / gate (pull_request) Successful in 0s
CI / deploy (pull_request) Successful in 1m37s
Phase A (2/2) of PWA offline mode: the offline robot picks its move exactly as the
server does, so a local vs_ai game plays the same. Builds on the move generator
from #188; not wired into a game loop yet (Phase B).

- ui/src/lib/robot/strategy.ts: port of backend/internal/robot/strategy.go's
  move-choice slice — mix (FNV-1a, via BigInt for bit-exact uint64), playToWin
  (~40% play-to-win), deviates (the fading off-strategy wobble) and selectMove
  (pick the candidate whose resulting margin lands closest to the +/-[1,30] band,
  conservative tie-break), composed by decide(). The generator's ranked moves feed
  straight in. Think-time/sleep/nudge scheduling is server-only and not ported.
- backend/internal/robot/strategyfixture_test.go: an in-package, env-gated emitter
  (EMIT_STRATEGY_FIXTURES=1) writing golden fixtures from the real Go strategy — it
  reaches the unexported mix/playToWin/deviates/selectMove.
- strategy.parity.test.ts: 21 mix + 56 decision cases match Go exactly (play/
  exchange/pass, the deviate flip, tie-break, band overshoot).

Pure additive library code; no runtime behavior change (unused at runtime, so the
bundle is unchanged).
2026-07-06 01:54:20 +02:00
developer cedc9ffae1 Merge pull request 'feat(offline): DAWG cursor + move generator in TS (parity-pinned)' (#188) from feature/offline-movegen into development
CI / changes (push) Successful in 1s
CI / unit (push) Successful in 10s
CI / integration (push) Successful in 15s
CI / ui (push) Successful in 1m9s
CI / conformance (push) Successful in 9s
CI / gate (push) Successful in 0s
CI / deploy (push) Successful in 2m32s
2026-07-05 23:42:58 +00:00
Ilia Denisov c334a9d7b7 feat(offline): port DAWG cursor + move generator to TS (parity-pinned)
CI / changes (pull_request) Successful in 1s
CI / unit (pull_request) Successful in 10s
CI / integration (pull_request) Successful in 15s
CI / ui (pull_request) Successful in 1m8s
CI / conformance (pull_request) Successful in 9s
CI / gate (pull_request) Successful in 0s
CI / deploy (pull_request) Successful in 1m38s
First engine-first step of PWA offline mode (Phase A): the client-side move
generator — the "robot brain" a local vs_ai game will run on-device — with no
runtime wiring yet (Phase B).

- dawg.ts: add the step-by-step cursor (root/final/next/arcs), a faithful port
  of dafsa traverse.go over the reader's existing bitstream.
- generate.ts: the Appel-Jacobson generator (leftPart/extendRight + cross-sets +
  counts-rack + board transpose + moveKey ranking), reusing the cursor and
  validate.ts evaluate/connected. A cross-set LetterSet is a Uint8Array, so the
  33-letter Russian alphabet (index 32) is exact under JS bit ops.
- validate.ts: export connected for the generator's connectivity filter.
- backend/cmd/movegen: dev tool building small sample dictionaries and emitting
  golden move-generation fixtures from the real Go solver (EN + RU).
- tests: dawg.cursor.test.ts (enumeration bijection vs indexOf) and
  generate.parity.test.ts (7/7 vs the Go solver: empty board, mid-game, blank,
  single-word rule, Russian index-32 cross-set). The committed EN sample also
  unblocks the existing skipped dawg.parity.test.ts once wired with DICT_* in CI.

Pure additive library code; no runtime behavior change.
2026-07-06 01:35:11 +02:00
246 changed files with 33614 additions and 603 deletions
+21 -3
View File
@@ -214,9 +214,21 @@ jobs:
run: pnpm exec playwright install chromium webkit run: pnpm exec playwright install chromium webkit
timeout-minutes: 5 timeout-minutes: 5
# The offline e2e plays a real local vs_ai game, so it needs the per-variant dawgs; fetch the
# release the same way the Go jobs do and point the mock preview's copy step (scripts/
# e2e-dict.mjs, via E2E_DICT_DIR below) at it.
- name: Fetch dictionary DAWGs
run: |
mkdir -p "${GITHUB_WORKSPACE}/dawg"
curl -fsSL -o /tmp/dawg.tar.gz "https://gitea.iliadenisov.ru/developer/scrabble-dictionary/releases/download/${DICT_VERSION}/scrabble-dawg-${DICT_VERSION}.tar.gz"
tar xzf /tmp/dawg.tar.gz -C "${GITHUB_WORKSPACE}/dawg"
ls -la "${GITHUB_WORKSPACE}/dawg"
- name: E2E smoke (mock) - name: E2E smoke (mock)
run: pnpm run test:e2e run: pnpm run test:e2e
timeout-minutes: 5 timeout-minutes: 5
env:
E2E_DICT_DIR: ${{ github.workspace }}/dawg
# conformance proves the client's local move preview (the ported dawg reader + # conformance proves the client's local move preview (the ported dawg reader +
# validator, ui/src/lib/dict) byte-for-byte against the authoritative Go engine: # validator, ui/src/lib/dict) byte-for-byte against the authoritative Go engine:
@@ -252,6 +264,7 @@ jobs:
run: | run: |
go run ./backend/cmd/dictgen -dawg-dir "${GITHUB_WORKSPACE}/dawg" -out /tmp/dictgold go run ./backend/cmd/dictgen -dawg-dir "${GITHUB_WORKSPACE}/dawg" -out /tmp/dictgold
go run ./backend/cmd/validategen -dawg-dir "${GITHUB_WORKSPACE}/dawg" -out /tmp/validgold go run ./backend/cmd/validategen -dawg-dir "${GITHUB_WORKSPACE}/dawg" -out /tmp/validgold
go run ./backend/cmd/movegen -dawg-dir "${GITHUB_WORKSPACE}/dawg" -out "${GITHUB_WORKSPACE}/movegold"
- name: Set up Node - name: Set up Node
uses: actions/setup-node@v4 uses: actions/setup-node@v4
@@ -271,6 +284,7 @@ jobs:
DICT_DAWG_DIR: ${{ github.workspace }}/dawg DICT_DAWG_DIR: ${{ github.workspace }}/dawg
DICT_GOLD_DIR: /tmp/dictgold DICT_GOLD_DIR: /tmp/dictgold
DICT_VALID_DIR: /tmp/validgold DICT_VALID_DIR: /tmp/validgold
DICT_MOVEGEN_DIR: ${{ github.workspace }}/movegold
run: pnpm exec vitest run src/lib/dict/ run: pnpm exec vitest run src/lib/dict/
# gate is the single branch-protection required check. It always runs and passes # gate is the single branch-protection required check. It always runs and passes
@@ -302,9 +316,13 @@ jobs:
# Auto test-deploy on a PR into development and on the push that merges it. # Auto test-deploy on a PR into development and on the push that merges it.
# A PR into master is test-only (this job is skipped); prod deploy is manual. # A PR into master is test-only (this job is skipped); prod deploy is manual.
# Gates on `gate` (so a real test failure blocks the deploy) but runs even when # Gates on `gate` (so a real test failure blocks the deploy) but runs even when
# some test jobs were path-skipped. # some test jobs were path-skipped. Skipped entirely when neither the Go nor the
needs: [gate] # UI side changed (e.g. a docs-only change): the contour image is unchanged, so
if: ${{ (github.event_name == 'push' && github.ref == 'refs/heads/development') || (github.event_name == 'pull_request' && github.base_ref == 'development') }} # there is nothing to redeploy. `changes` still defaults both to true when the
# diff is uncomputable, and a workflow/deploy edit forces both true, so an
# ambiguous or infra change still deploys as a safety net.
needs: [changes, gate]
if: ${{ (needs.changes.outputs.go == 'true' || needs.changes.outputs.ui == 'true') && ((github.event_name == 'push' && github.ref == 'refs/heads/development') || (github.event_name == 'pull_request' && github.base_ref == 'development')) }}
runs-on: ubuntu-latest runs-on: ubuntu-latest
defaults: defaults:
run: run:
+13 -1
View File
@@ -77,7 +77,7 @@ jobs:
# The main-stack images via compose (reuses the build args, incl. VERSION); # The main-stack images via compose (reuses the build args, incl. VERSION);
# the bot separately, since it is profiled out of the prod compose. # the bot separately, since it is profiled out of the prod compose.
docker compose -f docker-compose.yml -f docker-compose.prod.yml build docker compose -f docker-compose.yml -f docker-compose.prod.yml build
docker compose -f docker-compose.yml -f docker-compose.prod.yml push backend gateway landing validator renderer docker compose -f docker-compose.yml -f docker-compose.prod.yml push postgres backend gateway landing validator renderer
docker build -f ../platform/telegram/Dockerfile --target bot --build-arg VERSION="$TAG" -t "$REGISTRY/scrabble-telegram-bot:$TAG" .. docker build -f ../platform/telegram/Dockerfile --target bot --build-arg VERSION="$TAG" -t "$REGISTRY/scrabble-telegram-bot:$TAG" ..
docker push "$REGISTRY/scrabble-telegram-bot:$TAG" docker push "$REGISTRY/scrabble-telegram-bot:$TAG"
@@ -135,6 +135,18 @@ jobs:
DICT_VERSION: ${{ vars.DICT_VERSION }} DICT_VERSION: ${{ vars.DICT_VERSION }}
POSTGRES_DB: ${{ vars.PROD_POSTGRES_DB }} POSTGRES_DB: ${{ vars.PROD_POSTGRES_DB }}
POSTGRES_USER: ${{ vars.PROD_POSTGRES_USER }} POSTGRES_USER: ${{ vars.PROD_POSTGRES_USER }}
# Point-in-time recovery (pgBackRest -> S3), prod main host only. Endpoint/bucket/
# region + the archive-mode switch are variables; the S3 keys + the repository cipher
# passphrase are secrets. All empty/off until the operator arms archiving; flipping
# PROD_PGBACKREST_ARCHIVE_MODE=on activates it (deploy/README.md, arming).
PGBACKREST_ARCHIVE_MODE: ${{ vars.PROD_PGBACKREST_ARCHIVE_MODE }}
PGBACKREST_S3_ENDPOINT: ${{ vars.PROD_PGBACKREST_S3_ENDPOINT }}
PGBACKREST_S3_PORT: ${{ vars.PROD_PGBACKREST_S3_PORT }}
PGBACKREST_S3_BUCKET: ${{ vars.PROD_PGBACKREST_S3_BUCKET }}
PGBACKREST_S3_REGION: ${{ vars.PROD_PGBACKREST_S3_REGION }}
PGBACKREST_S3_KEY: ${{ secrets.PROD_PGBACKREST_S3_KEY }}
PGBACKREST_S3_KEY_SECRET: ${{ secrets.PROD_PGBACKREST_S3_KEY_SECRET }}
PGBACKREST_CIPHER_PASS: ${{ secrets.PROD_PGBACKREST_CIPHER_PASS }}
# TELEGRAM_MINIAPP_URL and GRAFANA_ROOT_URL are derived from PUBLIC_BASE_URL in # TELEGRAM_MINIAPP_URL and GRAFANA_ROOT_URL are derived from PUBLIC_BASE_URL in
# deploy/write-prod-env.sh, not stored variables. # deploy/write-prod-env.sh, not stored variables.
steps: steps:
+10
View File
@@ -77,6 +77,16 @@ jobs:
SERVICE_EMAIL: ${{ vars.PROD_SERVICE_EMAIL }} SERVICE_EMAIL: ${{ vars.PROD_SERVICE_EMAIL }}
GRAFANA_SMTP_PORT: ${{ vars.GRAFANA_SMTP_PORT }} GRAFANA_SMTP_PORT: ${{ vars.GRAFANA_SMTP_PORT }}
GF_SMTP_ENABLED: ${{ vars.PROD_GF_SMTP_ENABLED }} GF_SMTP_ENABLED: ${{ vars.PROD_GF_SMTP_ENABLED }}
# PITR archiving parity: a rollback must re-render the SAME env.sh, else it would
# silently disarm WAL archiving (PGBACKREST_ARCHIVE_MODE would fall back to off).
PGBACKREST_ARCHIVE_MODE: ${{ vars.PROD_PGBACKREST_ARCHIVE_MODE }}
PGBACKREST_S3_ENDPOINT: ${{ vars.PROD_PGBACKREST_S3_ENDPOINT }}
PGBACKREST_S3_PORT: ${{ vars.PROD_PGBACKREST_S3_PORT }}
PGBACKREST_S3_BUCKET: ${{ vars.PROD_PGBACKREST_S3_BUCKET }}
PGBACKREST_S3_REGION: ${{ vars.PROD_PGBACKREST_S3_REGION }}
PGBACKREST_S3_KEY: ${{ secrets.PROD_PGBACKREST_S3_KEY }}
PGBACKREST_S3_KEY_SECRET: ${{ secrets.PROD_PGBACKREST_S3_KEY_SECRET }}
PGBACKREST_CIPHER_PASS: ${{ secrets.PROD_PGBACKREST_CIPHER_PASS }}
INPUT_TARGET: ${{ inputs.target_version }} INPUT_TARGET: ${{ inputs.target_version }}
steps: steps:
- uses: actions/checkout@v4 - uses: actions/checkout@v4
+712
View File
@@ -0,0 +1,712 @@
# PLAN — monetization implementation
Technical, step-by-step implementation of the monetization domain. Business mechanics and
the rationale for every rule live in [`docs/PAYMENTS.md`](docs/PAYMENTS.md) (RU mirror
[`docs/PAYMENTS_ru.md`](docs/PAYMENTS_ru.md)); the frozen owner agreements they both derive
from (the `D1``D41` decisions log) live in
[`docs/PAYMENTS_DECISIONS_ru.md`](docs/PAYMENTS_DECISIONS_ru.md) — the authority when a rule
is disputed. This file is the *how*. Each stage is written to be self-sufficient: returning
to it gives full context — goal, exact touch-points, tests, done-criteria, and current
status — without re-deriving decisions.
## How to use this file
- **Stage granularity (E0E9) is fixed.** Do not split or merge stages. Plan each stage
densely enough to execute whole.
- **Never leak stage ids into the product.** Code, comments, commit messages, PR
titles/descriptions must read as finalized feature copy — never "E5", "stage 3", etc.
Stage ids exist only in this file.
- **Deviations are allowed** when new unknowns surface, but the plan is then edited **whole
and in agreement** (not piecemeal), keeping it coherent, and `docs/PAYMENTS.md` updated in
step.
- **Status marks:** each stage header carries `Status: TODO | WIP | DONE`. When a stage
lands, flip it to DONE and note the PR. The **Progress** table is the at-a-glance index.
- **Per-change discipline** (repo `CLAUDE.md`): update tests at the layers `docs/TESTING.md`
calls out, bake doc updates into the same PR, run local full verification before pushing,
feature branch → PR into `development`.
## Progress
| Stage | Title | Release | Status |
|-------|-------|---------|--------|
| E0 | Payments data foundation | 1 | DONE |
| E1 | Trusted platform signal | 1 | DONE |
| E2 | Currency + benefit core | 1 | DONE |
| E3 | Wallet UI | 1 | DONE |
| E4 | Durability (PITR) | 2 | WIP |
| E5 | Payment intake | 2 | TODO |
| E6 | Ads | 2 | TODO |
| E7 | Admin & reports | 2 | TODO |
| E8 | Guest limits | — | TODO |
| E9 | Tournament fee | future | TODO |
**Release 1** = full mechanics with no real money, exercised via `admin_grant` (E0→E1→E2→E3).
**Release 2** = money (E4→E5→E6→E7). E8 is standalone (game-behaviour change, can run in
parallel). E9 is future.
---
## Architecture baseline (applies to all stages)
Read once; individual stages assume it.
### Schema & isolation
- The payments domain owns its **own Postgres schema `payments`** in the shared instance
(`scrabble` DB). All payments tables are `payments.*`. `backend` schema is untouched
except for the deprecation of two legacy columns (E2).
- **DB role.** A dedicated **NOLOGIN** role holds ALL privileges on `payments.*` and
**nothing** on `backend` — grant-based confinement, asserted by a `SET ROLE` isolation
test. The application still connects on its single (superuser) pool, so these grants are a
stepping-stone to a real separate login/process, not the runtime wall. The **runtime wall
is code-level**: only the `internal/payments` package imports the payments jet code and
issues `payments.*` SQL (an **import-boundary test** enforces it); every other domain
reaches payments through the narrow Go interface.
- **No cross-schema link at all.** There is **no** foreign key from `payments.*` to
`backend.accounts`: `account_id` is a plain `uuid`, kept referentially honest in code and
joined to the tombstoned account / `retained_identities` dossier by the stable id. This
keeps the spend transaction (ledger INSERT + balance UPDATE + benefit UPDATE) fully
**within `payments`** and the domain extractable into its own database later. Benefits move
**into** `payments` (they no longer live on `accounts` — see E2); game code reads them
through the payments **Go interface**, never via SQL.
- **Money.** Monetary amounts are a single **`bigint` in the currency's minor units** (RUB
kopecks; Votes/Stars/chips are whole units, scale 1) carried in Go by the `payments.Money`
value type — the sole constructor/formatter/arithmetic, so **no `float64` ever touches an
amount** and a whole-unit currency structurally cannot hold a fraction. `chip_rate` is not a
table: a chip pack is a product, so its per-method rate **is** `product_price`.
### Domain boundary
- New package `backend/internal/payments/``payments.go` (types + `Service`),
`store.go` (`type Store struct{ db *sql.DB }`, go-jet against `internal/postgres/jet/
payments`), following the `ads` domain shape (`backend/internal/ads/{ads,service,store}.go`).
- Wired in `backend/cmd/backend/main.go` `run()` (construct after `account`, before
`server.New`), handed into `server.Deps` (`server.go`), routes registered in
`registerRoutes` gated `if s.payments != nil`, admin section in `registerConsole` gated
`if s.payments != nil`.
- Game/other domains depend on payments only through a **narrow interface** (e.g.
`AdFree(ctx, account, platform, present) bool`, `SpendHint(ctx, account, platform, present)
error`, `Balances(ctx, account, platform, present) …`), where `present` is the set of
identity sources the account currently holds (`vk`→vk, `telegram`→telegram, `email`→direct;
`robot` ignored), computed by the caller from `account.Identities` — payments carries no
cross-schema identity knowledge, so unlink/re-link availability (D14) falls out live. Keep
the surface small; this is the seam that lets payments become a separate process later
without touching callers.
- **Read model.** Hot reads (`AdFree`/`HintsAvailable`/`Balances` and the gate) are served
from an in-process, account-keyed, write-through cache inside the payments package
(mirroring `account/suspension.go`), invalidated on every payments mutation
(spend/grant/fund/refund/merge); the payments DB is touched only on a cache miss, so the
steady-state hot path issues no query to the `payments` schema. The D39 materialized
`balances`/`benefits` tables stay the in-transaction write target the cache fronts.
Single-instance, matching the deployment (a multi-instance backend would need a shared
cache).
### Migrations & codegen
- Migrations: same goose dir `backend/internal/postgres/migrations/`, sequential
`0000N_*.sql`, `-- +goose Up/Down`, schema-qualified, **expand-contract mandatory**
(image rollback must stay DB-safe). First payments migration does `CREATE SCHEMA IF NOT
EXISTS payments`, creates the role, grants.
- **jetgen** (`backend/cmd/jetgen/main.go`) is currently hardwired to `schema=backend`.
Extend it to also generate `schema=payments` into `internal/postgres/jet/payments/
{model,table}` (committed). Regenerate only after a payments migration; revert unrelated
`backend`-schema churn (jetgen may reorder untouched tables — commit only intended diffs).
- Transactions: reuse the local `withTx(ctx, db, fn)` idiom. Balance/benefit mutations are
guarded single-row atomic UPDATEs (mirror `account.SpendHint`); the ledger INSERT +
materialized-cache UPDATE go in one `withTx`. Default Read-Committed is sufficient given
the guarded updates + unique idempotency keys; do not reach for Serializable without a
demonstrated need.
### Transport
- client ↔ gateway: Connect-RPC + FlatBuffers (h2c). gateway ↔ backend: REST/JSON +
`X-User-ID` via `gateway/internal/backendclient`. Any user-facing payments call needs the
full chain: backend REST handler (`u := s.user` group) → `backendclient` method →
Connect-RPC method + transcode (`gateway/internal/transcode/`) → FlatBuffers schema
(`pkg/fbs/…`, regen with `make -C pkg fbs` + `pnpm -C ui codegen`).
- Provider webhooks (Robokassa/VK) do NOT use the Connect path — model on the existing
public HMAC-signed route `s.public.GET("/dl/:id/:kind", …)` (`handlers.go`): a public
route with signature verification, proxied by the gateway/Caddy `@gateway` matcher
(`deploy/caddy/Caddyfile` — a new edge route MUST be added there or it falls to the
landing catch-all).
### Testing layers (`docs/TESTING.md`)
- **unit** (Go `_test.go`, TS vitest): gate-by-context, no-ads stacking, priority draw,
rate math, idempotency keys, catalog snapshotting. UI logic must be extracted out of
`.svelte` into `ui/src/lib/*` to be unit-testable.
- **integration** (`backend/internal/inttest/`, `//go:build integration`, Postgres-backed):
atomic chip↔benefit spend, callback idempotency, order-flow, TG outbox delivery, merge/
unlink of segments.
- **UI** (Playwright mock e2e, Chromium+WebKit): Wallet screen, warnings, guest-hidden, GP
stub. Mock e2e bypasses codec — wire bugs need codec unit tests.
- Local full verification before push: integration (`-tags=integration` + DAWG sibling +
Ryuk-off), UI check/test/build, codegen (`make -C pkg proto fbs`, `pnpm -C ui codegen`).
---
## E0 — Payments data foundation
**Status:** DONE · **Release 1** · depends on: none · mechanics: PAYMENTS §14, §7, §11.
**Goal.** Stand up the `payments` schema, its confinement role, the jetgen target, the domain
package skeleton and all core tables — nothing wired to real money yet. This is the substrate
every later stage builds on.
**Migration `00010_payments_foundation.sql` (`payments` schema).**
- `CREATE SCHEMA payments`; an idempotent `DO $$` block creates a **NOLOGIN** `payments` role;
`GRANT USAGE` + `GRANT ALL ON ALL TABLES` (+ `ALTER DEFAULT PRIVILEGES … GRANT ALL`) confine it
to `payments.*` with nothing on `backend`. No `REFERENCES` grant (there is no cross-schema FK).
- `payments.ledger` — append-only. `ledger_id` (uuid PK, app-generated v7), `account_id` (plain
`uuid`, **no FK**), `kind` (`fund|spend|admin_grant|refund`), `source`/`origin` (nullable,
`vk|telegram|direct`), `chips_delta` (signed int, 0 for a grant), `product_id` (nullable
FK→product), `order_id` (nullable FK→orders), `provider` + `provider_payment_id` (nullable),
`snapshot` (jsonb, written from E2), `created_at`. Immutability is a **`BEFORE UPDATE OR DELETE`
trigger** (a superuser bypasses a privilege REVOKE). Unique **partial** index on `(provider,
provider_payment_id) WHERE provider_payment_id IS NOT NULL` — the idempotency key.
- `payments.balances``(account_id, source)` PK, `chips int CHECK (>=0)`, `updated_at`. Created
**lazily** on first fund (up to three rows/account, absent = zero).
- `payments.benefits``(account_id, origin)` PK, `ads_paid_until timestamptz` null,
`ads_forever bool`, `hints int CHECK (>=0)`, `updated_at`. Created lazily.
- `payments.catalog_atom``atom_type` PK (`chips|hints|noads_days|tournament`); the four atoms
are **seeded** here (the `tournament` atom is provisioned for E9).
- `payments.product` (`product_id` PK, `title`, `active bool` soft-delete, timestamps);
`payments.product_item` (`(product_id, atom_type)` PK → `quantity`); `payments.product_price`
(`(product_id, COALESCE(method,''), currency)` unique → `amount bigint` minor units): a chip
pack carries a money price per `method` (`vk|telegram|direct`), a value carries one
`currency='CHIP', method=NULL` row. `currency ∈ {RUB, VOTE, XTR, CHIP}`; `amount CHECK (>=0)`.
- `payments.config` — a single typed row (`only_row bool PK CHECK`): `rewarded_payout_chips`,
`cooldown_global_seconds` (300), `cooldown_vs_ai_seconds` (1800), `cooldown_hint_seconds` (60),
`order_ttl_seconds` (1800). No `chip_rate` table — the pack rate is `product_price`.
- `payments.orders``order_id` (uuid PK), `account_id`, `platform`, `product_id` (FK),
`expected_amount bigint` + `currency`, `origin`, `status` (`pending|paid|expired`), `provider`,
`provider_payment_id` (nullable), timestamps. Index `(status, created_at)` for the pending sweep.
- `payments.payment_events``event_id` (uuid PK), `account_id`, `order_id` (nullable FK), `type`
(`succeeded|failed|refunded`), `payload jsonb`, `created_at`, `dispatched_at` (nullable, partial
index for the dispatch queue).
- Full `-- +goose Down` (DROP SCHEMA CASCADE + DROP OWNED BY + DROP ROLE); expand-contract, proven
reversible by an integration test.
**Backend.**
- `backend/internal/payments/{payments,service,store}.go` — the `Money` value type + `Currency`
(bigint minor units, exact, no float); `Store{db *sql.DB}` with a `Ping` health read via jet;
`Service` over the store. (`withTx` arrives with E2's first transaction.)
- `cmd/jetgen` generates the `payments` schema into `internal/postgres/jet/payments/` (a second
`GenerateDB` call); committed. Constructed in `cmd/backend/main.go` and passed to `server.Deps`
(`Payments`) with a boot-time reachability check; its routes are registered from E2.
**Legacy deprecation (expand phase only).** A `--` header note marks `accounts.hint_balance` and
`accounts.paid_account` deprecated; they are **not** dropped (the DROP is the contract phase after
E2 flips reads and after Release 2). Reads still use the old columns until E2.
**Tests.**
- integration (`inttest`): schema + role + seeds present; `SET ROLE payments` cannot read
`backend.accounts` (grant confinement); ledger rejects UPDATE/DELETE (trigger); idempotency
partial index holds (NULL-keyed rows repeat); balance/benefit/amount/enum CHECKs bite; migration
applies forward **and backward** on a throwaway PG.
- unit: `Money` — exact round-trip, per-currency scale, no-float parse rejecting a fraction for a
whole-unit currency, arithmetic, formatting. Import-boundary test: only `internal/payments`
imports the payments jet code.
**Done-criteria (met).** Migrations apply forward+backward on a throwaway PG 17; `go build
./backend/...` + `go vet` + `gofmt -l .` clean; committed `jet/payments/`; all tests green; no
behaviour change for users.
**Notes.** jetgen regenerates the whole `backend` schema; its committed jet had drifted from the
migrations (`robot_blocks`, `robot_friend_requests`, the `feedback_messages` `app_version` /
`browser_tz` columns, and `UseSchema` gaps), so this change also **regenerates and commits
`jet/backend`** to bring it back in sync (additive only — all suites stay green). The `payments`
role creation is idempotent (`DO $$` / `IF NOT EXISTS`) for fresh volumes.
---
## E1 — Trusted platform signal
**Status:** DONE · **Release 1** · depends on: none (parallel to E0) · mechanics: PAYMENTS §8.
**Goal.** Make the server know the execution platform from a trusted, unforgeable source,
carried on the session. This is the foundation the gate (E2) stands on; without it the gate is
meaningless. Signal plumbing only — no user-visible change.
**Model.** `platform = {kind: vk|telegram|direct, subtype: ios|android|web}` is a property of
the **session**, captured at creation. `kind` is always trusted — the gateway derives it from
the validated establish path (VK launch / TG initData / a web-native session), never a client
field. `subtype` is **cryptographically trusted only for VK** (it rides inside the signed
`vk_platform` launch param); for telegram and direct it is client-reported best-effort, and the
gate never relies on it (only the VK-iOS-frozen case is compliance-critical, and VK subtype is
trusted). VK/TG re-mint a session on **every cold start**, so their platform is re-captured each
launch; web/direct/email reuse the stored token, so their platform is captured once at creation
(`direct` needs no signature).
**Validation stays at the gateway.** Both wrapper verifiers already live there and the backend
cannot import either (separate modules under `internal/`, and the VK app secret is gateway-only):
VK launch params via the in-process `gateway/internal/vkauth.Verify` (HMAC-SHA256 over the signed
`vk_*` params under `GATEWAY_VK_APP_SECRET`, extended here to expose the signed `vk_platform`
a trusted `Subtype()`), TG `initData` via the validator RPC. The gateway hands the derived
platform to the backend at establish; the backend persists it and returns it on resolve.
**Backend.**
- `backend.sessions` gains nullable `platform_kind` + `platform_subtype` columns (migration
`00011`, CHECK-constrained, jet regenerated). A `session.Platform` value type + `session.Create`
captures it; `Session` carries it through the store and the warm cache.
- `handleResolveSession``resolveResponse` (`dto.go`) returns the platform; the establish
handlers set `kind` from their own endpoint (`/sessions/telegram|vk|guest|email/*`) and
`subtype` from the request (VK: gateway-extracted from the signed params; TG/direct: the client
best-effort field, defaulting web). The account-merge session mint (`link.merge`) inherits the
caller's platform from the request context.
- Middleware (`middleware.go`) parses the gateway-injected `X-Platform` into the request context
on the `s.user` group; `platform(c)` exposes it. Absent ⇒ untrusted (fail-closed).
**Gateway.**
- `backendclient.WithPlatform(ctx, "<kind>/<subtype>")` + `do`/`getRaw` inject `X-Platform` on
every authenticated backend call; `connectsrv.Execute` enriches the request context from the
resolved session's platform (carried through the gateway session cache + `ResolveSession`).
Never from a client request body.
**Client (`ui/`).** `platformSubtype()` (`ui/src/lib/platform.ts`) supplies a best-effort device
subtype on the `auth.telegram` / `auth.guest` / `auth.email.login` requests (new FBS `subtype`
field): Telegram's `WebApp.platform` inside a Mini App, else the Capacitor/web channel. VK sends
nothing (the gateway derives the trusted subtype from the signed launch params).
**Tests.**
- unit (Go): `vkauth` exposes the signed `vk_platform` and maps iPhone/iPad → ios (the frozen
case); the `X-Platform` middleware round-trips + reports untrusted when absent; the backend
client injects `X-Platform` from the context and omits it when untrusted.
- integration: a session carries its platform through create + a cold-cache (DB) resolve for each
kind; an unattributed session is untrusted; the CHECK constraints bite; migration `00011`
applies forward **and backward** on a throwaway PG.
- UI: subtype normalization (vitest) + the new `subtype` field on the wire (codec unit test).
**Done-criteria (met).** A VK/TG session resolves to a trusted `{kind, subtype}`; a forged body
cannot change `kind` (nor the VK subtype); `direct` sessions resolve to `direct`; the untrusted
path is reachable and observable via `platform(c)`. No user-visible change; all layers green.
**Notes/risks.** High blast-radius (auth/session table + broad gateway header threading): additive
migration, no mixed-in refactors, `X-Platform` inert until E2 consumes it. **Sessions minted before
E1 carry no platform → untrusted (view-only) until re-login**; VK/TG self-heal on the next
cold-start re-mint, direct/email do not (accepted: Release 1 has no money, sessions cycle by
Release 2). TG/direct subtype is not cryptographically trusted — E2 must keep the gate on `kind`
+ the trusted VK subtype only.
---
## E2 — Currency + benefit core
**Status:** DONE · **Release 1** · depends on: E0, E1 · mechanics: PAYMENTS §2–§6, §11.
**Goal.** The full internal money mechanic, exercisable end-to-end **without real money**
via `admin_grant`: chip balances, spend→benefit atomically, the compliance gate by context,
benefit application (no-ads stacking, hints per-origin), the legacy migration, and the
`SpendHint` rewrite. This is the heart.
**Payments service (Go).** Every read/gate method also takes `present` (the account's live
identity sources, see *Domain boundary*) and is served from the read cache.
- `Balances(ctx, account, platform, present)` → per-segment `{source, chips, spendable}` for
the context (VK→vk spendable, direct/tg hidden; TG→tg; direct→direct+vk+tg by priority;
VK-iOS→vk shown as a **frozen** number, none spendable; untrusted→view-only, none spendable).
- `Spend(ctx, account, platform, present, productID)` → gate-checked purchase of a **value**
(CHIP price, `method=NULL`; never a chip pack) with chips: resolve spendable segments by
context, draw by priority direct→vk→tg, write a `spend` ledger row (+ a catalog **snapshot**
of atoms+price) + decrement balance + apply benefit (extend `ads_paid_until[origin]` from
`max(now,end)` / set `ads_forever` / add hints) **in one tx**, stamping `origin` = platform
context. Refuse if untrusted (fail-closed) or funds insufficient.
- `Grant(ctx, account, origin, atoms)` → a **0-price sale of a value**: an `admin_grant`
ledger row (`chips_delta=0`, + snapshot), same benefit application; concrete values only,
never chips (no chips move — it neither requires nor touches a balance). Origin chosen by
the admin. In E2 this is the Go service method only, exercised by tests; the admin grant UI
+ catalog editor are E7.
- `AdFree(ctx, account, platform, present)` / `HintsAvailable(…)` / `SpendHint(…)` → apply
the one-directional origin rule: in context P, usable origins = {P} plus, when P is
web/native, {vk,tg} (relaxation outward); in VK only vk; in TG only tg. Hint consumption
draws by the same priority direct→vk→tg.
- `Merge` / `Unlink` hooks: extend `accountmerge` to merge segments **and** benefits by origin
(same-origin add — chips sum, terms extend per origin; different origins coexist) in the
caller's tx (`MergeTx`), and make **both** segment *and* benefit availability follow
identity presence — unlink sleeps the balance *and* the benefit, re-link wakes them (D14),
resolved live from `present`. Warn-before-unlink surfaces the balance via the interface; the
warning UI itself is not E2.
**Backend wiring & migration.**
- Deprecate-and-flip `accounts.hint_balance` / `accounts.paid_account`: E0 added the tables;
here, move reads/writes to `payments.benefits`. `SpendHint` (`game/service.go` ~:1147) and
`ads.Eligible` (`ads/ads.go` :107) call the payments interface instead of the account
columns. Legacy values were never set in prod → zero them; the DROP is the contract phase
(guarded, after Release 2 — keep columns until then for rollback safety).
- `vs_ai` hints stay free/unlimited (unchanged 30-min gate); only online-game hint spend
routes through the segmented, context-aware path.
**API (user-facing, Connect chain).** Read-only wallet view + spend:
`GET /api/v1/user/wallet` (balances+benefits for the context), `POST /api/v1/user/wallet/
buy` (spend chips on a product). Add the backend handlers (`u := s.user`), `backendclient`
methods, Connect methods + transcode + FBS. Admin grant is internal/admin (E7 UI).
**Tests.**
- unit (Go): gate-by-context matrix (every row of PAYMENTS §4, incl. VK-iOS frozen +
untrusted); priority draw; no-ads stacking (`+=` from max(now,end)) + forever override;
per-origin hint applicability + direct→vk→tg draw; merge/unlink segment+benefit math;
catalog snapshotting; read-cache invalidation on each mutation.
- integration: atomic spend (ledger+balance+benefit in one tx; failure rolls all back);
admin_grant credits values not chips; legacy migration zeroes and flips reads; merge/
unlink over Postgres.
- UI: none new (E3 builds the screen); wallet DTO covered by codec unit tests.
**Done-criteria.** `admin_grant` applies no-ads/hints directly (a 0-price sale of a value —
no chips involved); the player chip-spend path (`Spend`) is proven by unit/integration tests
(balance seeded in-test, since a legitimate chip source arrives only with Release 2); the gate
blocks cross-context spend and cross-origin application; the ads gate reads the new benefit;
`SpendHint` uses segments; the hot read paths hit the read cache, not the `payments` schema;
all layers green; **compliance regression** (a `direct` benefit never activates inside VK/TG)
is a named test.
**Notes/risks.** This is the high-blast-radius core (money semantics + a live path
`SpendHint`/`ads.Eligible`). Minimize surface, keep the interface narrow, no mixed-in
refactors. The legacy flip is expand-contract: reads move first, DROP much later.
---
## E3 — Wallet UI
**Status:** DONE · **Release 1** · depends on: E2 · mechanics: PAYMENTS §1, §7, §6, §13, §4.
**Goal.** The user-facing "Кошелёк" (Wallet) section: balances + active benefits + the
catalog storefront, honouring guest-hidden, GP-stub, and the web-spend warning.
**Backend + wire (catalog read — new).** E2 shipped `wallet.get` / `wallet.buy` (segments +
benefits + a chip spend) but no way to *list* the catalog; the storefront needs one, so E3 adds a
read path following the E2 shape:
- `payments.Service.Catalog(ctx, cxt)` + `store.loadCatalog` (`internal/payments/{catalog,
store_catalog}.go`) read every **active** product with atoms and prices and project them to the
context (`projectCatalog`, pure + unit-tested): a **value** (no `chips` atom) carries its CHIP
price and shows everywhere; a **chip pack** (`chips` atom) carries the money price for the
context method (`cxt.Kind`: vk→VOTE, telegram→XTR, direct→RUB) and shows only where that method
is priced. Read **uncached** (the catalog is small and rarely edited — unlike the per-account
balances/benefits the read cache fronts).
- REST `GET /api/v1/user/wallet/catalog` (`handleWalletCatalog`, gated by `walletGate`) →
`catalogDTO`; gateway op `wallet.catalog` (`transcode.go` + `encodeCatalog`), `backendclient.
Catalog`; FBS `Catalog`/`CatalogProduct`/`CatalogAtom` (`pkg/fbs/scrabble.fbs`), client
`decodeCatalog`.
**UI (`ui/`).**
- New `'wallet'` tab in `ui/src/screens/SettingsHub.svelte` — `SettingsTab` union, tab button
**between Friends and About** (guest-hidden like Friends, offline-disabled like Profile/Friends),
body branch, `'wallet'` route in `ui/src/lib/routeparse.ts` + `ui/src/App.svelte`.
- `Wallet.svelte` screen: context-available chip balances + active benefits (no-ads until date /
forever, hints count). **No history feed** (PAYMENTS §11 — noise). Storefront: values priced in
chips (buyable with `wallet.buy`), chip packs priced per method.
- **Guest:** section hidden entirely (durable-only).
- **GP build:** the chip-pack purchases are hidden behind a RuStore stub; spending earned chips on
values still works (PAYMENTS §13). Detected by a build-time flag `VITE_GP_BUILD`
(`ui/src/lib/distribution.ts`), forcible under the mock e2e with `?gp`.
- **Web-spend warning:** before a value spend that would draw vk/tg chips in a direct context, an
own `Modal` (not `showPopup`) warns the benefit will be web/native-only. The context is inferred
client-side (`executionContext` — VK/Telegram/direct); the server enforces the real gate on
`wallet.buy` (fail-closed), so no wallet-DTO change was needed.
- Logic extracted to `ui/src/lib/{wallet,distribution}.ts` (unit-testable); `.svelte` stays thin.
**Pack purchase is display-only in E3.** Buying a chip pack needs the money order flow, which is
**E5**; here a pack card shows its price with a disabled **"Soon"** action. E5 replaces that with the
launch/order CTA. Value spends are wired now (E2 `wallet.buy`), though a Release-1 durable account
has 0 chips until funding exists (E5/E6), so a real value spend returns insufficient-funds until then.
**Tests.**
- unit (Go): `projectCatalog` context matrix (value everywhere; pack per context method; misconfig
omitted). unit (vitest): money/price formatting, spendable-segment selection, warning trigger,
the GP flag; `decodeCatalog` codec round-trip (mock e2e bypasses the codec).
- integration (`inttest`): `/wallet/catalog` returns active products with the context-correct price
over Postgres; soft-deleted excluded.
- UI (Playwright mock, Chromium+WebKit): Wallet renders between Friends/About; guest hides it
(`?guest` seam); GP stub (`?gp`); warning modal on a vk-drawing web spend, no warning on a
direct-covered spend. Mock overlay stays instant under `MODE==='mock'` (or it intercepts taps).
**Done-criteria (met).** Balances/benefits/storefront render per context; guest/GP/warning paths
verified by unit + integration + mock e2e on both engines. **A *populated* contour review depends
on later stages** (no products until the E7 catalog editor, no chip funding until E5/E6, no grant
UI until E7): on the contour E3 shows the correct **empty** wallet/storefront states; the populated
UI is proven by the mock e2e. (This replaces the earlier "demonstrable end-to-end via `admin_grant`"
line — the grant UI is E7.)
**Notes/risks.** No global `.btn`/`.ghost` in `ui/` — styled per-component with scoped CSS + tokens
(mirror NewGame `.invite`). Product titles are single-language catalog data (E0 `product.title`),
shown verbatim; currency/chip words are i18n, counts follow the app's label+number convention (no
noun agreement). Svelte whitespace/`$state` naming gotchas apply.
---
## E4 — Durability (PITR)
**Status:** WIP — repo artifacts landed; **prod arming pending** (owner-coordinated, before
E5). · **Release 2** · depends on: E0 (schema exists) · mechanics: PAYMENTS §14 (D4).
**Goal.** Continuous WAL archiving with point-in-time recovery, armed **before the first
real money** is accepted (E5 prod). Protects both money and game data.
**Locked decisions (this stage).**
- **Tool: pgBackRest.** **Destination: Selectel S3** object storage (encrypted AES-256-CBC,
path-style addressing), **prod main host only** — the test contour never archives. (D4 left
the tool + destination open; resolved here. Warm replica stays deferred, per D4.)
- **Retention 30 days**, **daily full base backup** (a systemd timer) + continuous WAL
(`archive_command`, a 5-minute forced switch bounds the recovery point). Assessment
(owner-reviewed prod gate): measured prod WAL **~0.77 MB/day**, DB **~9.6 MB** → archive
**< 1 ₽/month** on Selectel S3, **negligible** perf impact. Recorded in `deploy/README.md`.
- **Archiving ships gated OFF** (`PGBACKREST_ARCHIVE_MODE` on the DB, `pitr_enabled` for the
timer — both default off), so the merged/redeployed artifact is inert until armed and an
un-armed prod deploy **cannot** pile WAL onto the disk.
**Work (landed in the artifacts PR into `development`).**
- pgBackRest in the DB image (`deploy/postgres/Dockerfile`); postgres becomes a built + pushed
image (prod overlay `image:` + `prod-deploy.yaml` build/push list).
- Repository config + `archive_mode` via `PGBACKREST_*` env on the prod-overlay postgres
service; secrets/vars rendered by `deploy/write-prod-env.sh` from the `PROD_PGBACKREST_*`
Gitea set; **parity added to `prod-rollback.yaml`** (a rollback must not disarm archiving).
- Daily base-backup systemd timer (Ansible `main` role, gated by `pitr_enabled`).
- Two Grafana alerts on the exporter's `pg_stat_archiver` metrics (failing / stalled),
absent/NaN-safe on the contour.
- Belt-and-braces `pg_dump` fixed to dump the **whole DB** (`deploy/prod-deploy.sh` was
`-n backend`, silently excluding `payments`); manual-restore runbook updated.
- Full PITR runbook + arming sequence + recorded assessment in `deploy/README.md`.
**Prod arming (SEPARATE — owner-coordinated, before E5; NOT the artifacts PR).** Owner creates
the Selectel S3 bucket + the `PROD_PGBACKREST_*` secrets/variables (incl. `ARCHIVE_MODE=on`);
then promote `development → master` → `prod-deploy` (archive_mode on behind the maintenance
window), `pgbackrest stanza-create` + first base backup + `check`, re-run Ansible with
`-e pitr_enabled=true`, and run the restore drill on an isolated one-shot target. Exact steps:
`deploy/README.md` (point-in-time recovery — arming).
**Tests.** Restore drill on an isolated one-shot instance (base + WAL → target timestamp),
recorded in `deploy/README.md`. No app-level tests. Local verification: `docker compose config`
valid + the custom PG image builds (archiving inert on the contour).
**Done-criteria.** Artifacts merged with archiving inert. **Fully done** at arming: WAL
archiving live on prod PG; a timed restore verified; cost + perf assessment reviewed; runbook
current in `deploy/README.md`.
**Notes/risks.** The repository **cipher passphrase is unrecoverable if lost** — stored apart
from the S3 keys. Enabling `archive_mode` restarts postgres (rides the prod-deploy maintenance
window). Migrations stay expand-contract so image rollback remains DB-safe alongside PITR.
---
## E5 — Payment intake
**Status:** TODO · **Release 2** · depends on: E0, E1, E2, E4 · mechanics: PAYMENTS §9, §12.
**Goal.** Accept real money on all three rails into the payments domain: order-flow,
verified provider callbacks, idempotency, the TG bot SQLite outbox, the event dispatcher,
receipts, and refunds.
**Order-flow & intake (single writer).**
- `POST /api/v1/user/wallet/order` → create `order(pending)` (account/platform/product/
expected amount/origin), return the provider-specific launch payload with `order_id`
threaded in (Robokassa `InvId` / TG `invoice_payload` / VK `item`). The storefront's chip-pack
card wires its purchase CTA to this here, **replacing the disabled "Soon" placeholder E3 left**
(`ui/src/screens/Wallet.svelte`).
- Robokassa + VK **public webhooks**: new edge routes (add to `deploy/caddy/Caddyfile`
`@gateway` matcher — or they fall to the landing catch-all), signature/HMAC verified,
proxied into a payments intake handler. Match by `order_id`, verify amount, credit
(ledger `fund` + balance UPDATE in one tx), mark order `paid`, emit `payment_event`.
Idempotent by `(provider, provider_payment_id)`.
- **Pending sweep:** background reaper expires pending orders after the configured timeout
(~30 min). Expiry is cosmetic — a later valid callback still credits (revive/honour).
**TG bot outbox (`platform/telegram/`).**
- The bot receives `successful_payment` (and `pre_checkout_query`) via Bot API. Add a
**SQLite** store on the bot's disk: on receipt, persist → ack the Telegram update → forward
to a backend payments-intake endpoint (internal, authenticated over the existing reverse
mTLS bot-link) → on backend ack, mark `forwarded`. Retries with backoff; re-drive
undelivered on restart. Backend intake dedups by `telegram_payment_charge_id`.
- Provide the invoice creation path (Stars) from the Mini App via the bot as needed.
**Events & notifications.**
- `payment_events` dispatcher: `succeeded`/`failed`/`refunded` → live gRPC stream if the
user is connected, else `botlink` push / email relay (existing). "failed" = an active
provider decline (not an abandoned pending) surfaced to the user; "succeeded" hook
(email/bot message).
**Receipts (§12).** Robokassa self-employed НПД receipt on payment (provider config); VK
handles Votes tax itself; TG Stars — no receipt.
**Refunds (§9).** ToS non-refundable; admin manual refund (ties to `accountdelete`); external
`refunded` events honoured — best-effort benefit revoke (never negative; record loss + abuse
flag if spent), ledger `refund` row. Ledger export-ready (reconciliation not built).
**Tests.**
- unit: signature/HMAC verifiers per rail; order-id matching; idempotency-key dedup.
- integration: full order→callback→credit per rail; duplicate callback credits once; expired
order still honoured on late callback; TG outbox store→forward→ack→cleanup + restart
re-drive; refund revoke best-effort/never-negative.
- UI: purchase flow reaches the provider launch (mock); success/failure surfaced.
**Done-criteria.** A real (sandbox) payment on each rail credits chips exactly once; a
replayed callback does not double-credit; the TG outbox survives a bot restart mid-flight;
failed/succeeded events reach the user; refund path exercised. **PITR (E4) armed and the
cost/perf assessment reviewed before this goes to prod.**
**Notes/risks.** Manual `docker run -p` boot tests fail from the shell — verify the runnable
artifact via the testcontainers integration suite. Distroless services run UID 65532;
bind-mounted secrets must be 0644. Edge routes silently fall through if not added to the
Caddyfile — add a CI probe. The prod rolling deploy skips caddy on config-only changes —
force-recreate when the Caddyfile changes.
---
## E6 — Ads
**Status:** TODO · **Release 2** · depends on: E2 (chips), E5 (rewarded credits via intake) ·
mechanics: PAYMENTS §10.
**Goal.** VK video ads: the post-move interstitial (frequency-gated) and the rewarded video
(credits chips via server verify), plus extending the existing banner suppression to
per-origin.
**Work.**
- **Provider abstraction:** a small ads-network interface (VK impl now) so a future network
for other platforms slots in without rework. VK integration via `ui/src/lib/vk.ts`.
- **Rewarded:** voluntary view → the network's **server verify callback** → payments intake
credits chips to the `vk` segment (client not believed, like a payment). On-launch
anti-fraud = provider verify only (no own daily cap; abstraction allows later).
- **Interstitial** (post-move fullscreen), configurable server values (from `payments`
config): global per-user cooldown across all games (default 5 min); `vs_ai` 30 min; a hint
application triggers a post-move interstitial independently with its own 1-min cooldown;
offline banner-only; respect VK's own frequency caps. Cooldown state tracked server-side
(per user) or client-mirrored from a server value — pick and document at implementation.
- **Banner suppression:** extend `ads.Eligible` (`backend/internal/ads/ads.go` :107) to gate
on the **origin benefit applicable in the current context** (E2 interface) instead of the
single legacy flag. No-ads suppresses banner + interstitial; rewarded never suppressed.
**Tests.**
- unit: cooldown logic (global 5m / vs_ai 30m / hint-trigger 1m independence); rewarded
credit path; banner eligibility per context.
- integration: rewarded verify → credit exactly once (idempotent like a payment).
- UI: interstitial shows/respects cooldown (mock); rewarded button; no-ads hides banner +
interstitial; rewarded still available under no-ads.
**Done-criteria.** VK rewarded credits chips once per verified view; interstitial respects
all cooldowns incl. the hint trigger; no-ads suppresses banner+interstitial but not
rewarded; offline shows banner only.
**Notes/risks.** Crypto-payout networks stay rejected. Rewarded-without-payout is pointless —
only enable where the network pays (VK). Keep the interstitial frequency as server config so
it tunes without a store release.
---
## E7 — Admin & reports
**Status:** TODO · **Release 2** · depends on: E2 (ledger/grant), E5 (payments/refunds) ·
mechanics: PAYMENTS §11.
**Goal.** The admin console financial surface: per-user report, admin grant UI, manual
refund UI, ledger export.
**Work (`/_gm`, `backend/internal/server/handlers_admin_console.go` + `adminconsole/`).**
- **Per-user financial panel** on the existing user card (`consoleUserDetail` :343,
`UserDetailView`): segment balances, payments, spends, grants, refunds, full history —
read from the append-only ledger + materialized cache.
- **Admin grant** action (mirror the existing `POST /_gm/users/:id/grant-hints`): grant
concrete values (no-ads days / hints), **origin picker**, **never chips**; writes an
`admin_grant` ledger row (E2 `Grant`).
- **Manual refund** action: admin-initiated refund of a specific order (ties to the refund
path); records a `refund` ledger row; best-effort benefit revoke.
- **Ledger export**: CSV/JSON export of the ledger for tax reporting + future Robokassa
reconciliation (export-ready schema; reconciliation itself not built).
- Auth unchanged: gateway Basic-Auth in front of `/_gm` + backend same-origin CSRF on POSTs.
**Tests.**
- unit: report view assembly; grant refuses chips; export shape.
- integration: grant/refund write correct ledger rows; report reflects balances+history.
**Done-criteria.** An operator can see a user's full financial picture, grant concrete
values (origin-picked, never chips), issue a manual refund, and export the ledger.
**Notes/risks.** `/_gm` per-user detail already surfaces `PaidAccount`/`HintBalance` — replace
those with the segmented view as the legacy columns retire.
---
## E8 — Guest limits
**Status:** TODO · **standalone** (game-behaviour change; can run in parallel) · depends on:
none · mechanics: PAYMENTS §6.
**Goal.** A registration funnel: cap what a guest can do, enforced **server-side** (today the
gating is UI-only).
**Finding (verified).** The friend/invitation paths do **not** check `is_guest` on the
server — only the UI hides them: `social/friends.go:50` `SendFriendRequest`,
`robotfriends.go:41` `RequestInGame`, `friendcodes.go:67` `RedeemFriendCode`,
`lobby/invitations.go:208` `CreateInvitation`. A guest with a valid `X-User-ID` can call them
in the UI's stead.
**Work.**
- **Server guest gate** on friend-request / redeem-code / invitation-create: refuse when the
caller `is_guest` (add an `ErrGuestForbidden`-style guard, as `feedback` already has).
- **Active-game limits** for guests: at most **1 random-opponent game + 1 vs_ai** concurrently
(enforce on game/invitation creation in `lobby`/`game`; today no such limit exists).
- Keep the existing UI gating; this adds the server as the source of truth.
**Tests.**
- integration: guest is refused friend/redeem/invitation server-side; guest blocked from a
2nd random / 2nd vs_ai; durable account unaffected.
- UI: guest sees the funnel (existing hides + any new messaging).
**Done-criteria.** Guests are server-limited to 1 random + 1 vs_ai and cannot friend/invite;
durable accounts unchanged; regression that this does not break existing durable flows.
**Notes/risks.** This changes existing game behaviour — its own tests, its own PR, no
mixed-in payments changes. Decide whether a guest may finish an already-started game (limit
on creation, not mid-game) at implementation and record it here.
---
## E9 — Tournament fee (future)
**Status:** TODO · **future** · depends on: E0 (atom provisioned), tournament feature ·
mechanics: PAYMENTS §5, §7.
**Goal.** Charge a chip entry fee for tournaments. The `tournament` atom + a catalog product
are provisioned in E0/E7; the spend mechanic reuses E2 (`Spend`). The actual tournament
feature and its coupling to entry are out of scope until the tournament feature exists.
**Done-criteria.** Deferred — revisit when tournaments are built.
---
## Verification & CI (all stages)
- Per-stage tests at the layers above; **compliance-gate regression is mandatory** (a
`direct` benefit must never activate inside VK/TG) and runs from E2 onward.
- Local full verification before every push: `go build/vet ./backend/... && gofmt -l .`,
integration (`-tags=integration` + DAWG sibling + Ryuk-off), `pnpm -C ui check/test:unit/
build`, Playwright mock e2e, codegen (`make -C pkg proto fbs`, `pnpm -C ui codegen`).
- Contour: each PR into `development` auto-deploys the test contour; owner does visual sign-
off there (not local). Keep a multi-PR batch a linear stack (one shared contour,
last-deploy-wins).
- Prod (Release 2): expand-contract migrations only (image rollback DB-safe); PITR armed +
the E4 cost/perf assessment reviewed **before** the first money; new edge routes added to
the Caddyfile with a CI probe; caddy force-recreated on config-only changes.
- Release framing: **shipped docs/code/commits/PRs carry no stage ids** — finalize copy for
the release, not the plan.
+4 -1
View File
@@ -100,7 +100,10 @@ state, lobby enqueue, chat). The social/account/history operations under
`/api/v1/user`: `friends/*` (request/respond/cancel/unfriend, `/api/v1/user`: `friends/*` (request/respond/cancel/unfriend,
list/incoming, the one-time `code` issue/redeem), `blocks/*`, `invitations/*` list/incoming, the one-time `code` issue/redeem), `blocks/*`, `invitations/*`
(create/accept/decline/cancel/list), `PUT profile`, `email/{request,confirm}`, (create/accept/decline/cancel/list), `PUT profile`, `email/{request,confirm}`,
`stats`, and `games/:id/gcg` (finished-only). The `internal/notify` hub feeds a `stats`, `games/:id/gcg` (finished-only), and the payments `wallet` /
`wallet/catalog` (`GET` — the context-visible chip segments + benefits, and the
storefront catalog) / `wallet/buy` (`POST` — a chip spend on a value), served by
`internal/payments` behind the store-compliance gate. The `internal/notify` hub feeds a
second listener — `internal/pushgrpc`, a gRPC server (`BACKEND_GRPC_ADDR`) streaming second listener — `internal/pushgrpc`, a gRPC server (`BACKEND_GRPC_ADDR`) streaming
live events (your-turn, opponent-moved, chat, nudge, match-found, notify) to the live events (your-turn, opponent-moved, chat, nudge, match-found, notify) to the
gateway. The gateway-only `POST /api/v1/internal/push-target` (a user's gateway. The gateway-only `POST /api/v1/internal/push-target` (a user's
+19 -1
View File
@@ -31,6 +31,7 @@ import (
"scrabble/backend/internal/link" "scrabble/backend/internal/link"
"scrabble/backend/internal/lobby" "scrabble/backend/internal/lobby"
"scrabble/backend/internal/notify" "scrabble/backend/internal/notify"
"scrabble/backend/internal/payments"
"scrabble/backend/internal/postgres" "scrabble/backend/internal/postgres"
"scrabble/backend/internal/pushgrpc" "scrabble/backend/internal/pushgrpc"
"scrabble/backend/internal/ratewatch" "scrabble/backend/internal/ratewatch"
@@ -194,7 +195,8 @@ func run(ctx context.Context, cfg config.Config, logger *zap.Logger) error {
emails.SetSendLimiter(account.NewSendLimiter(time.Minute, 5)) emails.SetSendLimiter(account.NewSendLimiter(time.Minute, 5))
// Account linking & merge: the orchestrator over the account, merge and // Account linking & merge: the orchestrator over the account, merge and
// session layers. Wired to the /api/v1/user/link REST surface below. // session layers. Wired to the /api/v1/user/link REST surface below.
links := link.NewService(emails, accounts, accountmerge.NewMerger(db), sessions) merger := accountmerge.NewMerger(db)
links := link.NewService(emails, accounts, merger, sessions)
socialSvc := social.NewService(social.NewStore(db), accounts, games) socialSvc := social.NewService(social.NewStore(db), accounts, games)
socialSvc.SetNotifier(hub) socialSvc.SetNotifier(hub)
socialSvc.SetMetrics(tel.MeterProvider().Meter("scrabble/backend/social")) socialSvc.SetMetrics(tel.MeterProvider().Meter("scrabble/backend/social"))
@@ -260,6 +262,21 @@ func run(ctx context.Context, cfg config.Config, logger *zap.Logger) error {
// block and the banner admin console section. // block and the banner admin console section.
adsSvc := ads.NewService(ads.NewStore(db)) adsSvc := ads.NewService(ads.NewStore(db))
// In-game currency domain (data foundation): the payments schema behind a
// narrow interface. A boot-time reachability check fails fast if the schema
// did not migrate; the wallet routes are registered when that surface lands.
paymentsSvc := payments.NewService(payments.NewStore(db))
if err := paymentsSvc.Ping(ctx); err != nil {
return fmt.Errorf("payments schema unreachable: %w", err)
}
logger.Info("payments domain ready")
// 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
// schema fails boot before anything depends on it.
games.SetHintWallet(paymentsSvc)
merger.SetPayments(paymentsSvc)
// The image-render sidecar client for the PNG export artifact; nil (PNG // The image-render sidecar client for the PNG export artifact; nil (PNG
// download answers 404) when BACKEND_RENDERER_URL is unset. // download answers 404) when BACKEND_RENDERER_URL is unset.
var renderer *render.Client var renderer *render.Client
@@ -287,6 +304,7 @@ func run(ctx context.Context, cfg config.Config, logger *zap.Logger) error {
RateWatch: rateWatch, RateWatch: rateWatch,
BanView: banView, BanView: banView,
Ads: adsSvc, Ads: adsSvc,
Payments: paymentsSvc,
Notifier: hub, Notifier: hub,
ExportSignKey: cfg.ExportSignKey, ExportSignKey: cfg.ExportSignKey,
Renderer: renderer, Renderer: renderer,
+12 -5
View File
@@ -9,7 +9,8 @@
// 1. start a postgres:17-alpine container via testcontainers-go // 1. start a postgres:17-alpine container via testcontainers-go
// 2. open it with search_path=backend and apply the embedded goose migrations // 2. open it with search_path=backend and apply the embedded goose migrations
// 3. drop goose's bookkeeping table so jet does not generate a model for it // 3. drop goose's bookkeeping table so jet does not generate a model for it
// 4. run jet's PostgreSQL generator for schema=backend into internal/postgres/jet // 4. run jet's PostgreSQL generator for the backend and payments schemas into
// internal/postgres/jet (one subdirectory per schema)
package main package main
import ( import (
@@ -36,6 +37,7 @@ const (
superuserPassword = "scrabble" superuserPassword = "scrabble"
superuserDatabase = "scrabble_backend" superuserDatabase = "scrabble_backend"
backendSchema = "backend" backendSchema = "backend"
paymentsSchema = "payments"
containerStartup = 90 * time.Second containerStartup = 90 * time.Second
jetOutputDirSuffix = "internal/postgres/jet" jetOutputDirSuffix = "internal/postgres/jet"
) )
@@ -105,11 +107,16 @@ func run(ctx context.Context) error {
return fmt.Errorf("drop goose_db_version: %w", err) return fmt.Errorf("drop goose_db_version: %w", err)
} }
if err := jetpostgres.GenerateDB(db, backendSchema, outputDir); err != nil { // Each schema generates into its own jet/<schema>/ subdirectory (the
return fmt.Errorf("jet generate: %w", err) // generator wipes only that subtree), so the two calls do not clobber each
// other. GenerateDB takes the schema explicitly, so the connection's
// search_path=backend does not affect the payments introspection.
for _, schema := range []string{backendSchema, paymentsSchema} {
if err := jetpostgres.GenerateDB(db, schema, outputDir); err != nil {
return fmt.Errorf("jet generate schema=%s: %w", schema, err)
}
log.Printf("jetgen: generated jet code into %s (schema=%s)", outputDir, schema)
} }
log.Printf("jetgen: generated jet code into %s (schema=%s)", outputDir, backendSchema)
return nil return nil
} }
+417
View File
@@ -0,0 +1,417 @@
// Command movegen emits golden conformance fixtures for the client-side move
// generator port (ui/src/lib/dict). It is a dev tool, run by hand; its output is
// committed so the TypeScript parity tests run without a Go toolchain.
//
// For each small sample dictionary (English and Russian — the latter reaches
// alphabet index 32, exercising the 33-letter cross-set boundary) it writes:
//
// - sample_<tag>.dawg the serialized dictionary (the reader/cursor fixture)
// - sample_<tag>.words.json the stored words + their alphabet indexes
// - sample_<tag>.gen.json ranked move-generation results from the real solver,
// for a handful of positions, plus the ruleset the TS
// side rebuilds to score identically
//
// Positions are built with only the solver's public API: an empty board, and
// two-ply positions reached by applying the solver's own top move (so no internal
// encoding is needed). Regenerate with:
//
// go run ./backend/cmd/movegen -out ui/src/lib/dict/testdata
package main
import (
"bytes"
"encoding/json"
"flag"
"log"
"os"
"path/filepath"
"strings"
"gitea.iliadenisov.ru/developer/scrabble-solver/board"
"gitea.iliadenisov.ru/developer/scrabble-solver/rack"
"gitea.iliadenisov.ru/developer/scrabble-solver/rules"
"gitea.iliadenisov.ru/developer/scrabble-solver/scrabble"
dawg "github.com/iliadenisov/dafsa"
)
// sampleWordsEN is the English sample dictionary, in strictly increasing
// alphabet-index order (the builder requires it). Shared prefixes (car/care/cars),
// shared suffixes (cats/dogs), internal-final nodes (do, an) and a one-letter word.
var sampleWordsEN = []string{
"a", "an", "and", "ant",
"car", "care", "cared", "cares", "cars", "cat", "cats",
"do", "doe", "does", "dog", "dogs", "done", "dot",
}
// sampleWordsRU is the Russian sample dictionary, in strictly increasing index
// order. It deliberately includes words starting with я (index 32) so the ported
// cross-set handles alphabet indexes past JS's 31-bit shift boundary.
var sampleWordsRU = []string{"ад", "ар", "оса", "я", "яд", "яр"}
// sampleFixture is the JSON committed with the .dawg so the TypeScript cursor test
// knows the exact word set (as alphabet indexes) to expect from enumeration.
type sampleFixture struct {
Alphabet string `json:"alphabet"`
NumAdded int `json:"numAdded"`
Words []string `json:"words"`
Indexes [][]int `json:"indexes"`
}
// genFixture is the move-generation golden set for one sample dictionary.
type genFixture struct {
Ruleset genRuleset `json:"ruleset"`
Cases []genCase `json:"cases"`
}
// genRuleset is the scoring data the TS side rebuilds so evaluate() matches the Go
// solver: letter values, premium multipliers per square, the centre, rack size and bonus.
type genRuleset struct {
Size int `json:"size"`
Cols int `json:"cols"`
Center int `json:"center"`
RackSize int `json:"rackSize"`
Bingo int `json:"bingo"`
Values []int `json:"values"`
LetterMult [][]int `json:"letterMult"`
WordMult [][]int `json:"wordMult"`
}
// genTile is one placed tile (a board tile or a move placement).
type genTile struct {
Row int `json:"row"`
Col int `json:"col"`
Letter int `json:"letter"`
Blank bool `json:"blank"`
}
// genRack is a rack as a multiset of letter indexes plus a blank count.
type genRack struct {
Letters []int `json:"letters"`
Blanks int `json:"blanks"`
}
// genMove is one ranked generated play: its orientation, placed tiles and total score.
type genMove struct {
Dir int `json:"dir"`
Tiles []genTile `json:"tiles"`
Score int `json:"score"`
}
// genCase is one generation position: the tiles already on the board (empty when
// none), the rack, the mode/rule and the ranked moves the solver returns.
type genCase struct {
Name string `json:"name"`
Placed []genTile `json:"placed"`
Rack genRack `json:"rack"`
Mode int `json:"mode"`
IgnoreCrossWords bool `json:"ignoreCrossWords"`
Moves []genMove `json:"moves"`
}
func main() {
out := flag.String("out", "ui/src/lib/dict/testdata", "output directory for fixtures")
dawgDir := flag.String("dawg-dir", "", "when set, emit real-dictionary move-gen golden from the .dawg files in this dir (conformance mode) instead of the committed samples")
flag.Parse()
if err := os.MkdirAll(*out, 0o755); err != nil {
log.Fatalf("movegen: mkdir %s: %v", *out, err)
}
if *dawgDir != "" {
buildReal(*dawgDir, *out)
return
}
emitRulesets()
buildSample(*out, "en", rules.English(), sampleWordsEN, []genCase{
emptyCase("empty-cared", englishRack("caredts", 0), scrabble.Both, false),
emptyCase("empty-dogs", englishRack("dogsent", 0), scrabble.Both, false),
emptyCase("empty-blank", englishRack("caret", 1), scrabble.Both, false),
emptyCase("empty-single-word", englishRack("caredts", 0), scrabble.Both, true),
})
buildSample(*out, "ru", rules.RussianScrabble(), sampleWordsRU, []genCase{
emptyCase("empty-yad", russianRack("ядрасо", 0), scrabble.Both, false),
})
}
// buildSample writes the dawg, the word fixture and the generation golden set for
// one sample dictionary. Two-ply cases are appended: the solver's own top move from
// the first non-empty result is applied, then generation runs again on the new rack.
func buildSample(out, tag string, rs *rules.Ruleset, words []string, cases []genCase) {
idx := rs.Alphabet
b := dawg.New(idx)
indexes := make([][]int, 0, len(words))
for _, w := range words {
if err := b.Add(w); err != nil {
log.Fatalf("movegen[%s]: add %q: %v", tag, w, err)
}
enc, err := idx.Encode(w)
if err != nil {
log.Fatalf("movegen[%s]: encode %q: %v", tag, w, err)
}
ints := make([]int, len(enc))
for i, x := range enc {
ints[i] = int(x)
}
indexes = append(indexes, ints)
}
finder := b.Finish()
writeJSON(filepath.Join(out, "sample_"+tag+".words.json"), sampleFixture{
Alphabet: tag, NumAdded: finder.NumAdded(), Words: words, Indexes: indexes,
})
dawgPath := filepath.Join(out, "sample_"+tag+".dawg")
if _, err := finder.Save(dawgPath); err != nil {
log.Fatalf("movegen[%s]: save %s: %v", tag, dawgPath, err)
}
s := scrabble.NewSolver(rs, finder)
for i := range cases {
runCase(s, rs, &cases[i], nil)
}
// A two-ply position from the first standard case that produced a move.
if two := twoPly(s, rs, cases); two != nil {
cases = append(cases, *two)
}
writeJSON(filepath.Join(out, "sample_"+tag+".gen.json"), genFixture{
Ruleset: rulesetOf(rs), Cases: cases,
})
log.Printf("movegen[%s]: %d words, %d cases", tag, finder.NumAdded(), len(cases))
}
// realVariant maps a shipped dictionary file to the ruleset that scores it and the racks
// the conformance positions use. smallRack/blankRack keep the first-move (empty board)
// lists bounded on a dense dictionary; fullRack drives a deep 7-tile mid-game position,
// kept small by the anchors around the already-placed word.
type realVariant struct {
file, variant, smallRack, blankRack, fullRack string
rs *rules.Ruleset
}
// buildReal emits move-generation golden from the real shipped dictionaries in dawgDir —
// the full alphabets and deep graphs the tiny samples cannot reach — one
// <variant>.movegen.json per variant. Like the dictgen/validategen vectors it is
// regenerated in CI and never committed, so it pins no dictionary version into the repo.
func buildReal(dawgDir, out string) {
reals := []realVariant{
{"en_sowpods", "scrabble_en", "aine", "ain", "aeinrst", rules.English()},
{"ru_scrabble", "scrabble_ru", "аеин", "аен", "аеиноср", rules.RussianScrabble()},
{"ru_erudit", "erudit_ru", "аеин", "аен", "аеиноср", rules.Erudit()},
}
for _, v := range reals {
data, err := os.ReadFile(filepath.Join(dawgDir, v.file+".dawg"))
if err != nil {
log.Fatalf("movegen[%s]: read dawg: %v", v.variant, err)
}
finder, err := dawg.Read(bytes.NewReader(data), 0)
if err != nil {
log.Fatalf("movegen[%s]: parse dawg: %v", v.variant, err)
}
s := scrabble.NewSolver(v.rs, finder)
cases := []genCase{
emptyCase("first-move", encRack(v.rs, v.smallRack, 0), scrabble.Both, false),
emptyCase("first-move-blank", encRack(v.rs, v.blankRack, 1), scrabble.Both, false),
}
for i := range cases {
runCase(s, v.rs, &cases[i], nil)
}
// A deep 7-tile mid-game: place the top first move, then generate again. The
// anchors around the placed word bound the list while still exercising a full rack,
// deep left/right extension and wide cross-sets over the real graph.
full := encRack(v.rs, v.fullRack, 0)
b := board.New(v.rs.Rows, v.rs.Cols)
if m1 := s.GenerateMovesOpts(b, toRack(v.rs.Size(), full), scrabble.Both, scrabble.PlayOptions{}); len(m1) > 0 {
mid := genCase{Name: "mid-game", Rack: full, Mode: int(scrabble.Both)}
runCase(s, v.rs, &mid, tilesOf(m1[0].Tiles))
cases = append(cases, mid)
}
writeJSON(filepath.Join(out, v.variant+".movegen.json"), genFixture{Ruleset: rulesetOf(v.rs), Cases: cases})
total := 0
for _, c := range cases {
total += len(c.Moves)
}
_ = finder.Close()
log.Printf("movegen[%s]: %d cases, %d golden moves", v.variant, len(cases), total)
}
}
// encRack encodes a rack given as the variant's letters (plus a blank count) into the
// index-based genRack the fixtures carry.
func encRack(rs *rules.Ruleset, letters string, blanks int) genRack {
enc, err := rs.Alphabet.Encode(letters)
if err != nil {
log.Fatalf("movegen: encode rack %q: %v", letters, err)
}
idx := make([]int, len(enc))
for i, b := range enc {
idx[i] = int(b)
}
return genRack{Letters: idx, Blanks: blanks}
}
// runCase fills a case's Moves by generating on a board holding the given placed
// tiles (nil = empty board).
func runCase(s *scrabble.Solver, rs *rules.Ruleset, c *genCase, placed []genTile) {
bd := board.New(rs.Rows, rs.Cols)
for _, t := range placed {
bd.Set(t.Row, t.Col, cellByte(t.Letter, t.Blank))
}
c.Placed = placed
rk := toRack(rs.Size(), c.Rack)
moves := s.GenerateMovesOpts(bd, rk, scrabble.Mode(c.Mode), scrabble.PlayOptions{IgnoreCrossWords: c.IgnoreCrossWords})
c.Moves = movesOf(moves)
}
// twoPly reaches a mid-game position by applying the top move of the first standard
// case that generated one, then generates again with a fresh rack of the same tiles.
func twoPly(s *scrabble.Solver, rs *rules.Ruleset, cases []genCase) *genCase {
for _, c := range cases {
if c.IgnoreCrossWords || len(c.Moves) == 0 {
continue
}
placed := c.Moves[0].Tiles
next := genCase{Name: "two-ply", Rack: c.Rack, Mode: int(scrabble.Both)}
runCase(s, rs, &next, placed)
return &next
}
return nil
}
// cellByte encodes a board cell the way internal/encoding.Cell does (bits 0-5 hold
// letter+1, bit 7 marks a blank). Duplicated here because that package is internal
// to the solver module and cannot be imported.
func cellByte(letter int, blank bool) byte {
v := byte(letter+1) & 0x3f
if blank {
v |= 0x80
}
return v
}
func toRack(size int, r genRack) rack.Rack {
rk := rack.New(size)
for _, l := range r.Letters {
rk.Add(byte(l))
}
for i := 0; i < r.Blanks; i++ {
rk.AddBlank()
}
return rk
}
func rulesetOf(rs *rules.Ruleset) genRuleset {
lm := make([][]int, rs.Rows)
wm := make([][]int, rs.Rows)
for r := 0; r < rs.Rows; r++ {
lm[r] = make([]int, rs.Cols)
wm[r] = make([]int, rs.Cols)
for c := 0; c < rs.Cols; c++ {
p := rs.Premium(r, c)
lm[r][c] = p.LetterMult()
wm[r][c] = p.WordMult()
}
}
return genRuleset{
Size: rs.Size(), Cols: rs.Cols, Center: rs.Center, RackSize: rs.RackSize,
Bingo: rs.Bingo, Values: rs.Values, LetterMult: lm, WordMult: wm,
}
}
func movesOf(ms []scrabble.Move) []genMove {
out := make([]genMove, len(ms))
for i, m := range ms {
out[i] = genMove{Dir: int(m.Dir), Tiles: tilesOf(m.Tiles), Score: m.Score}
}
return out
}
func tilesOf(ps []scrabble.Placement) []genTile {
out := make([]genTile, len(ps))
for i, p := range ps {
out[i] = genTile{Row: p.Row, Col: p.Col, Letter: int(p.Letter), Blank: p.Blank}
}
return out
}
// emptyCase builds an empty-board case (Moves filled later by runCase).
func emptyCase(name string, r genRack, mode scrabble.Mode, ignoreCross bool) genCase {
return genCase{Name: name, Rack: r, Mode: int(mode), IgnoreCrossWords: ignoreCross}
}
// englishRack builds a rack from lowercase a-z letters (index = letter-'a').
func englishRack(letters string, blanks int) genRack {
idx := make([]int, 0, len(letters))
for _, ch := range letters {
idx = append(idx, int(ch-'a'))
}
return genRack{Letters: idx, Blanks: blanks}
}
// russianRack builds a rack from the Russian sample letters used above.
func russianRack(letters string, blanks int) genRack {
m := map[rune]int{'а': 0, 'д': 4, 'о': 15, 'р': 17, 'с': 18, 'я': 32}
idx := make([]int, 0, len([]rune(letters)))
for _, ch := range letters {
i, ok := m[ch]
if !ok {
log.Fatalf("movegen: russianRack: no index for %q", string(ch))
}
idx = append(idx, i)
}
return genRack{Letters: idx, Blanks: blanks}
}
// emitRulesets writes the per-variant static ruleset data (tile values, bag counts, blanks,
// bingo, rack size) the offline engine mirrors in ui/src/lib/localgame/ruleset.ts, so a TS
// parity test can pin that hand-copied table to the Go rulesets (scrabble-solver/rules).
func emitRulesets() {
type rsFix struct {
Size int `json:"size"`
RackSize int `json:"rackSize"`
Bingo int `json:"bingo"`
Blanks int `json:"blanks"`
Values []int `json:"values"`
Counts []int `json:"counts"`
Letters []string `json:"letters"`
}
out := map[string]rsFix{}
for _, v := range []struct {
name string
rs *rules.Ruleset
}{
{"scrabble_en", rules.English()},
{"scrabble_ru", rules.RussianScrabble()},
{"erudit_ru", rules.Erudit()},
} {
letters := make([]string, v.rs.Size())
for i := range letters {
ch, err := v.rs.Alphabet.Character(byte(i))
if err != nil {
log.Fatalf("movegen: %s letter %d: %v", v.name, i, err)
}
letters[i] = strings.ToUpper(ch)
}
out[v.name] = rsFix{Size: v.rs.Size(), RackSize: v.rs.RackSize, Bingo: v.rs.Bingo, Blanks: v.rs.Blanks, Values: v.rs.Values, Counts: v.rs.Counts, Letters: letters}
}
dir := filepath.Join("ui", "src", "lib", "localgame", "testdata")
if err := os.MkdirAll(dir, 0o755); err != nil {
log.Fatalf("movegen: mkdir %s: %v", dir, err)
}
writeJSON(filepath.Join(dir, "rulesets.json"), out)
log.Printf("movegen: wrote %s (3 variants)", filepath.Join(dir, "rulesets.json"))
}
func writeJSON(path string, v any) {
data, err := json.MarshalIndent(v, "", " ")
if err != nil {
log.Fatalf("movegen: marshal %s: %v", path, err)
}
if err := os.WriteFile(path, append(data, '\n'), 0o644); err != nil {
log.Fatalf("movegen: write %s: %v", path, err)
}
}
+40 -20
View File
@@ -51,8 +51,24 @@ var ErrSameAccount = errors.New("accountmerge: primary and secondary are the sam
type Merger struct { type Merger struct {
db *sql.DB db *sql.DB
now func() time.Time now func() time.Time
// payments, when set, merges the two accounts' chip segments and benefits by origin inside
// the merge transaction (SetPayments). Nil leaves payments untouched (tests that do not
// exercise the wallet).
payments PaymentsMerger
} }
// PaymentsMerger is the payments surface the account merge enlists: fold the secondary's
// segments and benefits into the primary within the merge transaction, then invalidate the
// affected read caches after the commit. *payments.Service satisfies it.
type PaymentsMerger interface {
MergeTx(ctx context.Context, tx *sql.Tx, primary, secondary uuid.UUID) error
Invalidate(ids ...uuid.UUID)
}
// SetPayments installs the payments merge hook. It must be called during startup wiring; the
// default (nil) merges no wallet state.
func (m *Merger) SetPayments(p PaymentsMerger) { m.payments = p }
// NewMerger constructs a Merger over db. // NewMerger constructs a Merger over db.
func NewMerger(db *sql.DB) *Merger { func NewMerger(db *sql.DB) *Merger {
return &Merger{db: db, now: func() time.Time { return time.Now().UTC() }} return &Merger{db: db, now: func() time.Time { return time.Now().UTC() }}
@@ -67,7 +83,7 @@ func (m *Merger) Merge(ctx context.Context, primary, secondary uuid.UUID) error
return ErrSameAccount return ErrSameAccount
} }
now := m.now() now := m.now()
return withTx(ctx, m.db, func(tx *sql.Tx) error { if err := withTx(ctx, m.db, func(tx *sql.Tx) error {
if err := guardActiveSharedGame(ctx, tx, primary, secondary); err != nil { if err := guardActiveSharedGame(ctx, tx, primary, secondary); err != nil {
return err return err
} }
@@ -107,8 +123,21 @@ func (m *Merger) Merge(ctx context.Context, primary, secondary uuid.UUID) error
if err := deleteEphemerals(ctx, tx, secondary); err != nil { if err := deleteEphemerals(ctx, tx, secondary); err != nil {
return err return err
} }
if m.payments != nil {
if err := m.payments.MergeTx(ctx, tx, primary, secondary); err != nil {
return fmt.Errorf("accountmerge: payments: %w", err)
}
}
return tombstone(ctx, tx, primary, secondary, now) return tombstone(ctx, tx, primary, secondary, now)
}) }); err != nil {
return err
}
// The payments read cache is invalidated only after the merge commits, so a read racing the
// transaction cannot re-cache pre-merge state (both accounts' rows are moved or dropped).
if m.payments != nil {
m.payments.Invalidate(primary, secondary)
}
return nil
} }
// guardActiveSharedGame returns ErrActiveGameConflict when primary and secondary // guardActiveSharedGame returns ErrActiveGameConflict when primary and secondary
@@ -248,25 +277,16 @@ func mergeBestMoves(ctx context.Context, tx *sql.Tx, primary, secondary uuid.UUI
return nil return nil
} }
// mergeAccountFields adds secondary's hint wallet to primary and ORs the paid flag; // mergeAccountFields bumps the primary account's updated_at to reflect the merge. The former
// all other profile fields stay the primary's. // hint-wallet and paid-flag merge moved to the payments domain, where segments and benefits
func mergeAccountFields(ctx context.Context, tx *sql.Tx, primary, secondary uuid.UUID, now time.Time) error { // merge by origin (see the payments MergeTx step and docs/PAYMENTS.md §6); the legacy
var sec model.Accounts // accounts.hint_balance / paid_account columns are deprecated and no longer read or written.
if err := postgres.SELECT(table.Accounts.AllColumns). func mergeAccountFields(ctx context.Context, tx *sql.Tx, primary, _ uuid.UUID, now time.Time) error {
FROM(table.Accounts). upd := table.Accounts.UPDATE(table.Accounts.UpdatedAt).
WHERE(table.Accounts.AccountID.EQ(postgres.UUID(secondary))). SET(postgres.TimestampzT(now)).
QueryContext(ctx, tx, &sec); err != nil { WHERE(table.Accounts.AccountID.EQ(postgres.UUID(primary)))
return fmt.Errorf("accountmerge: load secondary account: %w", err)
}
upd := table.Accounts.UPDATE(
table.Accounts.HintBalance, table.Accounts.PaidAccount, table.Accounts.UpdatedAt,
).SET(
table.Accounts.HintBalance.ADD(postgres.Int(int64(sec.HintBalance))),
table.Accounts.PaidAccount.OR(postgres.Bool(sec.PaidAccount)),
postgres.TimestampzT(now),
).WHERE(table.Accounts.AccountID.EQ(postgres.UUID(primary)))
if _, err := upd.ExecContext(ctx, tx); err != nil { if _, err := upd.ExecContext(ctx, tx); err != nil {
return fmt.Errorf("accountmerge: update primary account: %w", err) return fmt.Errorf("accountmerge: touch primary account: %w", err)
} }
return nil return nil
} }
-8
View File
@@ -100,14 +100,6 @@ type ActiveCampaign struct {
OverrideDark *ColorSet OverrideDark *ColorSet
} }
// Eligible reports whether an account should be shown the advertising banner: a
// free account (not paid) with an empty hint wallet and without the no_banner
// role. The no_banner role suppresses the banner unconditionally; buying a paid
// account or any hints also removes it.
func Eligible(paidAccount bool, hintBalance int, hasNoBanner bool) bool {
return !paidAccount && hintBalance <= 0 && !hasNoBanner
}
// computeActiveSet builds the resolved rotation feed from the enabled campaigns // computeActiveSet builds the resolved rotation feed from the enabled campaigns
// at time now, in language lang, and reports whether the feed is an urgent one. // at time now, in language lang, and reports whether the feed is an urgent one.
// Campaigns outside their validity window, and campaigns with no messages, are // Campaigns outside their validity window, and campaigns with no messages, are
-24
View File
@@ -6,30 +6,6 @@ import (
"time" "time"
) )
func TestEligible(t *testing.T) {
tests := []struct {
name string
paidAccount bool
hintBalance int
hasNoBanner bool
want bool
}{
{name: "free, empty wallet, no role", want: true},
{name: "paid", paidAccount: true, want: false},
{name: "has hints", hintBalance: 3, want: false},
{name: "no_banner role", hasNoBanner: true, want: false},
{name: "paid and has hints", paidAccount: true, hintBalance: 5, want: false},
{name: "no_banner overrides everything", hasNoBanner: true, want: false},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
if got := Eligible(tt.paidAccount, tt.hintBalance, tt.hasNoBanner); got != tt.want {
t.Errorf("Eligible(%v,%d,%v) = %v, want %v", tt.paidAccount, tt.hintBalance, tt.hasNoBanner, got, tt.want)
}
})
}
}
func TestComputeActiveSet(t *testing.T) { func TestComputeActiveSet(t *testing.T) {
now := time.Date(2026, 6, 15, 12, 0, 0, 0, time.UTC) now := time.Date(2026, 6, 15, 12, 0, 0, 0, time.UTC)
past := now.Add(-24 * time.Hour) past := now.Add(-24 * time.Hour)
+123
View File
@@ -0,0 +1,123 @@
package engine
import (
"encoding/json"
"os"
"path/filepath"
"testing"
"gitea.iliadenisov.ru/developer/scrabble-solver/rules"
)
// The offline engine (ui/src/lib/localgame) reproduces the end-of-game rack settlement and the
// winner rule so a local game finishes with the same scores as the server. These golden fixtures
// pin the ported pure functions (applyEndAdjustment / winner / rackValue) to the real Go engine.
// Being in-package, this emitter constructs Game values directly and drives the unexported
// end-game math on chosen positions.
type endCaseIn struct {
Name string `json:"name"`
Variant string `json:"variant"`
Reason string `json:"reason"`
Hands [][]int `json:"hands"`
Scores []int `json:"scores"`
Resigned []bool `json:"resigned"`
ToMove int `json:"toMove"`
}
type endCaseOut struct {
endCaseIn
ScoresAfter []int `json:"scoresAfter"`
Winner int `json:"winner"`
}
func rulesetFor(variant string) *rules.Ruleset {
switch variant {
case "scrabble_ru":
return rules.RussianScrabble()
case "erudit_ru":
return rules.Erudit()
default:
return rules.English()
}
}
func reasonFor(s string) EndReason {
switch s {
case "out_of_tiles":
return EndOutOfTiles
case "scoreless":
return EndScoreless
case "resign":
return EndResign
case "aborted":
return EndAborted
}
return EndNotOver
}
func handsBytes(hands [][]int) [][]byte {
out := make([][]byte, len(hands))
for i, h := range hands {
b := make([]byte, len(h))
for j, x := range h {
b[j] = byte(x)
}
out[i] = b
}
return out
}
// TestEmitEndgameFixtures regenerates ui/src/lib/localgame/testdata/endgame.json. Gated by
// EMIT_ENGINE_FIXTURES. Regenerate with:
//
// EMIT_ENGINE_FIXTURES=1 go test ./backend/internal/engine -run TestEmitEndgameFixtures
func TestEmitEndgameFixtures(t *testing.T) {
if os.Getenv("EMIT_ENGINE_FIXTURES") == "" {
t.Skip("set EMIT_ENGINE_FIXTURES=1 to regenerate ui/src/lib/localgame/testdata/endgame.json")
}
cases := []endCaseIn{
{"out-basic", "scrabble_en", "out_of_tiles", [][]int{{}, {0, 1, 2}}, []int{50, 40}, []bool{false, false}, 0},
{"out-blank", "scrabble_en", "out_of_tiles", [][]int{{}, {255, 0}}, []int{30, 30}, []bool{false, false}, 0},
{"out-erudit-yo", "erudit_ru", "out_of_tiles", [][]int{{}, {6, 32}}, []int{10, 10}, []bool{false, false}, 0},
{"out-tie", "scrabble_en", "out_of_tiles", [][]int{{}, {}}, []int{30, 30}, []bool{false, false}, 0},
{"out-3p", "scrabble_ru", "out_of_tiles", [][]int{{}, {0, 1}, {2}}, []int{10, 10, 10}, []bool{false, false, false}, 0},
{"scoreless", "scrabble_en", "scoreless", [][]int{{0, 1}, {2, 3}}, []int{20, 20}, []bool{false, false}, 0},
{"resign-2p", "scrabble_en", "resign", [][]int{{}, {0}}, []int{100, 10}, []bool{true, false}, 1},
{"resign-3p", "scrabble_en", "resign", [][]int{{}, {}, {}}, []int{50, 60, 40}, []bool{false, true, false}, 0},
{"aborted", "scrabble_en", "aborted", [][]int{{0}, {1}}, []int{40, 30}, []bool{false, false}, 0},
}
out := make([]endCaseOut, 0, len(cases))
for _, c := range cases {
rs := rulesetFor(c.Variant)
scores := append([]int(nil), c.Scores...)
g := &Game{
rules: rs,
hands: handsBytes(c.Hands),
scores: scores,
resigned: c.Resigned,
toMove: c.ToMove,
}
reason := reasonFor(c.Reason)
g.over = true
g.reason = reason
g.applyEndAdjustment(reason)
out = append(out, endCaseOut{endCaseIn: c, ScoresAfter: g.scores, Winner: g.winner()})
}
dir := filepath.Join("..", "..", "..", "ui", "src", "lib", "localgame", "testdata")
if err := os.MkdirAll(dir, 0o755); err != nil {
t.Fatalf("mkdir %s: %v", dir, err)
}
data, err := json.MarshalIndent(map[string]any{"cases": out}, "", " ")
if err != nil {
t.Fatalf("marshal: %v", err)
}
path := filepath.Join(dir, "endgame.json")
if err := os.WriteFile(path, append(data, '\n'), 0o644); err != nil {
t.Fatalf("write %s: %v", path, err)
}
t.Logf("wrote %s (%d cases)", path, len(out))
}
+23
View File
@@ -69,6 +69,29 @@ func TestHintsRemaining(t *testing.T) {
} }
} }
func TestHintUnlockLeftSeconds(t *testing.T) {
now := time.Now()
// A gated vs_ai game on the caller's turn, the robot having moved 10 min ago (turn started then).
gated := Game{VsAI: true, ToMove: 0, MoveCount: 2, TurnStartedAt: now.Add(-10 * time.Minute)}
cases := []struct {
name string
g Game
seat int
want int
}{
{"non-vs_ai is open", Game{VsAI: false, ToMove: 0, MoveCount: 2, TurnStartedAt: now.Add(-10 * time.Minute)}, 0, 0},
{"not the caller's turn is open", gated, 1, 0},
{"human first move (no robot move) is open", Game{VsAI: true, ToMove: 0, MoveCount: 0, TurnStartedAt: now}, 0, 0},
{"robot moved 10 min ago leaves 20 min", gated, 0, 20 * 60},
{"robot moved past the window is open", Game{VsAI: true, ToMove: 0, MoveCount: 2, TurnStartedAt: now.Add(-40 * time.Minute)}, 0, 0},
}
for _, c := range cases {
if got := hintUnlockLeftSeconds(c.g, c.seat, now); got != c.want {
t.Errorf("%s: hintUnlockLeftSeconds = %d, want %d", c.name, got, c.want)
}
}
}
func TestAllowedTimeout(t *testing.T) { func TestAllowedTimeout(t *testing.T) {
if !allowedTimeout(24 * time.Hour) { if !allowedTimeout(24 * time.Hour) {
t.Error("24h must be allowed") t.Error("24h must be allowed")
+98 -8
View File
@@ -18,6 +18,8 @@ import (
"scrabble/backend/internal/account" "scrabble/backend/internal/account"
"scrabble/backend/internal/engine" "scrabble/backend/internal/engine"
"scrabble/backend/internal/notify" "scrabble/backend/internal/notify"
"scrabble/backend/internal/payments"
"scrabble/backend/internal/session"
) )
// Service is the game domain: it drives the engine over a single match, persists // Service is the game domain: it drives the engine over a single match, persists
@@ -50,6 +52,11 @@ type Service struct {
// its asynchronous TriggerMove); nil disables the fast path (the scan still covers // its asynchronous TriggerMove); nil disables the fast path (the scan still covers
// these games). Kept as a func so the game package never imports the robot package. // these games). Kept as a func so the game package never imports the robot package.
aiTrigger func(gameID uuid.UUID) aiTrigger func(gameID uuid.UUID)
// hintWallet is the payments surface the online-game hint path spends against (the
// segmented, context-aware hint balance). It is set by SetHintWallet during wiring; when
// nil, only the free per-game allowance is served (no purchased hints). vs_ai hints are
// wallet-free and never touch it.
hintWallet HintWallet
// 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.
@@ -105,6 +112,39 @@ func (svc *Service) SetAITrigger(trigger func(gameID uuid.UUID)) {
svc.aiTrigger = trigger svc.aiTrigger = trigger
} }
// HintWallet is the payments surface the online-game hint path uses: the context-aware hint
// balance (HintsAvailable) and a one-hint spend (SpendHint), both keyed by the trusted execution
// context and the account's present identity sources. *payments.Service satisfies it.
type HintWallet interface {
HintsAvailable(ctx context.Context, accountID uuid.UUID, cxt payments.Context, present []payments.Source) (int, error)
SpendHint(ctx context.Context, accountID uuid.UUID, cxt payments.Context, present []payments.Source) (bool, error)
}
// SetHintWallet installs the payments hint wallet the online-game hint path spends against. It
// must be called during startup wiring; the default (nil) serves only the free per-game allowance.
func (svc *Service) SetHintWallet(w HintWallet) {
svc.hintWallet = w
}
// 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
// the account's present identity sources (which chip/benefit segments are awake, §6).
func (svc *Service) walletContext(ctx context.Context, accountID uuid.UUID) (payments.Context, []payments.Source, error) {
var cxt payments.Context
if p, ok := session.PlatformFromContext(ctx); ok {
cxt = payments.NewContext(p.Kind, p.Subtype)
}
ids, err := svc.accounts.Identities(ctx, accountID)
if err != nil {
return payments.Context{}, nil, err
}
kinds := make([]string, len(ids))
for i, id := range ids {
kinds[i] = id.Kind
}
return cxt, payments.PresentSources(kinds), nil
}
// SetNudgeClearer installs the hook that marks a mover's pending nudges read after // SetNudgeClearer installs the hook that marks a mover's pending nudges read after
// their move commits. It must be called during startup wiring; the default (nil) // their move commits. It must be called during startup wiring; the default (nil)
// leaves nudges to be cleared only when the recipient opens the move history or chat. // leaves nudges to be cleared only when the recipient opens the move history or chat.
@@ -1058,10 +1098,31 @@ func (svc *Service) MarkChangesApplied(ctx context.Context, variant engine.Varia
return svc.store.MarkChangesApplied(ctx, variant.String(), version) return svc.store.MarkChangesApplied(ctx, variant.String(), version)
} }
// Hint reveals the top-scoring legal play for the requesting player on their // hintIdleWindow is how long a vs_ai player must be stuck on a turn (since the robot's last move)
// turn, spending one hint from their per-game allowance and then their profile // before the idle hint unlocks. Mirrors the offline client (lib/hints HINT_GATE_MS = 30 min).
// wallet. It returns ErrHintsDisabled, ErrNoHintsLeft or ErrNoHintAvailable as const hintIdleWindow = 30 * time.Minute
// appropriate.
// hintUnlockLeftSeconds is the seconds until g's vs_ai idle hint unlocks for seat, measured from now:
// the robot's last move (the current turn's start, on the human's turn) plus the window, floored at
// 0 and ceiled to whole seconds. It is 0 for a non-vs_ai game, when it is not seat's turn, or on the
// human's first move (MoveCount 0, no robot move yet) — the gate is an anti-frustration aid, not a
// first-move tax. The client anchors a monotonic countdown to it, so a client clock cannot skew it.
func hintUnlockLeftSeconds(g Game, seat int, now time.Time) int {
if !g.VsAI || g.ToMove != seat || g.MoveCount < 1 {
return 0
}
left := g.TurnStartedAt.Add(hintIdleWindow).Sub(now)
if left <= 0 {
return 0
}
return int((left + time.Second - 1) / time.Second) // ceil to whole seconds (no math import)
}
// Hint reveals the top-scoring legal play for the requesting player on their turn. For a human game
// it spends one hint from the per-game allowance then the profile wallet (ErrHintsDisabled /
// ErrNoHintsLeft / ErrNoHintAvailable). For a vs_ai game the hint is unlimited and wallet-free but
// idle-gated from the server clock (ErrHintLocked until the window elapses) and counts toward no
// hint statistic.
func (svc *Service) Hint(ctx context.Context, gameID, accountID uuid.UUID) (HintResult, error) { func (svc *Service) Hint(ctx context.Context, gameID, accountID uuid.UUID) (HintResult, error) {
pre, err := svc.store.GetGame(ctx, gameID) pre, err := svc.store.GetGame(ctx, gameID)
if err != nil { if err != nil {
@@ -1080,13 +1141,40 @@ func (svc *Service) Hint(ctx context.Context, gameID, accountID uuid.UUID) (Hint
if !pre.HintsAllowed { if !pre.HintsAllowed {
return HintResult{}, ErrHintsDisabled return HintResult{}, ErrHintsDisabled
} }
acc, err := svc.accounts.GetByID(ctx, accountID) if pre.VsAI {
// vs_ai: unlimited and wallet-free, but idle-gated from the server clock — and counted toward
// no hint statistic. Enforce the gate (the client normally pre-gates from the view's
// HintUnlockLeftSeconds; this is the authoritative backstop), then serve the top move without
// touching the allowance, the wallet or hints_used.
if hintUnlockLeftSeconds(pre, seat, svc.clock()) > 0 {
return HintResult{}, ErrHintLocked
}
unlock := svc.locks.lock(gameID)
defer unlock()
g, err := svc.liveGame(ctx, pre)
if err != nil {
return HintResult{}, err
}
move, ok := g.HintView()
if !ok {
return HintResult{}, ErrNoHintAvailable
}
return HintResult{Move: move}, nil
}
cxt, present, err := svc.walletContext(ctx, accountID)
if err != nil { if err != nil {
return HintResult{}, err return HintResult{}, err
} }
wallet := 0
if svc.hintWallet != nil {
wallet, err = svc.hintWallet.HintsAvailable(ctx, accountID, cxt, present)
if err != nil {
return HintResult{}, err
}
}
used := pre.Seats[seat].HintsUsed used := pre.Seats[seat].HintsUsed
fromAllowance := used < pre.HintsPerPlayer fromAllowance := used < pre.HintsPerPlayer
if !fromAllowance && acc.HintBalance <= 0 { if !fromAllowance && wallet <= 0 {
return HintResult{}, ErrNoHintsLeft return HintResult{}, ErrNoHintsLeft
} }
@@ -1101,9 +1189,9 @@ func (svc *Service) Hint(ctx context.Context, gameID, accountID uuid.UUID) (Hint
return HintResult{}, ErrNoHintAvailable return HintResult{}, ErrNoHintAvailable
} }
walletAfter := acc.HintBalance walletAfter := wallet
if !fromAllowance { if !fromAllowance {
spent, err := svc.accounts.SpendHint(ctx, accountID) spent, err := svc.hintWallet.SpendHint(ctx, accountID, cxt, present)
if err != nil { if err != nil {
return HintResult{}, err return HintResult{}, err
} }
@@ -1208,6 +1296,8 @@ func (svc *Service) GameState(ctx context.Context, gameID, accountID uuid.UUID)
BagLen: g.BagLen(), BagLen: g.BagLen(),
HintsRemaining: hintsRemaining(pre.HintsPerPlayer, pre.Seats[seat].HintsUsed, acc.HintBalance), HintsRemaining: hintsRemaining(pre.HintsPerPlayer, pre.Seats[seat].HintsUsed, acc.HintBalance),
WalletBalance: acc.HintBalance, WalletBalance: acc.HintBalance,
// vs_ai idle-hint gate (seconds left; 0 for a human game / first move / not your turn).
HintUnlockLeftSeconds: hintUnlockLeftSeconds(pre, seat, svc.clock()),
}, nil }, nil
} }
+9
View File
@@ -65,6 +65,10 @@ var (
ErrNoHintsLeft = errors.New("game: no hints remaining") ErrNoHintsLeft = errors.New("game: no hints remaining")
// ErrNoHintAvailable is returned when the player has no legal play to reveal. // ErrNoHintAvailable is returned when the player has no legal play to reveal.
ErrNoHintAvailable = errors.New("game: no legal move to suggest") ErrNoHintAvailable = errors.New("game: no legal move to suggest")
// ErrHintLocked is returned when a vs_ai game's idle hint is still gated (the player has not yet
// been stuck the idle window since the robot's last move). The client normally pre-gates from
// StateView.HintUnlockLeftSeconds, so this is the authoritative server-clock backstop.
ErrHintLocked = errors.New("game: hint is not available yet")
// ErrGameLimitReached is returned when an account already holds MaxActiveQuickGames // ErrGameLimitReached is returned when an account already holds MaxActiveQuickGames
// simultaneous quick games and tries to create another game (quick or by invitation). // simultaneous quick games and tries to create another game (quick or by invitation).
ErrGameLimitReached = errors.New("game: simultaneous game limit reached") ErrGameLimitReached = errors.New("game: simultaneous game limit reached")
@@ -240,6 +244,11 @@ type StateView struct {
// WalletBalance is the player's global hint-wallet balance alone (HintsRemaining folds // 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. // it in with the per-game allowance), so the client keeps the wallet live across games.
WalletBalance int WalletBalance int
// 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);
// 0 for the human's first move, when it is not their turn, or a non-vs_ai game. The vs_ai hint is
// unlimited and wallet-free; this idle gate replaces the allowance/wallet for it.
HintUnlockLeftSeconds int
} }
// HistoryMove is one decoded journal row, independent of any dictionary. // HistoryMove is one decoded journal row, independent of any dictionary.
+48 -15
View File
@@ -15,13 +15,15 @@ import (
"scrabble/backend/internal/account" "scrabble/backend/internal/account"
"scrabble/backend/internal/ads" "scrabble/backend/internal/ads"
"scrabble/backend/internal/payments"
"scrabble/backend/internal/server" "scrabble/backend/internal/server"
) )
// bannerServer assembles a console-capable server with the ads domain wired. // bannerServer assembles a console-capable server with the ads domain wired.
func bannerServer(t *testing.T) (*server.Server, *ads.Service) { func bannerServer(t *testing.T) (*server.Server, *ads.Service, *payments.Service) {
t.Helper() t.Helper()
adsSvc := ads.NewService(ads.NewStore(testDB)) adsSvc := ads.NewService(ads.NewStore(testDB))
paySvc := newPaymentsService()
srv := server.New(":0", server.Deps{ srv := server.New(":0", server.Deps{
Logger: zap.NewNop(), Logger: zap.NewNop(),
Accounts: account.NewStore(testDB), Accounts: account.NewStore(testDB),
@@ -29,8 +31,9 @@ func bannerServer(t *testing.T) (*server.Server, *ads.Service) {
Registry: testRegistry, Registry: testRegistry,
DictDir: dictDir(), DictDir: dictDir(),
Ads: adsSvc, Ads: adsSvc,
Payments: paySvc,
}) })
return srv, adsSvc return srv, adsSvc, paySvc
} }
// findCampaign returns the id of the campaign with the given name, or fails. // findCampaign returns the id of the campaign with the given name, or fails.
@@ -71,7 +74,7 @@ func defaultCampaign(t *testing.T, svc *ads.Service) ads.Campaign {
// reorder/delete. // reorder/delete.
func TestBannerConsoleCRUD(t *testing.T) { func TestBannerConsoleCRUD(t *testing.T) {
ctx := context.Background() ctx := context.Background()
srv, adsSvc := bannerServer(t) srv, adsSvc, _ := bannerServer(t)
h := srv.Handler() h := srv.Handler()
base := "http://admin.test/_gm" base := "http://admin.test/_gm"
const origin = "http://admin.test" const origin = "http://admin.test"
@@ -151,7 +154,7 @@ func TestBannerConsoleCRUD(t *testing.T) {
// unrelated campaign's URL must be refused. // unrelated campaign's URL must be refused.
func TestBannerMessageOwnership(t *testing.T) { func TestBannerMessageOwnership(t *testing.T) {
ctx := context.Background() ctx := context.Background()
srv, adsSvc := bannerServer(t) srv, adsSvc, _ := bannerServer(t)
h := srv.Handler() h := srv.Handler()
base := "http://admin.test/_gm" base := "http://admin.test/_gm"
const origin = "http://admin.test" const origin = "http://admin.test"
@@ -207,10 +210,11 @@ type profileBanner struct {
// wallet each suppress it. // wallet each suppress it.
func TestBannerProfileEligibility(t *testing.T) { func TestBannerProfileEligibility(t *testing.T) {
ctx := context.Background() ctx := context.Background()
srv, _ := bannerServer(t) srv, _, pay := bannerServer(t)
accounts := account.NewStore(testDB) accounts := account.NewStore(testDB)
id := provisionAccount(t) id := provisionAccount(t)
// getBanner fetches the profile banner with no platform header (an untrusted context).
getBanner := func() profileBanner { getBanner := func() profileBanner {
t.Helper() t.Helper()
rec := userGet(t, srv, "/api/v1/user/profile", id) rec := userGet(t, srv, "/api/v1/user/profile", id)
@@ -223,10 +227,27 @@ func TestBannerProfileEligibility(t *testing.T) {
} }
return p return p
} }
// getBannerTG fetches the profile banner in a trusted Telegram context — the account's
// telegram identity makes telegram the applicable origin for its benefits.
getBannerTG := func() profileBanner {
t.Helper()
rec := httptest.NewRecorder()
req := httptest.NewRequest(http.MethodGet, "/api/v1/user/profile", nil)
req.Header.Set("X-User-ID", id.String())
req.Header.Set("X-Platform", "telegram/android")
srv.Handler().ServeHTTP(rec, req)
if rec.Code != http.StatusOK {
t.Fatalf("profile = %d, want 200", rec.Code)
}
var p profileBanner
if err := json.Unmarshal(rec.Body.Bytes(), &p); err != nil {
t.Fatalf("decode profile: %v", err)
}
return p
}
// A free account with an empty hint wallet and no role is eligible. // A free account with no role and no benefit is eligible.
p := getBanner() if p := getBanner(); p.Banner == nil || len(p.Banner.Campaigns) == 0 || p.Banner.Timings.HoldMs <= 0 {
if p.Banner == nil || len(p.Banner.Campaigns) == 0 || p.Banner.Timings.HoldMs <= 0 {
t.Fatalf("eligible account: banner=%v", p.Banner) t.Fatalf("eligible account: banner=%v", p.Banner)
} }
@@ -241,19 +262,31 @@ func TestBannerProfileEligibility(t *testing.T) {
t.Fatalf("revoke role: %v", err) t.Fatalf("revoke role: %v", err)
} }
// A non-empty hint wallet suppresses it. // A hint wallet no longer suppresses the banner — hints and no-ads are distinct benefits now.
if _, err := accounts.GrantHints(ctx, id, 5); err != nil { if err := pay.Grant(ctx, id, payments.SourceTelegram, 5, 0, false); err != nil {
t.Fatalf("grant hints: %v", err) t.Fatalf("grant hints: %v", err)
} }
if p := getBanner(); p.Banner != nil { if p := getBannerTG(); p.Banner == nil {
t.Fatal("a non-empty hint wallet still shows the banner") t.Fatal("a hint wallet must NOT suppress the banner")
}
// An active no-ads benefit applicable in the context suppresses the banner.
if err := pay.Grant(ctx, id, payments.SourceTelegram, 0, 30, false); err != nil {
t.Fatalf("grant no-ads: %v", err)
}
if p := getBannerTG(); p.Banner != nil {
t.Fatal("an active no-ads benefit must suppress the banner")
}
// Fail-closed: without a trusted platform the same benefit does not apply, so the banner shows.
if p := getBanner(); p.Banner == nil {
t.Fatal("an untrusted context must not apply the no-ads benefit (banner should show)")
} }
} }
// TestBannerSurvivesProfileUpdate guards that a profile update (e.g. a language switch) returns the // TestBannerSurvivesProfileUpdate guards that a profile update (e.g. a language switch) returns the
// banner block too, so the client's profile keeps the banner instead of losing it until reload. // banner block too, so the client's profile keeps the banner instead of losing it until reload.
func TestBannerSurvivesProfileUpdate(t *testing.T) { func TestBannerSurvivesProfileUpdate(t *testing.T) {
srv, _ := bannerServer(t) srv, _, _ := bannerServer(t)
id := provisionAccount(t) id := provisionAccount(t)
body := `{"display_name":"Tester","preferred_language":"ru","time_zone":"UTC","away_start":"00:00",` + body := `{"display_name":"Tester","preferred_language":"ru","time_zone":"UTC","away_start":"00:00",` +
@@ -283,7 +316,7 @@ func TestBannerSurvivesProfileUpdate(t *testing.T) {
// default campaign stays plain (colours + urgent are ignored for it). // default campaign stays plain (colours + urgent are ignored for it).
func TestBannerConsoleColorsAndUrgent(t *testing.T) { func TestBannerConsoleColorsAndUrgent(t *testing.T) {
ctx := context.Background() ctx := context.Background()
srv, adsSvc := bannerServer(t) srv, adsSvc, _ := bannerServer(t)
h := srv.Handler() h := srv.Handler()
base := "http://admin.test/_gm" base := "http://admin.test/_gm"
const origin = "http://admin.test" const origin = "http://admin.test"
@@ -351,7 +384,7 @@ func TestBannerConsoleColorsAndUrgent(t *testing.T) {
// otherwise suppress the banner. // otherwise suppress the banner.
func TestBannerUrgentBypassesEligibility(t *testing.T) { func TestBannerUrgentBypassesEligibility(t *testing.T) {
ctx := context.Background() ctx := context.Background()
srv, adsSvc := bannerServer(t) srv, adsSvc, _ := bannerServer(t)
accounts := account.NewStore(testDB) accounts := account.NewStore(testDB)
id := provisionAccount(t) id := provisionAccount(t)
+13 -10
View File
@@ -14,6 +14,8 @@ 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/payments"
"scrabble/backend/internal/session"
) )
// TestListForAccount checks the lobby "my games" query: it returns exactly the // TestListForAccount checks the lobby "my games" query: it returns exactly the
@@ -401,8 +403,13 @@ func gameStatus(t *testing.T, svc *game.Service, id uuid.UUID) (status, endReaso
// TestHintPolicy exercises the per-game allowance, the profile wallet and the // TestHintPolicy exercises the per-game allowance, the profile wallet and the
// disabled switch. // disabled switch.
func TestHintPolicy(t *testing.T) { func TestHintPolicy(t *testing.T) {
ctx := context.Background() // A trusted platform context so the online hint wallet (payments) is reachable; a provisioned
// account carries a telegram identity, so the telegram origin is the applicable one here.
ctx := session.WithPlatform(context.Background(),
session.Platform{Kind: session.PlatformKindTelegram, Subtype: session.SubtypeAndroid})
svc := newGameService() svc := newGameService()
pay := newPaymentsService()
svc.SetHintWallet(pay) // share the handle so a grant invalidates the wallet the game reads
seats := []uuid.UUID{provisionAccount(t), provisionAccount(t)} seats := []uuid.UUID{provisionAccount(t), provisionAccount(t)}
seed := openingSeed(t) seed := openingSeed(t)
g, err := svc.Create(ctx, game.CreateParams{ g, err := svc.Create(ctx, game.CreateParams{
@@ -428,7 +435,11 @@ func TestHintPolicy(t *testing.T) {
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)
} }
setHintBalance(t, seats[0], 2) // Grant 2 hints on the telegram origin through the service so its read cache is invalidated
// (a raw insert would leave the cache warmed at zero by the allowance hint above).
if err := pay.Grant(ctx, seats[0], payments.SourceTelegram, 2, 0, false); err != nil {
t.Fatalf("grant hints: %v", err)
}
res, err := svc.Hint(ctx, g.ID, seats[0]) // spends the wallet res, err := svc.Hint(ctx, g.ID, seats[0]) // spends the wallet
if err != nil { if err != nil {
t.Fatalf("wallet hint: %v", err) t.Fatalf("wallet hint: %v", err)
@@ -603,14 +614,6 @@ func setAway(t *testing.T, id uuid.UUID, tz, start, end string) {
} }
} }
func setHintBalance(t *testing.T, id uuid.UUID, n int) {
t.Helper()
if _, err := testDB.ExecContext(context.Background(),
`UPDATE backend.accounts SET hint_balance = $1 WHERE account_id = $2`, n, id); err != nil {
t.Fatalf("set hint balance: %v", err)
}
}
func equalStrings(a, b []string) bool { func equalStrings(a, b []string) bool {
if len(a) != len(b) { if len(a) != len(b) {
return false return false
+34
View File
@@ -17,6 +17,7 @@ import (
"scrabble/backend/internal/engine" "scrabble/backend/internal/engine"
"scrabble/backend/internal/game" "scrabble/backend/internal/game"
"scrabble/backend/internal/lobby" "scrabble/backend/internal/lobby"
"scrabble/backend/internal/payments"
"scrabble/backend/internal/robot" "scrabble/backend/internal/robot"
"scrabble/backend/internal/social" "scrabble/backend/internal/social"
) )
@@ -42,9 +43,42 @@ func newGameService() *game.Service {
zap.NewNop(), zap.NewNop(),
) )
svc.SetFirstMoveEntropy(seatZeroFirstMove) svc.SetFirstMoveEntropy(seatZeroFirstMove)
svc.SetHintWallet(newPaymentsService())
return svc return svc
} }
// newPaymentsService builds a payments service over the shared pool (its own in-process read
// cache), for wiring the game hint wallet and the account-merge fold in the integration suite.
func newPaymentsService() *payments.Service {
return payments.NewService(payments.NewStore(testDB))
}
// seedBenefit writes a payments benefit row for an account+origin directly (bypassing the
// service), used to stand up wallet state a test then spends or merges. A non-zero hints count
// and/or the forever flag are set; a caller wanting a no-ads term sets it via seedBenefitUntil.
func seedBenefit(t *testing.T, id uuid.UUID, origin string, hints int, forever bool) {
t.Helper()
if _, err := testDB.ExecContext(context.Background(),
`INSERT INTO payments.benefits (account_id, origin, hints, ads_forever) VALUES ($1,$2,$3,$4)
ON CONFLICT (account_id, origin) DO UPDATE SET hints = EXCLUDED.hints, ads_forever = EXCLUDED.ads_forever`,
id, origin, hints, forever); err != nil {
t.Fatalf("seed benefit: %v", err)
}
}
// readBenefit reads an account's benefit row for an origin: the hint count and the forever flag
// (both zero/false when the row is absent).
func readBenefit(t *testing.T, id uuid.UUID, origin string) (hints int, forever bool) {
t.Helper()
err := testDB.QueryRowContext(context.Background(),
`SELECT hints, ads_forever FROM payments.benefits WHERE account_id=$1 AND origin=$2`, id, origin).
Scan(&hints, &forever)
if err != nil && !errors.Is(err, sql.ErrNoRows) {
t.Fatalf("read benefit: %v", err)
}
return hints, forever
}
// seatZeroFirstMove is a first-move-draw entropy factory that always elects the first // seatZeroFirstMove is a first-move-draw entropy factory that always elects the first
// listed account as the leader, keeping the integration suite's turn order stable // listed account as the leader, keeping the integration suite's turn order stable
// despite the real draw's randomness: the first contender draws a blank — the best // despite the real draw's randomness: the first contender draws a blank — the best
+9 -18
View File
@@ -58,14 +58,6 @@ func bestMoveCount(t *testing.T, id uuid.UUID) int {
return n return n
} }
func setWallet(t *testing.T, id uuid.UUID, hints int, paid bool) {
t.Helper()
if _, err := testDB.ExecContext(context.Background(),
`UPDATE backend.accounts SET hint_balance=$2, paid_account=$3 WHERE account_id=$1`, id, hints, paid); err != nil {
t.Fatalf("set wallet: %v", err)
}
}
func bindEmailIdentity(t *testing.T, acc uuid.UUID, email string) { func bindEmailIdentity(t *testing.T, acc uuid.UUID, email string) {
t.Helper() t.Helper()
if _, err := testDB.ExecContext(context.Background(), if _, err := testDB.ExecContext(context.Background(),
@@ -131,6 +123,7 @@ func TestAccountMergeCore(t *testing.T) {
ctx := context.Background() ctx := context.Background()
store := account.NewStore(testDB) store := account.NewStore(testDB)
merger := accountmerge.NewMerger(testDB) merger := accountmerge.NewMerger(testDB)
merger.SetPayments(newPaymentsService())
primary := provisionAccount(t) primary := provisionAccount(t)
secondary := provisionAccount(t) secondary := provisionAccount(t)
@@ -140,8 +133,8 @@ func TestAccountMergeCore(t *testing.T) {
setStats(t, secondary, 3, 1, 2, 400, 80) setStats(t, secondary, 3, 1, 2, 400, 80)
setStatsCounts(t, primary, 100, 5) setStatsCounts(t, primary, 100, 5)
setStatsCounts(t, secondary, 50, 8) setStatsCounts(t, secondary, 50, 8)
setWallet(t, primary, 2, false) seedBenefit(t, primary, "telegram", 2, false)
setWallet(t, secondary, 5, true) seedBenefit(t, secondary, "telegram", 5, true) // hints sum, forever OR-s on merge
// Best moves: secondary's scrabble_en (80) beats primary's (50) and is kept; secondary's // Best moves: secondary's scrabble_en (80) beats primary's (50) and is kept; secondary's
// scrabble_ru (30) is new to primary and carried over. // scrabble_ru (30) is new to primary and carried over.
setBestMove(t, primary, "scrabble_en", 50) setBestMove(t, primary, "scrabble_en", 50)
@@ -165,15 +158,13 @@ func TestAccountMergeCore(t *testing.T) {
t.Error("secondary stats row should be deleted after merge") t.Error("secondary stats row should be deleted after merge")
} }
acc, err := store.GetByID(ctx, primary) // The payments wallet merged by origin: hints sum (2+5) and the forever flag OR-s; the
if err != nil { // deprecated accounts.hint_balance / paid_account columns are no longer touched by a merge.
t.Fatalf("get primary: %v", err) if hints, forever := readBenefit(t, primary, "telegram"); hints != 7 || !forever {
t.Errorf("merged telegram benefit = hints %d forever %v, want 7/true", hints, forever)
} }
if acc.HintBalance != 7 { if hints, _ := readBenefit(t, secondary, "telegram"); hints != 0 {
t.Errorf("hint balance = %d, want 7", acc.HintBalance) t.Errorf("secondary benefit should be cleared after merge, got hints %d", hints)
}
if !acc.PaidAccount {
t.Error("paid_account should be true (ORed from secondary)")
} }
if owner, ok, _ := store.AccountIDByIdentity(ctx, account.KindEmail, email); !ok || owner != primary { if owner, ok, _ := store.AccountIDByIdentity(ctx, account.KindEmail, email); !ok || owner != primary {
@@ -0,0 +1,113 @@
//go:build integration
package inttest
import (
"context"
"testing"
"github.com/google/uuid"
"scrabble/backend/internal/payments"
)
// methodPrice is one per-method money price for a seeded chip pack.
type methodPrice struct {
method string
currency string
amount int64
}
// seedPackProduct creates an active chip pack: a product carrying the chips atom and a money price
// per method. It returns the product id.
func seedPackProduct(t *testing.T, chips int, prices ...methodPrice) uuid.UUID {
t.Helper()
ctx := context.Background()
prod := uuid.New()
if _, err := testDB.ExecContext(ctx,
`INSERT INTO payments.product (product_id, title, active) VALUES ($1,'test pack',true)`, prod); err != nil {
t.Fatalf("seed pack product: %v", err)
}
if _, err := testDB.ExecContext(ctx,
`INSERT INTO payments.product_item (product_id, atom_type, quantity) VALUES ($1,'chips',$2)`, prod, chips); err != nil {
t.Fatalf("seed chips item: %v", err)
}
for _, p := range prices {
if _, err := testDB.ExecContext(ctx,
`INSERT INTO payments.product_price (product_id, method, currency, amount) VALUES ($1,$2,$3,$4)`,
prod, p.method, p.currency, p.amount); err != nil {
t.Fatalf("seed pack price %s: %v", p.method, err)
}
}
return prod
}
// findCatalogProduct returns the projected storefront product for id in the context (the catalog
// is global, so tests assert by id rather than count — testDB is shared).
func findCatalogProduct(t *testing.T, svc *payments.Service, cxt payments.Context, id uuid.UUID) (payments.CatalogProduct, bool) {
t.Helper()
view, err := svc.Catalog(context.Background(), cxt)
if err != nil {
t.Fatalf("catalog: %v", err)
}
for _, p := range view.Products {
if p.ProductID == id.String() {
return p, true
}
}
return payments.CatalogProduct{}, false
}
// TestPaymentsCatalogByContext verifies the storefront projects a value at its chip price in every
// context and a chip pack at the context method's money price, over Postgres.
func TestPaymentsCatalogByContext(t *testing.T) {
svc := newPaymentsService()
value := seedValueProduct(t, 500, 250, 0)
pack := seedPackProduct(t, 100,
methodPrice{"vk", "VOTE", 20},
methodPrice{"telegram", "XTR", 25},
methodPrice{"direct", "RUB", 14900},
)
for _, kind := range []string{"vk", "telegram", "direct"} {
v, ok := findCatalogProduct(t, svc, payments.NewContext(kind, "web"), value)
if !ok {
t.Fatalf("value missing in %s context", kind)
}
if v.Kind != "value" || v.Chips != 500 || v.MoneyCurrency != "" {
t.Errorf("value in %s = %+v, want kind=value chips=500 no money", kind, v)
}
}
cases := []struct {
kind string
amount int64
currency string
}{
{"vk", 20, "VOTE"},
{"telegram", 25, "XTR"},
{"direct", 14900, "RUB"},
}
for _, tc := range cases {
p, ok := findCatalogProduct(t, svc, payments.NewContext(tc.kind, "web"), pack)
if !ok {
t.Fatalf("pack missing in %s context", tc.kind)
}
if p.Kind != "pack" || p.Chips != 0 || p.MoneyAmount != tc.amount || p.MoneyCurrency != tc.currency {
t.Errorf("pack in %s = %+v, want kind=pack amount=%d currency=%s", tc.kind, p, tc.amount, tc.currency)
}
}
}
// TestPaymentsCatalogExcludesDeactivated verifies a soft-deleted product drops out of the storefront.
func TestPaymentsCatalogExcludesDeactivated(t *testing.T) {
svc := newPaymentsService()
prod := seedValueProduct(t, 100, 10, 0)
if _, err := testDB.ExecContext(context.Background(),
`UPDATE payments.product SET active=false WHERE product_id=$1`, prod); err != nil {
t.Fatalf("deactivate: %v", err)
}
if _, ok := findCatalogProduct(t, svc, payments.NewContext("direct", "web"), prod); ok {
t.Error("deactivated product must not appear in the storefront")
}
}
+263
View File
@@ -0,0 +1,263 @@
//go:build integration
package inttest
import (
"context"
"database/sql"
"errors"
"testing"
"github.com/google/uuid"
"github.com/jackc/pgx/v5/pgconn"
"github.com/pressly/goose/v3"
testcontainers "github.com/testcontainers/testcontainers-go"
tcpostgres "github.com/testcontainers/testcontainers-go/modules/postgres"
"github.com/testcontainers/testcontainers-go/wait"
"scrabble/backend/internal/postgres"
"scrabble/backend/internal/postgres/migrations"
)
// isPgCode reports whether err is a PostgreSQL server error with the given
// SQLSTATE code.
func isPgCode(err error, code string) bool {
var pgErr *pgconn.PgError
return errors.As(err, &pgErr) && pgErr.Code == code
}
// TestPaymentsSchemaAndSeeds checks the payments schema, its role and the fixed
// seed rows are present after migration.
func TestPaymentsSchemaAndSeeds(t *testing.T) {
ctx := context.Background()
var atoms int
if err := testDB.QueryRowContext(ctx, "SELECT count(*) FROM payments.catalog_atom").Scan(&atoms); err != nil {
t.Fatalf("count catalog_atom: %v", err)
}
if atoms != 4 {
t.Errorf("catalog_atom rows = %d, want 4 (chips/hints/noads_days/tournament)", atoms)
}
var cfg int
if err := testDB.QueryRowContext(ctx, "SELECT count(*) FROM payments.config").Scan(&cfg); err != nil {
t.Fatalf("count config: %v", err)
}
if cfg != 1 {
t.Errorf("config rows = %d, want the single seeded row", cfg)
}
var roleExists bool
if err := testDB.QueryRowContext(ctx, "SELECT EXISTS(SELECT 1 FROM pg_roles WHERE rolname = 'payments')").Scan(&roleExists); err != nil {
t.Fatalf("check payments role: %v", err)
}
if !roleExists {
t.Error("payments role missing")
}
}
// TestPaymentsRoleConfinement proves the payments role is confined to its own
// schema: it can read payments.* but is denied on backend.*. The application
// itself connects as a superuser (which bypasses this), so this asserts the
// grants are correct as the stepping-stone to a real separate login; the runtime
// wall for the superuser pool is the import-boundary test in the payments package.
func TestPaymentsRoleConfinement(t *testing.T) {
ctx := context.Background()
tx, err := testDB.BeginTx(ctx, nil)
if err != nil {
t.Fatalf("begin tx: %v", err)
}
defer func() { _ = tx.Rollback() }()
if _, err := tx.ExecContext(ctx, "SET LOCAL ROLE payments"); err != nil {
t.Fatalf("SET LOCAL ROLE payments: %v", err)
}
// The payments role can read its own schema.
var n int
if err := tx.QueryRowContext(ctx, "SELECT count(*) FROM payments.config").Scan(&n); err != nil {
t.Errorf("payments role denied on payments.config (grants wrong): %v", err)
}
// The payments role must NOT reach the backend schema.
err = tx.QueryRowContext(ctx, "SELECT count(*) FROM backend.accounts").Scan(&n)
if err == nil {
t.Fatal("payments role could read backend.accounts — cross-schema isolation broken")
}
if !isPgCode(err, "42501") {
t.Errorf("reading backend.accounts as payments: got %v, want permission-denied (42501)", err)
}
}
// TestPaymentsLedgerAppendOnly verifies the append-only trigger rejects any
// UPDATE or DELETE on the ledger (a superuser is bound by the trigger, unlike a
// mere privilege REVOKE).
func TestPaymentsLedgerAppendOnly(t *testing.T) {
ctx := context.Background()
id := uuid.New()
if _, err := testDB.ExecContext(ctx,
`INSERT INTO payments.ledger (ledger_id, account_id, kind, chips_delta) VALUES ($1, $2, 'admin_grant', 0)`,
id, uuid.New()); err != nil {
t.Fatalf("insert ledger row: %v", err)
}
if _, err := testDB.ExecContext(ctx, `UPDATE payments.ledger SET chips_delta = 1 WHERE ledger_id = $1`, id); err == nil {
t.Error("UPDATE on payments.ledger succeeded — append-only violated")
} else if !isPgCode(err, "P0001") {
t.Errorf("UPDATE ledger: got %v, want the append-only exception (P0001)", err)
}
if _, err := testDB.ExecContext(ctx, `DELETE FROM payments.ledger WHERE ledger_id = $1`, id); err == nil {
t.Error("DELETE on payments.ledger succeeded — append-only violated")
} else if !isPgCode(err, "P0001") {
t.Errorf("DELETE ledger: got %v, want the append-only exception (P0001)", err)
}
}
// TestPaymentsCheckConstraints verifies the domain CHECKs bite: non-negative
// balances/hints/amount and the enum-like columns.
func TestPaymentsCheckConstraints(t *testing.T) {
ctx := context.Background()
prod := uuid.New()
if _, err := testDB.ExecContext(ctx, `INSERT INTO payments.product (product_id, title) VALUES ($1, 'test')`, prod); err != nil {
t.Fatalf("insert product: %v", err)
}
cases := []struct {
name string
sql string
args []any
}{
{"balances chips negative", `INSERT INTO payments.balances (account_id, source, chips) VALUES ($1, 'vk', -1)`, []any{uuid.New()}},
{"balances bad source", `INSERT INTO payments.balances (account_id, source, chips) VALUES ($1, 'nope', 0)`, []any{uuid.New()}},
{"benefits hints negative", `INSERT INTO payments.benefits (account_id, origin, hints) VALUES ($1, 'vk', -1)`, []any{uuid.New()}},
{"ledger bad kind", `INSERT INTO payments.ledger (ledger_id, account_id, kind) VALUES ($1, $2, 'bogus')`, []any{uuid.New(), uuid.New()}},
{"product_price amount negative", `INSERT INTO payments.product_price (product_id, method, currency, amount) VALUES ($1, 'direct', 'RUB', -1)`, []any{prod}},
{"product_price bad currency", `INSERT INTO payments.product_price (product_id, method, currency, amount) VALUES ($1, 'direct', 'EUR', 100)`, []any{prod}},
}
for _, c := range cases {
if _, err := testDB.ExecContext(ctx, c.sql, c.args...); err == nil {
t.Errorf("%s: insert succeeded, want a CHECK violation", c.name)
} else if !isPgCode(err, "23514") {
t.Errorf("%s: got %v, want check_violation (23514)", c.name, err)
}
}
}
// TestPaymentsLedgerIdempotencyIndex verifies the partial unique index makes a
// (provider, provider_payment_id) pair collide once but leaves NULL-keyed rows
// unconstrained.
func TestPaymentsLedgerIdempotencyIndex(t *testing.T) {
ctx := context.Background()
insert := func(ppid *string) error {
_, err := testDB.ExecContext(ctx,
`INSERT INTO payments.ledger (ledger_id, account_id, kind, provider, provider_payment_id) VALUES ($1, $2, 'fund', 'robokassa', $3)`,
uuid.New(), uuid.New(), ppid)
return err
}
ppid := "inv-" + uuid.NewString()
if err := insert(&ppid); err != nil {
t.Fatalf("first insert: %v", err)
}
if err := insert(&ppid); err == nil {
t.Error("duplicate (provider, provider_payment_id) inserted — idempotency index missing")
} else if !isPgCode(err, "23505") {
t.Errorf("duplicate insert: got %v, want unique_violation (23505)", err)
}
// A NULL provider_payment_id is outside the partial index — repeats allowed.
if err := insert(nil); err != nil {
t.Fatalf("first null-ppid insert: %v", err)
}
if err := insert(nil); err != nil {
t.Errorf("second null-ppid insert should be allowed by the partial index: %v", err)
}
}
// TestPaymentsMigrationReversible proves the migration is expand-contract: it
// applies, rolls fully back (schema, role, trigger and all), and re-applies
// cleanly — so a backend image rollback stays DB-safe. It uses its own container
// so the Down does not disturb the shared suite database.
func TestPaymentsMigrationReversible(t *testing.T) {
ctx := context.Background()
container, err := tcpostgres.Run(ctx, pgImage,
tcpostgres.WithDatabase(pgDatabase),
tcpostgres.WithUsername(pgUser),
tcpostgres.WithPassword(pgPassword),
testcontainers.WithWaitStrategy(
wait.ForLog("database system is ready to accept connections").
WithOccurrence(2).
WithStartupTimeout(containerStartup),
),
)
if err != nil {
t.Fatalf("start container: %v", err)
}
defer func() { _ = container.Terminate(context.Background()) }()
baseDSN, err := container.ConnectionString(ctx, "sslmode=disable")
if err != nil {
t.Fatalf("connection string: %v", err)
}
dsn, err := withSearchPath(baseDSN, pgSchema)
if err != nil {
t.Fatalf("search path: %v", err)
}
cfg := postgres.DefaultConfig()
cfg.DSN = dsn
db, err := postgres.Open(ctx, cfg)
if err != nil {
t.Fatalf("open pool: %v", err)
}
defer func() { _ = db.Close() }()
// Up: apply every migration, including the payments foundation.
if err := postgres.ApplyMigrations(ctx, db); err != nil {
t.Fatalf("apply up: %v", err)
}
if !schemaExists(ctx, t, db, "payments") {
t.Fatal("payments schema absent after up")
}
// Down the payments migration and assert a clean teardown.
goose.SetBaseFS(migrations.Migrations())
defer goose.SetBaseFS(nil)
if err := goose.SetDialect("postgres"); err != nil {
t.Fatalf("set dialect: %v", err)
}
if err := goose.DownToContext(ctx, db, ".", 9); err != nil {
t.Fatalf("down to 9: %v", err)
}
if schemaExists(ctx, t, db, "payments") {
t.Error("payments schema survived the down migration")
}
var roleExists bool
if err := db.QueryRowContext(ctx, "SELECT EXISTS(SELECT 1 FROM pg_roles WHERE rolname = 'payments')").Scan(&roleExists); err != nil {
t.Fatalf("check role after down: %v", err)
}
if roleExists {
t.Error("payments role survived the down migration")
}
// Re-apply and assert it comes back cleanly.
if err := goose.UpContext(ctx, db, "."); err != nil {
t.Fatalf("re-apply up: %v", err)
}
if !schemaExists(ctx, t, db, "payments") {
t.Fatal("payments schema absent after re-apply")
}
}
// schemaExists reports whether a schema of the given name is present.
func schemaExists(ctx context.Context, t *testing.T, db *sql.DB, name string) bool {
t.Helper()
var exists bool
if err := db.QueryRowContext(ctx,
"SELECT EXISTS(SELECT 1 FROM information_schema.schemata WHERE schema_name = $1)", name).Scan(&exists); err != nil {
t.Fatalf("check schema %s: %v", name, err)
}
return exists
}
@@ -0,0 +1,279 @@
//go:build integration
package inttest
import (
"context"
"database/sql"
"errors"
"testing"
"time"
"github.com/google/uuid"
"scrabble/backend/internal/payments"
)
// seedBalance writes a chip balance for an account+source directly (payments has no FK to
// accounts, so a bare uuid is a valid account id here).
func seedBalance(t *testing.T, id uuid.UUID, source string, chips int) {
t.Helper()
if _, err := testDB.ExecContext(context.Background(),
`INSERT INTO payments.balances (account_id, source, chips) VALUES ($1,$2,$3)
ON CONFLICT (account_id, source) DO UPDATE SET chips = EXCLUDED.chips`,
id, source, chips); err != nil {
t.Fatalf("seed balance: %v", err)
}
}
// readBalance reads an account's chip balance for a source (0 when the row is absent).
func readBalance(t *testing.T, id uuid.UUID, source string) int {
t.Helper()
var chips int
err := testDB.QueryRowContext(context.Background(),
`SELECT chips FROM payments.balances WHERE account_id=$1 AND source=$2`, id, source).Scan(&chips)
if err != nil && !errors.Is(err, sql.ErrNoRows) {
t.Fatalf("read balance: %v", err)
}
return chips
}
// benefitUntil reads an account's no-ads term end for an origin (nil when none).
func benefitUntil(t *testing.T, id uuid.UUID, origin string) *time.Time {
t.Helper()
var until *time.Time
err := testDB.QueryRowContext(context.Background(),
`SELECT ads_paid_until FROM payments.benefits WHERE account_id=$1 AND origin=$2`, id, origin).Scan(&until)
if err != nil && !errors.Is(err, sql.ErrNoRows) {
t.Fatalf("read benefit until: %v", err)
}
return until
}
// ledgerRows counts an account's ledger rows of a given kind.
func ledgerRows(t *testing.T, id uuid.UUID, kind string) int {
t.Helper()
var n int
if err := testDB.QueryRowContext(context.Background(),
`SELECT count(*) FROM payments.ledger WHERE account_id=$1 AND kind=$2`, id, kind).Scan(&n); err != nil {
t.Fatalf("ledger count: %v", err)
}
return n
}
// seedValueProduct creates an active chip-priced value: a product with a CHIP price (method
// NULL) and the given hint / no-ads-day atoms. It returns the product id.
func seedValueProduct(t *testing.T, priceChips, hints, noAdsDays int) uuid.UUID {
t.Helper()
ctx := context.Background()
prod := uuid.New()
if _, err := testDB.ExecContext(ctx,
`INSERT INTO payments.product (product_id, title, active) VALUES ($1,'test value',true)`, prod); err != nil {
t.Fatalf("seed product: %v", err)
}
if _, err := testDB.ExecContext(ctx,
`INSERT INTO payments.product_price (product_id, method, currency, amount) VALUES ($1, NULL, 'CHIP', $2)`,
prod, priceChips); err != nil {
t.Fatalf("seed price: %v", err)
}
if hints > 0 {
if _, err := testDB.ExecContext(ctx,
`INSERT INTO payments.product_item (product_id, atom_type, quantity) VALUES ($1,'hints',$2)`, prod, hints); err != nil {
t.Fatalf("seed hints item: %v", err)
}
}
if noAdsDays > 0 {
if _, err := testDB.ExecContext(ctx,
`INSERT INTO payments.product_item (product_id, atom_type, quantity) VALUES ($1,'noads_days',$2)`, prod, noAdsDays); err != nil {
t.Fatalf("seed noads item: %v", err)
}
}
return prod
}
// TestPaymentsSpendAppliesAtomically verifies a chip spend writes the ledger row, decrements the
// balance and applies the benefit together over Postgres.
func TestPaymentsSpendAppliesAtomically(t *testing.T) {
ctx := context.Background()
svc := newPaymentsService()
acc := uuid.New()
seedBalance(t, acc, "direct", 100)
prod := seedValueProduct(t, 60, 3, 30)
if err := svc.Spend(ctx, acc, payments.NewContext("direct", "web"), []payments.Source{payments.SourceDirect}, prod); err != nil {
t.Fatalf("spend: %v", err)
}
if got := readBalance(t, acc, "direct"); got != 40 {
t.Errorf("balance after spend = %d, want 40", got)
}
if hints, _ := readBenefit(t, acc, "direct"); hints != 3 {
t.Errorf("hints after spend = %d, want 3", hints)
}
if benefitUntil(t, acc, "direct") == nil {
t.Error("no-ads term not applied by the spend")
}
if n := ledgerRows(t, acc, "spend"); n != 1 {
t.Errorf("spend ledger rows = %d, want 1", n)
}
}
// TestPaymentsSpendInsufficientNoWrite verifies an under-funded spend is refused and writes
// nothing (the balance, benefit and ledger are untouched).
func TestPaymentsSpendInsufficientNoWrite(t *testing.T) {
ctx := context.Background()
svc := newPaymentsService()
acc := uuid.New()
seedBalance(t, acc, "direct", 10)
prod := seedValueProduct(t, 60, 3, 0)
if err := svc.Spend(ctx, acc, payments.NewContext("direct", "web"), []payments.Source{payments.SourceDirect}, prod); !errors.Is(err, payments.ErrInsufficientChips) {
t.Fatalf("spend = %v, want ErrInsufficientChips", err)
}
if got := readBalance(t, acc, "direct"); got != 10 {
t.Errorf("balance changed to %d, want 10 (no write)", got)
}
if h, _ := readBenefit(t, acc, "direct"); h != 0 {
t.Errorf("benefit applied on a refused spend: hints %d", h)
}
if ledgerRows(t, acc, "spend") != 0 {
t.Error("spend ledger row written on a refused spend")
}
}
// TestPaymentsSpendGuardRollsBack forces the in-transaction guard to bite: with a stale read
// cache the draw plan looks affordable, but the guarded decrement matches no row and the whole
// transaction rolls back — no ledger row, no benefit, balance unchanged.
func TestPaymentsSpendGuardRollsBack(t *testing.T) {
ctx := context.Background()
svc := newPaymentsService()
acc := uuid.New()
seedBalance(t, acc, "direct", 100)
present := []payments.Source{payments.SourceDirect}
cxt := payments.NewContext("direct", "web")
if _, err := svc.Wallet(ctx, acc, cxt, present); err != nil { // warm the cache at 100
t.Fatalf("warm: %v", err)
}
// Drain the real balance behind the cache's back (no invalidation), so the plan over-commits.
if _, err := testDB.ExecContext(ctx, `UPDATE payments.balances SET chips = 10 WHERE account_id=$1 AND source='direct'`, acc); err != nil {
t.Fatalf("drain: %v", err)
}
prod := seedValueProduct(t, 60, 3, 0)
if err := svc.Spend(ctx, acc, cxt, present, prod); !errors.Is(err, payments.ErrInsufficientChips) {
t.Fatalf("spend = %v, want ErrInsufficientChips", err)
}
if got := readBalance(t, acc, "direct"); got != 10 {
t.Errorf("balance = %d, want 10 (rolled back)", got)
}
if ledgerRows(t, acc, "spend") != 0 {
t.Error("spend ledger row written despite the rollback")
}
if h, _ := readBenefit(t, acc, "direct"); h != 0 {
t.Error("benefit applied despite the rollback")
}
}
// TestPaymentsGrantCreditsValuesNotChips verifies admin_grant applies a benefit at price 0
// without ever crediting chips (D16).
func TestPaymentsGrantCreditsValuesNotChips(t *testing.T) {
ctx := context.Background()
svc := newPaymentsService()
acc := uuid.New()
if err := svc.Grant(ctx, acc, payments.SourceDirect, 5, 0, true); err != nil {
t.Fatalf("grant: %v", err)
}
if hints, forever := readBenefit(t, acc, "direct"); hints != 5 || !forever {
t.Errorf("granted benefit = hints %d forever %v, want 5/true", hints, forever)
}
if readBalance(t, acc, "direct") != 0 {
t.Error("a grant must not credit chips")
}
if ledgerRows(t, acc, "admin_grant") != 1 {
t.Error("admin_grant ledger row missing")
}
if ledgerRows(t, acc, "spend") != 0 || ledgerRows(t, acc, "fund") != 0 {
t.Error("a grant wrote a non-grant ledger row")
}
}
// TestPaymentsSpendHintByContext verifies a hint is drawn from an applicable origin, and that a
// direct-origin hint is not usable inside a VK context (the compliance wall for hints).
func TestPaymentsSpendHintByContext(t *testing.T) {
ctx := context.Background()
svc := newPaymentsService()
acc := uuid.New()
seedBenefit(t, acc, "direct", 2, false)
present := []payments.Source{payments.SourceDirect, payments.SourceVK}
if spent, err := svc.SpendHint(ctx, acc, payments.NewContext("direct", "web"), present); err != nil || !spent {
t.Fatalf("web spend hint = %v (err %v), want true", spent, err)
}
if h, _ := readBenefit(t, acc, "direct"); h != 1 {
t.Errorf("hints after spend = %d, want 1", h)
}
// Inside VK the applicable origins are {vk} only, so the direct-origin hint is untouched.
if spent, _ := svc.SpendHint(ctx, acc, payments.NewContext("vk", "android"), present); spent {
t.Error("direct-origin hint spent inside VK — compliance wall breached")
}
if h, _ := readBenefit(t, acc, "direct"); h != 1 {
t.Errorf("direct hints changed inside VK = %d, want 1 (untouched)", h)
}
}
// TestPaymentsComplianceGateOverPostgres is the named compliance regression at the integration
// layer: a direct-origin no-ads benefit applies on the web but never inside a store wrapper.
func TestPaymentsComplianceGateOverPostgres(t *testing.T) {
ctx := context.Background()
svc := newPaymentsService()
acc := uuid.New()
if err := svc.Grant(ctx, acc, payments.SourceDirect, 0, 30, false); err != nil {
t.Fatalf("grant: %v", err)
}
present := []payments.Source{payments.SourceDirect, payments.SourceVK}
if adFree, _ := svc.AdFree(ctx, acc, payments.NewContext("direct", "web"), present); !adFree {
t.Error("direct no-ads should apply on web")
}
if adFree, _ := svc.AdFree(ctx, acc, payments.NewContext("vk", "android"), present); adFree {
t.Error("direct no-ads must NOT apply inside VK (compliance wall)")
}
if adFree, _ := svc.AdFree(ctx, acc, payments.NewContext("telegram", "web"), present); adFree {
t.Error("direct no-ads must NOT apply inside Telegram (compliance wall)")
}
}
// TestPaymentsMergeFoldsSegmentsAndBenefits verifies the merge folds chip segments (sum) and
// benefits (hints sum, forever OR) by origin, and clears the secondary's rows, over Postgres.
func TestPaymentsMergeFoldsSegmentsAndBenefits(t *testing.T) {
ctx := context.Background()
svc := newPaymentsService()
primary, secondary := uuid.New(), uuid.New()
seedBalance(t, primary, "vk", 10)
seedBalance(t, secondary, "vk", 25)
seedBenefit(t, primary, "vk", 1, false)
seedBenefit(t, secondary, "vk", 4, true)
tx, err := testDB.BeginTx(ctx, nil)
if err != nil {
t.Fatalf("begin: %v", err)
}
if err := svc.MergeTx(ctx, tx, primary, secondary); err != nil {
_ = tx.Rollback()
t.Fatalf("merge: %v", err)
}
if err := tx.Commit(); err != nil {
t.Fatalf("commit: %v", err)
}
svc.Invalidate(primary, secondary)
if got := readBalance(t, primary, "vk"); got != 35 {
t.Errorf("merged chips = %d, want 35", got)
}
if h, forever := readBenefit(t, primary, "vk"); h != 5 || !forever {
t.Errorf("merged benefit = hints %d forever %v, want 5/true", h, forever)
}
if readBalance(t, secondary, "vk") != 0 {
t.Error("secondary balance not cleared after merge")
}
}
@@ -0,0 +1,178 @@
//go:build integration
package inttest
import (
"context"
"database/sql"
"testing"
"github.com/google/uuid"
"github.com/pressly/goose/v3"
testcontainers "github.com/testcontainers/testcontainers-go"
tcpostgres "github.com/testcontainers/testcontainers-go/modules/postgres"
"github.com/testcontainers/testcontainers-go/wait"
"scrabble/backend/internal/account"
"scrabble/backend/internal/postgres"
"scrabble/backend/internal/postgres/migrations"
"scrabble/backend/internal/session"
)
// TestSessionPlatformCaptureAndUntrusted proves a session round-trips its captured
// platform through create and a cold-cache (DB-backed) resolve for each kind, and
// that an unattributed session records no platform — the untrusted, view-only case.
func TestSessionPlatformCaptureAndUntrusted(t *testing.T) {
ctx := context.Background()
accounts := account.NewStore(testDB)
store := session.NewStore(testDB)
svc := session.NewService(store, session.NewCache())
for _, tc := range []struct {
name string
platform session.Platform
}{
{"vk-ios", session.Platform{Kind: session.PlatformKindVK, Subtype: session.SubtypeIOS}},
{"telegram-android", session.Platform{Kind: session.PlatformKindTelegram, Subtype: session.SubtypeAndroid}},
{"direct-web", session.Platform{Kind: session.PlatformKindDirect, Subtype: session.SubtypeWeb}},
{"untrusted", session.Platform{}},
} {
t.Run(tc.name, func(t *testing.T) {
acc, err := accounts.ProvisionByIdentity(ctx, account.KindTelegram, "tg-"+uuid.NewString())
if err != nil {
t.Fatalf("provision: %v", err)
}
token, sess, err := svc.Create(ctx, acc.ID, tc.platform)
if err != nil {
t.Fatalf("create: %v", err)
}
if sess.Platform != tc.platform {
t.Errorf("created platform = %+v, want %+v", sess.Platform, tc.platform)
}
// Resolve through a cold cache so the value comes back via the DB columns.
cold := session.NewService(store, session.NewCache())
if err := cold.Warm(ctx); err != nil {
t.Fatalf("warm: %v", err)
}
got, err := cold.Resolve(ctx, token)
if err != nil {
t.Fatalf("resolve: %v", err)
}
if got.Platform != tc.platform {
t.Errorf("resolved platform = %+v, want %+v", got.Platform, tc.platform)
}
if got.Platform.Trusted() != tc.platform.Trusted() {
t.Errorf("resolved Trusted() = %v, want %v", got.Platform.Trusted(), tc.platform.Trusted())
}
})
}
}
// TestSessionPlatformCheckConstraints proves the CHECK constraints reject an
// out-of-range kind or subtype, so a corrupt value cannot be persisted.
func TestSessionPlatformCheckConstraints(t *testing.T) {
ctx := context.Background()
acc, err := account.NewStore(testDB).ProvisionByIdentity(ctx, account.KindTelegram, "tg-"+uuid.NewString())
if err != nil {
t.Fatalf("provision: %v", err)
}
const pgCheckViolation = "23514"
for _, tc := range []struct {
name string
kind string
subtype string
}{
{"bad-kind", "facebook", "web"},
{"bad-subtype", "vk", "windows"},
} {
t.Run(tc.name, func(t *testing.T) {
_, err := testDB.ExecContext(ctx,
`INSERT INTO backend.sessions (session_id, account_id, token_hash, platform_kind, platform_subtype)
VALUES ($1, $2, $3, $4, $5)`,
uuid.New(), acc.ID, uuid.NewString(), tc.kind, tc.subtype)
if !isPgCode(err, pgCheckViolation) {
t.Errorf("insert %s: err = %v, want check_violation", tc.name, err)
}
})
}
}
// TestSessionPlatformMigrationReversible proves migration 00011 is expand-contract:
// it applies, rolls back (dropping the platform columns), and re-applies cleanly —
// so a backend image rollback stays DB-safe. It uses its own container so the Down
// does not disturb the shared suite database.
func TestSessionPlatformMigrationReversible(t *testing.T) {
ctx := context.Background()
container, err := tcpostgres.Run(ctx, pgImage,
tcpostgres.WithDatabase(pgDatabase),
tcpostgres.WithUsername(pgUser),
tcpostgres.WithPassword(pgPassword),
testcontainers.WithWaitStrategy(
wait.ForLog("database system is ready to accept connections").
WithOccurrence(2).
WithStartupTimeout(containerStartup),
),
)
if err != nil {
t.Fatalf("start container: %v", err)
}
defer func() { _ = container.Terminate(context.Background()) }()
baseDSN, err := container.ConnectionString(ctx, "sslmode=disable")
if err != nil {
t.Fatalf("connection string: %v", err)
}
dsn, err := withSearchPath(baseDSN, pgSchema)
if err != nil {
t.Fatalf("search path: %v", err)
}
cfg := postgres.DefaultConfig()
cfg.DSN = dsn
db, err := postgres.Open(ctx, cfg)
if err != nil {
t.Fatalf("open pool: %v", err)
}
defer func() { _ = db.Close() }()
if err := postgres.ApplyMigrations(ctx, db); err != nil {
t.Fatalf("apply up: %v", err)
}
if !sessionsPlatformColumnsExist(ctx, t, db) {
t.Fatal("platform columns absent after up")
}
goose.SetBaseFS(migrations.Migrations())
defer goose.SetBaseFS(nil)
if err := goose.SetDialect("postgres"); err != nil {
t.Fatalf("set dialect: %v", err)
}
// Down past 00011 (to 00010) and assert the columns are gone.
if err := goose.DownToContext(ctx, db, ".", 10); err != nil {
t.Fatalf("down to 10: %v", err)
}
if sessionsPlatformColumnsExist(ctx, t, db) {
t.Error("platform columns survived the down migration")
}
// Re-apply and assert they return.
if err := goose.UpContext(ctx, db, "."); err != nil {
t.Fatalf("re-apply up: %v", err)
}
if !sessionsPlatformColumnsExist(ctx, t, db) {
t.Fatal("platform columns absent after re-apply")
}
}
// sessionsPlatformColumnsExist reports whether both platform columns are present on
// backend.sessions.
func sessionsPlatformColumnsExist(ctx context.Context, t *testing.T, db *sql.DB) bool {
t.Helper()
var n int
if err := db.QueryRowContext(ctx,
`SELECT count(*) FROM information_schema.columns
WHERE table_schema = 'backend' AND table_name = 'sessions'
AND column_name IN ('platform_kind', 'platform_subtype')`).Scan(&n); err != nil {
t.Fatalf("count platform columns: %v", err)
}
return n == 2
}
+7 -3
View File
@@ -26,7 +26,8 @@ func TestSessionLifecycle(t *testing.T) {
store := session.NewStore(testDB) store := session.NewStore(testDB)
svc := session.NewService(store, session.NewCache()) svc := session.NewService(store, session.NewCache())
token, sess, err := svc.Create(ctx, acc.ID) platform := session.Platform{Kind: session.PlatformKindVK, Subtype: session.SubtypeIOS}
token, sess, err := svc.Create(ctx, acc.ID, platform)
if err != nil { if err != nil {
t.Fatalf("create session: %v", err) t.Fatalf("create session: %v", err)
} }
@@ -36,6 +37,9 @@ func TestSessionLifecycle(t *testing.T) {
if token == sess.TokenHash { if token == sess.TokenHash {
t.Error("plaintext token must not equal the stored hash") t.Error("plaintext token must not equal the stored hash")
} }
if sess.Platform != platform {
t.Errorf("session platform = %+v, want %+v", sess.Platform, platform)
}
// Resolve via the warm write-through cache. // Resolve via the warm write-through cache.
got, err := svc.Resolve(ctx, token) got, err := svc.Resolve(ctx, token)
@@ -63,8 +67,8 @@ func TestSessionLifecycle(t *testing.T) {
if _, ok := cold.Get(session.HashToken(token)); !ok { if _, ok := cold.Get(session.HashToken(token)); !ok {
t.Error("Warm must load the active session into the cache") t.Error("Warm must load the active session into the cache")
} }
if got2, err := svc2.Resolve(ctx, token); err != nil || got2.ID != sess.ID { if got2, err := svc2.Resolve(ctx, token); err != nil || got2.ID != sess.ID || got2.Platform != platform {
t.Errorf("resolve after warm = (%s, %v), want %s", got2.ID, err, sess.ID) t.Errorf("resolve after warm = (%s, %+v, %v), want %s / %+v", got2.ID, got2.Platform, err, sess.ID, platform)
} }
// Revoke, then the token no longer resolves; revoke again is a no-op. // Revoke, then the token no longer resolves; revoke again is a no-op.
+5 -1
View File
@@ -200,7 +200,11 @@ func (s *Service) merge(ctx context.Context, callerID, otherID uuid.UUID) (Merge
} }
res := MergeResult{PrimaryID: primary} res := MergeResult{PrimaryID: primary}
if primary != callerID { if primary != callerID {
token, _, err := s.sessions.Create(ctx, primary) // The switched-to session inherits the caller's current trusted platform
// (the context the merge was initiated from); absent when the caller's
// session itself is untrusted.
platform, _ := session.PlatformFromContext(ctx)
token, _, err := s.sessions.Create(ctx, primary, platform)
if err != nil { if err != nil {
return MergeResult{}, err return MergeResult{}, err
} }
@@ -0,0 +1,57 @@
package payments
import (
"go/parser"
"go/token"
"io/fs"
"path/filepath"
"runtime"
"strings"
"testing"
)
// TestPaymentsSchemaImportBoundary enforces that only this package reaches the
// payments jet code. The payments schema is isolated behind this domain; the
// application connects to Postgres as a superuser that bypasses the schema
// grants, so the runtime wall that keeps every other package out of payments.*
// is this import boundary, not the DB privileges. If another package imported
// the generated payments tables it could issue SQL against the schema directly,
// breaking the single-writer guarantee and the domain's extractability.
func TestPaymentsSchemaImportBoundary(t *testing.T) {
const jetPkg = "scrabble/backend/internal/postgres/jet/payments"
_, thisFile, _, ok := runtime.Caller(0)
if !ok {
t.Fatal("cannot resolve the test file path")
}
// thisFile = .../backend/internal/payments/boundary_test.go
backendRoot := filepath.Clean(filepath.Join(filepath.Dir(thisFile), "..", ".."))
allowedPrefix := filepath.Join(backendRoot, "internal", "payments") + string(filepath.Separator)
fset := token.NewFileSet()
walkErr := filepath.WalkDir(backendRoot, func(path string, d fs.DirEntry, err error) error {
if err != nil {
return err
}
if d.IsDir() || !strings.HasSuffix(path, ".go") {
return nil
}
file, perr := parser.ParseFile(fset, path, nil, parser.ImportsOnly)
if perr != nil {
return perr
}
for _, imp := range file.Imports {
p := strings.Trim(imp.Path.Value, `"`)
if p != jetPkg && !strings.HasPrefix(p, jetPkg+"/") {
continue
}
if !strings.HasPrefix(path, allowedPrefix) {
t.Errorf("%s imports %s — only internal/payments may reach the payments jet code", path, p)
}
}
return nil
})
if walkErr != nil {
t.Fatalf("walk backend tree: %v", walkErr)
}
}
+72
View File
@@ -0,0 +1,72 @@
package payments
import (
"sync"
"time"
"github.com/google/uuid"
)
// benefitState is an account's benefit row for one origin: the no-ads term end (nil = none),
// the perpetual forever flag, and the hint count. It is the context-independent stored form;
// the read paths aggregate it over the origins applicable in a context.
type benefitState struct {
adsPaidUntil *time.Time
adsForever bool
hints int
}
// walletState is an account's full payments state: chip balances by source and benefits by
// origin. It is the raw, context-independent data every read path filters per execution
// context — the value the cache holds and the store recomputes from the materialized tables.
type walletState struct {
chips map[Source]int
benefits map[Source]benefitState
}
// chipsOf returns the chip balance for a source, zero when the segment has no row (an absent
// segment reads as zero, §2).
func (w walletState) chipsOf(s Source) int { return w.chips[s] }
// benefitOf returns the benefit for an origin, the zero benefit when the origin has no row.
func (w walletState) benefitOf(o Source) benefitState { return w.benefits[o] }
// walletCache is the payments read model: each account's chip/benefit state, keyed by account
// id, read on every ad-eligibility / hint / wallet / gate request and invalidated on every
// payments mutation (spend, grant, fund, refund, merge). It is a write-through cache in front
// of the materialized balances/benefits tables — the same pattern the account-suspension gate
// uses (backend/internal/account/suspension.go) — so the steady-state hot path issues no query
// to the payments schema. It is single-instance, matching the deployment (one shared Store); a
// multi-instance backend would need a shared cache.
type walletCache struct {
mu sync.RWMutex
m map[uuid.UUID]walletState
}
// newWalletCache constructs an empty cache.
func newWalletCache() *walletCache {
return &walletCache{m: make(map[uuid.UUID]walletState)}
}
// get returns the cached state for an account and whether it was present.
func (c *walletCache) get(id uuid.UUID) (walletState, bool) {
c.mu.RLock()
defer c.mu.RUnlock()
s, ok := c.m[id]
return s, ok
}
// put stores an account's state.
func (c *walletCache) put(id uuid.UUID, s walletState) {
c.mu.Lock()
defer c.mu.Unlock()
c.m[id] = s
}
// invalidate drops an account's entry so the next read reloads it from the materialized tables.
// Called after every mutation once its transaction has committed.
func (c *walletCache) invalidate(id uuid.UUID) {
c.mu.Lock()
defer c.mu.Unlock()
delete(c.m, id)
}
+82
View File
@@ -0,0 +1,82 @@
package payments
// AtomQty is one atom line of a catalog product in the read model: the base value type it grants
// (chips / hints / no-ads days / tournament) and how many of it the product carries.
type AtomQty struct {
AtomType string
Quantity int
}
// CatalogProduct is one storefront product projected for the execution context. A value is
// bought with chips, so it carries Chips (its uniform chip price) and no money price. A chip pack
// funds chips with money, so it carries MoneyAmount (minor units) and MoneyCurrency for the
// context's payment method and no chip price. Atoms lists what the product grants.
type CatalogProduct struct {
Kind string // "value" (bought with chips) or "pack" (funds chips with money)
ProductID string
Title string
Chips int // a value's uniform chip price; 0 for a pack
MoneyAmount int64 // a pack's price in the context currency's minor units; 0 for a value
MoneyCurrency string // a pack's currency for the context method; empty for a value
Atoms []AtomQty
}
// CatalogView is the storefront read model for one execution context: the products a player sees
// and can buy there — every chip-priced value plus the chip packs priced in the context's method.
type CatalogView struct {
Products []CatalogProduct
}
// atomChips is the atom type that marks a product as a chip pack (it funds chips) rather than a
// value (bought with chips). A value never carries it.
const atomChips = "chips"
// projectCatalog turns the raw active catalog into the storefront for the context. A value (no
// chips atom) is shown everywhere at its CHIP price. A chip pack is shown only when it carries a
// price for the context's payment method (cxt.Kind: vk / telegram / direct), priced in that
// method's currency. A value missing its CHIP price and a pack missing a price for the context
// method are omitted (misconfigured or unavailable there). An untrusted context (empty Kind) has
// no method, so only values show; buying is gated server-side regardless.
func projectCatalog(entries []catalogEntry, cxt Context) CatalogView {
var out CatalogView
for _, e := range entries {
isPack := false
atoms := make([]AtomQty, 0, len(e.atoms))
for _, a := range e.atoms {
atoms = append(atoms, AtomQty{AtomType: a.atomType, Quantity: a.quantity})
if a.atomType == atomChips {
isPack = true
}
}
p := CatalogProduct{ProductID: e.id.String(), Title: e.title, Atoms: atoms}
if isPack {
pr, ok := findPrice(e.prices, string(cxt.Kind))
if !ok {
continue // no price for this context's method — not purchasable here
}
p.Kind = "pack"
p.MoneyAmount = pr.amount
p.MoneyCurrency = string(pr.currency)
} else {
pr, ok := findPrice(e.prices, "")
if !ok {
continue // a value must carry a CHIP price
}
p.Kind = "value"
p.Chips = int(pr.amount)
}
out.Products = append(out.Products, p)
}
return out
}
// findPrice returns the price row for the given payment method — the empty string selects a
// value's CHIP price row (stored with a NULL method).
func findPrice(prices []priceRow, method string) (priceRow, bool) {
for _, pr := range prices {
if pr.method == method {
return pr, true
}
}
return priceRow{}, false
}
+136
View File
@@ -0,0 +1,136 @@
package payments
import (
"testing"
"github.com/google/uuid"
)
// catalog fixtures: a chip-priced value (hints), a multi-method chip pack, a VK-only chip pack, a
// value with no CHIP price (misconfigured), and a pack whose only method is telegram.
func fixtureCatalog() []catalogEntry {
value := catalogEntry{
id: uuid.MustParse("00000000-0000-0000-0000-000000000001"),
title: "250 hints",
atoms: []atomQty{{atomType: "hints", quantity: 250}},
prices: []priceRow{
{method: "", currency: CurrencyChip, amount: 500},
},
}
pack := catalogEntry{
id: uuid.MustParse("00000000-0000-0000-0000-000000000002"),
title: "100 chips",
atoms: []atomQty{{atomType: "chips", quantity: 100}},
prices: []priceRow{
{method: "vk", currency: CurrencyVote, amount: 20},
{method: "telegram", currency: CurrencyStar, amount: 25},
{method: "direct", currency: CurrencyRUB, amount: 14900},
},
}
vkOnlyPack := catalogEntry{
id: uuid.MustParse("00000000-0000-0000-0000-000000000003"),
title: "VK-only chips",
atoms: []atomQty{{atomType: "chips", quantity: 50}},
prices: []priceRow{
{method: "vk", currency: CurrencyVote, amount: 10},
},
}
brokenValue := catalogEntry{
id: uuid.MustParse("00000000-0000-0000-0000-000000000004"),
title: "no chip price",
atoms: []atomQty{{atomType: "noads_days", quantity: 30}},
// no CHIP price row — misconfigured, must be omitted
}
return []catalogEntry{value, pack, vkOnlyPack, brokenValue}
}
// byID indexes a projected storefront by product id for assertions.
func byID(v CatalogView) map[string]CatalogProduct {
m := make(map[string]CatalogProduct, len(v.Products))
for _, p := range v.Products {
m[p.ProductID] = p
}
return m
}
func TestProjectCatalog_ContextMatrix(t *testing.T) {
const (
valueID = "00000000-0000-0000-0000-000000000001"
packID = "00000000-0000-0000-0000-000000000002"
vkPackID = "00000000-0000-0000-0000-000000000003"
brokenID = "00000000-0000-0000-0000-000000000004"
)
entries := fixtureCatalog()
tests := []struct {
name string
cxt Context
wantPackAmt int64
wantPackCur Currency
wantVKPack bool // the vk-only pack visible?
}{
{"vk", Context{Kind: SourceVK}, 20, CurrencyVote, true},
{"vk-ios frozen still lists vk price", Context{Kind: SourceVK, Subtype: SubtypeIOS}, 20, CurrencyVote, true},
{"telegram", Context{Kind: SourceTelegram}, 25, CurrencyStar, false},
{"direct", Context{Kind: SourceDirect}, 14900, CurrencyRUB, false},
}
for _, tc := range tests {
t.Run(tc.name, func(t *testing.T) {
got := byID(projectCatalog(entries, tc.cxt))
// The value shows everywhere at its uniform chip price, never a money price.
v, ok := got[valueID]
if !ok {
t.Fatalf("value missing from %s storefront", tc.name)
}
if v.Kind != "value" || v.Chips != 500 || v.MoneyCurrency != "" || v.MoneyAmount != 0 {
t.Errorf("value = %+v, want kind=value chips=500 no money", v)
}
if len(v.Atoms) != 1 || v.Atoms[0].AtomType != "hints" || v.Atoms[0].Quantity != 250 {
t.Errorf("value atoms = %+v, want [hints:250]", v.Atoms)
}
// The multi-method pack shows the context method's price, in that currency, no chips.
p, ok := got[packID]
if !ok {
t.Fatalf("pack missing from %s storefront", tc.name)
}
if p.Kind != "pack" || p.Chips != 0 || p.MoneyAmount != tc.wantPackAmt || p.MoneyCurrency != string(tc.wantPackCur) {
t.Errorf("pack = %+v, want kind=pack amount=%d currency=%s", p, tc.wantPackAmt, tc.wantPackCur)
}
// The vk-only pack shows only where a vk price exists.
if _, ok := got[vkPackID]; ok != tc.wantVKPack {
t.Errorf("vk-only pack present=%v, want %v (%s)", ok, tc.wantVKPack, tc.name)
}
// The misconfigured value (no CHIP price) is never shown.
if _, ok := got[brokenID]; ok {
t.Errorf("misconfigured value must be omitted (%s)", tc.name)
}
})
}
}
// An untrusted context (empty Kind) has no payment method, so it shows values only — no packs.
func TestProjectCatalog_UntrustedShowsValuesOnly(t *testing.T) {
got := byID(projectCatalog(fixtureCatalog(), Context{}))
if _, ok := got["00000000-0000-0000-0000-000000000001"]; !ok {
t.Error("untrusted context should still list the chip-priced value")
}
for _, id := range []string{
"00000000-0000-0000-0000-000000000002",
"00000000-0000-0000-0000-000000000003",
} {
if _, ok := got[id]; ok {
t.Errorf("untrusted context must not list pack %s", id)
}
}
}
// An empty catalog projects to an empty storefront (no products seeded yet — the Release-1 state).
func TestProjectCatalog_Empty(t *testing.T) {
if got := projectCatalog(nil, Context{Kind: SourceDirect}); len(got.Products) != 0 {
t.Errorf("empty catalog projected %d products, want 0", len(got.Products))
}
}
+202
View File
@@ -0,0 +1,202 @@
package payments
import (
"slices"
"time"
)
// Source is the platform axis a chip balance is segmented by (where it was funded) and a
// benefit is stamped with (where it was bought — its origin). The two roles share this value
// set but mean different things (see docs/PAYMENTS.md §3); Source names the axis for both.
type Source string
const (
// SourceVK is the VK platform: Votes purchases and VK rewarded ads fund here.
SourceVK Source = "vk"
// SourceTelegram is the Telegram platform: Stars purchases fund here.
SourceTelegram Source = "telegram"
// SourceDirect is the open web / native context: Robokassa purchases fund here.
SourceDirect Source = "direct"
)
// Valid reports whether s is one of the three known platform sources.
func (s Source) Valid() bool {
switch s {
case SourceVK, SourceTelegram, SourceDirect:
return true
default:
return false
}
}
// SubtypeIOS is the one device subtype the gate keys on: VK on iOS is frozen for spending
// (Apple forbids spending virtual currency on digital goods outside IAP). It is trusted only
// for VK, where it rides inside the signed launch parameters.
const SubtypeIOS = "ios"
// SourceForIdentityKind maps a backend identity kind to the payments source whose segment that
// identity makes available: a vk/telegram identity to its own source, a durable email identity
// to the direct source (the web/native recovery anchor). A robot identity — or any unknown
// kind — maps to no source (second return false). Callers use it to build the present set the
// payments interface takes, since payments cannot read the account schema.
func SourceForIdentityKind(kind string) (Source, bool) {
switch kind {
case "vk":
return SourceVK, true
case "telegram":
return SourceTelegram, true
case "email":
return SourceDirect, true
default:
return "", false
}
}
// PresentSources maps an account's identity kinds to the payments sources they make available
// (§6), de-duplicated: vk→vk, telegram→telegram, email→direct; a robot or unknown kind maps to
// nothing. Callers pass the kinds from account.Identities so the gate — which holds no
// cross-schema identity knowledge — can resolve which segments are awake.
func PresentSources(kinds []string) []Source {
var out []Source
for _, k := range kinds {
if s, ok := SourceForIdentityKind(k); ok && !has(out, s) {
out = append(out, s)
}
}
return out
}
// Context is the trusted execution context the store-compliance gate keys on: the platform
// Kind (the source the wrapper enforces) plus, for VK, the trusted Subtype (only the VK-iOS
// freeze depends on it). The zero Context (empty Kind) is an untrusted platform — the gate is
// fail-closed there: no spend, no purchase, no foreign-origin benefit, view only.
type Context struct {
Kind Source
Subtype string
}
// NewContext builds a Context from the session platform's kind and subtype strings (as carried
// on the trusted X-Platform signal). An unrecognised or empty kind yields an untrusted Context.
func NewContext(kind, subtype string) Context {
k := Source(kind)
if !k.Valid() {
return Context{}
}
return Context{Kind: k, Subtype: subtype}
}
// Trusted reports whether the platform is trusted (a known kind). An untrusted context denies
// every spend/purchase and the application of any foreign origin.
func (c Context) Trusted() bool { return c.Kind.Valid() }
// vkFrozen reports whether this is the VK-iOS spend freeze: VK context on the trusted iOS
// subtype. A previously bought benefit still applies there, but no spend or purchase is possible.
func (c Context) vkFrozen() bool { return c.Kind == SourceVK && c.Subtype == SubtypeIOS }
// spendPriority is the fixed draw order when several segments are spendable in one context
// (D7): the "home" direct segment first, the store-funded segments after.
var spendPriority = []Source{SourceDirect, SourceVK, SourceTelegram}
// has reports whether present contains s.
func has(present []Source, s Source) bool {
return slices.Contains(present, s)
}
// spendableSources returns the chip segments that may be SPENT in the context, in draw-priority
// order, restricted to the sources the account actually has (present). It is empty when the
// platform is untrusted (fail-closed) or VK-iOS (frozen): inside VK/TG only the same-named
// segment is spendable; on web/native all attached segments are, drained direct→vk→tg.
func spendableSources(c Context, present []Source) []Source {
if !c.Trusted() || c.vkFrozen() {
return nil
}
switch c.Kind {
case SourceVK, SourceTelegram:
if has(present, c.Kind) {
return []Source{c.Kind}
}
return nil
default: // direct (web/native)
var out []Source
for _, s := range spendPriority {
if has(present, s) {
out = append(out, s)
}
}
return out
}
}
// applicableOrigins returns the benefit origins that APPLY in the context, in draw-priority
// order, restricted to present sources. It differs from spendableSources in one way: VK-iOS is
// NOT excluded — a benefit bought earlier still applies while spending is frozen. Inside VK/TG
// only the same-named origin applies (a foreign, e.g. direct, origin never activates inside a
// store — the compliance wall); on web/native direct+vk+tg all apply, drained direct→vk→tg.
func applicableOrigins(c Context, present []Source) []Source {
if !c.Trusted() {
return nil
}
switch c.Kind {
case SourceVK, SourceTelegram:
if has(present, c.Kind) {
return []Source{c.Kind}
}
return nil
default: // direct (web/native)
var out []Source
for _, s := range spendPriority {
if has(present, s) {
out = append(out, s)
}
}
return out
}
}
// visibleSources returns the segments the wallet shows in the context, regardless of whether
// they are spendable: inside a store only the same-named segment (the others are invisible
// there); on web/native or an untrusted context all three (untrusted shows them view-only).
func visibleSources(c Context) []Source {
switch c.Kind {
case SourceVK, SourceTelegram:
return []Source{c.Kind}
default:
return spendPriority
}
}
// Segment is one chip balance the wallet shows: the source, its chip count, and whether it can
// be spent in the current context (false for a frozen VK-iOS balance or an untrusted platform).
type Segment struct {
Source Source
Chips int
Spendable bool
}
// BenefitView is the benefit state applicable in the current context: whether ads are off (and
// until when, or forever) and how many hints are available. It aggregates over the origins
// applicable in the context (§5).
type BenefitView struct {
AdsForever bool
AdsPaidUntil *time.Time
Hints int
}
// WalletView is the read model returned to the wallet: the visible segments plus the
// context-applicable benefits.
type WalletView struct {
Segments []Segment
Benefits BenefitView
}
// benefitDelta is the benefit change a spend or grant applies to one origin: hints added, a
// no-ads term in whole days (stacked from max(now, current end)), and the perpetual forever
// flag (which overrides terms).
type benefitDelta struct {
hintsAdd int
noAdsDays int
forever bool
}
// zero reports whether the delta changes nothing.
func (d benefitDelta) zero() bool { return d.hintsAdd == 0 && d.noAdsDays == 0 && !d.forever }
+132
View File
@@ -0,0 +1,132 @@
package payments
import (
"slices"
"testing"
)
// allPresent is an account attached to every source (the maximal present set).
var allPresent = []Source{SourceDirect, SourceVK, SourceTelegram}
func TestSpendableSources(t *testing.T) {
tests := []struct {
name string
ctx Context
present []Source
want []Source
}{
{"vk android, vk present", Context{Kind: SourceVK, Subtype: "android"}, allPresent, []Source{SourceVK}},
{"vk ios frozen", Context{Kind: SourceVK, Subtype: SubtypeIOS}, allPresent, nil},
{"vk android, vk absent", Context{Kind: SourceVK, Subtype: "android"}, []Source{SourceDirect}, nil},
{"telegram", Context{Kind: SourceTelegram, Subtype: "web"}, allPresent, []Source{SourceTelegram}},
{"telegram, tg absent", Context{Kind: SourceTelegram}, []Source{SourceVK}, nil},
{"direct all present, priority", Context{Kind: SourceDirect, Subtype: "web"}, allPresent, []Source{SourceDirect, SourceVK, SourceTelegram}},
{"direct, only vk+tg attached", Context{Kind: SourceDirect}, []Source{SourceTelegram, SourceVK}, []Source{SourceVK, SourceTelegram}},
{"direct, nothing attached", Context{Kind: SourceDirect}, nil, nil},
{"untrusted fail-closed", Context{}, allPresent, nil},
}
for _, tc := range tests {
t.Run(tc.name, func(t *testing.T) {
if got := spendableSources(tc.ctx, tc.present); !slices.Equal(got, tc.want) {
t.Errorf("spendableSources(%+v, %v) = %v, want %v", tc.ctx, tc.present, got, tc.want)
}
})
}
}
func TestApplicableOrigins(t *testing.T) {
tests := []struct {
name string
ctx Context
present []Source
want []Source
}{
// A benefit still APPLIES on VK-iOS while spending is frozen.
{"vk ios still applies", Context{Kind: SourceVK, Subtype: SubtypeIOS}, allPresent, []Source{SourceVK}},
{"vk android", Context{Kind: SourceVK, Subtype: "android"}, allPresent, []Source{SourceVK}},
{"telegram", Context{Kind: SourceTelegram}, allPresent, []Source{SourceTelegram}},
{"direct all, priority", Context{Kind: SourceDirect}, allPresent, []Source{SourceDirect, SourceVK, SourceTelegram}},
{"untrusted fail-closed", Context{}, allPresent, nil},
}
for _, tc := range tests {
t.Run(tc.name, func(t *testing.T) {
if got := applicableOrigins(tc.ctx, tc.present); !slices.Equal(got, tc.want) {
t.Errorf("applicableOrigins(%+v, %v) = %v, want %v", tc.ctx, tc.present, got, tc.want)
}
})
}
}
// TestComplianceWall is the named unit-level compliance regression: a direct origin (externally
// paid, outside any store cash desk) must NEVER be applicable inside a VK or TG wrapper, and no
// segment is ever spendable there beyond the same-named one. The dangerous direction stays shut.
func TestComplianceWall(t *testing.T) {
for _, kind := range []Source{SourceVK, SourceTelegram} {
for _, sub := range []string{"android", "ios", "web"} {
ctx := Context{Kind: kind, Subtype: sub}
if slices.Contains(applicableOrigins(ctx, allPresent), SourceDirect) {
t.Errorf("direct origin applies inside %s/%s — compliance wall breached", kind, sub)
}
for _, s := range spendableSources(ctx, allPresent) {
if s != kind {
t.Errorf("foreign segment %s spendable inside %s/%s", s, kind, sub)
}
}
// The opposite store's origin must not leak in either (vk⊥tg).
other := SourceVK
if kind == SourceVK {
other = SourceTelegram
}
if slices.Contains(applicableOrigins(ctx, allPresent), other) {
t.Errorf("%s origin applies inside %s — cross-store leak", other, kind)
}
}
}
}
func TestVisibleSources(t *testing.T) {
if got := visibleSources(Context{Kind: SourceVK, Subtype: SubtypeIOS}); !slices.Equal(got, []Source{SourceVK}) {
t.Errorf("VK visible = %v, want [vk] (direct/tg hidden in a store)", got)
}
if got := visibleSources(Context{Kind: SourceDirect}); !slices.Equal(got, allPresent) {
t.Errorf("direct visible = %v, want all three", got)
}
if got := visibleSources(Context{}); !slices.Equal(got, allPresent) {
t.Errorf("untrusted visible = %v, want all three (view-only)", got)
}
}
func TestSourceForIdentityKind(t *testing.T) {
tests := []struct {
kind string
want Source
ok bool
}{
{"vk", SourceVK, true},
{"telegram", SourceTelegram, true},
{"email", SourceDirect, true},
{"robot", "", false},
{"unknown", "", false},
}
for _, tc := range tests {
got, ok := SourceForIdentityKind(tc.kind)
if got != tc.want || ok != tc.ok {
t.Errorf("SourceForIdentityKind(%q) = %q,%v want %q,%v", tc.kind, got, ok, tc.want, tc.ok)
}
}
}
func TestNewContextTrusted(t *testing.T) {
if c := NewContext("vk", "ios"); !c.Trusted() || !c.vkFrozen() {
t.Errorf("vk/ios: trusted=%v frozen=%v, want true/true", c.Trusted(), c.vkFrozen())
}
if c := NewContext("bogus", "web"); c.Trusted() {
t.Errorf("bogus kind should be untrusted")
}
if c := NewContext("", ""); c.Trusted() {
t.Errorf("empty kind should be untrusted")
}
if c := NewContext("direct", "web"); c.vkFrozen() {
t.Errorf("direct is never frozen")
}
}
+136
View File
@@ -0,0 +1,136 @@
package payments
import "testing"
func TestMoneyFromMinor(t *testing.T) {
m, err := MoneyFromMinor(14950, CurrencyRUB)
if err != nil {
t.Fatalf("MoneyFromMinor: %v", err)
}
if m.Minor() != 14950 || m.Currency() != CurrencyRUB {
t.Fatalf("got minor=%d currency=%s", m.Minor(), m.Currency())
}
if _, err := MoneyFromMinor(1, Currency("EUR")); err == nil {
t.Fatal("expected an error for an unknown currency")
}
}
func TestMoneyFromMajor(t *testing.T) {
cases := []struct {
major int64
cur Currency
minor int64
}{
{149, CurrencyRUB, 14900}, // roubles scale to kopecks
{250, CurrencyStar, 250}, // Stars are whole units
{7, CurrencyVote, 7},
{50, CurrencyChip, 50},
}
for _, c := range cases {
m, err := MoneyFromMajor(c.major, c.cur)
if err != nil {
t.Fatalf("MoneyFromMajor(%d,%s): %v", c.major, c.cur, err)
}
if m.Minor() != c.minor {
t.Errorf("MoneyFromMajor(%d,%s): minor=%d, want %d", c.major, c.cur, m.Minor(), c.minor)
}
}
if _, err := MoneyFromMajor(1<<62, CurrencyRUB); err == nil {
t.Fatal("expected an overflow error")
}
}
func TestParseMoneyExact(t *testing.T) {
cases := []struct {
text string
cur Currency
minor int64
}{
{"149.50", CurrencyRUB, 14950},
{"149", CurrencyRUB, 14900},
{"0.01", CurrencyRUB, 1},
{"0.10", CurrencyRUB, 10}, // 0.1 is inexact as a float; big.Rat keeps it exact
{"-5.00", CurrencyRUB, -500},
{"250", CurrencyStar, 250},
{"7", CurrencyVote, 7},
{"50", CurrencyChip, 50},
}
for _, c := range cases {
m, err := ParseMoney(c.text, c.cur)
if err != nil {
t.Fatalf("ParseMoney(%q,%s): %v", c.text, c.cur, err)
}
if m.Minor() != c.minor {
t.Errorf("ParseMoney(%q,%s): minor=%d, want %d", c.text, c.cur, m.Minor(), c.minor)
}
}
}
func TestParseMoneyRejectsFraction(t *testing.T) {
// A whole-unit currency (Vote/Star/chip) must never accept a fraction, and
// the rouble must never accept finer than a kopeck — the no-float gate.
bad := []struct {
text string
cur Currency
}{
{"250.5", CurrencyStar},
{"1.5", CurrencyChip},
{"7.01", CurrencyVote},
{"1.999", CurrencyRUB},
{"0.001", CurrencyRUB},
{"abc", CurrencyRUB},
{"12.34", Currency("EUR")}, // unknown currency
}
for _, c := range bad {
if m, err := ParseMoney(c.text, c.cur); err == nil {
t.Errorf("ParseMoney(%q,%s) = %d, want an error", c.text, c.cur, m.Minor())
}
}
}
func TestMoneyAddCmp(t *testing.T) {
a, _ := MoneyFromMinor(14900, CurrencyRUB)
b, _ := MoneyFromMinor(50, CurrencyRUB)
sum, err := a.Add(b)
if err != nil || sum.Minor() != 14950 {
t.Fatalf("Add: minor=%d err=%v", sum.Minor(), err)
}
if c, err := a.Cmp(b); err != nil || c != 1 {
t.Fatalf("Cmp a>b: got %d err=%v", c, err)
}
if c, err := b.Cmp(a); err != nil || c != -1 {
t.Fatalf("Cmp b<a: got %d err=%v", c, err)
}
if c, err := a.Cmp(a); err != nil || c != 0 {
t.Fatalf("Cmp a==a: got %d err=%v", c, err)
}
// Cross-currency arithmetic is refused, not silently coerced.
star, _ := MoneyFromMinor(1, CurrencyStar)
if _, err := a.Add(star); err == nil {
t.Error("Add across currencies: expected an error")
}
if _, err := a.Cmp(star); err == nil {
t.Error("Cmp across currencies: expected an error")
}
}
func TestMoneyString(t *testing.T) {
cases := []struct {
minor int64
cur Currency
want string
}{
{14900, CurrencyRUB, "149.00 RUB"},
{14950, CurrencyRUB, "149.50 RUB"},
{1, CurrencyRUB, "0.01 RUB"},
{-500, CurrencyRUB, "-5.00 RUB"},
{250, CurrencyStar, "250 XTR"},
{5, CurrencyChip, "5 CHIP"},
}
for _, c := range cases {
m, _ := MoneyFromMinor(c.minor, c.cur)
if got := m.String(); got != c.want {
t.Errorf("Money{%d,%s}.String() = %q, want %q", c.minor, c.cur, got, c.want)
}
}
}
+171
View File
@@ -0,0 +1,171 @@
// Package payments is the in-game currency, wallet, benefit and catalog domain.
//
// It owns its own Postgres schema (payments) and is the only backend package
// that issues SQL against it — an import-boundary test forbids any other package
// from importing the payments jet code, which keeps the domain extractable into
// its own database or process later. There is no cross-schema foreign key to the
// account schema: an account id is a plain uuid here, kept referentially honest
// in code.
//
// Money is carried exclusively by [Money] — an exact integer amount in a
// currency's minor units — so no floating-point value ever reaches a monetary
// amount, and a whole-unit currency (Vote, Star, chip) can never hold a
// fraction. This file is the data-foundation layer: the currency value type; the
// wallet mechanics build on the schema and this package later.
package payments
import (
"fmt"
"math/big"
)
// Currency identifies the unit a monetary amount is denominated in.
type Currency string
const (
// CurrencyRUB is the Russian rouble; its minor unit is the kopeck (1/100).
CurrencyRUB Currency = "RUB"
// CurrencyVote is the VK Vote — a whole unit with no sub-unit.
CurrencyVote Currency = "VOTE"
// CurrencyStar is the Telegram Star (XTR) — a whole unit with no sub-unit.
CurrencyStar Currency = "XTR"
// CurrencyChip is the in-game chip, the unit a value's price is quoted in —
// a whole unit with no sub-unit.
CurrencyChip Currency = "CHIP"
)
// minorPerUnit reports how many minor units make one major unit of the currency.
// Every currency except the rouble is a whole-unit currency (scale 1), so an
// amount in it structurally cannot carry a fraction.
func (c Currency) minorPerUnit() int64 {
if c == CurrencyRUB {
return 100
}
return 1
}
// Valid reports whether the currency is one of the known units.
func (c Currency) Valid() bool {
switch c {
case CurrencyRUB, CurrencyVote, CurrencyStar, CurrencyChip:
return true
default:
return false
}
}
// Money is an exact monetary amount: a signed integer count of a currency's
// minor units (rouble kopecks; Vote/Star/chip whole units). It is the sole
// carrier of money in the payments domain — construction, arithmetic and
// formatting all go through it, so no float ever reaches an amount and a
// whole-unit currency can never hold a fraction. The zero value has an empty,
// invalid currency; build a value with [MoneyFromMinor], [MoneyFromMajor] or
// [ParseMoney].
type Money struct {
minor int64
currency Currency
}
// MoneyFromMinor builds a Money from a raw count of the currency's minor units
// (kopecks for the rouble, whole units otherwise). It errors on an unknown
// currency.
func MoneyFromMinor(minor int64, c Currency) (Money, error) {
if !c.Valid() {
return Money{}, fmt.Errorf("payments: unknown currency %q", c)
}
return Money{minor: minor, currency: c}, nil
}
// MoneyFromMajor builds a Money from a whole number of major units (roubles,
// Votes, Stars, chips). It errors on an unknown currency or on overflow.
func MoneyFromMajor(major int64, c Currency) (Money, error) {
if !c.Valid() {
return Money{}, fmt.Errorf("payments: unknown currency %q", c)
}
scaled := new(big.Int).Mul(big.NewInt(major), big.NewInt(c.minorPerUnit()))
if !scaled.IsInt64() {
return Money{}, fmt.Errorf("payments: %d %s overflows", major, c)
}
return Money{minor: scaled.Int64(), currency: c}, nil
}
// ParseMoney parses a decimal amount (e.g. "149.50", "250") in the currency,
// exactly and without floating point (via math/big). It rejects a value with
// finer precision than the currency allows — in particular, any fractional part
// for a whole-unit currency — which is the gate that keeps a fraction out of an
// integer currency.
func ParseMoney(text string, c Currency) (Money, error) {
if !c.Valid() {
return Money{}, fmt.Errorf("payments: unknown currency %q", c)
}
r, ok := new(big.Rat).SetString(text)
if !ok {
return Money{}, fmt.Errorf("payments: %q is not a valid amount", text)
}
scaled := new(big.Rat).Mul(r, new(big.Rat).SetInt64(c.minorPerUnit()))
if !scaled.IsInt() {
return Money{}, fmt.Errorf("payments: %q has finer precision than %s allows", text, c)
}
num := scaled.Num() // the denominator is 1 once scaled to an integer
if !num.IsInt64() {
return Money{}, fmt.Errorf("payments: %q overflows %s", text, c)
}
return Money{minor: num.Int64(), currency: c}, nil
}
// Minor returns the amount as a count of the currency's minor units — the value
// persisted to an amount column.
func (m Money) Minor() int64 { return m.minor }
// Currency returns the amount's currency.
func (m Money) Currency() Currency { return m.currency }
// IsZero reports whether the amount is zero.
func (m Money) IsZero() bool { return m.minor == 0 }
// Add returns the sum of the two amounts. It errors when the currencies differ.
func (m Money) Add(o Money) (Money, error) {
if m.currency != o.currency {
return Money{}, fmt.Errorf("payments: cannot add %s to %s", o.currency, m.currency)
}
return Money{minor: m.minor + o.minor, currency: m.currency}, nil
}
// Cmp compares the two amounts, returning -1, 0 or +1. It errors when the
// currencies differ.
func (m Money) Cmp(o Money) (int, error) {
if m.currency != o.currency {
return 0, fmt.Errorf("payments: cannot compare %s to %s", o.currency, m.currency)
}
switch {
case m.minor < o.minor:
return -1, nil
case m.minor > o.minor:
return 1, nil
default:
return 0, nil
}
}
// String renders the amount as "<value> <currency>", with the currency's
// fractional digits and no floating point (e.g. "149.50 RUB", "250 XTR").
func (m Money) String() string {
scale := m.currency.minorPerUnit()
if scale == 1 {
return fmt.Sprintf("%d %s", m.minor, m.currency)
}
neg := m.minor < 0
abs := m.minor
if neg {
abs = -abs
}
width := 0
for s := scale; s > 1; s /= 10 {
width++
}
sign := ""
if neg {
sign = "-"
}
return fmt.Sprintf("%s%d.%0*d %s", sign, abs/scale, width, abs%scale, m.currency)
}
+246
View File
@@ -0,0 +1,246 @@
package payments
import (
"context"
"database/sql"
"encoding/json"
"fmt"
"time"
"github.com/google/uuid"
)
// Service is the payments domain's application layer — the narrow surface other domains depend
// on, keeping the schema reachable through one seam. Every read/gate method takes the trusted
// execution Context and the account's present identity sources (which segments are awake, §6);
// payments holds no cross-schema identity knowledge, so the caller supplies present. Reads are
// served from the store's in-process cache, so the steady-state hot path issues no query to the
// payments schema.
type Service struct {
store *Store
clock func() time.Time
}
// NewService constructs a Service over store with a wall-clock time source.
func NewService(store *Store) *Service {
return &Service{store: store, clock: func() time.Time { return time.Now().UTC() }}
}
// Ping reports whether the payments schema is reachable.
func (s *Service) Ping(ctx context.Context) error { return s.store.Ping(ctx) }
// Wallet returns the read model for the account in the execution context: the segments visible
// there (each with its chip count and whether it is spendable) plus the context-applicable
// benefits. In a store context only the same-named segment is shown; on web/native or an
// untrusted platform all attached segments are shown, spendable only when the gate allows.
func (s *Service) Wallet(ctx context.Context, accountID uuid.UUID, cxt Context, present []Source) (WalletView, error) {
st, err := s.store.state(ctx, accountID)
if err != nil {
return WalletView{}, err
}
now := s.clock()
view := WalletView{Benefits: benefitView(st, cxt, present, now)}
spendable := spendableSources(cxt, present)
for _, src := range visibleSources(cxt) {
if !has(present, src) {
continue // only the account's own (attached) segments are shown
}
view.Segments = append(view.Segments, Segment{
Source: src,
Chips: st.chipsOf(src),
Spendable: has(spendable, src),
})
}
return view, nil
}
// Catalog returns the storefront for the execution context: every chip-priced value (shown in
// every context) plus the chip packs priced in the context's payment method. It is read straight
// from the catalog tables — small and rarely edited, so uncached. An untrusted context has no
// method and so shows values only; buying is gate-checked on Spend regardless (fail-closed).
func (s *Service) Catalog(ctx context.Context, cxt Context) (CatalogView, error) {
entries, err := s.store.loadCatalog(ctx)
if err != nil {
return CatalogView{}, err
}
return projectCatalog(entries, cxt), nil
}
// AdFree reports whether ads are suppressed for the account in the context: some origin
// applicable there has an active no-ads term or the forever flag. Fail-closed on an untrusted
// platform (no origin applies).
func (s *Service) AdFree(ctx context.Context, accountID uuid.UUID, cxt Context, present []Source) (bool, error) {
st, err := s.store.state(ctx, accountID)
if err != nil {
return false, err
}
now := s.clock()
for _, o := range applicableOrigins(cxt, present) {
b := st.benefitOf(o)
if b.adsForever || (b.adsPaidUntil != nil && b.adsPaidUntil.After(now)) {
return true, nil
}
}
return false, nil
}
// HintsAvailable returns how many hints the account can use in the context — the sum over the
// applicable origins. Zero on an untrusted platform.
func (s *Service) HintsAvailable(ctx context.Context, accountID uuid.UUID, cxt Context, present []Source) (int, error) {
st, err := s.store.state(ctx, accountID)
if err != nil {
return 0, err
}
total := 0
for _, o := range applicableOrigins(cxt, present) {
total += st.benefitOf(o).hints
}
return total, nil
}
// SpendHint consumes one hint from the first applicable origin that has one (priority
// direct→vk→tg), returning whether a hint was spent. It spends nothing when no origin is
// applicable (untrusted platform or no attached segment).
func (s *Service) SpendHint(ctx context.Context, accountID uuid.UUID, cxt Context, present []Source) (bool, error) {
origins := applicableOrigins(cxt, present)
if len(origins) == 0 {
return false, nil
}
return s.store.consumeHint(ctx, accountID, origins, s.clock())
}
// Spend buys a chip-priced value: it gate-checks the context, draws the price across the
// spendable segments by priority direct→vk→tg, and applies the benefit — atomically. The
// benefit's origin is the purchase context. It fails closed on an untrusted or frozen platform
// (ErrUntrusted) and on insufficient chips (ErrInsufficientChips).
func (s *Service) Spend(ctx context.Context, accountID uuid.UUID, cxt Context, present []Source, productID uuid.UUID) error {
spendable := spendableSources(cxt, present)
if len(spendable) == 0 {
return ErrUntrusted // untrusted, frozen, or no attached segment — no spend
}
prod, err := s.store.loadProduct(ctx, productID)
if err != nil {
return err
}
st, err := s.store.state(ctx, accountID)
if err != nil {
return err
}
draws, ok := planDraws(st, spendable, prod.priceChips)
if !ok {
return ErrInsufficientChips
}
snapshot, err := marshalSnapshot(prod)
if err != nil {
return err
}
return s.store.spend(ctx, accountID, draws, cxt.Kind, productID, prod.delta, snapshot, s.clock())
}
// Grant applies a benefit to an origin as a zero-price sale — an admin_grant ledger row and the
// benefit (hints, a no-ads term in whole days, or the forever flag). It never grants chips (no
// balance is touched — D16), so its signature has no chip amount. The origin is the admin's
// compliance choice.
func (s *Service) Grant(ctx context.Context, accountID uuid.UUID, origin Source, hints, noAdsDays int, forever bool) error {
if !origin.Valid() {
return fmt.Errorf("payments: invalid grant origin %q", origin)
}
d := benefitDelta{hintsAdd: hints, noAdsDays: noAdsDays, forever: forever}
if d.zero() {
return fmt.Errorf("payments: empty grant")
}
snapshot, err := marshalGrant(d)
if err != nil {
return err
}
return s.store.grant(ctx, accountID, origin, d, snapshot, s.clock())
}
// 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
// after committing (Invalidate).
func (s *Service) MergeTx(ctx context.Context, tx *sql.Tx, primary, secondary uuid.UUID) error {
return s.store.MergeTx(ctx, tx, primary, secondary, s.clock())
}
// Invalidate drops the cached state of the listed accounts (called after a merge commit).
func (s *Service) Invalidate(ids ...uuid.UUID) { s.store.Invalidate(ids...) }
// benefitView aggregates the benefits applicable in the context into the wallet view: ads-off
// forever if any applicable origin is perpetual, else the latest active term end, plus the total
// available hints.
func benefitView(st walletState, cxt Context, present []Source, now time.Time) BenefitView {
var v BenefitView
for _, o := range applicableOrigins(cxt, present) {
b := st.benefitOf(o)
if b.adsForever {
v.AdsForever = true
}
if b.adsPaidUntil != nil && b.adsPaidUntil.After(now) {
if v.AdsPaidUntil == nil || b.adsPaidUntil.After(*v.AdsPaidUntil) {
v.AdsPaidUntil = b.adsPaidUntil
}
}
v.Hints += b.hints
}
return v
}
// planDraws greedily allocates price across the spendable segments in priority order, draining
// each before moving on. It returns the per-segment draws and whether the segments together held
// enough.
func planDraws(st walletState, spendable []Source, price int) ([]sourceAmount, bool) {
remaining := price
var draws []sourceAmount
for _, src := range spendable {
if remaining <= 0 {
break
}
avail := st.chipsOf(src)
if avail <= 0 {
continue
}
take := min(avail, remaining)
draws = append(draws, sourceAmount{source: src, amount: take})
remaining -= take
}
if remaining > 0 {
return nil, false
}
return draws, true
}
// purchaseSnapshot is the catalog snapshot stored on a spend/grant ledger row, so history and
// receipts stay independent of later catalog edits (§7/D34).
type purchaseSnapshot struct {
ProductID string `json:"product_id,omitempty"`
Title string `json:"title,omitempty"`
Atoms map[string]int `json:"atoms,omitempty"`
PriceChips int `json:"price_chips"`
Forever bool `json:"forever,omitempty"`
}
// marshalSnapshot builds the snapshot for a chip spend.
func marshalSnapshot(p catalogProduct) ([]byte, error) {
b, err := json.Marshal(purchaseSnapshot{ProductID: p.id.String(), Title: p.title, Atoms: p.atoms, PriceChips: p.priceChips})
if err != nil {
return nil, fmt.Errorf("payments: marshal snapshot: %w", err)
}
return b, nil
}
// marshalGrant builds the snapshot for an admin grant (price 0).
func marshalGrant(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{Atoms: atoms, PriceChips: 0, Forever: d.forever})
if err != nil {
return nil, fmt.Errorf("payments: marshal grant snapshot: %w", err)
}
return b, nil
}
+40
View File
@@ -0,0 +1,40 @@
package payments
import (
"context"
"database/sql"
"fmt"
"github.com/go-jet/jet/v2/postgres"
"scrabble/backend/internal/postgres/jet/payments/model"
"scrabble/backend/internal/postgres/jet/payments/table"
)
// Store is the Postgres-backed query surface for the payments schema. It is the
// only place in the backend that issues SQL against payments.* (an
// import-boundary test enforces it), so the domain stays extractable into its
// own database. It fronts the materialized balances/benefits tables with an
// in-process write-through cache (see cache.go) so hot reads issue no query on
// the steady-state path.
type Store struct {
db *sql.DB
cache *walletCache
}
// NewStore constructs a Store wrapping db, with an empty read cache.
func NewStore(db *sql.DB) *Store { return &Store{db: db, cache: newWalletCache()} }
// Ping verifies the payments schema is reachable by reading the singleton config
// row. It is the data-foundation health check; the wallet query surface arrives
// with the currency mechanics.
func (s *Store) Ping(ctx context.Context) error {
var row model.Config
if err := postgres.SELECT(table.Config.AllColumns).
FROM(table.Config).
LIMIT(1).
QueryContext(ctx, s.db, &row); err != nil {
return fmt.Errorf("payments: ping: %w", err)
}
return nil
}
@@ -0,0 +1,89 @@
package payments
import (
"context"
"fmt"
"github.com/go-jet/jet/v2/postgres"
"github.com/google/uuid"
"scrabble/backend/internal/postgres/jet/payments/model"
"scrabble/backend/internal/postgres/jet/payments/table"
)
// atomQty is one atom line of a product as loaded from the catalog: the atom type and quantity.
type atomQty struct {
atomType string
quantity int
}
// priceRow is one price of a product as loaded from the catalog: the payment method (empty for a
// value's CHIP price, stored with a NULL method) and the amount in that currency's minor units.
type priceRow struct {
method string
currency Currency
amount int64
}
// catalogEntry is a raw active product loaded for the storefront: its atom composition and every
// price row. [projectCatalog] turns it into the context-visible [CatalogProduct].
type catalogEntry struct {
id uuid.UUID
title string
atoms []atomQty
prices []priceRow
}
// loadCatalog reads every active product with its atoms and prices, ordered by creation, straight
// from the catalog tables. The catalog is small and rarely edited (admin console only), so it is
// read uncached — unlike the per-account balances/benefits the read cache fronts.
func (s *Store) loadCatalog(ctx context.Context) ([]catalogEntry, error) {
var prods []model.Product
if err := postgres.SELECT(table.Product.AllColumns).
FROM(table.Product).
WHERE(table.Product.Active.IS_TRUE()).
ORDER_BY(table.Product.CreatedAt.ASC()).
QueryContext(ctx, s.db, &prods); err != nil {
return nil, fmt.Errorf("payments: load catalog products: %w", err)
}
if len(prods) == 0 {
return nil, nil
}
entries := make([]catalogEntry, len(prods))
index := make(map[uuid.UUID]int, len(prods))
for i, p := range prods {
entries[i] = catalogEntry{id: p.ProductID, title: p.Title}
index[p.ProductID] = i
}
var items []model.ProductItem
if err := postgres.SELECT(table.ProductItem.AllColumns).
FROM(table.ProductItem).
QueryContext(ctx, s.db, &items); err != nil {
return nil, fmt.Errorf("payments: load catalog items: %w", err)
}
for _, it := range items {
if i, ok := index[it.ProductID]; ok {
entries[i].atoms = append(entries[i].atoms, atomQty{atomType: it.AtomType, quantity: int(it.Quantity)})
}
}
var prices []model.ProductPrice
if err := postgres.SELECT(table.ProductPrice.AllColumns).
FROM(table.ProductPrice).
QueryContext(ctx, s.db, &prices); err != nil {
return nil, fmt.Errorf("payments: load catalog prices: %w", err)
}
for _, pr := range prices {
i, ok := index[pr.ProductID]
if !ok {
continue // a price for a deactivated product — not in the storefront
}
method := ""
if pr.Method != nil {
method = *pr.Method
}
entries[i].prices = append(entries[i].prices, priceRow{method: method, currency: Currency(pr.Currency), amount: pr.Amount})
}
return entries, nil
}
+421
View File
@@ -0,0 +1,421 @@
package payments
import (
"context"
"database/sql"
"errors"
"fmt"
"time"
"github.com/go-jet/jet/v2/postgres"
"github.com/go-jet/jet/v2/qrm"
"github.com/google/uuid"
"scrabble/backend/internal/postgres/jet/payments/model"
"scrabble/backend/internal/postgres/jet/payments/table"
)
// Domain errors surfaced by the payments store and service.
var (
// ErrInsufficientChips means the spendable segments held fewer chips than the price.
ErrInsufficientChips = errors.New("payments: insufficient chips")
// ErrUntrusted means the platform context is untrusted, so the gate is fail-closed.
ErrUntrusted = errors.New("payments: untrusted platform")
// ErrProductNotFound means the product is absent or deactivated.
ErrProductNotFound = errors.New("payments: product not found")
// ErrNotAValue means the product has no chip price (it is a chip pack or unpriced), so it
// cannot be bought with chips.
ErrNotAValue = errors.New("payments: product is not a chip-priced value")
)
// withTx runs fn inside a transaction on db, rolling back on error or panic.
func withTx(ctx context.Context, db *sql.DB, fn func(*sql.Tx) error) (err error) {
tx, err := db.BeginTx(ctx, nil)
if err != nil {
return fmt.Errorf("payments: begin tx: %w", err)
}
defer func() {
if p := recover(); p != nil {
_ = tx.Rollback()
panic(p)
}
}()
if err := fn(tx); err != nil {
_ = tx.Rollback()
return err
}
if err := tx.Commit(); err != nil {
return fmt.Errorf("payments: commit tx: %w", err)
}
return nil
}
// sourceAmount is one chip draw from a single segment during a spend.
type sourceAmount struct {
source Source
amount int
}
// catalogProduct is a product resolved for a chip spend: its chip price, the benefit its atoms
// fold into, and the raw atom composition for the purchase snapshot.
type catalogProduct struct {
id uuid.UUID
title string
priceChips int
delta benefitDelta
atoms map[string]int
}
// loadState reads an account's balances and benefits straight from the materialized tables.
func (s *Store) loadState(ctx context.Context, accountID uuid.UUID) (walletState, error) {
st := walletState{chips: map[Source]int{}, benefits: map[Source]benefitState{}}
var brows []model.Balances
if err := postgres.SELECT(table.Balances.AllColumns).
FROM(table.Balances).
WHERE(table.Balances.AccountID.EQ(postgres.UUID(accountID))).
QueryContext(ctx, s.db, &brows); err != nil {
return walletState{}, fmt.Errorf("payments: load balances %s: %w", accountID, err)
}
for _, r := range brows {
st.chips[Source(r.Source)] = int(r.Chips)
}
var frows []model.Benefits
if err := postgres.SELECT(table.Benefits.AllColumns).
FROM(table.Benefits).
WHERE(table.Benefits.AccountID.EQ(postgres.UUID(accountID))).
QueryContext(ctx, s.db, &frows); err != nil {
return walletState{}, fmt.Errorf("payments: load benefits %s: %w", accountID, err)
}
for _, r := range frows {
st.benefits[Source(r.Origin)] = benefitState{adsPaidUntil: r.AdsPaidUntil, adsForever: r.AdsForever, hints: int(r.Hints)}
}
return st, nil
}
// state returns an account's payments state, served from the read cache when warm, otherwise
// loaded from the materialized tables and cached. The returned maps are read-only.
func (s *Store) state(ctx context.Context, accountID uuid.UUID) (walletState, error) {
if st, ok := s.cache.get(accountID); ok {
return st, nil
}
st, err := s.loadState(ctx, accountID)
if err != nil {
return walletState{}, err
}
s.cache.put(accountID, st)
return st, nil
}
// Invalidate drops the cached state of every listed account so the next read reloads it. It is
// called after a committed mutation whose transaction the payments package does not own — the
// account-merge flow (after its own commit) and, later, external fund/refund intake.
func (s *Store) Invalidate(ids ...uuid.UUID) {
for _, id := range ids {
s.cache.invalidate(id)
}
}
// loadProduct resolves a chip-priced value: an active product with a CHIP price row
// (method NULL) whose atoms are benefits (hints / no-ads days). It rejects a missing or
// deactivated product (ErrProductNotFound), a product with no chip price (ErrNotAValue), and a
// product carrying the chips atom (a chip pack is funded, never bought with chips — ErrNotAValue).
func (s *Store) loadProduct(ctx context.Context, productID uuid.UUID) (catalogProduct, error) {
var p model.Product
err := postgres.SELECT(table.Product.AllColumns).
FROM(table.Product).
WHERE(table.Product.ProductID.EQ(postgres.UUID(productID))).
LIMIT(1).
QueryContext(ctx, s.db, &p)
if errors.Is(err, qrm.ErrNoRows) || (err == nil && !p.Active) {
return catalogProduct{}, ErrProductNotFound
}
if err != nil {
return catalogProduct{}, fmt.Errorf("payments: load product %s: %w", productID, err)
}
var price model.ProductPrice
err = postgres.SELECT(table.ProductPrice.AllColumns).
FROM(table.ProductPrice).
WHERE(table.ProductPrice.ProductID.EQ(postgres.UUID(productID)).
AND(table.ProductPrice.Method.IS_NULL()).
AND(table.ProductPrice.Currency.EQ(postgres.String(string(CurrencyChip))))).
LIMIT(1).
QueryContext(ctx, s.db, &price)
if errors.Is(err, qrm.ErrNoRows) {
return catalogProduct{}, ErrNotAValue
}
if err != nil {
return catalogProduct{}, fmt.Errorf("payments: load price %s: %w", productID, err)
}
var items []model.ProductItem
if err := postgres.SELECT(table.ProductItem.AllColumns).
FROM(table.ProductItem).
WHERE(table.ProductItem.ProductID.EQ(postgres.UUID(productID))).
QueryContext(ctx, s.db, &items); err != nil {
return catalogProduct{}, fmt.Errorf("payments: load items %s: %w", productID, err)
}
cp := catalogProduct{id: productID, title: p.Title, priceChips: int(price.Amount), atoms: map[string]int{}}
for _, it := range items {
cp.atoms[it.AtomType] = int(it.Quantity)
switch it.AtomType {
case "hints":
cp.delta.hintsAdd += int(it.Quantity)
case "noads_days":
cp.delta.noAdsDays += int(it.Quantity)
case "chips":
return catalogProduct{}, ErrNotAValue // a value never grants chips
}
}
return cp, nil
}
// stackNoAds returns the new no-ads term end after adding addDays whole days from
// max(now, current end) — terms add up, the remainder is never lost (§5/D33). A non-positive
// addDays leaves the term unchanged; a nil current means no term yet.
func stackNoAds(current *time.Time, addDays int, now time.Time) *time.Time {
if addDays <= 0 {
return current
}
base := now
if current != nil && current.After(now) {
base = *current
}
end := base.Add(time.Duration(addDays) * 24 * time.Hour)
return &end
}
// combineNoAds folds secondary's remaining no-ads term onto primary's during a merge: the
// remaining duration of each is preserved (§6/D15 "terms extend per origin").
func combineNoAds(primary, secondary *time.Time, now time.Time) *time.Time {
if secondary == nil || !secondary.After(now) {
return primary
}
base := now
if primary != nil && primary.After(now) {
base = *primary
}
end := base.Add(secondary.Sub(now))
return &end
}
// applyBenefitTx applies a benefit delta to one origin inside tx, stacking the no-ads term,
// OR-ing the forever flag and adding hints. It ensures the (account, origin) row exists, locks
// it, then writes the recomputed values — so concurrent applies serialise on the row lock.
func applyBenefitTx(ctx context.Context, tx *sql.Tx, accountID uuid.UUID, origin Source, d benefitDelta, now time.Time) error {
if d.zero() {
return nil
}
if _, err := tx.ExecContext(ctx,
`INSERT INTO payments.benefits (account_id, origin) VALUES ($1, $2) ON CONFLICT DO NOTHING`,
accountID, string(origin)); err != nil {
return fmt.Errorf("payments: ensure benefit row %s: %w", origin, err)
}
var untilCur *time.Time
var foreverCur bool
var hintsCur int32
if err := tx.QueryRowContext(ctx,
`SELECT ads_paid_until, ads_forever, hints FROM payments.benefits
WHERE account_id = $1 AND origin = $2 FOR UPDATE`, accountID, string(origin)).
Scan(&untilCur, &foreverCur, &hintsCur); err != nil {
return fmt.Errorf("payments: lock benefit %s: %w", origin, err)
}
if _, err := tx.ExecContext(ctx,
`UPDATE payments.benefits SET ads_paid_until = $3, ads_forever = $4, hints = $5, updated_at = now()
WHERE account_id = $1 AND origin = $2`,
accountID, string(origin), stackNoAds(untilCur, d.noAdsDays, now), foreverCur || d.forever, int(hintsCur)+d.hintsAdd); err != nil {
return fmt.Errorf("payments: apply benefit %s: %w", origin, err)
}
return nil
}
// insertLedgerTx appends one append-only ledger row inside tx.
func insertLedgerTx(ctx context.Context, tx *sql.Tx, accountID uuid.UUID, kind string, source, origin *Source, chipsDelta int, productID *uuid.UUID, snapshot []byte, now time.Time) error {
id, err := uuid.NewV7()
if err != nil {
return fmt.Errorf("payments: ledger id: %w", err)
}
// The snapshot is a bare value, not a postgres.String literal: a jsonb column infers its
// type from an untyped parameter (the game-moves payload idiom), whereas a text-typed
// literal is rejected against jsonb.
var snap any = postgres.NULL
if snapshot != nil {
snap = string(snapshot)
}
stmt := table.Ledger.INSERT(
table.Ledger.LedgerID, table.Ledger.AccountID, table.Ledger.Kind,
table.Ledger.Source, table.Ledger.Origin, table.Ledger.ChipsDelta,
table.Ledger.ProductID, table.Ledger.Snapshot, table.Ledger.CreatedAt,
).VALUES(
id, accountID, kind,
sourceOrNull(source), sourceOrNull(origin), int32(chipsDelta),
uuidOrNull(productID), snap, now,
)
if _, err := stmt.ExecContext(ctx, tx); err != nil {
return fmt.Errorf("payments: insert %s ledger: %w", kind, err)
}
return nil
}
// spend draws the chips across the given segments, appends a spend ledger row per draw (carrying
// the purchase snapshot), and applies the benefit — all in one transaction. It fails closed on
// an insufficient balance (a guarded decrement), rolling everything back.
func (s *Store) spend(ctx context.Context, accountID uuid.UUID, draws []sourceAmount, origin Source, productID uuid.UUID, d benefitDelta, snapshot []byte, now time.Time) error {
err := withTx(ctx, s.db, func(tx *sql.Tx) error {
for _, dr := range draws {
res, err := table.Balances.
UPDATE(table.Balances.Chips, table.Balances.UpdatedAt).
SET(table.Balances.Chips.SUB(postgres.Int(int64(dr.amount))), postgres.TimestampzT(now)).
WHERE(table.Balances.AccountID.EQ(postgres.UUID(accountID)).
AND(table.Balances.Source.EQ(postgres.String(string(dr.source)))).
AND(table.Balances.Chips.GT_EQ(postgres.Int(int64(dr.amount))))).
ExecContext(ctx, tx)
if err != nil {
return fmt.Errorf("payments: decrement %s: %w", dr.source, err)
}
if n, _ := res.RowsAffected(); n == 0 {
return ErrInsufficientChips
}
src := dr.source
if err := insertLedgerTx(ctx, tx, accountID, "spend", &src, &origin, -dr.amount, &productID, 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
}
// grant records an admin_grant ledger row (price 0, no chips) and applies the granted benefit to
// the chosen origin, in one transaction — a zero-price sale of a value.
func (s *Store) grant(ctx context.Context, accountID uuid.UUID, origin Source, 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, 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)
// 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) {
for _, o := range origins {
res, err := table.Benefits.
UPDATE(table.Benefits.Hints, table.Benefits.UpdatedAt).
SET(table.Benefits.Hints.SUB(postgres.Int(1)), postgres.TimestampzT(now)).
WHERE(table.Benefits.AccountID.EQ(postgres.UUID(accountID)).
AND(table.Benefits.Origin.EQ(postgres.String(string(o)))).
AND(table.Benefits.Hints.GT(postgres.Int(0)))).
ExecContext(ctx, s.db)
if err != nil {
return false, fmt.Errorf("payments: consume hint %s: %w", o, err)
}
if n, _ := res.RowsAffected(); n > 0 {
s.cache.invalidate(accountID)
return true, nil
}
}
return false, nil
}
// MergeTx folds the secondary account's segments and benefits into the primary, by source and by
// origin, inside the caller's transaction (the account-merge flow): chips sum, no-ads terms
// extend per origin, forever OR-s, hints add. The secondary's payments rows are removed. The
// caller invalidates the primary's cache after its own commit (Invalidate). It does not touch
// the account schema — the JET import boundary stays intact, only the shared connection is used.
func (s *Store) MergeTx(ctx context.Context, tx *sql.Tx, primary, secondary uuid.UUID, now time.Time) error {
if _, err := tx.ExecContext(ctx,
`INSERT INTO payments.balances (account_id, source, chips, updated_at)
SELECT $1, source, chips, now() FROM payments.balances WHERE account_id = $2
ON CONFLICT (account_id, source) DO UPDATE
SET chips = payments.balances.chips + EXCLUDED.chips, updated_at = now()`,
primary, secondary); err != nil {
return fmt.Errorf("payments: merge balances: %w", err)
}
if _, err := tx.ExecContext(ctx, `DELETE FROM payments.balances WHERE account_id = $1`, secondary); err != nil {
return fmt.Errorf("payments: clear secondary balances: %w", err)
}
rows, err := tx.QueryContext(ctx,
`SELECT origin, ads_paid_until, ads_forever, hints FROM payments.benefits WHERE account_id = $1`, secondary)
if err != nil {
return fmt.Errorf("payments: read secondary benefits: %w", err)
}
type secBenefit struct {
origin Source
until *time.Time
forever bool
hints int
}
var secs []secBenefit
for rows.Next() {
var b secBenefit
var o string
var h int32
if err := rows.Scan(&o, &b.until, &b.forever, &h); err != nil {
rows.Close()
return fmt.Errorf("payments: scan secondary benefit: %w", err)
}
b.origin, b.hints = Source(o), int(h)
secs = append(secs, b)
}
if err := rows.Err(); err != nil {
rows.Close()
return fmt.Errorf("payments: iterate secondary benefits: %w", err)
}
rows.Close()
for _, b := range secs {
if _, err := tx.ExecContext(ctx,
`INSERT INTO payments.benefits (account_id, origin) VALUES ($1, $2) ON CONFLICT DO NOTHING`,
primary, string(b.origin)); err != nil {
return fmt.Errorf("payments: ensure primary benefit %s: %w", b.origin, err)
}
var untilCur *time.Time
var foreverCur bool
var hintsCur int32
if err := tx.QueryRowContext(ctx,
`SELECT ads_paid_until, ads_forever, hints FROM payments.benefits
WHERE account_id = $1 AND origin = $2 FOR UPDATE`, primary, string(b.origin)).
Scan(&untilCur, &foreverCur, &hintsCur); err != nil {
return fmt.Errorf("payments: lock primary benefit %s: %w", b.origin, err)
}
if _, err := tx.ExecContext(ctx,
`UPDATE payments.benefits SET ads_paid_until = $3, ads_forever = $4, hints = $5, updated_at = now()
WHERE account_id = $1 AND origin = $2`,
primary, string(b.origin), combineNoAds(untilCur, b.until, now), foreverCur || b.forever, int(hintsCur)+b.hints); err != nil {
return fmt.Errorf("payments: merge benefit %s: %w", b.origin, err)
}
}
if _, err := tx.ExecContext(ctx, `DELETE FROM payments.benefits WHERE account_id = $1`, secondary); err != nil {
return fmt.Errorf("payments: clear secondary benefits: %w", err)
}
return nil
}
// sourceOrNull renders an optional source as a SQL string or NULL.
func sourceOrNull(s *Source) postgres.Expression {
if s == nil {
return postgres.NULL
}
return postgres.String(string(*s))
}
// uuidOrNull renders an optional id as a SQL uuid or NULL.
func uuidOrNull(id *uuid.UUID) postgres.Expression {
if id == nil {
return postgres.NULL
}
return postgres.UUID(*id)
}
+182
View File
@@ -0,0 +1,182 @@
package payments
import (
"context"
"testing"
"time"
"github.com/google/uuid"
)
// base is a fixed clock instant for the deterministic tests.
var base = time.Date(2026, 7, 8, 12, 0, 0, 0, time.UTC)
// seededService builds a Service whose read cache already holds st for id, so the read methods
// resolve without a database (the store's db is nil). The clock is pinned to base.
func seededService(id uuid.UUID, st walletState) *Service {
store := NewStore(nil)
store.cache.put(id, st)
return &Service{store: store, clock: func() time.Time { return base }}
}
func TestStackNoAds(t *testing.T) {
future := base.Add(48 * time.Hour)
past := base.Add(-time.Hour)
tests := []struct {
name string
current *time.Time
addDays int
want *time.Time
}{
{"fresh term", nil, 3, new(base.Add(72 * time.Hour))},
{"stack onto future", new(future), 2, new(future.Add(48 * time.Hour))},
{"lapsed term restarts from now", new(past), 1, new(base.Add(24 * time.Hour))},
{"zero days unchanged", new(future), 0, new(future)},
{"zero days, nil stays nil", nil, 0, nil},
}
for _, tc := range tests {
t.Run(tc.name, func(t *testing.T) {
got := stackNoAds(tc.current, tc.addDays, base)
if (got == nil) != (tc.want == nil) || (got != nil && !got.Equal(*tc.want)) {
t.Errorf("stackNoAds = %v, want %v", got, tc.want)
}
})
}
}
func TestCombineNoAds(t *testing.T) {
pFuture := base.Add(24 * time.Hour)
sFuture := base.Add(72 * time.Hour) // 3 days remaining
// primary future + secondary future: primary end + secondary's remaining (72h).
if got := combineNoAds(new(pFuture), new(sFuture), base); !got.Equal(pFuture.Add(72 * time.Hour)) {
t.Errorf("combine both future = %v, want %v", got, pFuture.Add(72*time.Hour))
}
// primary nil + secondary future: now + secondary's remaining.
if got := combineNoAds(nil, new(sFuture), base); !got.Equal(base.Add(72 * time.Hour)) {
t.Errorf("combine nil primary = %v, want %v", got, base.Add(72*time.Hour))
}
// secondary lapsed: primary unchanged.
if got := combineNoAds(new(pFuture), new(base.Add(-time.Hour)), base); !got.Equal(pFuture) {
t.Errorf("combine lapsed secondary = %v, want %v", got, pFuture)
}
// secondary nil: primary unchanged.
if got := combineNoAds(new(pFuture), nil, base); !got.Equal(pFuture) {
t.Errorf("combine nil secondary = %v, want %v", got, pFuture)
}
}
func TestPlanDraws(t *testing.T) {
st := walletState{chips: map[Source]int{SourceDirect: 30, SourceVK: 50, SourceTelegram: 5}}
// price met entirely by the first (direct) segment.
if draws, ok := planDraws(st, []Source{SourceDirect, SourceVK}, 20); !ok || len(draws) != 1 || draws[0] != (sourceAmount{SourceDirect, 20}) {
t.Errorf("single-segment draw = %v ok=%v", draws, ok)
}
// price spills from direct into vk by priority.
draws, ok := planDraws(st, []Source{SourceDirect, SourceVK, SourceTelegram}, 60)
want := []sourceAmount{{SourceDirect, 30}, {SourceVK, 30}}
if !ok || len(draws) != 2 || draws[0] != want[0] || draws[1] != want[1] {
t.Errorf("priority spill draw = %v ok=%v, want %v", draws, ok, want)
}
// insufficient total.
if _, ok := planDraws(st, []Source{SourceDirect, SourceVK, SourceTelegram}, 200); ok {
t.Error("expected insufficient")
}
}
func TestWalletSegments(t *testing.T) {
id := uuid.New()
st := walletState{chips: map[Source]int{SourceDirect: 100, SourceVK: 50}}
svc := seededService(id, st)
present := []Source{SourceDirect, SourceVK}
// Web/native: both attached segments shown, both spendable.
got, err := svc.Wallet(context.Background(), id, NewContext("direct", "web"), present)
if err != nil {
t.Fatal(err)
}
if len(got.Segments) != 2 || !got.Segments[0].Spendable || got.Segments[0].Source != SourceDirect {
t.Errorf("direct wallet segments = %+v", got.Segments)
}
// VK Android: only vk shown, spendable.
got, _ = svc.Wallet(context.Background(), id, NewContext("vk", "android"), present)
if len(got.Segments) != 1 || got.Segments[0].Source != SourceVK || !got.Segments[0].Spendable {
t.Errorf("vk-android wallet = %+v", got.Segments)
}
// VK iOS: only vk shown, frozen (not spendable) but the balance is visible.
got, _ = svc.Wallet(context.Background(), id, NewContext("vk", "ios"), present)
if len(got.Segments) != 1 || got.Segments[0].Chips != 50 || got.Segments[0].Spendable {
t.Errorf("vk-ios wallet = %+v (want vk 50 frozen)", got.Segments)
}
}
func TestAdFreeByContext(t *testing.T) {
id := uuid.New()
future := base.Add(48 * time.Hour)
ctx := context.Background()
// A vk-origin no-ads term applies inside VK and out on web, but a direct-origin term never
// applies inside VK (the compliance wall).
svc := seededService(id, walletState{benefits: map[Source]benefitState{
SourceVK: {adsPaidUntil: new(future)},
SourceDirect: {adsPaidUntil: new(future)},
}})
present := []Source{SourceDirect, SourceVK}
cases := []struct {
name string
cxt Context
want bool
}{
{"vk term inside vk", NewContext("vk", "android"), true},
{"vk term inside vk-ios (applies while frozen)", NewContext("vk", "ios"), true},
{"terms on web", NewContext("direct", "web"), true},
{"untrusted fail-closed", NewContext("", ""), false},
}
for _, tc := range cases {
t.Run(tc.name, func(t *testing.T) {
got, err := svc.AdFree(ctx, id, tc.cxt, present)
if err != nil || got != tc.want {
t.Errorf("AdFree = %v (err %v), want %v", got, err, tc.want)
}
})
}
// Compliance: ONLY a direct-origin term, checked inside VK, must not suppress ads.
svc2 := seededService(id, walletState{benefits: map[Source]benefitState{SourceDirect: {adsPaidUntil: new(future)}}})
if got, _ := svc2.AdFree(ctx, id, NewContext("vk", "android"), present); got {
t.Error("direct-origin no-ads must NOT apply inside VK (compliance wall)")
}
}
func TestHintsAvailableByContext(t *testing.T) {
id := uuid.New()
svc := seededService(id, walletState{benefits: map[Source]benefitState{
SourceDirect: {hints: 3},
SourceVK: {hints: 2},
}})
present := []Source{SourceDirect, SourceVK}
ctx := context.Background()
// Web: both applicable origins summed.
if n, _ := svc.HintsAvailable(ctx, id, NewContext("direct", "web"), present); n != 5 {
t.Errorf("web hints = %d, want 5", n)
}
// VK: only vk-origin hints.
if n, _ := svc.HintsAvailable(ctx, id, NewContext("vk", "android"), present); n != 2 {
t.Errorf("vk hints = %d, want 2", n)
}
// Untrusted: none.
if n, _ := svc.HintsAvailable(ctx, id, NewContext("", ""), present); n != 0 {
t.Errorf("untrusted hints = %d, want 0", n)
}
}
func TestSpendHintUntrustedNoOp(t *testing.T) {
id := uuid.New()
svc := seededService(id, walletState{benefits: map[Source]benefitState{SourceDirect: {hints: 3}}})
// Untrusted context: no applicable origin, so nothing is spent and the DB (nil) is never hit.
spent, err := svc.SpendHint(context.Background(), id, NewContext("", ""), []Source{SourceDirect})
if err != nil || spent {
t.Errorf("untrusted SpendHint = %v (err %v), want false", spent, err)
}
}
@@ -27,4 +27,6 @@ type FeedbackMessages struct {
ReplyReadAt *time.Time ReplyReadAt *time.Time
CreatedAt time.Time CreatedAt time.Time
Lang *string Lang *string
AppVersion *string
BrowserTz *string
} }
@@ -0,0 +1,23 @@
//
// 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
import (
"github.com/google/uuid"
"time"
)
type RobotBlocks struct {
ID uuid.UUID `sql:"primary_key"`
BlockerID uuid.UUID
GameID uuid.UUID
Seat int16
RobotID uuid.UUID
DisplayName string
CreatedAt time.Time
}
@@ -0,0 +1,23 @@
//
// 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
import (
"github.com/google/uuid"
"time"
)
type RobotFriendRequests struct {
ID uuid.UUID `sql:"primary_key"`
RequesterID uuid.UUID
GameID uuid.UUID
Seat int16
RobotID uuid.UUID
DisplayName string
CreatedAt time.Time
}
@@ -13,11 +13,13 @@ import (
) )
type Sessions struct { type Sessions struct {
SessionID uuid.UUID `sql:"primary_key"` SessionID uuid.UUID `sql:"primary_key"`
AccountID uuid.UUID AccountID uuid.UUID
TokenHash string TokenHash string
Status string Status string
CreatedAt time.Time CreatedAt time.Time
LastSeenAt *time.Time LastSeenAt *time.Time
RevokedAt *time.Time RevokedAt *time.Time
PlatformKind *string
PlatformSubtype *string
} }
@@ -31,6 +31,8 @@ type feedbackMessagesTable struct {
ReplyReadAt postgres.ColumnTimestampz ReplyReadAt postgres.ColumnTimestampz
CreatedAt postgres.ColumnTimestampz CreatedAt postgres.ColumnTimestampz
Lang postgres.ColumnString Lang postgres.ColumnString
AppVersion postgres.ColumnString
BrowserTz postgres.ColumnString
AllColumns postgres.ColumnList AllColumns postgres.ColumnList
MutableColumns postgres.ColumnList MutableColumns postgres.ColumnList
@@ -86,8 +88,10 @@ func newFeedbackMessagesTableImpl(schemaName, tableName, alias string) feedbackM
ReplyReadAtColumn = postgres.TimestampzColumn("reply_read_at") ReplyReadAtColumn = postgres.TimestampzColumn("reply_read_at")
CreatedAtColumn = postgres.TimestampzColumn("created_at") CreatedAtColumn = postgres.TimestampzColumn("created_at")
LangColumn = postgres.StringColumn("lang") LangColumn = postgres.StringColumn("lang")
allColumns = postgres.ColumnList{MessageIDColumn, AccountIDColumn, BodyColumn, AttachmentColumn, AttachmentNameColumn, SenderIPColumn, ChannelColumn, ReadAtColumn, ArchivedAtColumn, ReplyBodyColumn, RepliedAtColumn, ReplyReadAtColumn, CreatedAtColumn, LangColumn} AppVersionColumn = postgres.StringColumn("app_version")
mutableColumns = postgres.ColumnList{AccountIDColumn, BodyColumn, AttachmentColumn, AttachmentNameColumn, SenderIPColumn, ChannelColumn, ReadAtColumn, ArchivedAtColumn, ReplyBodyColumn, RepliedAtColumn, ReplyReadAtColumn, CreatedAtColumn, LangColumn} BrowserTzColumn = postgres.StringColumn("browser_tz")
allColumns = postgres.ColumnList{MessageIDColumn, AccountIDColumn, BodyColumn, AttachmentColumn, AttachmentNameColumn, SenderIPColumn, ChannelColumn, ReadAtColumn, ArchivedAtColumn, ReplyBodyColumn, RepliedAtColumn, ReplyReadAtColumn, CreatedAtColumn, LangColumn, AppVersionColumn, BrowserTzColumn}
mutableColumns = postgres.ColumnList{AccountIDColumn, BodyColumn, AttachmentColumn, AttachmentNameColumn, SenderIPColumn, ChannelColumn, ReadAtColumn, ArchivedAtColumn, ReplyBodyColumn, RepliedAtColumn, ReplyReadAtColumn, CreatedAtColumn, LangColumn, AppVersionColumn, BrowserTzColumn}
defaultColumns = postgres.ColumnList{CreatedAtColumn} defaultColumns = postgres.ColumnList{CreatedAtColumn}
) )
@@ -109,6 +113,8 @@ func newFeedbackMessagesTableImpl(schemaName, tableName, alias string) feedbackM
ReplyReadAt: ReplyReadAtColumn, ReplyReadAt: ReplyReadAtColumn,
CreatedAt: CreatedAtColumn, CreatedAt: CreatedAtColumn,
Lang: LangColumn, Lang: LangColumn,
AppVersion: AppVersionColumn,
BrowserTz: BrowserTzColumn,
AllColumns: allColumns, AllColumns: allColumns,
MutableColumns: mutableColumns, MutableColumns: mutableColumns,
@@ -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 RobotBlocks = newRobotBlocksTable("backend", "robot_blocks", "")
type robotBlocksTable struct {
postgres.Table
// Columns
ID postgres.ColumnString
BlockerID postgres.ColumnString
GameID postgres.ColumnString
Seat postgres.ColumnInteger
RobotID postgres.ColumnString
DisplayName postgres.ColumnString
CreatedAt postgres.ColumnTimestampz
AllColumns postgres.ColumnList
MutableColumns postgres.ColumnList
DefaultColumns postgres.ColumnList
}
type RobotBlocksTable struct {
robotBlocksTable
EXCLUDED robotBlocksTable
}
// AS creates new RobotBlocksTable with assigned alias
func (a RobotBlocksTable) AS(alias string) *RobotBlocksTable {
return newRobotBlocksTable(a.SchemaName(), a.TableName(), alias)
}
// Schema creates new RobotBlocksTable with assigned schema name
func (a RobotBlocksTable) FromSchema(schemaName string) *RobotBlocksTable {
return newRobotBlocksTable(schemaName, a.TableName(), a.Alias())
}
// WithPrefix creates new RobotBlocksTable with assigned table prefix
func (a RobotBlocksTable) WithPrefix(prefix string) *RobotBlocksTable {
return newRobotBlocksTable(a.SchemaName(), prefix+a.TableName(), a.TableName())
}
// WithSuffix creates new RobotBlocksTable with assigned table suffix
func (a RobotBlocksTable) WithSuffix(suffix string) *RobotBlocksTable {
return newRobotBlocksTable(a.SchemaName(), a.TableName()+suffix, a.TableName())
}
func newRobotBlocksTable(schemaName, tableName, alias string) *RobotBlocksTable {
return &RobotBlocksTable{
robotBlocksTable: newRobotBlocksTableImpl(schemaName, tableName, alias),
EXCLUDED: newRobotBlocksTableImpl("", "excluded", ""),
}
}
func newRobotBlocksTableImpl(schemaName, tableName, alias string) robotBlocksTable {
var (
IDColumn = postgres.StringColumn("id")
BlockerIDColumn = postgres.StringColumn("blocker_id")
GameIDColumn = postgres.StringColumn("game_id")
SeatColumn = postgres.IntegerColumn("seat")
RobotIDColumn = postgres.StringColumn("robot_id")
DisplayNameColumn = postgres.StringColumn("display_name")
CreatedAtColumn = postgres.TimestampzColumn("created_at")
allColumns = postgres.ColumnList{IDColumn, BlockerIDColumn, GameIDColumn, SeatColumn, RobotIDColumn, DisplayNameColumn, CreatedAtColumn}
mutableColumns = postgres.ColumnList{BlockerIDColumn, GameIDColumn, SeatColumn, RobotIDColumn, DisplayNameColumn, CreatedAtColumn}
defaultColumns = postgres.ColumnList{CreatedAtColumn}
)
return robotBlocksTable{
Table: postgres.NewTable(schemaName, tableName, alias, allColumns...),
//Columns
ID: IDColumn,
BlockerID: BlockerIDColumn,
GameID: GameIDColumn,
Seat: SeatColumn,
RobotID: RobotIDColumn,
DisplayName: DisplayNameColumn,
CreatedAt: CreatedAtColumn,
AllColumns: allColumns,
MutableColumns: mutableColumns,
DefaultColumns: defaultColumns,
}
}
@@ -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 RobotFriendRequests = newRobotFriendRequestsTable("backend", "robot_friend_requests", "")
type robotFriendRequestsTable struct {
postgres.Table
// Columns
ID postgres.ColumnString
RequesterID postgres.ColumnString
GameID postgres.ColumnString
Seat postgres.ColumnInteger
RobotID postgres.ColumnString
DisplayName postgres.ColumnString
CreatedAt postgres.ColumnTimestampz
AllColumns postgres.ColumnList
MutableColumns postgres.ColumnList
DefaultColumns postgres.ColumnList
}
type RobotFriendRequestsTable struct {
robotFriendRequestsTable
EXCLUDED robotFriendRequestsTable
}
// AS creates new RobotFriendRequestsTable with assigned alias
func (a RobotFriendRequestsTable) AS(alias string) *RobotFriendRequestsTable {
return newRobotFriendRequestsTable(a.SchemaName(), a.TableName(), alias)
}
// Schema creates new RobotFriendRequestsTable with assigned schema name
func (a RobotFriendRequestsTable) FromSchema(schemaName string) *RobotFriendRequestsTable {
return newRobotFriendRequestsTable(schemaName, a.TableName(), a.Alias())
}
// WithPrefix creates new RobotFriendRequestsTable with assigned table prefix
func (a RobotFriendRequestsTable) WithPrefix(prefix string) *RobotFriendRequestsTable {
return newRobotFriendRequestsTable(a.SchemaName(), prefix+a.TableName(), a.TableName())
}
// WithSuffix creates new RobotFriendRequestsTable with assigned table suffix
func (a RobotFriendRequestsTable) WithSuffix(suffix string) *RobotFriendRequestsTable {
return newRobotFriendRequestsTable(a.SchemaName(), a.TableName()+suffix, a.TableName())
}
func newRobotFriendRequestsTable(schemaName, tableName, alias string) *RobotFriendRequestsTable {
return &RobotFriendRequestsTable{
robotFriendRequestsTable: newRobotFriendRequestsTableImpl(schemaName, tableName, alias),
EXCLUDED: newRobotFriendRequestsTableImpl("", "excluded", ""),
}
}
func newRobotFriendRequestsTableImpl(schemaName, tableName, alias string) robotFriendRequestsTable {
var (
IDColumn = postgres.StringColumn("id")
RequesterIDColumn = postgres.StringColumn("requester_id")
GameIDColumn = postgres.StringColumn("game_id")
SeatColumn = postgres.IntegerColumn("seat")
RobotIDColumn = postgres.StringColumn("robot_id")
DisplayNameColumn = postgres.StringColumn("display_name")
CreatedAtColumn = postgres.TimestampzColumn("created_at")
allColumns = postgres.ColumnList{IDColumn, RequesterIDColumn, GameIDColumn, SeatColumn, RobotIDColumn, DisplayNameColumn, CreatedAtColumn}
mutableColumns = postgres.ColumnList{RequesterIDColumn, GameIDColumn, SeatColumn, RobotIDColumn, DisplayNameColumn, CreatedAtColumn}
defaultColumns = postgres.ColumnList{CreatedAtColumn}
)
return robotFriendRequestsTable{
Table: postgres.NewTable(schemaName, tableName, alias, allColumns...),
//Columns
ID: IDColumn,
RequesterID: RequesterIDColumn,
GameID: GameIDColumn,
Seat: SeatColumn,
RobotID: RobotIDColumn,
DisplayName: DisplayNameColumn,
CreatedAt: CreatedAtColumn,
AllColumns: allColumns,
MutableColumns: mutableColumns,
DefaultColumns: defaultColumns,
}
}
@@ -17,13 +17,15 @@ type sessionsTable struct {
postgres.Table postgres.Table
// Columns // Columns
SessionID postgres.ColumnString SessionID postgres.ColumnString
AccountID postgres.ColumnString AccountID postgres.ColumnString
TokenHash postgres.ColumnString TokenHash postgres.ColumnString
Status postgres.ColumnString Status postgres.ColumnString
CreatedAt postgres.ColumnTimestampz CreatedAt postgres.ColumnTimestampz
LastSeenAt postgres.ColumnTimestampz LastSeenAt postgres.ColumnTimestampz
RevokedAt postgres.ColumnTimestampz RevokedAt postgres.ColumnTimestampz
PlatformKind postgres.ColumnString
PlatformSubtype postgres.ColumnString
AllColumns postgres.ColumnList AllColumns postgres.ColumnList
MutableColumns postgres.ColumnList MutableColumns postgres.ColumnList
@@ -65,29 +67,33 @@ func newSessionsTable(schemaName, tableName, alias string) *SessionsTable {
func newSessionsTableImpl(schemaName, tableName, alias string) sessionsTable { func newSessionsTableImpl(schemaName, tableName, alias string) sessionsTable {
var ( var (
SessionIDColumn = postgres.StringColumn("session_id") SessionIDColumn = postgres.StringColumn("session_id")
AccountIDColumn = postgres.StringColumn("account_id") AccountIDColumn = postgres.StringColumn("account_id")
TokenHashColumn = postgres.StringColumn("token_hash") TokenHashColumn = postgres.StringColumn("token_hash")
StatusColumn = postgres.StringColumn("status") StatusColumn = postgres.StringColumn("status")
CreatedAtColumn = postgres.TimestampzColumn("created_at") CreatedAtColumn = postgres.TimestampzColumn("created_at")
LastSeenAtColumn = postgres.TimestampzColumn("last_seen_at") LastSeenAtColumn = postgres.TimestampzColumn("last_seen_at")
RevokedAtColumn = postgres.TimestampzColumn("revoked_at") RevokedAtColumn = postgres.TimestampzColumn("revoked_at")
allColumns = postgres.ColumnList{SessionIDColumn, AccountIDColumn, TokenHashColumn, StatusColumn, CreatedAtColumn, LastSeenAtColumn, RevokedAtColumn} PlatformKindColumn = postgres.StringColumn("platform_kind")
mutableColumns = postgres.ColumnList{AccountIDColumn, TokenHashColumn, StatusColumn, CreatedAtColumn, LastSeenAtColumn, RevokedAtColumn} PlatformSubtypeColumn = postgres.StringColumn("platform_subtype")
defaultColumns = postgres.ColumnList{StatusColumn, CreatedAtColumn} allColumns = postgres.ColumnList{SessionIDColumn, AccountIDColumn, TokenHashColumn, StatusColumn, CreatedAtColumn, LastSeenAtColumn, RevokedAtColumn, PlatformKindColumn, PlatformSubtypeColumn}
mutableColumns = postgres.ColumnList{AccountIDColumn, TokenHashColumn, StatusColumn, CreatedAtColumn, LastSeenAtColumn, RevokedAtColumn, PlatformKindColumn, PlatformSubtypeColumn}
defaultColumns = postgres.ColumnList{StatusColumn, CreatedAtColumn}
) )
return sessionsTable{ return sessionsTable{
Table: postgres.NewTable(schemaName, tableName, alias, allColumns...), Table: postgres.NewTable(schemaName, tableName, alias, allColumns...),
//Columns //Columns
SessionID: SessionIDColumn, SessionID: SessionIDColumn,
AccountID: AccountIDColumn, AccountID: AccountIDColumn,
TokenHash: TokenHashColumn, TokenHash: TokenHashColumn,
Status: StatusColumn, Status: StatusColumn,
CreatedAt: CreatedAtColumn, CreatedAt: CreatedAtColumn,
LastSeenAt: LastSeenAtColumn, LastSeenAt: LastSeenAtColumn,
RevokedAt: RevokedAtColumn, RevokedAt: RevokedAtColumn,
PlatformKind: PlatformKindColumn,
PlatformSubtype: PlatformSubtypeColumn,
AllColumns: allColumns, AllColumns: allColumns,
MutableColumns: mutableColumns, MutableColumns: mutableColumns,
@@ -10,6 +10,7 @@ package table
// UseSchema sets a new schema name for all generated table SQL builder types. It is recommended to invoke // UseSchema sets a new schema name for all generated table SQL builder types. It is recommended to invoke
// this method only once at the beginning of the program. // this method only once at the beginning of the program.
func UseSchema(schema string) { func UseSchema(schema string) {
AccountBestMove = AccountBestMove.FromSchema(schema)
AccountRoles = AccountRoles.FromSchema(schema) AccountRoles = AccountRoles.FromSchema(schema)
AccountStats = AccountStats.FromSchema(schema) AccountStats = AccountStats.FromSchema(schema)
AccountSuspensions = AccountSuspensions.FromSchema(schema) AccountSuspensions = AccountSuspensions.FromSchema(schema)
@@ -35,6 +36,8 @@ func UseSchema(schema string) {
Games = Games.FromSchema(schema) Games = Games.FromSchema(schema)
Identities = Identities.FromSchema(schema) Identities = Identities.FromSchema(schema)
RetainedIdentities = RetainedIdentities.FromSchema(schema) RetainedIdentities = RetainedIdentities.FromSchema(schema)
RobotBlocks = RobotBlocks.FromSchema(schema)
RobotFriendRequests = RobotFriendRequests.FromSchema(schema)
Sessions = Sessions.FromSchema(schema) Sessions = Sessions.FromSchema(schema)
SuspensionReasons = SuspensionReasons.FromSchema(schema) SuspensionReasons = SuspensionReasons.FromSchema(schema)
} }
@@ -0,0 +1,20 @@
//
// 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
import (
"github.com/google/uuid"
"time"
)
type Balances struct {
AccountID uuid.UUID `sql:"primary_key"`
Source string `sql:"primary_key"`
Chips int32
UpdatedAt time.Time
}
@@ -0,0 +1,22 @@
//
// 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
import (
"github.com/google/uuid"
"time"
)
type Benefits struct {
AccountID uuid.UUID `sql:"primary_key"`
Origin string `sql:"primary_key"`
AdsPaidUntil *time.Time
AdsForever bool
Hints int32
UpdatedAt time.Time
}
@@ -0,0 +1,12 @@
//
// 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 CatalogAtom struct {
AtomType string `sql:"primary_key"`
}
@@ -0,0 +1,17 @@
//
// 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"`
RewardedPayoutChips int32
CooldownGlobalSeconds int32
CooldownVsAiSeconds int32
CooldownHintSeconds int32
OrderTTLSeconds int32
}
@@ -0,0 +1,28 @@
//
// 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
import (
"github.com/google/uuid"
"time"
)
type Ledger struct {
LedgerID uuid.UUID `sql:"primary_key"`
AccountID uuid.UUID
Kind string
Source *string
Origin *string
ChipsDelta int32
ProductID *uuid.UUID
OrderID *uuid.UUID
Provider *string
ProviderPaymentID *string
Snapshot *string
CreatedAt time.Time
}
@@ -0,0 +1,28 @@
//
// 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
import (
"github.com/google/uuid"
"time"
)
type Orders struct {
OrderID uuid.UUID `sql:"primary_key"`
AccountID uuid.UUID
Platform string
ProductID uuid.UUID
ExpectedAmount int64
Currency string
Origin string
Status string
Provider *string
ProviderPaymentID *string
CreatedAt time.Time
UpdatedAt time.Time
}
@@ -0,0 +1,23 @@
//
// 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
import (
"github.com/google/uuid"
"time"
)
type PaymentEvents struct {
EventID uuid.UUID `sql:"primary_key"`
AccountID uuid.UUID
OrderID *uuid.UUID
Type string
Payload *string
CreatedAt time.Time
DispatchedAt *time.Time
}
@@ -0,0 +1,21 @@
//
// 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
import (
"github.com/google/uuid"
"time"
)
type Product struct {
ProductID uuid.UUID `sql:"primary_key"`
Title string
Active bool
CreatedAt time.Time
UpdatedAt time.Time
}
@@ -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
import (
"github.com/google/uuid"
)
type ProductItem struct {
ProductID uuid.UUID `sql:"primary_key"`
AtomType string `sql:"primary_key"`
Quantity int32
}
@@ -0,0 +1,19 @@
//
// 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
import (
"github.com/google/uuid"
)
type ProductPrice struct {
ProductID uuid.UUID
Method *string
Currency string
Amount int64
}
@@ -0,0 +1,87 @@
//
// 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 Balances = newBalancesTable("payments", "balances", "")
type balancesTable struct {
postgres.Table
// Columns
AccountID postgres.ColumnString
Source postgres.ColumnString
Chips postgres.ColumnInteger
UpdatedAt postgres.ColumnTimestampz
AllColumns postgres.ColumnList
MutableColumns postgres.ColumnList
DefaultColumns postgres.ColumnList
}
type BalancesTable struct {
balancesTable
EXCLUDED balancesTable
}
// AS creates new BalancesTable with assigned alias
func (a BalancesTable) AS(alias string) *BalancesTable {
return newBalancesTable(a.SchemaName(), a.TableName(), alias)
}
// Schema creates new BalancesTable with assigned schema name
func (a BalancesTable) FromSchema(schemaName string) *BalancesTable {
return newBalancesTable(schemaName, a.TableName(), a.Alias())
}
// WithPrefix creates new BalancesTable with assigned table prefix
func (a BalancesTable) WithPrefix(prefix string) *BalancesTable {
return newBalancesTable(a.SchemaName(), prefix+a.TableName(), a.TableName())
}
// WithSuffix creates new BalancesTable with assigned table suffix
func (a BalancesTable) WithSuffix(suffix string) *BalancesTable {
return newBalancesTable(a.SchemaName(), a.TableName()+suffix, a.TableName())
}
func newBalancesTable(schemaName, tableName, alias string) *BalancesTable {
return &BalancesTable{
balancesTable: newBalancesTableImpl(schemaName, tableName, alias),
EXCLUDED: newBalancesTableImpl("", "excluded", ""),
}
}
func newBalancesTableImpl(schemaName, tableName, alias string) balancesTable {
var (
AccountIDColumn = postgres.StringColumn("account_id")
SourceColumn = postgres.StringColumn("source")
ChipsColumn = postgres.IntegerColumn("chips")
UpdatedAtColumn = postgres.TimestampzColumn("updated_at")
allColumns = postgres.ColumnList{AccountIDColumn, SourceColumn, ChipsColumn, UpdatedAtColumn}
mutableColumns = postgres.ColumnList{ChipsColumn, UpdatedAtColumn}
defaultColumns = postgres.ColumnList{ChipsColumn, UpdatedAtColumn}
)
return balancesTable{
Table: postgres.NewTable(schemaName, tableName, alias, allColumns...),
//Columns
AccountID: AccountIDColumn,
Source: SourceColumn,
Chips: ChipsColumn,
UpdatedAt: UpdatedAtColumn,
AllColumns: allColumns,
MutableColumns: mutableColumns,
DefaultColumns: defaultColumns,
}
}
@@ -0,0 +1,93 @@
//
// 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 Benefits = newBenefitsTable("payments", "benefits", "")
type benefitsTable struct {
postgres.Table
// Columns
AccountID postgres.ColumnString
Origin postgres.ColumnString
AdsPaidUntil postgres.ColumnTimestampz
AdsForever postgres.ColumnBool
Hints postgres.ColumnInteger
UpdatedAt postgres.ColumnTimestampz
AllColumns postgres.ColumnList
MutableColumns postgres.ColumnList
DefaultColumns postgres.ColumnList
}
type BenefitsTable struct {
benefitsTable
EXCLUDED benefitsTable
}
// AS creates new BenefitsTable with assigned alias
func (a BenefitsTable) AS(alias string) *BenefitsTable {
return newBenefitsTable(a.SchemaName(), a.TableName(), alias)
}
// Schema creates new BenefitsTable with assigned schema name
func (a BenefitsTable) FromSchema(schemaName string) *BenefitsTable {
return newBenefitsTable(schemaName, a.TableName(), a.Alias())
}
// WithPrefix creates new BenefitsTable with assigned table prefix
func (a BenefitsTable) WithPrefix(prefix string) *BenefitsTable {
return newBenefitsTable(a.SchemaName(), prefix+a.TableName(), a.TableName())
}
// WithSuffix creates new BenefitsTable with assigned table suffix
func (a BenefitsTable) WithSuffix(suffix string) *BenefitsTable {
return newBenefitsTable(a.SchemaName(), a.TableName()+suffix, a.TableName())
}
func newBenefitsTable(schemaName, tableName, alias string) *BenefitsTable {
return &BenefitsTable{
benefitsTable: newBenefitsTableImpl(schemaName, tableName, alias),
EXCLUDED: newBenefitsTableImpl("", "excluded", ""),
}
}
func newBenefitsTableImpl(schemaName, tableName, alias string) benefitsTable {
var (
AccountIDColumn = postgres.StringColumn("account_id")
OriginColumn = postgres.StringColumn("origin")
AdsPaidUntilColumn = postgres.TimestampzColumn("ads_paid_until")
AdsForeverColumn = postgres.BoolColumn("ads_forever")
HintsColumn = postgres.IntegerColumn("hints")
UpdatedAtColumn = postgres.TimestampzColumn("updated_at")
allColumns = postgres.ColumnList{AccountIDColumn, OriginColumn, AdsPaidUntilColumn, AdsForeverColumn, HintsColumn, UpdatedAtColumn}
mutableColumns = postgres.ColumnList{AdsPaidUntilColumn, AdsForeverColumn, HintsColumn, UpdatedAtColumn}
defaultColumns = postgres.ColumnList{AdsForeverColumn, HintsColumn, UpdatedAtColumn}
)
return benefitsTable{
Table: postgres.NewTable(schemaName, tableName, alias, allColumns...),
//Columns
AccountID: AccountIDColumn,
Origin: OriginColumn,
AdsPaidUntil: AdsPaidUntilColumn,
AdsForever: AdsForeverColumn,
Hints: HintsColumn,
UpdatedAt: UpdatedAtColumn,
AllColumns: allColumns,
MutableColumns: mutableColumns,
DefaultColumns: defaultColumns,
}
}
@@ -0,0 +1,78 @@
//
// 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 CatalogAtom = newCatalogAtomTable("payments", "catalog_atom", "")
type catalogAtomTable struct {
postgres.Table
// Columns
AtomType postgres.ColumnString
AllColumns postgres.ColumnList
MutableColumns postgres.ColumnList
DefaultColumns postgres.ColumnList
}
type CatalogAtomTable struct {
catalogAtomTable
EXCLUDED catalogAtomTable
}
// AS creates new CatalogAtomTable with assigned alias
func (a CatalogAtomTable) AS(alias string) *CatalogAtomTable {
return newCatalogAtomTable(a.SchemaName(), a.TableName(), alias)
}
// Schema creates new CatalogAtomTable with assigned schema name
func (a CatalogAtomTable) FromSchema(schemaName string) *CatalogAtomTable {
return newCatalogAtomTable(schemaName, a.TableName(), a.Alias())
}
// WithPrefix creates new CatalogAtomTable with assigned table prefix
func (a CatalogAtomTable) WithPrefix(prefix string) *CatalogAtomTable {
return newCatalogAtomTable(a.SchemaName(), prefix+a.TableName(), a.TableName())
}
// WithSuffix creates new CatalogAtomTable with assigned table suffix
func (a CatalogAtomTable) WithSuffix(suffix string) *CatalogAtomTable {
return newCatalogAtomTable(a.SchemaName(), a.TableName()+suffix, a.TableName())
}
func newCatalogAtomTable(schemaName, tableName, alias string) *CatalogAtomTable {
return &CatalogAtomTable{
catalogAtomTable: newCatalogAtomTableImpl(schemaName, tableName, alias),
EXCLUDED: newCatalogAtomTableImpl("", "excluded", ""),
}
}
func newCatalogAtomTableImpl(schemaName, tableName, alias string) catalogAtomTable {
var (
AtomTypeColumn = postgres.StringColumn("atom_type")
allColumns = postgres.ColumnList{AtomTypeColumn}
mutableColumns = postgres.ColumnList{}
defaultColumns = postgres.ColumnList{}
)
return catalogAtomTable{
Table: postgres.NewTable(schemaName, tableName, alias, allColumns...),
//Columns
AtomType: AtomTypeColumn,
AllColumns: allColumns,
MutableColumns: mutableColumns,
DefaultColumns: defaultColumns,
}
}
@@ -0,0 +1,93 @@
//
// 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("payments", "config", "")
type configTable struct {
postgres.Table
// Columns
OnlyRow postgres.ColumnBool
RewardedPayoutChips postgres.ColumnInteger
CooldownGlobalSeconds postgres.ColumnInteger
CooldownVsAiSeconds postgres.ColumnInteger
CooldownHintSeconds postgres.ColumnInteger
OrderTTLSeconds 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")
RewardedPayoutChipsColumn = postgres.IntegerColumn("rewarded_payout_chips")
CooldownGlobalSecondsColumn = postgres.IntegerColumn("cooldown_global_seconds")
CooldownVsAiSecondsColumn = postgres.IntegerColumn("cooldown_vs_ai_seconds")
CooldownHintSecondsColumn = postgres.IntegerColumn("cooldown_hint_seconds")
OrderTTLSecondsColumn = postgres.IntegerColumn("order_ttl_seconds")
allColumns = postgres.ColumnList{OnlyRowColumn, RewardedPayoutChipsColumn, CooldownGlobalSecondsColumn, CooldownVsAiSecondsColumn, CooldownHintSecondsColumn, OrderTTLSecondsColumn}
mutableColumns = postgres.ColumnList{RewardedPayoutChipsColumn, CooldownGlobalSecondsColumn, CooldownVsAiSecondsColumn, CooldownHintSecondsColumn, OrderTTLSecondsColumn}
defaultColumns = postgres.ColumnList{OnlyRowColumn, RewardedPayoutChipsColumn, CooldownGlobalSecondsColumn, CooldownVsAiSecondsColumn, CooldownHintSecondsColumn, OrderTTLSecondsColumn}
)
return configTable{
Table: postgres.NewTable(schemaName, tableName, alias, allColumns...),
//Columns
OnlyRow: OnlyRowColumn,
RewardedPayoutChips: RewardedPayoutChipsColumn,
CooldownGlobalSeconds: CooldownGlobalSecondsColumn,
CooldownVsAiSeconds: CooldownVsAiSecondsColumn,
CooldownHintSeconds: CooldownHintSecondsColumn,
OrderTTLSeconds: OrderTTLSecondsColumn,
AllColumns: allColumns,
MutableColumns: mutableColumns,
DefaultColumns: defaultColumns,
}
}
@@ -0,0 +1,111 @@
//
// 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 Ledger = newLedgerTable("payments", "ledger", "")
type ledgerTable struct {
postgres.Table
// Columns
LedgerID postgres.ColumnString
AccountID postgres.ColumnString
Kind postgres.ColumnString
Source postgres.ColumnString
Origin postgres.ColumnString
ChipsDelta postgres.ColumnInteger
ProductID postgres.ColumnString
OrderID postgres.ColumnString
Provider postgres.ColumnString
ProviderPaymentID postgres.ColumnString
Snapshot postgres.ColumnString
CreatedAt postgres.ColumnTimestampz
AllColumns postgres.ColumnList
MutableColumns postgres.ColumnList
DefaultColumns postgres.ColumnList
}
type LedgerTable struct {
ledgerTable
EXCLUDED ledgerTable
}
// AS creates new LedgerTable with assigned alias
func (a LedgerTable) AS(alias string) *LedgerTable {
return newLedgerTable(a.SchemaName(), a.TableName(), alias)
}
// Schema creates new LedgerTable with assigned schema name
func (a LedgerTable) FromSchema(schemaName string) *LedgerTable {
return newLedgerTable(schemaName, a.TableName(), a.Alias())
}
// WithPrefix creates new LedgerTable with assigned table prefix
func (a LedgerTable) WithPrefix(prefix string) *LedgerTable {
return newLedgerTable(a.SchemaName(), prefix+a.TableName(), a.TableName())
}
// WithSuffix creates new LedgerTable with assigned table suffix
func (a LedgerTable) WithSuffix(suffix string) *LedgerTable {
return newLedgerTable(a.SchemaName(), a.TableName()+suffix, a.TableName())
}
func newLedgerTable(schemaName, tableName, alias string) *LedgerTable {
return &LedgerTable{
ledgerTable: newLedgerTableImpl(schemaName, tableName, alias),
EXCLUDED: newLedgerTableImpl("", "excluded", ""),
}
}
func newLedgerTableImpl(schemaName, tableName, alias string) ledgerTable {
var (
LedgerIDColumn = postgres.StringColumn("ledger_id")
AccountIDColumn = postgres.StringColumn("account_id")
KindColumn = postgres.StringColumn("kind")
SourceColumn = postgres.StringColumn("source")
OriginColumn = postgres.StringColumn("origin")
ChipsDeltaColumn = postgres.IntegerColumn("chips_delta")
ProductIDColumn = postgres.StringColumn("product_id")
OrderIDColumn = postgres.StringColumn("order_id")
ProviderColumn = postgres.StringColumn("provider")
ProviderPaymentIDColumn = postgres.StringColumn("provider_payment_id")
SnapshotColumn = postgres.StringColumn("snapshot")
CreatedAtColumn = postgres.TimestampzColumn("created_at")
allColumns = postgres.ColumnList{LedgerIDColumn, AccountIDColumn, KindColumn, SourceColumn, OriginColumn, ChipsDeltaColumn, ProductIDColumn, OrderIDColumn, ProviderColumn, ProviderPaymentIDColumn, SnapshotColumn, CreatedAtColumn}
mutableColumns = postgres.ColumnList{AccountIDColumn, KindColumn, SourceColumn, OriginColumn, ChipsDeltaColumn, ProductIDColumn, OrderIDColumn, ProviderColumn, ProviderPaymentIDColumn, SnapshotColumn, CreatedAtColumn}
defaultColumns = postgres.ColumnList{ChipsDeltaColumn, CreatedAtColumn}
)
return ledgerTable{
Table: postgres.NewTable(schemaName, tableName, alias, allColumns...),
//Columns
LedgerID: LedgerIDColumn,
AccountID: AccountIDColumn,
Kind: KindColumn,
Source: SourceColumn,
Origin: OriginColumn,
ChipsDelta: ChipsDeltaColumn,
ProductID: ProductIDColumn,
OrderID: OrderIDColumn,
Provider: ProviderColumn,
ProviderPaymentID: ProviderPaymentIDColumn,
Snapshot: SnapshotColumn,
CreatedAt: CreatedAtColumn,
AllColumns: allColumns,
MutableColumns: mutableColumns,
DefaultColumns: defaultColumns,
}
}
@@ -0,0 +1,111 @@
//
// 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 Orders = newOrdersTable("payments", "orders", "")
type ordersTable struct {
postgres.Table
// Columns
OrderID postgres.ColumnString
AccountID postgres.ColumnString
Platform postgres.ColumnString
ProductID postgres.ColumnString
ExpectedAmount postgres.ColumnInteger
Currency postgres.ColumnString
Origin postgres.ColumnString
Status postgres.ColumnString
Provider postgres.ColumnString
ProviderPaymentID postgres.ColumnString
CreatedAt postgres.ColumnTimestampz
UpdatedAt postgres.ColumnTimestampz
AllColumns postgres.ColumnList
MutableColumns postgres.ColumnList
DefaultColumns postgres.ColumnList
}
type OrdersTable struct {
ordersTable
EXCLUDED ordersTable
}
// AS creates new OrdersTable with assigned alias
func (a OrdersTable) AS(alias string) *OrdersTable {
return newOrdersTable(a.SchemaName(), a.TableName(), alias)
}
// Schema creates new OrdersTable with assigned schema name
func (a OrdersTable) FromSchema(schemaName string) *OrdersTable {
return newOrdersTable(schemaName, a.TableName(), a.Alias())
}
// WithPrefix creates new OrdersTable with assigned table prefix
func (a OrdersTable) WithPrefix(prefix string) *OrdersTable {
return newOrdersTable(a.SchemaName(), prefix+a.TableName(), a.TableName())
}
// WithSuffix creates new OrdersTable with assigned table suffix
func (a OrdersTable) WithSuffix(suffix string) *OrdersTable {
return newOrdersTable(a.SchemaName(), a.TableName()+suffix, a.TableName())
}
func newOrdersTable(schemaName, tableName, alias string) *OrdersTable {
return &OrdersTable{
ordersTable: newOrdersTableImpl(schemaName, tableName, alias),
EXCLUDED: newOrdersTableImpl("", "excluded", ""),
}
}
func newOrdersTableImpl(schemaName, tableName, alias string) ordersTable {
var (
OrderIDColumn = postgres.StringColumn("order_id")
AccountIDColumn = postgres.StringColumn("account_id")
PlatformColumn = postgres.StringColumn("platform")
ProductIDColumn = postgres.StringColumn("product_id")
ExpectedAmountColumn = postgres.IntegerColumn("expected_amount")
CurrencyColumn = postgres.StringColumn("currency")
OriginColumn = postgres.StringColumn("origin")
StatusColumn = postgres.StringColumn("status")
ProviderColumn = postgres.StringColumn("provider")
ProviderPaymentIDColumn = postgres.StringColumn("provider_payment_id")
CreatedAtColumn = postgres.TimestampzColumn("created_at")
UpdatedAtColumn = postgres.TimestampzColumn("updated_at")
allColumns = postgres.ColumnList{OrderIDColumn, AccountIDColumn, PlatformColumn, ProductIDColumn, ExpectedAmountColumn, CurrencyColumn, OriginColumn, StatusColumn, ProviderColumn, ProviderPaymentIDColumn, CreatedAtColumn, UpdatedAtColumn}
mutableColumns = postgres.ColumnList{AccountIDColumn, PlatformColumn, ProductIDColumn, ExpectedAmountColumn, CurrencyColumn, OriginColumn, StatusColumn, ProviderColumn, ProviderPaymentIDColumn, CreatedAtColumn, UpdatedAtColumn}
defaultColumns = postgres.ColumnList{StatusColumn, CreatedAtColumn, UpdatedAtColumn}
)
return ordersTable{
Table: postgres.NewTable(schemaName, tableName, alias, allColumns...),
//Columns
OrderID: OrderIDColumn,
AccountID: AccountIDColumn,
Platform: PlatformColumn,
ProductID: ProductIDColumn,
ExpectedAmount: ExpectedAmountColumn,
Currency: CurrencyColumn,
Origin: OriginColumn,
Status: StatusColumn,
Provider: ProviderColumn,
ProviderPaymentID: ProviderPaymentIDColumn,
CreatedAt: CreatedAtColumn,
UpdatedAt: UpdatedAtColumn,
AllColumns: allColumns,
MutableColumns: mutableColumns,
DefaultColumns: defaultColumns,
}
}
@@ -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 PaymentEvents = newPaymentEventsTable("payments", "payment_events", "")
type paymentEventsTable struct {
postgres.Table
// Columns
EventID postgres.ColumnString
AccountID postgres.ColumnString
OrderID postgres.ColumnString
Type postgres.ColumnString
Payload postgres.ColumnString
CreatedAt postgres.ColumnTimestampz
DispatchedAt postgres.ColumnTimestampz
AllColumns postgres.ColumnList
MutableColumns postgres.ColumnList
DefaultColumns postgres.ColumnList
}
type PaymentEventsTable struct {
paymentEventsTable
EXCLUDED paymentEventsTable
}
// AS creates new PaymentEventsTable with assigned alias
func (a PaymentEventsTable) AS(alias string) *PaymentEventsTable {
return newPaymentEventsTable(a.SchemaName(), a.TableName(), alias)
}
// Schema creates new PaymentEventsTable with assigned schema name
func (a PaymentEventsTable) FromSchema(schemaName string) *PaymentEventsTable {
return newPaymentEventsTable(schemaName, a.TableName(), a.Alias())
}
// WithPrefix creates new PaymentEventsTable with assigned table prefix
func (a PaymentEventsTable) WithPrefix(prefix string) *PaymentEventsTable {
return newPaymentEventsTable(a.SchemaName(), prefix+a.TableName(), a.TableName())
}
// WithSuffix creates new PaymentEventsTable with assigned table suffix
func (a PaymentEventsTable) WithSuffix(suffix string) *PaymentEventsTable {
return newPaymentEventsTable(a.SchemaName(), a.TableName()+suffix, a.TableName())
}
func newPaymentEventsTable(schemaName, tableName, alias string) *PaymentEventsTable {
return &PaymentEventsTable{
paymentEventsTable: newPaymentEventsTableImpl(schemaName, tableName, alias),
EXCLUDED: newPaymentEventsTableImpl("", "excluded", ""),
}
}
func newPaymentEventsTableImpl(schemaName, tableName, alias string) paymentEventsTable {
var (
EventIDColumn = postgres.StringColumn("event_id")
AccountIDColumn = postgres.StringColumn("account_id")
OrderIDColumn = postgres.StringColumn("order_id")
TypeColumn = postgres.StringColumn("type")
PayloadColumn = postgres.StringColumn("payload")
CreatedAtColumn = postgres.TimestampzColumn("created_at")
DispatchedAtColumn = postgres.TimestampzColumn("dispatched_at")
allColumns = postgres.ColumnList{EventIDColumn, AccountIDColumn, OrderIDColumn, TypeColumn, PayloadColumn, CreatedAtColumn, DispatchedAtColumn}
mutableColumns = postgres.ColumnList{AccountIDColumn, OrderIDColumn, TypeColumn, PayloadColumn, CreatedAtColumn, DispatchedAtColumn}
defaultColumns = postgres.ColumnList{CreatedAtColumn}
)
return paymentEventsTable{
Table: postgres.NewTable(schemaName, tableName, alias, allColumns...),
//Columns
EventID: EventIDColumn,
AccountID: AccountIDColumn,
OrderID: OrderIDColumn,
Type: TypeColumn,
Payload: PayloadColumn,
CreatedAt: CreatedAtColumn,
DispatchedAt: DispatchedAtColumn,
AllColumns: allColumns,
MutableColumns: mutableColumns,
DefaultColumns: defaultColumns,
}
}
@@ -0,0 +1,90 @@
//
// 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 Product = newProductTable("payments", "product", "")
type productTable struct {
postgres.Table
// Columns
ProductID postgres.ColumnString
Title postgres.ColumnString
Active postgres.ColumnBool
CreatedAt postgres.ColumnTimestampz
UpdatedAt postgres.ColumnTimestampz
AllColumns postgres.ColumnList
MutableColumns postgres.ColumnList
DefaultColumns postgres.ColumnList
}
type ProductTable struct {
productTable
EXCLUDED productTable
}
// AS creates new ProductTable with assigned alias
func (a ProductTable) AS(alias string) *ProductTable {
return newProductTable(a.SchemaName(), a.TableName(), alias)
}
// Schema creates new ProductTable with assigned schema name
func (a ProductTable) FromSchema(schemaName string) *ProductTable {
return newProductTable(schemaName, a.TableName(), a.Alias())
}
// WithPrefix creates new ProductTable with assigned table prefix
func (a ProductTable) WithPrefix(prefix string) *ProductTable {
return newProductTable(a.SchemaName(), prefix+a.TableName(), a.TableName())
}
// WithSuffix creates new ProductTable with assigned table suffix
func (a ProductTable) WithSuffix(suffix string) *ProductTable {
return newProductTable(a.SchemaName(), a.TableName()+suffix, a.TableName())
}
func newProductTable(schemaName, tableName, alias string) *ProductTable {
return &ProductTable{
productTable: newProductTableImpl(schemaName, tableName, alias),
EXCLUDED: newProductTableImpl("", "excluded", ""),
}
}
func newProductTableImpl(schemaName, tableName, alias string) productTable {
var (
ProductIDColumn = postgres.StringColumn("product_id")
TitleColumn = postgres.StringColumn("title")
ActiveColumn = postgres.BoolColumn("active")
CreatedAtColumn = postgres.TimestampzColumn("created_at")
UpdatedAtColumn = postgres.TimestampzColumn("updated_at")
allColumns = postgres.ColumnList{ProductIDColumn, TitleColumn, ActiveColumn, CreatedAtColumn, UpdatedAtColumn}
mutableColumns = postgres.ColumnList{TitleColumn, ActiveColumn, CreatedAtColumn, UpdatedAtColumn}
defaultColumns = postgres.ColumnList{TitleColumn, ActiveColumn, CreatedAtColumn, UpdatedAtColumn}
)
return productTable{
Table: postgres.NewTable(schemaName, tableName, alias, allColumns...),
//Columns
ProductID: ProductIDColumn,
Title: TitleColumn,
Active: ActiveColumn,
CreatedAt: CreatedAtColumn,
UpdatedAt: UpdatedAtColumn,
AllColumns: allColumns,
MutableColumns: mutableColumns,
DefaultColumns: defaultColumns,
}
}
@@ -0,0 +1,84 @@
//
// 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 ProductItem = newProductItemTable("payments", "product_item", "")
type productItemTable struct {
postgres.Table
// Columns
ProductID postgres.ColumnString
AtomType postgres.ColumnString
Quantity postgres.ColumnInteger
AllColumns postgres.ColumnList
MutableColumns postgres.ColumnList
DefaultColumns postgres.ColumnList
}
type ProductItemTable struct {
productItemTable
EXCLUDED productItemTable
}
// AS creates new ProductItemTable with assigned alias
func (a ProductItemTable) AS(alias string) *ProductItemTable {
return newProductItemTable(a.SchemaName(), a.TableName(), alias)
}
// Schema creates new ProductItemTable with assigned schema name
func (a ProductItemTable) FromSchema(schemaName string) *ProductItemTable {
return newProductItemTable(schemaName, a.TableName(), a.Alias())
}
// WithPrefix creates new ProductItemTable with assigned table prefix
func (a ProductItemTable) WithPrefix(prefix string) *ProductItemTable {
return newProductItemTable(a.SchemaName(), prefix+a.TableName(), a.TableName())
}
// WithSuffix creates new ProductItemTable with assigned table suffix
func (a ProductItemTable) WithSuffix(suffix string) *ProductItemTable {
return newProductItemTable(a.SchemaName(), a.TableName()+suffix, a.TableName())
}
func newProductItemTable(schemaName, tableName, alias string) *ProductItemTable {
return &ProductItemTable{
productItemTable: newProductItemTableImpl(schemaName, tableName, alias),
EXCLUDED: newProductItemTableImpl("", "excluded", ""),
}
}
func newProductItemTableImpl(schemaName, tableName, alias string) productItemTable {
var (
ProductIDColumn = postgres.StringColumn("product_id")
AtomTypeColumn = postgres.StringColumn("atom_type")
QuantityColumn = postgres.IntegerColumn("quantity")
allColumns = postgres.ColumnList{ProductIDColumn, AtomTypeColumn, QuantityColumn}
mutableColumns = postgres.ColumnList{QuantityColumn}
defaultColumns = postgres.ColumnList{}
)
return productItemTable{
Table: postgres.NewTable(schemaName, tableName, alias, allColumns...),
//Columns
ProductID: ProductIDColumn,
AtomType: AtomTypeColumn,
Quantity: QuantityColumn,
AllColumns: allColumns,
MutableColumns: mutableColumns,
DefaultColumns: defaultColumns,
}
}
@@ -0,0 +1,87 @@
//
// 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 ProductPrice = newProductPriceTable("payments", "product_price", "")
type productPriceTable struct {
postgres.Table
// Columns
ProductID postgres.ColumnString
Method postgres.ColumnString
Currency postgres.ColumnString
Amount postgres.ColumnInteger
AllColumns postgres.ColumnList
MutableColumns postgres.ColumnList
DefaultColumns postgres.ColumnList
}
type ProductPriceTable struct {
productPriceTable
EXCLUDED productPriceTable
}
// AS creates new ProductPriceTable with assigned alias
func (a ProductPriceTable) AS(alias string) *ProductPriceTable {
return newProductPriceTable(a.SchemaName(), a.TableName(), alias)
}
// Schema creates new ProductPriceTable with assigned schema name
func (a ProductPriceTable) FromSchema(schemaName string) *ProductPriceTable {
return newProductPriceTable(schemaName, a.TableName(), a.Alias())
}
// WithPrefix creates new ProductPriceTable with assigned table prefix
func (a ProductPriceTable) WithPrefix(prefix string) *ProductPriceTable {
return newProductPriceTable(a.SchemaName(), prefix+a.TableName(), a.TableName())
}
// WithSuffix creates new ProductPriceTable with assigned table suffix
func (a ProductPriceTable) WithSuffix(suffix string) *ProductPriceTable {
return newProductPriceTable(a.SchemaName(), a.TableName()+suffix, a.TableName())
}
func newProductPriceTable(schemaName, tableName, alias string) *ProductPriceTable {
return &ProductPriceTable{
productPriceTable: newProductPriceTableImpl(schemaName, tableName, alias),
EXCLUDED: newProductPriceTableImpl("", "excluded", ""),
}
}
func newProductPriceTableImpl(schemaName, tableName, alias string) productPriceTable {
var (
ProductIDColumn = postgres.StringColumn("product_id")
MethodColumn = postgres.StringColumn("method")
CurrencyColumn = postgres.StringColumn("currency")
AmountColumn = postgres.IntegerColumn("amount")
allColumns = postgres.ColumnList{ProductIDColumn, MethodColumn, CurrencyColumn, AmountColumn}
mutableColumns = postgres.ColumnList{ProductIDColumn, MethodColumn, CurrencyColumn, AmountColumn}
defaultColumns = postgres.ColumnList{}
)
return productPriceTable{
Table: postgres.NewTable(schemaName, tableName, alias, allColumns...),
//Columns
ProductID: ProductIDColumn,
Method: MethodColumn,
Currency: CurrencyColumn,
Amount: AmountColumn,
AllColumns: allColumns,
MutableColumns: mutableColumns,
DefaultColumns: defaultColumns,
}
}
@@ -0,0 +1,23 @@
//
// 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
// UseSchema sets a new schema name for all generated table SQL builder types. It is recommended to invoke
// this method only once at the beginning of the program.
func UseSchema(schema string) {
Balances = Balances.FromSchema(schema)
Benefits = Benefits.FromSchema(schema)
CatalogAtom = CatalogAtom.FromSchema(schema)
Config = Config.FromSchema(schema)
Ledger = Ledger.FromSchema(schema)
Orders = Orders.FromSchema(schema)
PaymentEvents = PaymentEvents.FromSchema(schema)
Product = Product.FromSchema(schema)
ProductItem = ProductItem.FromSchema(schema)
ProductPrice = ProductPrice.FromSchema(schema)
}
@@ -0,0 +1,266 @@
-- Payments data foundation: a self-contained `payments` schema for the in-game
-- currency, wallets, benefits, catalog, orders and the append-only operations
-- ledger. Nothing is wired to real money yet — this is the substrate the rest of
-- the payments domain builds on. Mechanics live in docs/PAYMENTS.md.
--
-- Isolation. The schema is confined to a dedicated NOLOGIN role that holds ALL
-- privileges on `payments.*` and none on `backend.*`; the backend application
-- keeps its single (superuser) pool, so the DB grants are a stepping-stone to a
-- future separate login/process rather than a runtime wall. The runtime wall is
-- code-level: only the payments package reaches these tables (an import-boundary
-- test guards it). There is deliberately NO cross-schema foreign key to
-- `backend.accounts`: `account_id` is a plain uuid, kept referentially honest in
-- code and joined to the tombstoned account / retained-identities dossier by the
-- stable account id — which keeps the domain extractable into its own database.
--
-- The ledger is append-only, enforced by a trigger (a superuser bypasses table
-- privileges, so a REVOKE would not bind the application). Money is stored as an
-- integer amount in the currency's minor units (RUB kopecks; Votes/Stars/chips
-- are whole units, scale 1), never a float — integer-currency integrality is
-- therefore structural.
--
-- Legacy note: backend.accounts.hint_balance and backend.accounts.paid_account
-- are superseded by the segmented model here and are DEPRECATED. They are NOT
-- dropped in this expand phase — reads still use them until the currency core
-- flips them, and the DROP is a later contract-phase migration (image rollback
-- must stay DB-safe). Neither was ever set in production.
--
-- Expand-contract: everything here is additive in its own schema, so a backend
-- image rollback stays DB-safe (older code simply ignores the new schema).
-- +goose Up
CREATE SCHEMA IF NOT EXISTS payments;
-- +goose StatementBegin
DO $$
BEGIN
IF NOT EXISTS (SELECT FROM pg_roles WHERE rolname = 'payments') THEN
CREATE ROLE payments NOLOGIN;
END IF;
END
$$;
-- +goose StatementEnd
GRANT USAGE ON SCHEMA payments TO payments;
-- Base value types (atoms) a product is composed of. A fixed, code-known set.
CREATE TABLE payments.catalog_atom (
atom_type text NOT NULL,
CONSTRAINT catalog_atom_pkey PRIMARY KEY (atom_type),
CONSTRAINT catalog_atom_type_chk
CHECK ((atom_type = ANY (ARRAY['chips'::text, 'hints'::text, 'noads_days'::text, 'tournament'::text])))
);
INSERT INTO payments.catalog_atom (atom_type) VALUES
('chips'), ('hints'), ('noads_days'), ('tournament');
-- A sellable product: a set of atoms at a price. Soft-deleted (deactivated),
-- never removed, so historical orders/ledger keep resolving it.
CREATE TABLE payments.product (
product_id uuid NOT NULL,
title text DEFAULT ''::text NOT NULL,
active boolean DEFAULT true NOT NULL,
created_at timestamp with time zone DEFAULT now() NOT NULL,
updated_at timestamp with time zone DEFAULT now() NOT NULL,
CONSTRAINT product_pkey PRIMARY KEY (product_id)
);
-- The atom composition of a product (e.g. 250 hints + 30 no-ads days).
CREATE TABLE payments.product_item (
product_id uuid NOT NULL,
atom_type text NOT NULL,
quantity integer NOT NULL,
CONSTRAINT product_item_pkey PRIMARY KEY (product_id, atom_type),
CONSTRAINT product_item_product_fkey FOREIGN KEY (product_id)
REFERENCES payments.product (product_id) ON DELETE CASCADE,
CONSTRAINT product_item_atom_fkey FOREIGN KEY (atom_type)
REFERENCES payments.catalog_atom (atom_type),
CONSTRAINT product_item_quantity_chk CHECK ((quantity > 0))
);
-- Price of a product. A chip pack carries a money price per method
-- (multi-currency Votes/Stars/RUB); a value carries a single chips price
-- (currency='CHIP', method NULL). `amount` is an integer in the currency's
-- minor units (RUB kopecks; Votes/Stars/chips whole, scale 1).
CREATE TABLE payments.product_price (
product_id uuid NOT NULL,
method text,
currency text NOT NULL,
amount bigint NOT NULL,
CONSTRAINT product_price_product_fkey FOREIGN KEY (product_id)
REFERENCES payments.product (product_id) ON DELETE CASCADE,
CONSTRAINT product_price_method_chk
CHECK ((method IS NULL) OR (method = ANY (ARRAY['vk'::text, 'telegram'::text, 'direct'::text]))),
CONSTRAINT product_price_currency_chk
CHECK ((currency = ANY (ARRAY['RUB'::text, 'VOTE'::text, 'XTR'::text, 'CHIP'::text]))),
CONSTRAINT product_price_amount_chk CHECK ((amount >= 0))
);
-- One price row per (product, method, currency); NULL method (a value's chip
-- price) collapses to one row via the COALESCE key.
CREATE UNIQUE INDEX product_price_unique_idx
ON payments.product_price (product_id, COALESCE(method, ''::text), currency);
-- A pre-created purchase intent. `expected_amount` is in `currency` minor units.
CREATE TABLE payments.orders (
order_id uuid NOT NULL,
account_id uuid NOT NULL,
platform text NOT NULL,
product_id uuid NOT NULL,
expected_amount bigint NOT NULL,
currency text NOT NULL,
origin text NOT NULL,
status text DEFAULT 'pending'::text NOT NULL,
provider text,
provider_payment_id text,
created_at timestamp with time zone DEFAULT now() NOT NULL,
updated_at timestamp with time zone DEFAULT now() NOT NULL,
CONSTRAINT orders_pkey PRIMARY KEY (order_id),
CONSTRAINT orders_product_fkey FOREIGN KEY (product_id)
REFERENCES payments.product (product_id),
CONSTRAINT orders_status_chk
CHECK ((status = ANY (ARRAY['pending'::text, 'paid'::text, 'expired'::text]))),
CONSTRAINT orders_origin_chk
CHECK ((origin = ANY (ARRAY['vk'::text, 'telegram'::text, 'direct'::text]))),
CONSTRAINT orders_currency_chk
CHECK ((currency = ANY (ARRAY['RUB'::text, 'VOTE'::text, 'XTR'::text, 'CHIP'::text]))),
CONSTRAINT orders_expected_amount_chk CHECK ((expected_amount >= 0))
);
-- The pending-expiry sweep scans (status, created_at).
CREATE INDEX orders_status_created_at_idx ON payments.orders (status, created_at);
-- The append-only operations ledger: every fund / spend / admin_grant / refund.
-- `chips_delta` is signed (0 for a grant, which credits values not chips);
-- `snapshot` records the sold atoms + price for a spend/grant. Never updated or
-- deleted (a trigger enforces it); a reversal is a new `refund` row.
CREATE TABLE payments.ledger (
ledger_id uuid NOT NULL,
account_id uuid NOT NULL,
kind text NOT NULL,
source text,
origin text,
chips_delta integer DEFAULT 0 NOT NULL,
product_id uuid,
order_id uuid,
provider text,
provider_payment_id text,
snapshot jsonb,
created_at timestamp with time zone DEFAULT now() NOT NULL,
CONSTRAINT ledger_pkey PRIMARY KEY (ledger_id),
CONSTRAINT ledger_product_fkey FOREIGN KEY (product_id)
REFERENCES payments.product (product_id),
CONSTRAINT ledger_order_fkey FOREIGN KEY (order_id)
REFERENCES payments.orders (order_id),
CONSTRAINT ledger_kind_chk
CHECK ((kind = ANY (ARRAY['fund'::text, 'spend'::text, 'admin_grant'::text, 'refund'::text]))),
CONSTRAINT ledger_source_chk
CHECK ((source IS NULL) OR (source = ANY (ARRAY['vk'::text, 'telegram'::text, 'direct'::text]))),
CONSTRAINT ledger_origin_chk
CHECK ((origin IS NULL) OR (origin = ANY (ARRAY['vk'::text, 'telegram'::text, 'direct'::text])))
);
-- Idempotency key: a provider callback credits at most once. Partial so rows
-- without a provider payment id (spend/admin_grant) are unconstrained.
CREATE UNIQUE INDEX ledger_provider_payment_idx
ON payments.ledger (provider, provider_payment_id)
WHERE provider_payment_id IS NOT NULL;
-- +goose StatementBegin
CREATE FUNCTION payments.ledger_append_only() RETURNS trigger LANGUAGE plpgsql AS $$
BEGIN
RAISE EXCEPTION 'payments.ledger is append-only: % denied', TG_OP;
END;
$$;
-- +goose StatementEnd
CREATE TRIGGER ledger_no_mutation
BEFORE UPDATE OR DELETE ON payments.ledger
FOR EACH ROW EXECUTE FUNCTION payments.ledger_append_only();
-- Materialised chip balance, segmented by funding source. A fast cache of the
-- ledger, recomputable from it; created lazily on first fund (up to three rows
-- per account, absent = zero).
CREATE TABLE payments.balances (
account_id uuid NOT NULL,
source text NOT NULL,
chips integer DEFAULT 0 NOT NULL,
updated_at timestamp with time zone DEFAULT now() NOT NULL,
CONSTRAINT balances_pkey PRIMARY KEY (account_id, source),
CONSTRAINT balances_source_chk
CHECK ((source = ANY (ARRAY['vk'::text, 'telegram'::text, 'direct'::text]))),
CONSTRAINT balances_chips_chk CHECK ((chips >= 0))
);
-- Materialised benefits, segmented by the purchase origin. no-ads is a duration
-- (ads_paid_until) plus a perpetual override (ads_forever); hints is a count.
-- Created lazily on first grant/spend.
CREATE TABLE payments.benefits (
account_id uuid NOT NULL,
origin text NOT NULL,
ads_paid_until timestamp with time zone,
ads_forever boolean DEFAULT false NOT NULL,
hints integer DEFAULT 0 NOT NULL,
updated_at timestamp with time zone DEFAULT now() NOT NULL,
CONSTRAINT benefits_pkey PRIMARY KEY (account_id, origin),
CONSTRAINT benefits_origin_chk
CHECK ((origin = ANY (ARRAY['vk'::text, 'telegram'::text, 'direct'::text]))),
CONSTRAINT benefits_hints_chk CHECK ((hints >= 0))
);
-- The single-row tunable config (durations in whole seconds — go-jet stringifies
-- interval; payouts as counts). Edited in the admin console, no release needed.
CREATE TABLE payments.config (
only_row boolean DEFAULT true NOT NULL,
rewarded_payout_chips integer DEFAULT 0 NOT NULL,
cooldown_global_seconds integer DEFAULT 300 NOT NULL,
cooldown_vs_ai_seconds integer DEFAULT 1800 NOT NULL,
cooldown_hint_seconds integer DEFAULT 60 NOT NULL,
order_ttl_seconds integer DEFAULT 1800 NOT NULL,
CONSTRAINT config_pkey PRIMARY KEY (only_row),
CONSTRAINT config_single_row_chk CHECK (only_row)
);
INSERT INTO payments.config (only_row) VALUES (true);
-- Payment lifecycle events for the dispatcher (live stream / botlink / email).
CREATE TABLE payments.payment_events (
event_id uuid NOT NULL,
account_id uuid NOT NULL,
order_id uuid,
type text NOT NULL,
payload jsonb,
created_at timestamp with time zone DEFAULT now() NOT NULL,
dispatched_at timestamp with time zone,
CONSTRAINT payment_events_pkey PRIMARY KEY (event_id),
CONSTRAINT payment_events_order_fkey FOREIGN KEY (order_id)
REFERENCES payments.orders (order_id),
CONSTRAINT payment_events_type_chk
CHECK ((type = ANY (ARRAY['succeeded'::text, 'failed'::text, 'refunded'::text])))
);
CREATE INDEX payment_events_dispatch_idx
ON payments.payment_events (dispatched_at)
WHERE dispatched_at IS NULL;
-- Confine the payments role to this schema: ALL on its tables, nothing on
-- backend. New tables added later inherit the grant via default privileges.
GRANT ALL PRIVILEGES ON ALL TABLES IN SCHEMA payments TO payments;
ALTER DEFAULT PRIVILEGES IN SCHEMA payments GRANT ALL ON TABLES TO payments;
-- +goose Down
ALTER DEFAULT PRIVILEGES IN SCHEMA payments REVOKE ALL ON TABLES FROM payments;
DROP SCHEMA IF EXISTS payments CASCADE;
-- +goose StatementBegin
DO $$
BEGIN
IF EXISTS (SELECT FROM pg_roles WHERE rolname = 'payments') THEN
EXECUTE 'DROP OWNED BY payments';
DROP ROLE payments;
END IF;
END
$$;
-- +goose StatementEnd
@@ -0,0 +1,23 @@
-- Record the trusted execution platform on a session: platform_kind (the wrapper the
-- session was established through — vk/telegram/direct) plus platform_subtype (the device
-- family — ios/android/web). Both are nullable: a session minted before this migration, or
-- one the gateway could not attribute, carries NULL and is treated as untrusted (view-only)
-- by the payments compliance gate. kind is derived server-side from the validated establish
-- path (never a client field); the subtype is trustworthy only for VK (vk_platform rides
-- inside the signed launch params) and best-effort for telegram/direct.
--
-- Expand-contract: the columns are additive and nullable, so a backend image rollback stays
-- DB-safe (older code neither writes nor reads them). The table shape changes, so the
-- generated go-jet model IS regenerated.
-- +goose Up
ALTER TABLE backend.sessions ADD COLUMN platform_kind text;
ALTER TABLE backend.sessions ADD COLUMN platform_subtype text;
ALTER TABLE backend.sessions ADD CONSTRAINT sessions_platform_kind_chk CHECK ((platform_kind IS NULL) OR (platform_kind = ANY (ARRAY['vk'::text, 'telegram'::text, 'direct'::text])));
ALTER TABLE backend.sessions ADD CONSTRAINT sessions_platform_subtype_chk CHECK ((platform_subtype IS NULL) OR (platform_subtype = ANY (ARRAY['ios'::text, 'android'::text, 'web'::text])));
-- +goose Down
ALTER TABLE backend.sessions DROP CONSTRAINT sessions_platform_subtype_chk;
ALTER TABLE backend.sessions DROP CONSTRAINT sessions_platform_kind_chk;
ALTER TABLE backend.sessions DROP COLUMN platform_subtype;
ALTER TABLE backend.sessions DROP COLUMN platform_kind;
@@ -0,0 +1,146 @@
package robot
import (
"encoding/json"
"os"
"path/filepath"
"strconv"
"testing"
"scrabble/backend/internal/engine"
)
// The client-side offline robot (ui/src/lib/robot) reproduces the robot's move choice
// so a local vs_ai game plays the same way as the server. These golden fixtures pin that
// TS port to the real Go strategy. Being in-package, this emitter can exercise the
// unexported mix / playToWin / deviates / selectMove that the port mirrors.
type mixCase struct {
Seed string `json:"seed"`
Salt string `json:"salt"`
Nums []int `json:"nums"`
Value string `json:"value"` // the mix() uint64, as a decimal string (exceeds JS safe ints)
}
type decisionCase struct {
Seed string `json:"seed"`
MoveCount int `json:"moveCount"`
MyScore int `json:"myScore"`
OppScore int `json:"oppScore"`
CandScores []int `json:"candScores"`
RackLen int `json:"rackLen"`
BagLen int `json:"bagLen"`
PlayToWin bool `json:"playToWin"`
Win bool `json:"win"` // the per-turn intent after the deviate flip
Kind string `json:"kind"`
Index int `json:"index"` // chosen candidate index for a play, else -1
}
type strategyFixture struct {
PlayToWinPercent int `json:"playToWinPercent"`
Mix []mixCase `json:"mix"`
Decisions []decisionCase `json:"decisions"`
}
// scenario is one selectMove situation exercised across several seeds.
type scenario struct {
moveCount, myScore, oppScore, rackLen, bagLen int
cands []int
}
// TestEmitStrategyFixtures regenerates ui/src/lib/robot/testdata/strategy.json. It is
// gated by EMIT_STRATEGY_FIXTURES so a normal `go test` skips it; the committed output is
// what the TS parity test consumes. Regenerate with:
//
// EMIT_STRATEGY_FIXTURES=1 go test ./backend/internal/robot -run TestEmitStrategyFixtures
func TestEmitStrategyFixtures(t *testing.T) {
if os.Getenv("EMIT_STRATEGY_FIXTURES") == "" {
t.Skip("set EMIT_STRATEGY_FIXTURES=1 to regenerate ui/src/lib/robot/testdata/strategy.json")
}
seeds := []int64{1, 42, -7, 1000003, 9999999999, 123456789, -123456789}
mixCases := []mixCase{}
addMix := func(seed int64, salt string, nums ...int) {
mixCases = append(mixCases, mixCase{
Seed: strconv.FormatInt(seed, 10),
Salt: salt,
Nums: append([]int{}, nums...),
Value: strconv.FormatUint(mix(seed, salt, nums...), 10),
})
}
for _, sd := range seeds {
addMix(sd, "win")
addMix(sd, "deviate", 0)
addMix(sd, "deviate", 7)
}
scenarios := []scenario{
{3, 40, 55, 7, 20, []int{30, 12, 8}}, // behind, mid-game
{10, 120, 90, 7, 8, []int{25, 18, 5}}, // ahead, late
{1, 0, 0, 7, 30, []int{60, 30, 15, 7}}, // first move, big spread
{20, 200, 40, 5, 2, []int{50, 20}}, // big lead, bag near empty (no deviate)
{5, 30, 30, 7, 14, []int{20, 20, 20}}, // equal margins (tie-break)
{8, 50, 60, 7, 20, []int{}}, // no play, bag can refill -> exchange
{8, 50, 60, 3, 2, []int{}}, // no play, bag can't refill -> pass
{15, 300, 10, 7, 6, []int{80, 40, 10}}, // overshoots the band -> nearest to +30
}
decisions := []decisionCase{}
for _, sd := range seeds {
for _, sc := range scenarios {
cands := make([]engine.MoveRecord, len(sc.cands))
for i, s := range sc.cands {
cands[i] = engine.MoveRecord{Score: s, Player: i}
}
rack := make([]string, sc.rackLen)
for i := range rack {
rack[i] = "a"
}
ptw := playToWin(sd)
win := ptw
if deviates(sd, sc.moveCount, sc.bagLen) {
win = !win
}
d := selectMove(cands, sc.myScore, sc.oppScore, win, defaultBand, rack, sc.bagLen)
kind, index := "pass", -1
switch d.kind {
case decidePlay:
kind, index = "play", d.move.Player
case decideExchange:
kind = "exchange"
}
decisions = append(decisions, decisionCase{
Seed: strconv.FormatInt(sd, 10),
MoveCount: sc.moveCount,
MyScore: sc.myScore,
OppScore: sc.oppScore,
CandScores: append([]int{}, sc.cands...),
RackLen: sc.rackLen,
BagLen: sc.bagLen,
PlayToWin: ptw,
Win: win,
Kind: kind,
Index: index,
})
}
}
fx := strategyFixture{PlayToWinPercent: playToWinPercent, Mix: mixCases, Decisions: decisions}
dir := filepath.Join("..", "..", "..", "ui", "src", "lib", "robot", "testdata")
if err := os.MkdirAll(dir, 0o755); err != nil {
t.Fatalf("mkdir %s: %v", dir, err)
}
data, err := json.MarshalIndent(fx, "", " ")
if err != nil {
t.Fatalf("marshal: %v", err)
}
path := filepath.Join(dir, "strategy.json")
if err := os.WriteFile(path, append(data, '\n'), 0o644); err != nil {
t.Fatalf("write %s: %v", path, err)
}
t.Logf("wrote %s (%d mix, %d decisions)", path, len(mixCases), len(decisions))
}
+55 -6
View File
@@ -8,7 +8,9 @@ import (
"scrabble/backend/internal/account" "scrabble/backend/internal/account"
"scrabble/backend/internal/ads" "scrabble/backend/internal/ads"
"scrabble/backend/internal/engine"
"scrabble/backend/internal/notify" "scrabble/backend/internal/notify"
"scrabble/backend/internal/payments"
) )
// bannerDTO is the advertising-banner block attached to an eligible viewer's // bannerDTO is the advertising-banner block attached to an eligible viewer's
@@ -54,11 +56,50 @@ type bannerTimingsDTO struct {
// a language switch). // a language switch).
func (s *Server) profileResponse(ctx context.Context, acc account.Account) profileResponse { func (s *Server) profileResponse(ctx context.Context, acc account.Account) profileResponse {
r := profileResponseFor(acc) r := profileResponseFor(acc)
r.Banner = s.bannerFor(ctx, acc) // Resolve the payments gate once (execution context + present sources) and feed it to both
// the hint count and the banner. The profile hint balance now comes from the payments benefit
// (context-aware), not the deprecated accounts.hint_balance column; on any failure the legacy
// value from profileResponseFor (zeroed in production) stands.
cxt, present, err := s.walletGate(ctx, acc.ID)
if err != nil {
s.log.Warn("profile: wallet gate failed", zap.String("account", acc.ID.String()), zap.Error(err))
} else if s.payments != nil {
if hints, herr := s.payments.HintsAvailable(ctx, acc.ID, cxt, present); herr == nil {
r.HintBalance = hints
} else {
s.log.Warn("profile: hint balance read failed", zap.String("account", acc.ID.String()), zap.Error(herr))
}
}
r.Banner = s.bannerFor(ctx, acc, cxt, present)
s.fillLinkedIdentities(ctx, &r, acc.ID) s.fillLinkedIdentities(ctx, &r, acc.ID)
r.DictVersions = s.currentDictVersions()
return r return r
} }
// currentDictVersions reports the current dictionary version of every game variant with a
// resident dictionary, as engine.Variant stable labels. It backs profileResponse.DictVersions
// so an offline-capable client preloads the matching dawg per variant off the cold-start
// profile. A variant without a loaded dictionary is omitted (Registry.Latest reports
// ErrUnknownVariant); the returned slice is nil only when no variant is resident.
func (s *Server) currentDictVersions() []dictVersion {
if s.registry == nil {
return nil // no dictionary registry wired (e.g. a minimal test server): advertise none
}
variants := []engine.Variant{engine.VariantEnglish, engine.VariantRussianScrabble, engine.VariantErudit}
out := make([]dictVersion, 0, len(variants))
for _, v := range variants {
version, _, err := s.registry.Latest(v)
if err != nil {
continue
}
out = append(out, dictVersion{Variant: v.String(), Version: version})
}
if len(out) == 0 {
return nil
}
return out
}
// fillLinkedIdentities sets the profile's confirmed email address and platform-linked // fillLinkedIdentities sets the profile's confirmed email address and platform-linked
// flags from the account's identities, so the client offers the right link / unlink / // flags from the account's identities, so the client offers the right link / unlink /
// change-email controls. A read failure leaves them zero (no controls), logged as a // change-email controls. A read failure leaves them zero (no controls), logged as a
@@ -89,7 +130,7 @@ func (s *Server) fillLinkedIdentities(ctx context.Context, r *profileResponse, a
// language, falling back to its interface language and then English. A failure // language, falling back to its interface language and then English. A failure
// reading roles or campaigns is logged and treated as "no banner" so the profile // reading roles or campaigns is logged and treated as "no banner" so the profile
// response still succeeds. // response still succeeds.
func (s *Server) bannerFor(ctx context.Context, acc account.Account) *bannerDTO { func (s *Server) bannerFor(ctx context.Context, acc account.Account, cxt payments.Context, present []payments.Source) *bannerDTO {
if s.ads == nil { if s.ads == nil {
return nil return nil
} }
@@ -98,16 +139,24 @@ func (s *Server) bannerFor(ctx context.Context, acc account.Account) *bannerDTO
s.log.Warn("banner: active set failed", zap.String("account", acc.ID.String()), zap.Error(err)) s.log.Warn("banner: active set failed", zap.String("account", acc.ID.String()), zap.Error(err))
return nil return nil
} }
// An urgent campaign is shown to every viewer; otherwise the normal eligibility // An urgent campaign is shown to every viewer; otherwise the banner is suppressed by the
// gate applies (a paid account, a non-empty hint wallet or the no_banner role // no_banner role or by an active no-ads benefit applicable in the viewer's context (the
// hides the banner). // payments gate — a hint balance no longer suppresses the banner). An untrusted platform is
// fail-closed by the gate, so it does not suppress the banner.
if !urgent { if !urgent {
hasNoBanner, err := s.accounts.HasRole(ctx, acc.ID, account.RoleNoBanner) hasNoBanner, err := s.accounts.HasRole(ctx, acc.ID, account.RoleNoBanner)
if err != nil { if err != nil {
s.log.Warn("banner: role check failed", zap.String("account", acc.ID.String()), zap.Error(err)) s.log.Warn("banner: role check failed", zap.String("account", acc.ID.String()), zap.Error(err))
return nil return nil
} }
if !ads.Eligible(acc.PaidAccount, acc.HintBalance, hasNoBanner) { adFree := false
if s.payments != nil {
if adFree, err = s.payments.AdFree(ctx, acc.ID, cxt, present); err != nil {
s.log.Warn("banner: ad-free check failed", zap.String("account", acc.ID.String()), zap.Error(err))
return nil
}
}
if hasNoBanner || adFree {
return nil return nil
} }
} }
+38 -16
View File
@@ -28,10 +28,14 @@ type okResponse struct {
} }
// resolveResponse maps a session token to its account. IsGuest lets the gateway // resolveResponse maps a session token to its account. IsGuest lets the gateway
// gate guest-forbidden operations without an extra round-trip. // gate guest-forbidden operations without an extra round-trip. PlatformKind and
// PlatformSubtype carry the session's trusted execution platform so the gateway can
// inject X-Platform; both are empty for an untrusted (pre-capture) session.
type resolveResponse struct { type resolveResponse struct {
UserID string `json:"user_id"` UserID string `json:"user_id"`
IsGuest bool `json:"is_guest"` IsGuest bool `json:"is_guest"`
PlatformKind string `json:"platform_kind"`
PlatformSubtype string `json:"platform_subtype"`
} }
// profileResponse is the authenticated account's own profile. AwayStart and AwayEnd // profileResponse is the authenticated account's own profile. AwayStart and AwayEnd
@@ -63,6 +67,20 @@ type profileResponse struct {
Email string `json:"email"` Email string `json:"email"`
TelegramLinked bool `json:"telegram_linked"` TelegramLinked bool `json:"telegram_linked"`
VkLinked bool `json:"vk_linked"` VkLinked bool `json:"vk_linked"`
// DictVersions lists the current dictionary version per game variant (engine.Variant
// stable labels). An installed PWA preparing for offline play reads it to preload the
// right dawg per enabled variant off this cold-start response, without an extra request.
// 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.
DictVersions []dictVersion `json:"dict_versions,omitempty"`
}
// dictVersion pairs a game variant's stable label (engine.Variant.String) with its current
// dictionary version. profileResponse.DictVersions carries the set so an offline-capable
// client preloads the matching dawg per variant.
type dictVersion struct {
Variant string `json:"variant"`
Version string `json:"version"`
} }
// tileDTO is one placed (or to-place) tile. // tileDTO is one placed (or to-place) tile.
@@ -149,13 +167,16 @@ type alphabetEntryDTO struct {
// stateDTO is a player's view of a game. Rack carries wire alphabet indices (a // stateDTO is a player's view of a game. Rack carries wire alphabet indices (a
// blank is engine.BlankIndex). Alphabet is present only when the request asked for it. // blank is engine.BlankIndex). Alphabet is present only when the request asked for it.
type stateDTO struct { type stateDTO struct {
Game gameDTO `json:"game"` Game gameDTO `json:"game"`
Seat int `json:"seat"` Seat int `json:"seat"`
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"` WalletBalance int `json:"wallet_balance"`
Alphabet []alphabetEntryDTO `json:"alphabet,omitempty"` // 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.
HintUnlockLeftSeconds int `json:"hint_unlock_left_seconds"`
Alphabet []alphabetEntryDTO `json:"alphabet,omitempty"`
} }
// matchDTO reports whether the caller has been paired into a game. // matchDTO reports whether the caller has been paired into a game.
@@ -301,12 +322,13 @@ func stateDTOFrom(v game.StateView, includeAlphabet bool) (stateDTO, error) {
return stateDTO{}, err return stateDTO{}, err
} }
dto := stateDTO{ dto := stateDTO{
Game: gameDTOFromGame(v.Game), Game: gameDTOFromGame(v.Game),
Seat: v.Seat, Seat: v.Seat,
Rack: rack, Rack: rack,
BagLen: v.BagLen, BagLen: v.BagLen,
HintsRemaining: v.HintsRemaining, HintsRemaining: v.HintsRemaining,
WalletBalance: v.WalletBalance, WalletBalance: v.WalletBalance,
HintUnlockLeftSeconds: v.HintUnlockLeftSeconds,
} }
if includeAlphabet { if includeAlphabet {
tab, err := engine.AlphabetTable(v.Game.Variant) tab, err := engine.AlphabetTable(v.Game.Variant)
+19
View File
@@ -15,6 +15,7 @@ import (
"scrabble/backend/internal/feedback" "scrabble/backend/internal/feedback"
"scrabble/backend/internal/game" "scrabble/backend/internal/game"
"scrabble/backend/internal/lobby" "scrabble/backend/internal/lobby"
"scrabble/backend/internal/payments"
"scrabble/backend/internal/session" "scrabble/backend/internal/session"
"scrabble/backend/internal/social" "scrabble/backend/internal/social"
) )
@@ -65,6 +66,13 @@ func (s *Server) registerRoutes() {
// client may still reach, to fetch the block's expiry and reason for the blocked screen. // client may still reach, to fetch the block's expiry and reason for the blocked screen.
u.GET("/block-status", s.handleBlockStatus) u.GET("/block-status", s.handleBlockStatus)
} }
if s.payments != nil {
// The wallet: the context-visible chip segments + benefits, the storefront catalog, and a
// chip spend on a value.
u.GET("/wallet", s.handleWallet)
u.GET("/wallet/catalog", s.handleWalletCatalog)
u.POST("/wallet/buy", s.handleWalletBuy)
}
if s.links != nil { if s.links != nil {
// Account linking & merge. The request step always mails a code; // Account linking & merge. The request step always mails a code;
// a required merge is revealed only after the code is verified, and the // a required merge is revealed only after the code is verified, and the
@@ -239,6 +247,9 @@ func statusForError(err error) (int, string) {
return http.StatusConflict, "no_hint_available" return http.StatusConflict, "no_hint_available"
case errors.Is(err, game.ErrHintsDisabled), errors.Is(err, game.ErrNoHintsLeft): case errors.Is(err, game.ErrHintsDisabled), errors.Is(err, game.ErrNoHintsLeft):
return http.StatusConflict, "hint_unavailable" return http.StatusConflict, "hint_unavailable"
case errors.Is(err, game.ErrHintLocked):
// A vs_ai idle hint is still gated (the server-clock backstop); the client shows the lock.
return http.StatusConflict, "hint_locked"
case errors.Is(err, engine.ErrIllegalPlay), errors.Is(err, engine.ErrTilesNotOnRack), errors.Is(err, engine.ErrGameOver): case errors.Is(err, engine.ErrIllegalPlay), errors.Is(err, engine.ErrTilesNotOnRack), errors.Is(err, engine.ErrGameOver):
return http.StatusUnprocessableEntity, "illegal_play" return http.StatusUnprocessableEntity, "illegal_play"
case errors.Is(err, account.ErrEmailTaken), errors.Is(err, account.ErrIdentityTaken): case errors.Is(err, account.ErrEmailTaken), errors.Is(err, account.ErrIdentityTaken):
@@ -247,6 +258,14 @@ func statusForError(err error) (int, string) {
return http.StatusConflict, "last_identity" return http.StatusConflict, "last_identity"
case errors.Is(err, accountmerge.ErrActiveGameConflict): case errors.Is(err, accountmerge.ErrActiveGameConflict):
return http.StatusConflict, "merge_active_game_conflict" return http.StatusConflict, "merge_active_game_conflict"
case errors.Is(err, payments.ErrUntrusted):
return http.StatusForbidden, "payments_untrusted"
case errors.Is(err, payments.ErrInsufficientChips):
return http.StatusConflict, "insufficient_chips"
case errors.Is(err, payments.ErrProductNotFound):
return http.StatusNotFound, "product_not_found"
case errors.Is(err, payments.ErrNotAValue):
return http.StatusBadRequest, "not_a_value"
case errors.Is(err, account.ErrInvalidEmail): case errors.Is(err, account.ErrInvalidEmail):
return http.StatusBadRequest, "invalid_email" return http.StatusBadRequest, "invalid_email"
case errors.Is(err, account.ErrCodeMismatch), errors.Is(err, account.ErrCodeExpired), case errors.Is(err, account.ErrCodeMismatch), errors.Is(err, account.ErrCodeExpired),
+32 -14
View File
@@ -10,6 +10,7 @@ import (
"scrabble/backend/internal/account" "scrabble/backend/internal/account"
"scrabble/backend/internal/notify" "scrabble/backend/internal/notify"
"scrabble/backend/internal/session"
) )
// The /api/v1/internal/sessions/* endpoints are gateway-only: the gateway has // The /api/v1/internal/sessions/* endpoints are gateway-only: the gateway has
@@ -23,7 +24,9 @@ import (
// brand-new account's display name and language; BrowserTZ (the client's detected // brand-new account's display name and language; BrowserTZ (the client's detected
// "±HH:MM" UTC offset) seeds its time zone; StartParam is the validated launch // "±HH:MM" UTC offset) seeds its time zone; StartParam is the validated launch
// deep-link payload, which may seed the new account's variant preferences (first // deep-link payload, which may seed the new account's variant preferences (first
// contact only). // contact only). Subtype is the client-reported device family (ios/android/web);
// Telegram's initData does not sign it, so it is recorded best-effort and the
// payments gate never relies on it.
type telegramAuthRequest struct { type telegramAuthRequest struct {
ExternalID string `json:"external_id"` ExternalID string `json:"external_id"`
Username string `json:"username"` Username string `json:"username"`
@@ -31,6 +34,7 @@ type telegramAuthRequest struct {
LanguageCode string `json:"language_code"` LanguageCode string `json:"language_code"`
BrowserTZ string `json:"browser_tz"` BrowserTZ string `json:"browser_tz"`
StartParam string `json:"start_param"` StartParam string `json:"start_param"`
Subtype string `json:"subtype"`
} }
// handleTelegramAuth provisions (or finds) the account bound to a Telegram // handleTelegramAuth provisions (or finds) the account bound to a Telegram
@@ -63,19 +67,22 @@ func (s *Server) handleTelegramAuth(c *gin.Context) {
} }
} }
} }
s.mintSession(c, acc) s.mintSession(c, acc, session.Platform{Kind: session.PlatformKindTelegram, Subtype: session.NormalizeSubtype(req.Subtype)})
} }
// vkAuthRequest carries the identity the gateway extracted from verified VK launch // vkAuthRequest carries the identity the gateway extracted from verified VK launch
// params. LanguageCode (vk_language) and DisplayName (read client-side via // params. LanguageCode (vk_language) and DisplayName (read client-side via
// VKWebAppGetUserInfo, since VK omits the name from the signed params) seed a brand-new // VKWebAppGetUserInfo, since VK omits the name from the signed params) seed a brand-new
// account's language and display name; BrowserTZ (the client's detected "±HH:MM" UTC // account's language and display name; BrowserTZ (the client's detected "±HH:MM" UTC
// offset) seeds its time zone. All seeds apply on first contact only. // offset) seeds its time zone. All seeds apply on first contact only. Subtype is the
// device family the gateway derived from the signed vk_platform param — trusted,
// since it rides inside the verified launch signature.
type vkAuthRequest struct { type vkAuthRequest struct {
ExternalID string `json:"external_id"` ExternalID string `json:"external_id"`
LanguageCode string `json:"language_code"` LanguageCode string `json:"language_code"`
DisplayName string `json:"display_name"` DisplayName string `json:"display_name"`
BrowserTZ string `json:"browser_tz"` BrowserTZ string `json:"browser_tz"`
Subtype string `json:"subtype"`
} }
// handleVKAuth provisions (or finds) the account bound to a VK identity and mints a // handleVKAuth provisions (or finds) the account bound to a VK identity and mints a
@@ -94,7 +101,7 @@ func (s *Server) handleVKAuth(c *gin.Context) {
s.abortErr(c, err) s.abortErr(c, err)
return return
} }
s.mintSession(c, acc) s.mintSession(c, acc, session.Platform{Kind: session.PlatformKindVK, Subtype: session.NormalizeSubtype(req.Subtype)})
} }
// pushTargetRequest asks for a user's out-of-app push routing data by account id. // pushTargetRequest asks for a user's out-of-app push routing data by account id.
@@ -150,6 +157,9 @@ func (s *Server) handlePushTarget(c *gin.Context) {
// time zone, so robot timing is anchored to the player's zone from the first game. // time zone, so robot timing is anchored to the player's zone from the first game.
type guestAuthRequest struct { type guestAuthRequest struct {
BrowserTZ string `json:"browser_tz"` BrowserTZ string `json:"browser_tz"`
// Subtype is the client-reported device family (ios/android/web) of this direct
// (web/native) session; there is no external signer, so it is recorded best-effort.
Subtype string `json:"subtype"`
} }
// handleGuestAuth provisions a fresh ephemeral guest account and mints a session, // handleGuestAuth provisions a fresh ephemeral guest account and mints a session,
@@ -164,7 +174,7 @@ func (s *Server) handleGuestAuth(c *gin.Context) {
s.abortErr(c, err) s.abortErr(c, err)
return return
} }
s.mintSession(c, acc) s.mintSession(c, acc, session.Platform{Kind: session.PlatformKindDirect, Subtype: session.NormalizeSubtype(req.Subtype)})
} }
// emailRequest is an email-login code request. BrowserTZ (the client's detected // emailRequest is an email-login code request. BrowserTZ (the client's detected
@@ -196,10 +206,12 @@ func (s *Server) handleEmailRequest(c *gin.Context) {
c.JSON(http.StatusOK, okResponse{OK: true}) c.JSON(http.StatusOK, okResponse{OK: true})
} }
// emailLoginRequest verifies an email login code. // emailLoginRequest verifies an email login code. Subtype is the client-reported
// device family (ios/android/web) of this direct session; recorded best-effort.
type emailLoginRequest struct { type emailLoginRequest struct {
Email string `json:"email"` Email string `json:"email"`
Code string `json:"code"` Code string `json:"code"`
Subtype string `json:"subtype"`
} }
// handleEmailLogin verifies the code and mints a session for the owning account. // handleEmailLogin verifies the code and mints a session for the owning account.
@@ -214,7 +226,7 @@ func (s *Server) handleEmailLogin(c *gin.Context) {
s.abortErr(c, err) s.abortErr(c, err)
return return
} }
s.mintSession(c, acc) s.mintSession(c, acc, session.Platform{Kind: session.PlatformKindDirect, Subtype: session.NormalizeSubtype(req.Subtype)})
} }
// confirmLinkResponse is the outcome of a one-tap deeplink confirmation. For a login, // confirmLinkResponse is the outcome of a one-tap deeplink confirmation. For a login,
@@ -248,7 +260,8 @@ func (s *Server) handleEmailConfirmLink(c *gin.Context) {
s.abortErr(c, err) s.abortErr(c, err)
return return
} }
token, _, err := s.sessions.Create(c.Request.Context(), acc.ID) // A magic-link login always opens in a browser, so its session is direct/web.
token, _, err := s.sessions.Create(c.Request.Context(), acc.ID, session.Platform{Kind: session.PlatformKindDirect, Subtype: session.SubtypeWeb})
if err != nil { if err != nil {
s.abortErr(c, err) s.abortErr(c, err)
return return
@@ -288,7 +301,11 @@ func (s *Server) handleResolveSession(c *gin.Context) {
// is_guest is best-effort: a transient account read must not fail an otherwise // is_guest is best-effort: a transient account read must not fail an otherwise
// valid resolve (the auth hot path), so a read error falls back to false; the // valid resolve (the auth hot path), so a read error falls back to false; the
// per-operation backend gate remains the authoritative guest check. // per-operation backend gate remains the authoritative guest check.
resp := resolveResponse{UserID: sess.AccountID.String()} resp := resolveResponse{
UserID: sess.AccountID.String(),
PlatformKind: sess.Platform.Kind,
PlatformSubtype: sess.Platform.Subtype,
}
if acc, err := s.accounts.GetByID(c.Request.Context(), sess.AccountID); err == nil { if acc, err := s.accounts.GetByID(c.Request.Context(), sess.AccountID); err == nil {
resp.IsGuest = acc.IsGuest resp.IsGuest = acc.IsGuest
} }
@@ -309,9 +326,10 @@ func (s *Server) handleRevokeSession(c *gin.Context) {
c.JSON(http.StatusOK, okResponse{OK: true}) c.JSON(http.StatusOK, okResponse{OK: true})
} }
// mintSession creates a session for acc and writes the credential response. // mintSession creates a session for acc carrying the captured platform and writes
func (s *Server) mintSession(c *gin.Context, acc account.Account) { // the credential response.
token, _, err := s.sessions.Create(c.Request.Context(), acc.ID) func (s *Server) mintSession(c *gin.Context, acc account.Account, platform session.Platform) {
token, _, err := s.sessions.Create(c.Request.Context(), acc.ID, platform)
if err != nil { if err != nil {
s.abortErr(c, err) s.abortErr(c, err)
return return
+164
View File
@@ -0,0 +1,164 @@
package server
import (
"net/http"
"github.com/gin-gonic/gin"
"github.com/google/uuid"
"scrabble/backend/internal/payments"
)
// walletSegmentDTO is one chip balance in the wallet view: the funding source, the chip count,
// and whether it can be spent in the current execution context (false for a frozen VK-iOS
// balance or an untrusted platform).
type walletSegmentDTO struct {
Source string `json:"source"`
Chips int `json:"chips"`
Spendable bool `json:"spendable"`
}
// walletDTO is the user-facing wallet: the context-visible chip segments and the
// context-applicable benefits (the no-ads term or forever flag, and the available hints).
type walletDTO struct {
Segments []walletSegmentDTO `json:"segments"`
AdsForever bool `json:"ads_forever"`
AdsPaidUntil int64 `json:"ads_paid_until_ms"` // unix millis; 0 = no active term
Hints int `json:"hints"`
}
// walletBuyRequest is the POST body of a chip spend: the product to buy with chips.
type walletBuyRequest struct {
ProductID string `json:"product_id"`
}
// walletDTOFrom projects a payments wallet view into the wire DTO.
func walletDTOFrom(v payments.WalletView) walletDTO {
out := walletDTO{AdsForever: v.Benefits.AdsForever, Hints: v.Benefits.Hints}
if v.Benefits.AdsPaidUntil != nil {
out.AdsPaidUntil = v.Benefits.AdsPaidUntil.UnixMilli()
}
out.Segments = make([]walletSegmentDTO, 0, len(v.Segments))
for _, seg := range v.Segments {
out.Segments = append(out.Segments, walletSegmentDTO{Source: string(seg.Source), Chips: seg.Chips, Spendable: seg.Spendable})
}
return out
}
// catalogAtomDTO is one atom line of a storefront product: the base value type it grants and how
// many of it the product carries.
type catalogAtomDTO struct {
AtomType string `json:"atom_type"`
Quantity int `json:"quantity"`
}
// catalogProductDTO is one storefront product for the caller's context: a chip-priced value
// (chips set, no money price) or a chip pack priced in the context's payment method
// (money_amount minor units + money_currency, no chips), plus what it grants.
type catalogProductDTO struct {
Kind string `json:"kind"`
ProductID string `json:"product_id"`
Title string `json:"title"`
Chips int `json:"chips"`
MoneyAmount int64 `json:"money_amount"`
MoneyCurrency string `json:"money_currency"`
Atoms []catalogAtomDTO `json:"atoms"`
}
// catalogDTO is the storefront: the products visible and purchasable in the caller's context.
type catalogDTO struct {
Products []catalogProductDTO `json:"products"`
}
// catalogDTOFrom projects a payments catalog view into the wire DTO.
func catalogDTOFrom(v payments.CatalogView) catalogDTO {
out := catalogDTO{Products: make([]catalogProductDTO, 0, len(v.Products))}
for _, p := range v.Products {
atoms := make([]catalogAtomDTO, 0, len(p.Atoms))
for _, a := range p.Atoms {
atoms = append(atoms, catalogAtomDTO{AtomType: a.AtomType, Quantity: a.Quantity})
}
out.Products = append(out.Products, catalogProductDTO{
Kind: p.Kind, ProductID: p.ProductID, Title: p.Title,
Chips: p.Chips, MoneyAmount: p.MoneyAmount, MoneyCurrency: p.MoneyCurrency, Atoms: atoms,
})
}
return out
}
// handleWalletCatalog returns the storefront for the caller's trusted execution context: the
// chip-priced values and the chip packs priced in the context's payment method.
func (s *Server) handleWalletCatalog(c *gin.Context) {
uid, ok := userID(c)
if !ok {
return
}
ctx := c.Request.Context()
cxt, _, err := s.walletGate(ctx, uid)
if err != nil {
s.abortErr(c, err)
return
}
view, err := s.payments.Catalog(ctx, cxt)
if err != nil {
s.abortErr(c, err)
return
}
c.JSON(http.StatusOK, catalogDTOFrom(view))
}
// handleWallet returns the caller's wallet — the segments and benefits visible in the current
// trusted execution context.
func (s *Server) handleWallet(c *gin.Context) {
uid, ok := userID(c)
if !ok {
return
}
ctx := c.Request.Context()
cxt, present, err := s.walletGate(ctx, uid)
if err != nil {
s.abortErr(c, err)
return
}
view, err := s.payments.Wallet(ctx, uid, cxt, present)
if err != nil {
s.abortErr(c, err)
return
}
c.JSON(http.StatusOK, walletDTOFrom(view))
}
// handleWalletBuy spends chips on a chip-priced value and returns the updated wallet. It is
// fail-closed: an untrusted or frozen context, or an insufficient balance, is refused.
func (s *Server) handleWalletBuy(c *gin.Context) {
uid, ok := userID(c)
if !ok {
return
}
var req walletBuyRequest
if err := c.ShouldBindJSON(&req); err != nil {
c.AbortWithStatusJSON(http.StatusBadRequest, errorResponse{Error: errorBody{Code: "invalid_request", Message: "invalid request body"}})
return
}
productID, err := uuid.Parse(req.ProductID)
if err != nil {
c.AbortWithStatusJSON(http.StatusBadRequest, errorResponse{Error: errorBody{Code: "invalid_request", Message: "invalid product id"}})
return
}
ctx := c.Request.Context()
cxt, present, err := s.walletGate(ctx, uid)
if err != nil {
s.abortErr(c, err)
return
}
if err := s.payments.Spend(ctx, uid, cxt, present, productID); err != nil {
s.abortErr(c, err)
return
}
view, err := s.payments.Wallet(ctx, uid, cxt, present)
if err != nil {
s.abortErr(c, err)
return
}
c.JSON(http.StatusOK, walletDTOFrom(view))
}
+43
View File
@@ -4,9 +4,12 @@ import (
"context" "context"
"net/http" "net/http"
"net/url" "net/url"
"strings"
"github.com/gin-gonic/gin" "github.com/gin-gonic/gin"
"github.com/google/uuid" "github.com/google/uuid"
"scrabble/backend/internal/session"
) )
// headerUserID is the identity header the gateway injects after resolving a // headerUserID is the identity header the gateway injects after resolving a
@@ -41,6 +44,46 @@ func UserIDFromContext(ctx context.Context) (uuid.UUID, bool) {
return id, ok return id, ok
} }
// headerPlatform is the trusted execution-platform header the gateway injects after
// resolving a session's platform. Its value is "<kind>/<subtype>" (e.g. "vk/ios");
// it is omitted for an untrusted session, in which case platform(c) reports the
// zero Platform. Like X-User-ID, the value is the gateway's — never a client body.
const headerPlatform = "X-Platform"
// platformContext returns middleware that parses the gateway-injected X-Platform
// header into the request context, so handlers (and the link/merge session mint)
// read the caller's trusted platform. An absent or malformed header leaves the
// context without a platform (untrusted) and never rejects the request — X-Platform
// is a capability signal, not an identity gate.
func platformContext() gin.HandlerFunc {
return func(c *gin.Context) {
if p, ok := parsePlatformHeader(c.GetHeader(headerPlatform)); ok {
c.Request = c.Request.WithContext(session.WithPlatform(c.Request.Context(), p))
}
c.Next()
}
}
// parsePlatformHeader splits a "<kind>/<subtype>" X-Platform value into a Platform.
// A blank value or blank kind yields no platform (an untrusted session).
func parsePlatformHeader(h string) (session.Platform, bool) {
if h == "" {
return session.Platform{}, false
}
kind, subtype, _ := strings.Cut(h, "/")
if kind == "" {
return session.Platform{}, false
}
return session.Platform{Kind: kind, Subtype: subtype}, true
}
// platform returns the caller's trusted execution platform, or the zero Platform
// and false when the session was not attributed to one — an untrusted, view-only
// context for the payments gate.
func platform(c *gin.Context) (session.Platform, bool) {
return session.PlatformFromContext(c.Request.Context())
}
// requireSameOrigin guards the admin console's state-changing requests: it rejects // requireSameOrigin guards the admin console's state-changing requests: it rejects
// a non-safe request whose Origin (or, failing that, Referer) host does not match // a non-safe request whose Origin (or, failing that, Referer) host does not match
// the request Host. The gateway authenticates the operator with Basic-Auth in front // the request Host. The gateway authenticates the operator with Basic-Auth in front
@@ -7,6 +7,8 @@ import (
"github.com/gin-gonic/gin" "github.com/gin-gonic/gin"
"github.com/google/uuid" "github.com/google/uuid"
"scrabble/backend/internal/session"
) )
// TestRequireUserID checks that the middleware accepts a valid X-User-ID, // TestRequireUserID checks that the middleware accepts a valid X-User-ID,
@@ -58,3 +60,51 @@ func TestRequireUserID(t *testing.T) {
} }
}) })
} }
// TestPlatformContext checks that the middleware parses the gateway-injected
// X-Platform header into a trusted platform reachable via platform(c), treats an
// absent or blank-kind header as untrusted, and never rejects the request.
func TestPlatformContext(t *testing.T) {
gin.SetMode(gin.TestMode)
var seen session.Platform
var ok bool
r := gin.New()
r.Use(platformContext())
r.GET("/x", func(c *gin.Context) {
seen, ok = platform(c)
c.String(http.StatusOK, "ok")
})
for _, tc := range []struct {
name string
header string
setHeader bool
wantOK bool
want session.Platform
}{
{"vk-ios", "vk/ios", true, true, session.Platform{Kind: session.PlatformKindVK, Subtype: session.SubtypeIOS}},
{"telegram-no-subtype", "telegram", true, true, session.Platform{Kind: session.PlatformKindTelegram}},
{"direct-web", "direct/web", true, true, session.Platform{Kind: session.PlatformKindDirect, Subtype: session.SubtypeWeb}},
{"absent", "", false, false, session.Platform{}},
{"empty", "", true, false, session.Platform{}},
{"blank-kind", "/ios", true, false, session.Platform{}},
} {
t.Run(tc.name, func(t *testing.T) {
seen, ok = session.Platform{}, false
req := httptest.NewRequest(http.MethodGet, "/x", nil)
if tc.setHeader {
req.Header.Set("X-Platform", tc.header)
}
rec := httptest.NewRecorder()
r.ServeHTTP(rec, req)
if rec.Code != http.StatusOK {
t.Fatalf("status = %d, want 200 (X-Platform never rejects)", rec.Code)
}
if ok != tc.wantOK || seen != tc.want {
t.Fatalf("platform = %+v (ok=%v), want %+v (ok=%v)", seen, ok, tc.want, tc.wantOK)
}
})
}
}
+31
View File
@@ -0,0 +1,31 @@
package server
import (
"context"
"github.com/google/uuid"
"scrabble/backend/internal/payments"
"scrabble/backend/internal/session"
)
// walletGate resolves the payments gate inputs for an account on the current request: the
// trusted execution context (the session platform carried on ctx by the platformContext
// middleware; absent ⇒ untrusted, fail-closed) and the account's present identity sources
// (which chip/benefit segments are awake, §6). The payments domain holds no cross-schema
// identity knowledge, so the server supplies present from account.Identities.
func (s *Server) walletGate(ctx context.Context, accountID uuid.UUID) (payments.Context, []payments.Source, error) {
var cxt payments.Context
if p, ok := session.PlatformFromContext(ctx); ok {
cxt = payments.NewContext(p.Kind, p.Subtype)
}
ids, err := s.accounts.Identities(ctx, accountID)
if err != nil {
return payments.Context{}, nil, err
}
kinds := make([]string, len(ids))
for i, id := range ids {
kinds[i] = id.Kind
}
return cxt, payments.PresentSources(kinds), nil
}
+12
View File
@@ -28,6 +28,7 @@ import (
"scrabble/backend/internal/link" "scrabble/backend/internal/link"
"scrabble/backend/internal/lobby" "scrabble/backend/internal/lobby"
"scrabble/backend/internal/notify" "scrabble/backend/internal/notify"
"scrabble/backend/internal/payments"
"scrabble/backend/internal/ratewatch" "scrabble/backend/internal/ratewatch"
"scrabble/backend/internal/render" "scrabble/backend/internal/render"
"scrabble/backend/internal/session" "scrabble/backend/internal/session"
@@ -93,6 +94,11 @@ type Deps struct {
// profile.get banner block, plus the banner admin console section. A nil Ads // profile.get banner block, plus the banner admin console section. A nil Ads
// omits the banner block and disables the banner console. // omits the banner block and disables the banner console.
Ads *ads.Service Ads *ads.Service
// Payments is the in-game currency domain service (wallet, benefits, catalog).
// The data-foundation layer exposes only a reachability check; its user and
// console routes are registered when the wallet surface lands. A nil Payments
// omits them.
Payments *payments.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).
@@ -129,6 +135,7 @@ type Server struct {
ratewatch *ratewatch.Watch ratewatch *ratewatch.Watch
banview *banview.View banview *banview.View
ads *ads.Service ads *ads.Service
payments *payments.Service
notifier notify.Publisher notifier notify.Publisher
console *adminconsole.Renderer console *adminconsole.Renderer
exportKey []byte exportKey []byte
@@ -181,6 +188,7 @@ func New(addr string, deps Deps) *Server {
ratewatch: deps.RateWatch, ratewatch: deps.RateWatch,
banview: deps.BanView, banview: deps.BanView,
ads: deps.Ads, ads: deps.Ads,
payments: deps.Payments,
notifier: notifier, notifier: notifier,
renderer: deps.Renderer, renderer: deps.Renderer,
http: &http.Server{Addr: addr, Handler: engine}, http: &http.Server{Addr: addr, Handler: engine},
@@ -230,6 +238,10 @@ func (s *Server) registerAPIGroups(engine *gin.Engine) {
s.public = v1.Group("/public") s.public = v1.Group("/public")
s.user = v1.Group("/user") s.user = v1.Group("/user")
s.user.Use(RequireUserID()) s.user.Use(RequireUserID())
// Capture the gateway-injected trusted platform (X-Platform) into the request context,
// so the payments gate can read it via platform(c). Optional: an untrusted session simply
// carries no platform and is treated as view-only. Never rejects.
s.user.Use(platformContext())
// The suspension gate runs after identity is established: a blocked account is refused on // The suspension gate runs after identity is established: a blocked account is refused on
// every user route (except the block-status probe) so the UI can show the blocked screen. // every user route (except the block-status probe) so the UI can show the blocked screen.
s.user.Use(s.requireNotSuspended()) s.user.Use(s.requireNotSuspended())
+65
View File
@@ -0,0 +1,65 @@
package session
import "context"
// Platform is the trusted execution context recorded on a session: the wrapper
// Kind the session was established through and the device Subtype. An empty Kind
// marks an untrusted session — one minted before platform capture, or one the
// gateway could not attribute — which the payments compliance gate treats as
// view-only.
//
// Kind is always trustworthy: it is derived server-side from the validated
// establish path, never from a client-supplied field. Subtype is trustworthy only
// for VK (vk_platform rides inside the signed launch params); for telegram and
// direct it is client-reported and best-effort, so the gate must never rely on it.
type Platform struct {
Kind string
Subtype string
}
// Platform kinds recorded in platform_kind.
const (
PlatformKindVK = "vk"
PlatformKindTelegram = "telegram"
PlatformKindDirect = "direct"
)
// Platform subtypes recorded in platform_subtype.
const (
SubtypeIOS = "ios"
SubtypeAndroid = "android"
SubtypeWeb = "web"
)
// Trusted reports whether the session was attributed to a platform (a non-empty
// Kind). The zero Platform is untrusted.
func (p Platform) Trusted() bool { return p.Kind != "" }
// NormalizeSubtype coerces subtype to one of the known device families, defaulting
// an empty or unrecognised value to web. A newly established session always carries
// at least a web subtype, so a trusted session is never left without one.
func NormalizeSubtype(subtype string) string {
switch subtype {
case SubtypeIOS, SubtypeAndroid, SubtypeWeb:
return subtype
default:
return SubtypeWeb
}
}
// platformCtxKey types the request-context slot the trusted platform rides in.
type platformCtxKey struct{}
// WithPlatform returns a copy of ctx carrying platform. The backend middleware
// calls it after parsing the gateway-injected X-Platform header, so downstream
// handlers and the link/merge session mint read the caller's platform from ctx.
func WithPlatform(ctx context.Context, platform Platform) context.Context {
return context.WithValue(ctx, platformCtxKey{}, platform)
}
// PlatformFromContext returns the platform stored by WithPlatform and whether one
// was present. A missing value yields the zero (untrusted) Platform.
func PlatformFromContext(ctx context.Context) (Platform, bool) {
p, ok := ctx.Value(platformCtxKey{}).(Platform)
return p, ok
}
+6 -4
View File
@@ -29,14 +29,16 @@ func (svc *Service) Ready() bool {
return svc.cache.Ready() return svc.cache.Ready()
} }
// Create mints a new active session for accountID and returns the plaintext // Create mints a new active session for accountID carrying the captured platform
// token (shown to the caller once) together with the persisted session. // and returns the plaintext token (shown to the caller once) together with the
func (svc *Service) Create(ctx context.Context, accountID uuid.UUID) (string, Session, error) { // persisted session. Pass the zero Platform for a session that could not be
// attributed to a trusted platform.
func (svc *Service) Create(ctx context.Context, accountID uuid.UUID, platform Platform) (string, Session, error) {
token, tokenHash, err := GenerateToken() token, tokenHash, err := GenerateToken()
if err != nil { if err != nil {
return "", Session{}, err return "", Session{}, err
} }
sess, err := svc.store.Insert(ctx, accountID, tokenHash) sess, err := svc.store.Insert(ctx, accountID, tokenHash, platform)
if err != nil { if err != nil {
return "", Session{}, err return "", Session{}, err
} }
+25 -5
View File
@@ -25,7 +25,8 @@ const (
var ErrNotFound = errors.New("session: not found") var ErrNotFound = errors.New("session: not found")
// Session mirrors a row in backend.sessions. TokenHash is the hex-encoded // Session mirrors a row in backend.sessions. TokenHash is the hex-encoded
// SHA-256 of the bearer token. // SHA-256 of the bearer token. Platform is the trusted execution context captured
// at creation; its zero value marks an untrusted session (see Platform).
type Session struct { type Session struct {
ID uuid.UUID ID uuid.UUID
AccountID uuid.UUID AccountID uuid.UUID
@@ -34,6 +35,7 @@ type Session struct {
CreatedAt time.Time CreatedAt time.Time
LastSeenAt *time.Time LastSeenAt *time.Time
RevokedAt *time.Time RevokedAt *time.Time
Platform Platform
} }
// Store is the Postgres-backed query surface for backend.sessions. // Store is the Postgres-backed query surface for backend.sessions.
@@ -46,9 +48,10 @@ func NewStore(db *sql.DB) *Store {
return &Store{db: db} return &Store{db: db}
} }
// Insert persists a new active session for accountID carrying tokenHash and // Insert persists a new active session for accountID carrying tokenHash and the
// returns the persisted row. // captured platform, and returns the persisted row. An empty platform Kind/Subtype
func (s *Store) Insert(ctx context.Context, accountID uuid.UUID, tokenHash string) (Session, error) { // is written as SQL NULL, so an untrusted session records no platform.
func (s *Store) Insert(ctx context.Context, accountID uuid.UUID, tokenHash string, platform Platform) (Session, error) {
id, err := uuid.NewV7() id, err := uuid.NewV7()
if err != nil { if err != nil {
return Session{}, fmt.Errorf("session: new id: %w", err) return Session{}, fmt.Errorf("session: new id: %w", err)
@@ -57,7 +60,9 @@ func (s *Store) Insert(ctx context.Context, accountID uuid.UUID, tokenHash strin
table.Sessions.SessionID, table.Sessions.SessionID,
table.Sessions.AccountID, table.Sessions.AccountID,
table.Sessions.TokenHash, table.Sessions.TokenHash,
).VALUES(id, accountID, tokenHash).RETURNING(table.Sessions.AllColumns) table.Sessions.PlatformKind,
table.Sessions.PlatformSubtype,
).VALUES(id, accountID, tokenHash, stringOrNull(platform.Kind), stringOrNull(platform.Subtype)).RETURNING(table.Sessions.AllColumns)
var row model.Sessions var row model.Sessions
if err := stmt.QueryContext(ctx, s.db, &row); err != nil { if err := stmt.QueryContext(ctx, s.db, &row); err != nil {
@@ -173,5 +178,20 @@ func modelToSession(row model.Sessions) Session {
t := *row.RevokedAt t := *row.RevokedAt
s.RevokedAt = &t s.RevokedAt = &t
} }
if row.PlatformKind != nil {
s.Platform.Kind = *row.PlatformKind
}
if row.PlatformSubtype != nil {
s.Platform.Subtype = *row.PlatformSubtype
}
return s return s
} }
// stringOrNull maps an empty string to a SQL NULL literal and any other value to a
// string literal, so an untrusted session's absent platform is stored as NULL.
func stringOrNull(s string) postgres.Expression {
if s == "" {
return postgres.NULL
}
return postgres.String(s)
}
+16
View File
@@ -13,6 +13,22 @@ POSTGRES_DB=scrabble
POSTGRES_USER=scrabble POSTGRES_USER=scrabble
POSTGRES_PASSWORD=change-me # required POSTGRES_PASSWORD=change-me # required
# --- Point-in-time recovery (pgBackRest -> S3; PROD main host only) ----------
# Continuous WAL archiving for PITR. PROD-ONLY: the test contour never archives (these
# stay unset there and archive_mode stays off). The artifact ships DISARMED —
# PGBACKREST_ARCHIVE_MODE defaults off; arm it only after setting up the S3 repository +
# secrets and creating the stanza, then flip it on (deploy/README.md, point-in-time
# recovery). Gitea: PROD_PGBACKREST_* — endpoint/bucket/region/archive-mode are variables,
# the two S3 keys + the cipher passphrase are secrets.
PGBACKREST_ARCHIVE_MODE=off # on = archiving active (arm only after the stanza exists)
PGBACKREST_S3_ENDPOINT= # S3 HOST ONLY — no https://, no port, no bucket (e.g. s3.ru-1.storage.selcloud.ru); path-style addressing
PGBACKREST_S3_PORT= # optional; default 443 — set only if the provider uses a non-standard S3 port
PGBACKREST_S3_BUCKET= # bucket name; lowercase, no dots/underscores
PGBACKREST_S3_REGION= # e.g. ru-1
PGBACKREST_S3_KEY= # secret: S3 access key id
PGBACKREST_S3_KEY_SECRET= # secret: S3 secret access key
PGBACKREST_CIPHER_PASS= # secret: repo encryption passphrase — KEEP SAFE, stored apart from the S3 keys (losing it makes the archive unrecoverable)
# --- Dictionary ------------------------------------------------------------- # --- Dictionary -------------------------------------------------------------
# scrabble-dictionary release tag baked into the image as the SEED dictionary for a # scrabble-dictionary release tag baked into the image as the SEED dictionary for a
# FRESH volume (image build-arg; also labels the resident seed version). After first # FRESH volume (image build-arg; also labels the resident seed version). After first
+90 -8
View File
@@ -220,10 +220,89 @@ release tag, so any prior release is reachable.
**Migrations** must be **expand-contract** (backward-compatible; goose is forward-only): **Migrations** must be **expand-contract** (backward-compatible; goose is forward-only):
the automatic rollback is image-only and never restores the DB. A deploy that changes the automatic rollback is image-only and never restores the DB. A deploy that changes
`backend/internal/postgres/migrations/` opens a maintenance window — the backend (sole `backend/internal/postgres/migrations/` opens a maintenance window — the backend (sole
writer) is stopped for a consistent `pg_dump` into `/opt/scrabble/dumps` before the new writer) is stopped for a consistent **whole-database** `pg_dump` into `/opt/scrabble/dumps`
backend migrates. **Manual DB restore** (only if a migration was destructive): (it covers `backend` **and** `payments`) before the new backend migrates. **Manual DB
`docker exec -i scrabble-postgres psql -U scrabble -d scrabble -c 'DROP SCHEMA backend CASCADE'`, restore** (only if a migration was destructive): drop the app schemas in the same instance
then pipe the dump into the same `psql`, and redeploy the matching old tag. and pipe the dump back —
`docker exec -i scrabble-postgres psql -U scrabble -d scrabble -c 'DROP SCHEMA IF EXISTS backend CASCADE; DROP SCHEMA IF EXISTS payments CASCADE;'`,
then `docker exec -i scrabble-postgres psql -U scrabble -d scrabble < the-dump.sql`, and
redeploy the matching old tag. This dump is a belt-and-braces net for a bad migration;
**point-in-time recovery** (below) is the primary recovery path once armed, and the only one
that survives losing the host.
## Point-in-time recovery (PITR)
The main host archives Postgres continuously with **pgBackRest** to **Selectel S3**
(encrypted at rest, path-style addressing) so the database can be restored to any moment —
protecting the money ledger and the game state against corruption or host loss. It is the
primary recovery path; the migration-window `pg_dump` above is the secondary net.
**Shape.** A daily full **base backup** (a systemd timer on the main host, `04:00`) plus
**continuous WAL** archived by Postgres `archive_command` (a segment is forced at least every
5 minutes, so the recovery point is never more than a few minutes behind). Retention is **30
days** (`repo1-retention-full=30`); the repository is AES-256-CBC encrypted. This is main-host
only — the test contour never archives.
**Wiring.** pgBackRest ships inside the DB image (`deploy/postgres/Dockerfile`); the
repository + S3 credentials + cipher are the `PGBACKREST_*` environment on the postgres
service in `docker-compose.prod.yml`, rendered from the `PROD_` Gitea set by
`write-prod-env.sh`. Archiving is gated by **`PGBACKREST_ARCHIVE_MODE` (default `off`)**, so
shipping or redeploying this stack does **not** start archiving — the artifact is inert until
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
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
absent/NaN-safe, so they stay quiet until archiving is armed.
**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
30-day retention on Selectel S3 (~2 ₽/GB·month) the archive is **under ~0.3 GB → well under
1 ₽/month** (compression halves it again); request volume is trivial. Performance impact is
**negligible**: archive-push moves tiny compressed segments, and the daily full base backup
is a ~10 MB, sub-second job on the 2 vCPU host. Revisit both if traffic grows ~100× (watch
`node_exporter` during a base backup).
**Arming (owner-coordinated, once, before real payments).** Ships disarmed; to turn it on:
1. **Owner (Selectel + Gitea):** create an S3 bucket (name **lowercase, no dots/underscores**)
and an S3 access key/secret; pick a repository **cipher passphrase** and store it **apart
from the S3 keys** (losing it makes the archive unrecoverable). Set the Gitea `PROD_` set —
variables `PROD_PGBACKREST_S3_ENDPOINT` (the S3 **host only** — no `https://`, no port, no
bucket) / `_S3_BUCKET` / `_S3_REGION`, an optional `_S3_PORT` (default 443, set only for a
non-standard provider port), and `PROD_PGBACKREST_ARCHIVE_MODE=on`; secrets
`PROD_PGBACKREST_S3_KEY` (the S3 **access key**) / `_S3_KEY_SECRET` (its paired **secret
key**, shown once at creation) / `_CIPHER_PASS` (a fresh repository passphrase, e.g.
`openssl rand -base64 48`).
2. Promote `development → master`, tag, and run **prod-deploy**. The roll recreates postgres
with `archive_mode=on` behind the maintenance page. (Archive pushes fail harmlessly for the
minute until step 3 creates the repository — the WAL is retained, not lost.)
3. On the main host, create the repository, take the first base backup, and verify:
```sh
docker exec scrabble-postgres pgbackrest --stanza=scrabble stanza-create
docker exec scrabble-postgres pgbackrest --stanza=scrabble --type=full backup
docker exec scrabble-postgres pgbackrest --stanza=scrabble check
```
4. Enable the daily timer: `ansible-playbook site.yml -e pitr_enabled=true` (installs + starts
`pgbackrest-backup.timer` on the main host).
5. Confirm in Grafana that the two archiving alerts are green (`last_archive_age` now tracks a
real number, `failed_count` flat).
**Restore drill (proves recoverability; re-run after arming and after any major change).** On
an **isolated, one-shot** target (a throwaway VM or a container with no ingress), pgBackRest,
the matching Postgres major, an empty `PGDATA`, and the same `PGBACKREST_*` environment:
```sh
pgbackrest --stanza=scrabble --type=time "--target=YYYY-MM-DD HH:MM:SS+00" --delta restore
# start Postgres; it replays WAL to the target; then verify a known row / the ledger tail
```
The target holds **real money + personal data** while it exists — keep it network-isolated and
**destroy it (wipe `PGDATA` + the instance) afterwards**. Record each drill (date, target
timestamp, outcome) here:
| Date | Target timestamp | Result |
| --- | --- | --- |
| _pending first arming_ | — | — |
**bot-link cert rotation:** regenerate (`deploy/gen-certs.sh /tmp/c --force`), reset the **bot-link cert rotation:** regenerate (`deploy/gen-certs.sh /tmp/c --force`), reset the
five `PROD_BOTLINK_*` secrets from `/tmp/c`, and re-run the workflow — both hosts redeploy five `PROD_BOTLINK_*` secrets from `/tmp/c`, and re-run the workflow — both hosts redeploy
@@ -249,14 +328,17 @@ VITE_VK_APP_LINK, VITE_VK_APP_ID`. **Derived from `PUBLIC_BASE_URL` at deploy**
`PROD_{POSTGRES_PASSWORD, GM_BASICAUTH_HASH, GRAFANA_ADMIN_PASSWORD, TELEGRAM_BOT_TOKEN, `PROD_{POSTGRES_PASSWORD, GM_BASICAUTH_HASH, GRAFANA_ADMIN_PASSWORD, TELEGRAM_BOT_TOKEN,
TELEGRAM_PROMO_BOT_TOKEN, EXPORT_SIGN_KEY, GATEWAY_HONEYTOKEN, REGISTRY_PASSWORD, SSH_KEY, TELEGRAM_PROMO_BOT_TOKEN, EXPORT_SIGN_KEY, GATEWAY_HONEYTOKEN, REGISTRY_PASSWORD, SSH_KEY,
SSH_KNOWN_HOSTS, BOTLINK_CA, BOTLINK_GATEWAY_CERT, BOTLINK_GATEWAY_KEY, BOTLINK_BOT_CERT, SSH_KNOWN_HOSTS, BOTLINK_CA, BOTLINK_GATEWAY_CERT, BOTLINK_GATEWAY_KEY, BOTLINK_BOT_CERT,
BOTLINK_BOT_KEY}`; variables: BOTLINK_BOT_KEY, PGBACKREST_S3_KEY, PGBACKREST_S3_KEY_SECRET, PGBACKREST_CIPHER_PASS}`;
variables:
`PROD_{REGISTRY_USER, MAIN_HOST, TG_HOST, CADDY_SITE_ADDRESS, GM_BASICAUTH_USER, LOG_LEVEL, `PROD_{REGISTRY_USER, MAIN_HOST, TG_HOST, CADDY_SITE_ADDRESS, GM_BASICAUTH_USER, LOG_LEVEL,
TELEGRAM_GAME_CHANNEL_ID, TELEGRAM_CHAT_ID, TELEGRAM_SUPPORT_CHAT_ID, TELEGRAM_BOT_USERNAME, TELEGRAM_GAME_CHANNEL_ID, TELEGRAM_CHAT_ID, TELEGRAM_SUPPORT_CHAT_ID, TELEGRAM_BOT_USERNAME,
VITE_TELEGRAM_BOT_ID, VITE_TELEGRAM_LINK, VITE_TELEGRAM_GAME_CHANNEL_NAME, SMTP_RELAY_FROM, VITE_TELEGRAM_BOT_ID, VITE_TELEGRAM_LINK, VITE_TELEGRAM_GAME_CHANNEL_NAME, SMTP_RELAY_FROM,
PUBLIC_BASE_URL, SMTP_RELAY_ADMIN_FROM, ADMIN_EMAIL, SMTP_RELAY_SERVICE_FROM, SERVICE_EMAIL, PUBLIC_BASE_URL, SMTP_RELAY_ADMIN_FROM, ADMIN_EMAIL, SMTP_RELAY_SERVICE_FROM, SERVICE_EMAIL,
GF_SMTP_ENABLED}`. The test contour uses the same names under `TEST_`, minus the prod-only GF_SMTP_ENABLED, PGBACKREST_S3_ENDPOINT, PGBACKREST_S3_PORT, PGBACKREST_S3_BUCKET,
infra (`MAIN_HOST`/`TG_HOST`/`REGISTRY_*`/`SSH_*`/`BOTLINK_*`, which the test deploy generates PGBACKREST_S3_REGION, PGBACKREST_ARCHIVE_MODE}`. The test contour uses the same names under `TEST_`, minus the
or runs locally) and plus `TEST_AWG_CONF` (the bot's VPN egress). prod-only infra (`MAIN_HOST`/`TG_HOST`/`REGISTRY_*`/`SSH_*`/`BOTLINK_*` and the `PGBACKREST_*`
PITR set, which is prod-only — the test contour never archives) and plus `TEST_AWG_CONF` (the
bot's VPN egress).
## Host-side setup (outside this repo) ## Host-side setup (outside this repo)
+3 -1
View File
@@ -45,5 +45,7 @@ safe (idempotent) and survives a host resize.
10m×3 log rotation), `deploy` user (docker group, no sudo), key-only sshd, 10m×3 log rotation), `deploy` user (docker group, no sudo), key-only sshd,
`ufw` default-deny incoming + allow SSH, fail2ban sshd jail, unattended `ufw` default-deny incoming + allow SSH, fail2ban sshd jail, unattended
upgrades, chrony, `/opt/scrabble/{config,certs,dumps,images}`. upgrades, chrony, `/opt/scrabble/{config,certs,dumps,images}`.
- **main**: `ufw` opens 80/443/9443; the external `edge` docker network. - **main**: `ufw` opens 80/443/9443; the external `edge` docker network; the pgBackRest
daily base-backup systemd timer — installed only when `pitr_enabled=true` (off by default;
turned on as part of arming point-in-time recovery, see [`../README.md`](../README.md)).
- **tg**: verifies direct `api.telegram.org` egress (the no-VPN assumption). - **tg**: verifies direct `api.telegram.org` egress (the no-VPN assumption).
+9
View File
@@ -27,3 +27,12 @@ docker_log_max_file: "3"
# path (a slow service beats a killed one). Set swap_size to "0" to skip provisioning. # path (a slow service beats a killed one). Set swap_size to "0" to skip provisioning.
swap_size: "1G" swap_size: "1G"
swap_swappiness: 10 swap_swappiness: 10
# Point-in-time recovery (pgBackRest). The base-backup systemd timer on the main host is
# gated by pitr_enabled so provisioning stays inert until archiving is armed — the timer
# would fail nightly if it ran before the S3 repository + stanza exist. Arm it as part of
# the PITR rollout (deploy/README.md): flip it on with `-e pitr_enabled=true` (or a main
# host_var) once the stanza is created. Only the `main` role reads these.
pitr_enabled: false
# systemd OnCalendar for the daily full base backup — a low-traffic hour on the main host.
pitr_backup_oncalendar: "*-*-* 04:00:00"
+27
View File
@@ -16,3 +16,30 @@
community.docker.docker_network: community.docker.docker_network:
name: edge name: edge
state: present state: present
# --- pgBackRest base-backup schedule (point-in-time recovery) ------------------
# A daily full base backup via a systemd timer that runs pgBackRest inside the postgres
# container; archive_command handles the continuous WAL between backups. Gated by
# pitr_enabled so the schedule stays inert until archiving is armed (the S3 repository +
# stanza must exist first, or the run fails nightly). Arm per deploy/README.md.
- name: Install the pgBackRest base-backup service
ansible.builtin.template:
src: pgbackrest-backup.service.j2
dest: /etc/systemd/system/pgbackrest-backup.service
mode: "0644"
when: pitr_enabled | default(false) | bool
- name: Install the pgBackRest base-backup timer
ansible.builtin.template:
src: pgbackrest-backup.timer.j2
dest: /etc/systemd/system/pgbackrest-backup.timer
mode: "0644"
when: pitr_enabled | default(false) | bool
- name: Enable and start the pgBackRest base-backup timer
ansible.builtin.systemd:
name: pgbackrest-backup.timer
enabled: true
state: started
daemon_reload: true
when: pitr_enabled | default(false) | bool
@@ -0,0 +1,15 @@
# Managed by Ansible (deploy/ansible).
# Daily full pgBackRest base backup for the payments/game database. Runs pgBackRest
# inside the postgres container (where the repository configuration and PGDATA live);
# the continuous WAL between base backups is handled by the container's archive_command.
# Installed only when pitr_enabled is set (see deploy/ansible/group_vars/all.yml).
[Unit]
Description=pgBackRest full base backup (scrabble)
After=docker.service
Requires=docker.service
[Service]
Type=oneshot
# docker exec runs as the image's postgres user and inherits the container's PGBACKREST_*
# environment (the S3 repository + cipher), so no repository config is needed on the host.
ExecStart=/usr/bin/docker exec scrabble-postgres pgbackrest --stanza=scrabble --type=full backup
@@ -0,0 +1,12 @@
# Managed by Ansible (deploy/ansible).
# Fires the daily full base backup. Persistent=true catches up a run missed while the
# host was down. Installed only when pitr_enabled is set.
[Unit]
Description=Daily pgBackRest full base backup (scrabble)
[Timer]
OnCalendar={{ pitr_backup_oncalendar }}
Persistent=true
[Install]
WantedBy=timers.target
+37
View File
@@ -45,6 +45,43 @@ services:
memory: 384M memory: 384M
postgres: postgres:
image: ${REGISTRY:?set REGISTRY}/scrabble-postgres:${TAG:?set TAG}
# Continuous WAL archiving to S3 via pgBackRest (point-in-time recovery), on the prod
# main host only. archive_mode is gated by PGBACKREST_ARCHIVE_MODE (default "off"), so
# merging/redeploying this stack does NOT start archiving until the operator arms it:
# archiving stays inert — and cannot pile WAL onto the disk — until the S3 repository is
# set up, the stanza is created and the switch is flipped on. See deploy/README.md
# (point-in-time recovery — arming). Non-secret settings are inline; the endpoint,
# bucket, region, S3 keys and repository cipher passphrase come from the prod env.sh
# (deploy/write-prod-env.sh, PROD_ secrets/variables).
environment:
PGBACKREST_STANZA: scrabble
PGBACKREST_PG1_PATH: /var/lib/postgresql/data
PGBACKREST_REPO1_TYPE: s3
PGBACKREST_REPO1_PATH: /pgbackrest
PGBACKREST_REPO1_S3_URI_STYLE: path
PGBACKREST_REPO1_S3_ENDPOINT: ${PGBACKREST_S3_ENDPOINT:-}
PGBACKREST_REPO1_S3_PORT: ${PGBACKREST_S3_PORT:-443}
PGBACKREST_REPO1_S3_BUCKET: ${PGBACKREST_S3_BUCKET:-}
PGBACKREST_REPO1_S3_REGION: ${PGBACKREST_S3_REGION:-}
PGBACKREST_REPO1_S3_KEY: ${PGBACKREST_S3_KEY:-}
PGBACKREST_REPO1_S3_KEY_SECRET: ${PGBACKREST_S3_KEY_SECRET:-}
PGBACKREST_REPO1_CIPHER_TYPE: aes-256-cbc
PGBACKREST_REPO1_CIPHER_PASS: ${PGBACKREST_CIPHER_PASS:-}
PGBACKREST_REPO1_RETENTION_FULL: "30"
PGBACKREST_COMPRESS_TYPE: zst
PGBACKREST_LOG_LEVEL_CONSOLE: info
PGBACKREST_LOG_LEVEL_FILE: "off"
# archive_command is inert while archive_mode is off, so it is always present and only
# the mode switches. A forced 5-minute segment switch bounds the recovery point.
command:
- postgres
- -c
- archive_mode=${PGBACKREST_ARCHIVE_MODE:-off}
- -c
- "archive_command=pgbackrest --stanza=scrabble archive-push %p"
- -c
- archive_timeout=300
deploy: deploy:
resources: resources:
limits: limits:
+7 -1
View File
@@ -38,7 +38,13 @@ x-logging: &default-logging
services: services:
postgres: postgres:
container_name: scrabble-postgres container_name: scrabble-postgres
image: postgres:17-alpine # Built from postgres:17-alpine + pgBackRest (deploy/postgres/Dockerfile) so the prod
# main host can archive WAL for point-in-time recovery. Archiving is switched on only by
# the prod overlay (docker-compose.prod.yml); on this contour the extra binary is present
# but idle and this is a plain postgres. See deploy/README.md (point-in-time recovery).
image: scrabble-postgres:latest
build:
context: postgres
restart: unless-stopped restart: unless-stopped
logging: *default-logging logging: *default-logging
environment: environment:
@@ -212,3 +212,62 @@ groups:
- evaluator: { type: gt, params: [0.8] } - evaluator: { type: gt, params: [0.8] }
labels: { severity: warning } labels: { severity: warning }
annotations: { summary: 'Postgres using over 80% of max_connections.' } annotations: { summary: 'Postgres using over 80% of max_connections.' }
# Continuous WAL archiving health (pgBackRest -> S3, point-in-time recovery). Both rules
# read the postgres_exporter's built-in pg_stat_archiver metrics and are absent/NaN-safe:
# on the test contour (and any host before archiving is armed) archive_mode is off, so
# failed_count stays 0 and last_archive_age is NaN — neither threshold trips.
- orgId: 1
name: scrabble-backup
folder: Alerts
interval: 1m
rules:
- uid: pg_archive_failing
title: WAL archiving failing
condition: C
for: 15m
noDataState: OK
execErrState: OK
data:
- refId: A
relativeTimeRange: { from: 900, to: 0 }
datasourceUid: prometheus
model:
refId: A
expr: increase(pg_stat_archiver_failed_count[15m])
instant: true
- refId: C
datasourceUid: __expr__
model:
refId: C
type: threshold
expression: A
conditions:
- evaluator: { type: gt, params: [0] }
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`.' }
- uid: pg_archive_stalled
title: WAL archiving stalled
condition: C
for: 15m
noDataState: OK
execErrState: OK
data:
- refId: A
relativeTimeRange: { from: 300, to: 0 }
datasourceUid: prometheus
model:
refId: A
expr: pg_stat_archiver_last_archive_age
instant: true
- refId: C
datasourceUid: __expr__
model:
refId: C
type: threshold
expression: A
conditions:
- evaluator: { type: gt, params: [1800] }
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.' }
+12
View File
@@ -0,0 +1,12 @@
# Postgres for the Scrabble contour, extended with pgBackRest for continuous WAL
# archiving and point-in-time recovery. pgBackRest must live in the database container
# because Postgres runs archive_command in-process; the scheduled base backups and the
# manual restore drills invoke the same binary through `docker exec`, inheriting the
# repository configuration from the container environment.
#
# Archiving is enabled only on the production main host, where the prod overlay
# (docker-compose.prod.yml) sets archive_mode and supplies the S3 repository. On the test
# contour this image behaves exactly as a plain postgres:17-alpine. See deploy/README.md
# (point-in-time recovery).
FROM postgres:17-alpine
RUN apk add --no-cache pgbackrest
+4 -1
View File
@@ -140,7 +140,10 @@ if [ "$MIGRATION" = 1 ]; then
echo "migration deploy: opening maintenance window (stopping the backend = the only writer)" echo "migration deploy: opening maintenance window (stopping the backend = the only writer)"
dc stop backend dc stop backend
dump="$DUMP_DIR/pre-$TAG-$(date +%Y%m%d-%H%M%S).sql" dump="$DUMP_DIR/pre-$TAG-$(date +%Y%m%d-%H%M%S).sql"
if ! docker exec scrabble-postgres pg_dump -U "$PG_USER" -d "$PG_DB" -n backend > "$dump"; then # Dump the WHOLE database (no -n): the payments schema — and any schema added later —
# must be in the belt-and-braces snapshot, not just backend. Restored manually into the
# same instance only if a migration turned out destructive (see deploy/README.md).
if ! docker exec scrabble-postgres pg_dump -U "$PG_USER" -d "$PG_DB" > "$dump"; then
echo "pg_dump failed; restarting the old backend and aborting" echo "pg_dump failed; restarting the old backend and aborting"
dc start backend dc start backend
exit 1 exit 1
+12
View File
@@ -73,4 +73,16 @@ 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'
# 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
# operator sets the S3 repository values + secrets, creates the stanza and flips the switch
# on (deploy/README.md). Empty repository values are harmless while the mode is off.
export PGBACKREST_ARCHIVE_MODE='${PGBACKREST_ARCHIVE_MODE:-off}'
export PGBACKREST_S3_ENDPOINT='$PGBACKREST_S3_ENDPOINT'
export PGBACKREST_S3_PORT='${PGBACKREST_S3_PORT:-443}'
export PGBACKREST_S3_BUCKET='$PGBACKREST_S3_BUCKET'
export PGBACKREST_S3_REGION='$PGBACKREST_S3_REGION'
export PGBACKREST_S3_KEY='$PGBACKREST_S3_KEY'
export PGBACKREST_S3_KEY_SECRET='$PGBACKREST_S3_KEY_SECRET'
export PGBACKREST_CIPHER_PASS='$PGBACKREST_CIPHER_PASS'
EOF EOF
+86 -9
View File
@@ -130,8 +130,8 @@ dropped). Horizontal scaling is explicit future work.
solver version, so it cannot drift from the running backend. The **move journal, history solver version, so it cannot drift from the running backend. The **move journal, history
and GCG are unaffected** (they stay decoded concrete characters, §9.1). and GCG are unaffected** (they stay decoded concrete characters, §9.1).
- **gateway ↔ backend (sync)**: plain HTTP REST/JSON. The gateway injects - **gateway ↔ backend (sync)**: plain HTTP REST/JSON. The gateway injects
`X-User-ID` for authenticated requests; `backend` never re-derives identity `X-User-ID` (and the session's trusted `X-Platform`, §3) for authenticated
from the body. Because every sync call targets the one backend host, the requests; `backend` never re-derives identity or platform from the body. Because every sync call targets the one backend host, the
gateway's REST client widens its keep-alive pool well past the stdlib default gateway's REST client widens its keep-alive pool well past the stdlib default
of 2 idle connections per host; otherwise the per-request connection churn of 2 idle connections per host; otherwise the per-request connection churn
exhausts ephemeral ports and burns gateway CPU under load (see exhausts ephemeral ports and burns gateway CPU under load (see
@@ -199,6 +199,21 @@ arrive from a platform rather than completing a mandatory registration).
until explicitly revoked (`status``revoked`). A revoke can target one token or, until explicitly revoked (`status``revoked`). A revoke can target one token or,
on an account merge (§4), **every** session of the retired account on an account merge (§4), **every** session of the retired account
(`RevokeAllForAccount`, which also evicts them from the warm cache). (`RevokeAllForAccount`, which also evicts them from the warm cache).
- **Trusted execution platform.** Each session also records a `platform` — a `kind`
(`vk`/`telegram`/`direct`) plus a device `subtype` (`ios`/`android`/`web`) — captured
at creation, so a store-compliance gate has an unforgeable execution context
(`docs/PAYMENTS.md` §8). `kind` is derived server-side from the validated establish
path (the VK launch, the Telegram initData, or a web-native session), **never a client
field**; the `subtype` is trusted only for VK (it rides inside the signed `vk_platform`
parameter) and is client-reported best-effort for telegram/direct. VK and Telegram
re-mint (and so re-validate and re-capture) on every cold start; a direct session
captures it once. The gateway injects the resolved platform as **`X-Platform`**
(`<kind>/<subtype>`) alongside `X-User-ID`, sourced from the session and never the
request body; an absent or unrecorded platform — a session predating the feature, or
one the gateway could not attribute — is **untrusted**, treated as view-only (no
spends). Consistent with the revoke-only model, platform freshness carries no separate
TTL; a stale untrusted session recovers on its next VK/TG cold-start re-mint, or (for a
reused direct/email session) on re-login.
- **Guest** = ephemeral web session (no platform, no email). A guest is backed by - **Guest** = ephemeral web session (no platform, no email). A guest is backed by
a durable `accounts` row flagged `is_guest` and carrying **no identity** — the a durable `accounts` row flagged `is_guest` and carrying **no identity** — the
row is a technical necessity (the `sessions` and `game_players` foreign keys row is a technical necessity (the `sessions` and `game_players` foreign keys
@@ -1150,7 +1165,7 @@ link — misses the event; while an add-email confirmation is pending the client
| Public rate limiting / anti-abuse | gateway (per-IP public/email/admin classes, per-user authenticated class; a request body cap of `GATEWAY_MAX_BODY_BYTES`; rejections are metered, summarised to the backend and surfaced in the admin console with a conservative reversible auto-flag — §11). In prod a **temporary IP ban** (`GATEWAY_ABUSE_BAN_ENABLED`) blocks an IP that sustains rejections or trips a **honeypot** decoy path / **honeytoken**, refused with 429 before any work; operators lift bans from the console. Off in the shared-NAT test contour, where the client IP is not real (§11) | | Public rate limiting / anti-abuse | gateway (per-IP public/email/admin classes, per-user authenticated class; a request body cap of `GATEWAY_MAX_BODY_BYTES`; rejections are metered, summarised to the backend and surfaced in the admin console with a conservative reversible auto-flag — §11). In prod a **temporary IP ban** (`GATEWAY_ABUSE_BAN_ENABLED`) blocks an IP that sustains rejections or trips a **honeypot** decoy path / **honeytoken**, refused with 429 before any work; operators lift bans from the console. Off in the shared-NAT test contour, where the client IP is not real (§11) |
| Telegram initData validation (bot-token HMAC) | the Telegram **validator**; the gateway delegates it over gRPC, so the bot token (the HMAC secret) lives only in the validator and the bot, never in the gateway. The validator also **rejects a bot principal** (the signed `is_bot` flag) before any account is provisioned | | Telegram initData validation (bot-token HMAC) | the Telegram **validator**; the gateway delegates it over gRPC, so the bot token (the HMAC secret) lives only in the validator and the bot, never in the gateway. The validator also **rejects a bot principal** (the signed `is_bot` flag) before any account is provisioned |
| Session minting; email-code / guest validation | gateway (with backend) | | Session minting; email-code / guest validation | gateway (with backend) |
| Session → `user_id` resolution, `X-User-ID` injection | gateway | | Session → `user_id` + trusted platform resolution, `X-User-ID` / `X-Platform` injection | gateway (platform sourced from the session, never the request body — §3) |
| Authorisation, ownership, state transitions | backend (`X-User-ID` is the sole identity input) | | Authorisation, ownership, state transitions | backend (`X-User-ID` is the sole identity input) |
| Manual account block (suspension) | backend: a per-request gate refuses a blocked account on every `/api/v1/user/*` route except the block-status probe with **403 `account_blocked`**; the operator blocks/unblocks from the admin console (§11) | | Manual account block (suspension) | backend: a per-request gate refuses a blocked account on every `/api/v1/user/*` route except the block-status probe with **403 `account_blocked`**; the operator blocks/unblocks from the admin console (§11) |
| User feedback gate | backend rejects a guest or a `feedback_banned` account from submitting; the **gateway** also rejects a guest's `feedback.submit` (the `Op.NonGuest` flag + `is_guest` from session resolve) with **`guest_forbidden`** before any backend call; attachments are served `nosniff` with a download disposition for non-images (§15) | | User feedback gate | backend rejects a guest or a `feedback_banned` account from submitting; the **gateway** also rejects a guest's `feedback.submit` (the `Op.NonGuest` flag + `is_guest` from session resolve) with **`guest_forbidden`** before any backend call; attachments are served `nosniff` with a download disposition for non-images (§15) |
@@ -1238,12 +1253,74 @@ separate site; the SPA shell is `noindex`, and the landing always boots in Russi
browser-language detection — crawlers render with arbitrary languages). The favicon set, browser-language detection — crawlers render with arbitrary languages). The favicon set,
`og-image.png` and `robots.txt` ship unhashed from `ui/public/` (generated by `og-image.png` and `robots.txt` ship unhashed from `ui/public/` (generated by
`assets/icons/`, same tile design as the VK loader). On the web (`/app/`) the SPA is an installable **PWA**: `assets/icons/`, same tile design as the VK loader). On the web (`/app/`) the SPA is an installable **PWA**:
`manifest.webmanifest` and an install-only **service worker** (`sw.js`) ship unhashed from `manifest.webmanifest` ships unhashed from `ui/public/`, while the **service worker** (`sw.js`) is
`ui/public/`, and the client registers the worker **web-only** — never inside a Mini App or the built from `ui/src/sw.ts` by **vite-plugin-pwa** (injectManifest strategy) — the client registers it
mock build. The worker intercepts only top-level navigations (network-first with a cached-shell **web-only**, never inside a Mini App or the mock build (the plugin is disabled there entirely). It
fallback), leaving `/assets/*` and the Connect stream untouched; it exists to satisfy Chromium's **precaches the app shell and the hashed assets** (Workbox) so an installed PWA cold-launches with
installability requirement (a registered SW, needed for install on Android) and is the single no network. Shell **navigations are network-first** — the fresh `no-cache` shell is fetched from the
growth point for a future opt-in offline mode. The gateway registers the `.webmanifest` MIME type server (so a new deploy is picked up on the very next launch, not the one after), falling back to the
precached shell only when the network is unreachable or too slow; the immutable hashed assets stay
cache-first, and the hash router resolves the route client-side. This navigation route is registered
**before** the precache route on purpose: Workbox matches in registration order, and the precache
route (its `directoryIndex` is `index.html`) would otherwise shadow it and serve the shell
cache-first — the old build's version until a second load. The landing page and the conditional
polyfill bundle are excluded, and the Connect stream and runtime API POSTs are never precached nor
intercepted, so the live app is never served stale. This satisfies Chromium's installability requirement (a registered SW, needed
for install on Android) and powers the opt-in **offline mode** (in progress): a deliberate, device-scoped Settings
toggle — distinct from the transient gateway-reachability signal — that tints the header blue with
an *Offline* chip and confines play to on-device `vs_ai` games. The **offline lobby lists only those
device-local games** (reconstructed by replaying the IndexedDB move journal) and its New-vs-AI entry
creates one through the in-browser engine — the same game screen then drives it, the robot replying
locally; online-only affordances (the Stats tab, the random-opponent option) are disabled
or hidden, and New Game's *with friends* becomes the entry to **local pass-and-play (hotseat)**.
A hotseat game is a device-local **2-4 human** game built through the same engine (`hotseat` +
per-seat/host PIN locks on the record; the seats carry names but no accounts). A **mandatory host
(referee) PIN** gates the roster at creation and, in-game, the referee overrides — **skip** the
current turn, **exclude** a player (a forced seat resign, `resignSeat`) or **terminate** the game
(deleted, no winner/loser); each seat may also carry its own **optional PIN** that withholds its rack
(the board stays visible — only the tray is gated) until it is entered, so players pass the device
around freely and lock a seat only against a distrusted tablemate. The unlock is **per turn**
(re-locks on advance). PINs are a **social lock, not cryptography** — a salted SHA-256 kept only in
the device record (`lib/pin`); a 4-digit PIN is trivially brute-forceable and the racks live in
client memory regardless, so they defeat a casual glance, not a determined peek. A hotseat game is
**not `vs_ai`**: hints (the freed control becomes the host button) and chat/self-resign are gone, and
its lobby card — active *and* finished — is master-PIN-gated to delete (so the last mover cannot
instantly wipe it); a naturally finished game is saved with its result like any local game. A `vs_ai`
hint (online and offline alike) is unlimited and wallet-free but idle-gated
(unlocked ~30 min into a stuck turn), and counts toward no hint statistic. The **source** reports the
**seconds left** (`hint_unlock_left_seconds` on the game view) — computed by the **backend from the
server clock** online (which also enforces the gate, returning `hint_locked` for an early request),
and by the offline source from the device clock (persisted, capped at the window). The **client
anchors a MONOTONIC countdown** (`performance.now()`, `lib/hints`) to that seconds-left when it lands
(on load, and to the full window when the robot moves), so a client clock change cannot skew it, and
a relaunch re-reads a fresh value so the wait is not forgotten. To have data ready before the switch, the **Profile advertises the current dictionary
version per variant** (`dict_versions`,
filled from the registry on the existing cold-start profile request — no extra round-trip), and an
eligible installed PWA (standalone web + confirmed email) **background-preloads** those dictionaries
— on lobby entry and on a variant-preference change — through the same three-tier loader, retried
with backoff and honouring the session miss-breaker; the move generator, the loader and the preload
orchestration stay in lazy chunks. A first-lobby preload failure shows a *poor-connection* notice in
the ad-banner slot. Flipping the Settings toggle **to** offline runs that same cache-first fetch
bounded by a ~5 s UI wait (`raceOfflineReady` + the lazy `dict/offlineready`): it enters offline only
once every enabled variant is ready, otherwise it stays online with a *needs internet* note while the
fetch finishes in the background (the next flip is then instant). A **cold launch already in offline
mode** boots from the persisted session and
profile (the profile is saved on every online adopt/refresh) — `bootstrap` skips the session
adoption and profile fetch that would otherwise hang with no network, and lands straight in the
offline lobby; without a cached profile (an install that was never online) it drops the sticky flag
and boots online instead. Beyond the sticky toggle the app **auto-detects connectivity**: the
reactive flag carries an *auto* bit, so a self-entered offline (no network) is transient
(session-only, never persisted) and self-heals to online when the network returns, while a deliberate
one (the toggle, or the cold-start dialog's *Switch*) persists. At cold start `navigator.onLine ===
false` enters offline for the session; otherwise a single bounded reachability probe (a `profile.get`,
~3 s) decides — success boots online, a timeout on an eligible install raises a *No connection* dialog
(*Switch* = sticky offline, *Wait for network* = boot online with the reachability watcher retrying).
Mid-session the `window` `online`/`offline` events drive it — `offline` auto-enters, `online`
re-verifies reachability before returning — backed by a `navigator.onLine` poll because those events
are unreliable on some platforms (notably iOS PWAs). Offline is a real **transport kill switch**
(every gateway call is refused, so no traffic leaks); the reachability probe is the one call exempt
from it (it is the return-to-online mechanism), and the transient reachability watcher is suppressed
while offline. The gateway registers the `.webmanifest` MIME type
in-process (the distroless image has no `/etc/mime.types`). Hash-named `/assets/*` are served in-process (the distroless image has no `/etc/mime.types`). Hash-named `/assets/*` are served
`immutable` (a relaunch is a cache hit, not a re-download); the HTML shells are `immutable` (a relaunch is a cache hit, not a re-download); the HTML shells are
`no-cache` so a new deploy is picked up — both containers apply the same caching. An `no-cache` so a new deploy is picked up — both containers apply the same caching. An
+67 -1
View File
@@ -215,7 +215,16 @@ unlimited and offers a complaint on any result; for a word it finds, it also lin
external reference dictionary (gramota.ru for Russian games, scrabblewordfinder.org for external reference dictionary (gramota.ru for Russian games, scrabblewordfinder.org for
English) to look it up. Hints are governed per game — English) to look it up. Hints are governed per game —
whether they are allowed and how many each player starts with — and draw on a whether they are allowed and how many each player starts with — and draw on a
personal hint wallet once the per-game allowance is spent. The game ends when the personal hint wallet once the per-game allowance is spent. Against the robot
(**vs_ai**) hints are instead **unlimited and wallet-free**, but idle-gated as an
anti-frustration aid: one unlocks only after the player has been **stuck ~30 minutes
on a turn** (timed from the robot's last move; the very first move, before the robot
has played, is exempt). While gated the hint button carries a small **🔒 lock** and a
tap shows how long remains; the lock lifts live at the mark. The same gate applies **online and
offline**: the countdown runs on a **steady in-app timer**, not the device clock, so changing the
clock cannot skew it, and a relaunch re-reads the remaining time so a stuck turn is not forgotten.
Online the **server enforces** it from its own clock (which the player cannot touch); a vs_ai hint
counts toward no hint statistic. The game ends when the
bag empties and a player clears their rack, after 6 consecutive scoreless turns, bag empties and a player clears their rack, after 6 consecutive scoreless turns,
by resignation, or by the per-game move timeout (5 minutes to 24 hours, default by resignation, or by the per-game move timeout (5 minutes to 24 hours, default
24 hours): a missed turn auto-resigns, except while the player is inside their 24 hours): a missed turn auto-resigns, except while the player is inside their
@@ -256,6 +265,44 @@ the same occasional move against its plan that fades out by the endgame), and ch
add-friend are off. AI games are **practice** — they never count toward a player's add-friend are off. AI games are **practice** — they never count toward a player's
statistics. statistics.
### Offline mode
An installed web PWA signed in with a confirmed email can switch to a deliberate **offline mode**
(Settings → Play mode). Offline, the app never touches the network: the header turns blue with an
*Offline* chip, the lobby lists only the games stored on the device, and online-only surfaces are
disabled or hidden (the Stats tab; the *random opponent* option in New Game).
A New Game against the robot creates a device-local **vs_ai** game — it
plays entirely in the browser with no backend. Local games are kept on the device, visible only in
offline mode, and never sync to the account. So a game can be created and played with no connection,
the app quietly preloads the dictionaries for the player's enabled variants while still online, and
(once installed) launches from a precached shell even with no network. Switching **to** offline first
checks that the enabled variants' dictionaries are on the device — if any are missing it fetches them
and waits briefly, and if they cannot be readied it stays online with a short *needs internet* note
(the download keeps running in the background, so a later switch is instant). The mode is
device-scoped and sticky across launches.
Offline, New Game's **with friends** starts a **local pass-and-play** game for 2-4 people sharing one
device. You first set a **host (leader) password** — a 4-digit PIN entered on a lock-screen-style
keypad; until it is set the player rows stay locked. The app then asks whether you are playing too —
if so you take the first seat with your profile name. You name each player (2 to 4; add or remove
rows, where a removal asks for the leader password) and may give any seat its **own optional PIN**.
During the game the board is always visible, but a PIN-locked seat shows an **Unlock** button in place
of its rack until its owner enters the PIN — so the device passes hand to hand and only a protected
seat hides its letters; the lock returns each turn. The **leader** (the host button in the game) can,
with the leader password, **skip** the current player's turn, **remove** a player, or **end the game
early** — an early end discards the game with no winner or loser. Hints and chat are off in a
pass-and-play game. A game that finishes normally is saved to the offline lobby like any other;
deleting any pass-and-play game — running or finished — from the lobby asks for the leader password,
so no one can wipe a game the moment they have moved.
The app also **enters offline mode on its own** when it cannot reach the network. On a cold launch
with no connection it switches to offline for that session; when the device is online but the gateway
stays silent for a few seconds it asks first — *No connection. Switch to offline mode?*, with
**Switch** (go offline) or **Wait for network** (keep retrying online). While the app is open, losing
the network — airplane mode — switches to offline automatically, and restoring it returns online by
itself. A self-entered offline is temporary: it reverts to online the moment the network is back, and
the next launch re-checks. A **deliberate** offline — the Settings toggle, or **Switch** in that
dialog — is the player's choice: it is kept, and never undone automatically.
### Social: friends, block, chat, nudge ### Social: friends, block, chat, nudge
Become friends in two ways: redeem a **one-time code** the other player issues (six Become friends in two ways: redeem a **one-time code** the other player issues (six
digits, valid for twelve hours), or send a **request to someone you have played digits, valid for twelve hours), or send a **request to someone you have played
@@ -475,6 +522,25 @@ stops only feedback submission). Roles are listed and granted/revoked on the use
message does not mark it read — only the explicit actions do; message bodies and attachments are message does not mark it read — only the explicit actions do; message bodies and attachments are
shown defensively (text escaped, attachments downloaded rather than rendered). shown defensively (text escaped, attachments downloaded rather than rendered).
### Wallet
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
or the web), their **active benefits** (ads off until a date or forever, and the available hint
count), and a **store**. What is visible and spendable depends on **where the app runs**, by the
store-compliance rules in [`PAYMENTS.md`](PAYMENTS.md): inside a store only that store's chips are
usable; on the open web all attached chips are, drawn web → VK → Telegram; on VK-iOS the balance is
shown but frozen for spending.
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
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.
### Advertising banner ### Advertising banner
A one-line banner under the nav shows short promotional or operational messages to **free A one-line banner under the nav shows short promotional or operational messages to **free
+68 -1
View File
@@ -222,7 +222,16 @@ e-mail) либо ввод фразы. Активные игры форфейтя
внешний справочный словарь (gramota.ru для русских игр, scrabblewordfinder.org для английских), внешний справочный словарь (gramota.ru для русских игр, scrabblewordfinder.org для английских),
чтобы его посмотреть. Подсказки управляются настройками чтобы его посмотреть. Подсказки управляются настройками
партии — разрешены ли они и сколько их у каждого игрока на старте — и расходуют партии — разрешены ли они и сколько их у каждого игрока на старте — и расходуют
личный кошелёк подсказок после исчерпания внутриигрового лимита. Партия личный кошелёк подсказок после исчерпания внутриигрового лимита. Против робота
(**vs_ai**) подсказки, наоборот, **безлимитны и без кошелька**, но с idle-гейтом как
анти-фрустрация: подсказка открывается, только когда игрок **застрял на ходу ~30 минут**
(отсчёт от последнего хода робота; самый первый ход, до хода робота, исключён). Пока гейт
закрыт, кнопка подсказки несёт маленький **🔒 замок**, а тап показывает, сколько осталось;
замок снимается вживую в нужный момент. Один и тот же гейт работает **онлайн и офлайн**: отсчёт
идёт по **ровному внутриигровому таймеру**, а не по системным часам, поэтому их перевод его не
собьёт, а повторный вход перечитывает остаток — застрявший ход не забывается. Онлайн гейт
**принуждает сервер** по своим часам (их игрок не тронет); vs_ai-подсказка не учитывается ни в
одной статистике подсказок. Партия
завершается, когда мешок пуст и игрок выложил стойку, после 6 подряд бесплодных завершается, когда мешок пуст и игрок выложил стойку, после 6 подряд бесплодных
ходов, по сдаче, либо по таймауту хода (от 5 минут до 24 часов, дефолт 24 часа): ходов, по сдаче, либо по таймауту хода (от 5 минут до 24 часов, дефолт 24 часа):
пропущенный ход означает авто-сдачу, кроме как когда игрок внутри своего пропущенный ход означает авто-сдачу, кроме как когда игрок внутри своего
@@ -261,6 +270,45 @@ e-mail) либо ввод фразы. Активные игры форфейтя
паузы), сохраняет ту же силу (по-прежнему играет на победу лишь примерно в 40% партий, с теми же паузы), сохраняет ту же силу (по-прежнему играет на победу лишь примерно в 40% партий, с теми же
редкими ходами вопреки плану, затухающими к эндшпилю), а чат, nudge и «добавить в друзья» выключены. Партии с ИИ — это **тренировка**: они не идут в статистику игрока. редкими ходами вопреки плану, затухающими к эндшпилю), а чат, nudge и «добавить в друзья» выключены. Партии с ИИ — это **тренировка**: они не идут в статистику игрока.
### Офлайн-режим
Установленный веб-PWA со входом по подтверждённой почте может переключиться в осознанный
**офлайн-режим** (Настройки → Режим игры). В офлайне приложение не обращается к сети: шапка синеет с
меткой *Офлайн*, лобби показывает только сохранённые на устройстве игры, а сетевые поверхности
отключены или скрыты (вкладка Статистика; вариант «случайный соперник» в Новой игре).
Новая игра против робота создаёт локальную **vs_ai**-игру — он ходит
целиком в браузере без бэкенда. Локальные игры хранятся на устройстве, видны только в офлайн-режиме и
никогда не синхронизируются с аккаунтом. Чтобы игру можно было создать и сыграть без связи, приложение
заранее, пока ещё онлайн, подгружает словари включённых игроком вариантов и (после установки)
запускается из прекешированного шелла даже без сети. Переключение **в** офлайн сначала проверяет, что
словари включённых вариантов есть на устройстве — если каких-то не хватает, оно их подгружает и недолго
ждёт, а если подготовить их не удаётся, остаётся онлайн с короткой заметкой *нужен интернет* (загрузка
продолжается в фоне, так что следующее переключение мгновенно). Режим привязан к устройству и
сохраняется между запусками.
В офлайне «с друзьями» в Новой игре запускает **локальную игру по очереди (hotseat)** на 24 человек
за одним устройством. Сначала задаётся **пароль ведущего** — 4-значный PIN на клавиатуре в стиле
экрана блокировки; пока он не задан, строки игроков заблокированы. Затем приложение спрашивает,
играете ли вы сами — если да, вы занимаете первое место под именем из профиля. Вы называете каждого
игрока (от 2 до 4; строки можно добавлять и удалять, удаление спрашивает пароль ведущего) и можете
дать любому месту **свой необязательный PIN**. Во время игры доска видна всегда, но место с PIN
вместо стойки показывает кнопку **«Разблокировать»**, пока владелец не введёт PIN, — так устройство
передаётся из рук в руки, и только защищённое место прячет свои буквы; на каждом ходу замок
возвращается. **Ведущий** (кнопка ведущего в игре) может по паролю ведущего **пропустить** ход
текущего игрока, **исключить** игрока или **завершить игру досрочно** — досрочное завершение стирает
партию без победителей и проигравших. Подсказки и чат в игре по очереди отключены. Нормально
завершённая партия сохраняется в офлайн-лобби как любая другая; удаление любой игры по очереди —
идущей или завершённой — из лобби спрашивает пароль ведущего, чтобы никто не стёр партию сразу после
своего хода.
Приложение также **само включает офлайн-режим**, когда не может достучаться до сети. При холодном
запуске без связи оно переходит в офлайн на текущую сессию; когда устройство онлайн, но шлюз молчит
несколько секунд, оно сперва спрашивает — *Нет связи. Включить офлайн-режим?*, с выбором **Включить**
(уйти в офлайн) или **Ждать сеть** (продолжить попытки онлайн). Пока приложение открыто, потеря сети —
режим полёта — переключает в офлайн автоматически, а её восстановление само возвращает онлайн.
Самостоятельно включённый офлайн временный: он возвращается в онлайн, как только сеть появилась, и
следующий запуск проверяет заново. **Осознанный** офлайн — тумблер в Настройках или **Включить** в том
диалоге — это выбор игрока: он сохраняется и никогда не отменяется автоматически.
### Социальное: друзья, блок, чат, nudge ### Социальное: друзья, блок, чат, nudge
Подружиться можно двумя способами: погасить **одноразовый код**, который выпускает Подружиться можно двумя способами: погасить **одноразовый код**, который выпускает
другой игрок (шесть цифр, действует двенадцать часов), либо отправить **заявку другой игрок (шесть цифр, действует двенадцать часов), либо отправить **заявку
@@ -487,6 +535,25 @@ high-rate флага. С карточки пользователя операт
сообщения и вложения показываются защищённо (текст экранируется, вложения отдаются на скачивание, а не сообщения и вложения показываются защищённо (текст экранируется, вложения отдаются на скачивание, а не
рендерятся). рендерятся).
### Кошелёк
У постоянных (не гостевых) игроков есть **Кошелёк** — вкладка в разделе настроек, между «Друзьями» и
«О программе»; у гостей его нет. Он показывает **фишки** (внутриигровую валюту, разделённую по тому,
где она была пополнена — VK, Telegram или веб), **активные блага** (реклама выключена до даты или
навсегда, и число доступных подсказок) и **магазин**. Что видно и что можно потратить, зависит от
того, **где запущено приложение**, по правилам соответствия магазинам из [`PAYMENTS.md`](PAYMENTS.md):
внутри магазина доступны только его фишки; в открытом вебе — все привязанные, списываются в порядке
веб → VK → Telegram; на VK-iOS баланс показан, но трата заморожена.
**Магазин** перечисляет **ценности**, покупаемые за фишки (дополнительные подсказки, дни без рекламы),
по фиксированной цене в фишках, одинаковой во всех контекстах, и **наборы фишек**, покупаемые за
настоящие деньги, в валюте текущего контекста (голоса VK, звёзды Telegram или рубли в вебе). Покупка
ценности сразу применяет её благо. Перед покупкой в вебе, которая списала бы фишки VK/Telegram,
приложение **предупреждает**, что благо будет работать только в вебе и в приложении — это правило
магазинов — и просит подтверждения. В сборке для **Google Play** покупки за деньги скрыты за подсказкой
установить сборку из RuStore (правило Google о внутриигровой валюте); трата уже заработанных фишек
по-прежнему работает. Кошелёк **не хранит историю покупок** — только текущие балансы, блага и магазин.
### Рекламный баннер ### Рекламный баннер
Однострочный баннер под навбаром показывает короткие рекламные или служебные сообщения **бесплатным Однострочный баннер под навбаром показывает короткие рекламные или служебные сообщения **бесплатным
+351
View File
@@ -0,0 +1,351 @@
# PAYMENTS — monetization mechanics
Authoritative specification of the monetization domain: the in-game currency, wallets,
benefits, store-compliance rules, payment intake, ads, catalog, ledger, and reporting.
English is authoritative; [`PAYMENTS_ru.md`](PAYMENTS_ru.md) mirrors it in plain Russian
(mirror every point edit in the same PR, like `FUNCTIONAL.md`/`FUNCTIONAL_ru.md`).
Read this before changing any payments behaviour. The technical implementation plan lives
in [`../PLAN.md`](../PLAN.md).
> Status: specification approved; implementation staged (see `PLAN.md`). Nothing here is
> live yet.
## 1. Overview
The game earns through two orthogonal channels:
- **Purchases** of the in-game currency **Фишка** (chips) with real money, then spending
chips on **values** (benefits).
- **Ads** — a rewarded video tops chips up; an interstitial and a house banner monetize by
impression.
The currency is **two-tier**:
```
money (VK Votes / TG Stars / RUB via Robokassa) ─┐
├─► Фишки (chips) ──► values
rewarded ad view ─┘ (no-ads, hints,
tournament fee)
```
Chips are the single storefront unit. Money and rewarded views **fund** chips; chips
**buy** values. There is no path that spends money directly on a value — always through
chips.
## 2. Currency model
**One chip is one chip everywhere** — the unit is uniform. The difference between payment
methods lives entirely in the **purchase rate**: a chip pack costs *X* Votes / *Y* Stars /
*Z* RUB, and the rate absorbs each store's commission. Value prices are fixed **in chips**
and identical across methods.
The chip balance is **segmented by `source`** — the platform where the chips were funded:
| `source` | Funded by |
|------------|----------------------------------|
| `vk` | VK Votes purchase, VK rewarded ad |
| `telegram` | TG Stars purchase |
| `direct` | Robokassa purchase (web / native)|
A single account holds all three segments simultaneously: `balance = (account_id, source)`,
up to three rows — a row is materialised lazily on the segment's first funding, and an absent
segment reads as zero. Segmentation is **not** per-identity — see §6.
Why segmented, not one pooled balance: store rules forbid activating value that was paid
for outside the store's own cash desk. Chips funded inside VK (Votes) may only be spent in
the VK context; Stars only inside Telegram; Robokassa-funded (`direct`) chips only outside
the stores. See §4.
## 3. Three operations, kept distinct
Do not conflate these — they use different keys:
1. **Fund chips** — money/ad → chip `source` segment, keyed by the **execution context**.
2. **Spend chips = buy a value** — segment → benefit, keyed by context with the gate (§4).
This is where a benefit is **born** and stamped with its `origin`.
3. **Apply a benefit** over time (e.g. "no-ads until *T*") — governed by the origin rule
(§5).
`source` (where chips were funded) and `origin` (where a value was bought) share the same
value set `{vk, telegram, direct}` but mean different things. On the web they can diverge:
web spending may draw `vk` chips (`source=vk`) into a `direct` purchase (`origin=direct`).
## 4. Store-compliance gate
The compliance wall is **one-directional**. The dangerous direction — activating externally
paid value *inside* a store wrapper — is blocked; the safe direction (a store benefit
leaking out to the open web) is allowed.
**Spend context → which segments/benefits are usable:**
| Execution context | Spendable chip segments | Spend priority |
|---------------------|-------------------------|--------------------|
| Inside VK (Android) | `vk` | — |
| Inside VK (iOS) | none — frozen (view only)| — |
| Inside Telegram | `telegram` | — |
| Web / native (Direct)| `direct` + `vk` + `telegram` | direct → vk → tg |
- Inside VK/TG only the same-named segment is usable; everything else (chiefly `direct`) is
invisible-as-spendable there.
- On the web the store has no jurisdiction, so all attached segments are spendable, drained
by priority direct → vk → tg.
- **VK iOS** is frozen for spending (Apple forbids spending virtual currency on digital
goods outside IAP inside VK on iOS): the balance is shown as a number, but no purchase or
spend is possible. A previously bought benefit still *applies* there.
The account is **single** (identities merge, one profile/friends/stats). The gate is
**logical**: in a VK/TG context the server activates only the same-named segment. It rests
on a **trusted platform signal** (§8) — the client is never believed. When the platform
cannot be trusted, the gate is **fail-closed**: spends/purchases are denied, view only.
### One-directional benefit application
A benefit carries `origin` = the **purchase context** (not "what it was paid with"). Buying
on the web with `vk` chips still yields `origin=direct`.
| `origin` | Where the benefit applies |
|------------------|----------------------------------------------------|
| `vk` / `telegram`| **Everywhere** — inside its store *and* out on web/native |
| `direct` | **Only** web/native — never inside VK/TG (= store ban) |
Before a web spend draws `vk`/`telegram` chips, the UI **warns** the user that the value
will be available here (web/native) only, because of VK/TG restrictions.
## 5. Benefits
Three distinct entities, do not merge:
- **Chips** — currency. Segment by `source`.
- **Hints** — consumable, bought with chips. Segment by `origin`. Spent one-per-hint in
online games; `vs_ai` hints stay free/unlimited (30-min idle gate, not counted). A hint
spent in a game is drawn from an `origin` applicable to the current context (same
one-directional relaxation as above).
- **No-ads** — a duration benefit, bought with chips. Segment by `origin`.
**No-ads stacking.** Buying a term extends `paid_until[origin] += term` from
`max(now, current end)` — the remainder is never lost ("terms add up"). **Forever** is a
separate perpetual flag that overrides terms. "Ads off in context *P*" ⇔ some `origin`
applicable in *P* has `paid_until > now` (on the web take the max over direct/vk/tg; in VK
only vk; in TG only tg).
**What no-ads suppresses:** the top banner **and** the post-move fullscreen interstitial.
The voluntary **rewarded** view (for chips) is never suppressed — it is the user's choice.
**Tournament fee** — a future value type; the atom is provisioned but the mechanic ships
later.
## 6. Wallet lifecycle
**Segment availability rule.** A segment is spendable ⇔ the account has an identity of that
`source` (for `direct`: a durable identity/email). This makes unlink/merge fall out
naturally.
**Unlink (vk/tg).** Allowed even with a non-zero balance/active benefit. The segment is not
burned — it **sleeps** (no identity ⇒ unavailable in VK *and*, lacking the attachment,
unavailable as an attached web segment); re-linking wakes it. The user is warned before
unlinking ("N chips will be unavailable until you re-link"). The last identity cannot be
unlinked (existing `ErrLastIdentity`).
**Merge.** Segments and benefits merge **by origin**: same-origin segments add (chips sum,
benefit terms extend per origin), different origins coexist. This extends the current
account-merge (`hint_balance +=`, `paid_account OR=`) so origin is preserved and nothing
leaks across platforms.
**Guest.** A guest account has **no wallet at all** — the Wallet section is hidden, no
purchases, no balance, by design. Balances belong only to durable accounts. The guest
reaper therefore deletes guests freely (no money can exist there). In `direct`, email is
required **before the first purchase** as a recovery anchor (in VK/TG the vk/tg identity is
already the anchor); the existing email flow (request code → confirm → clear guest →
durable) is reused.
## 7. Catalog and pricing
The catalog is **configurable** — products, prices, purchase rates, and rewarded payout
live in the database and are edited in the admin console, no release required.
- **Base values (atoms):** chips, hints, no-ads days, tournament entry.
- **Product = a set of atoms + a price.** Sold singly or as a combo (e.g. "250 hints + 30
no-ads days").
- **Chip pack** (funds chips) — priced **per method** (multi-currency Votes/Stars/RUB) as a
single product.
- **Value** (bought with chips) — priced **in chips** (uniform across methods).
**Deactivation, not deletion.** Products are soft-deleted (deactivated). A completed
purchase stores a **snapshot** of what was sold (atom composition + price at the time) into
an archive, so history/receipts/tax are independent of later catalog edits.
## 8. Trusted platform signal
The gate (§4) needs a **trusted, unforgeable** platform context on the server. The client is
never the source of truth.
- The platform is a **property of the session**, captured when the session is created. VK and
Telegram wrappers re-mint (and so re-validate the launch signature — VK launch-params `sign`
/ Telegram `initData`) on **every cold start**, so their platform is re-confirmed each launch;
a `direct` session captures it once, established by the fact of a web/native session's creation
(no external signer, and none needed — reaching vk/tg segments in a direct context still
requires a real attachment, §6).
- The platform carries **kind** (`vk`/`telegram`/`direct`) **plus subtype**
(`ios`/`android`/`web`). `kind` is always trusted — the server derives it from the validated
launch, never a client field. The **subtype is trusted only for VK**: it rides inside the
signed launch parameters, which is what makes the **VK iOS freeze** enforceable; for Telegram
and direct the subtype is client-reported and best-effort, so the gate never keys off it.
- The gateway resolves the session and passes `platform` to the backend (alongside the existing
`X-User-ID`), sourced from the session — never the client request body.
- **Fail-closed:** an untrusted platform — a session with no recorded platform, one predating
this feature or one the gateway could not attribute — denies spends/purchases and applying any
foreign origin (view only). A VK/TG session recovers on its next cold-start re-mint; a reused
direct/email session on re-login.
## 9. Payment intake
**Server callback only.** Chips are credited solely on a **verified** (signature/HMAC)
provider callback — Robokassa webhook / TG `successful_payment` / VK callback. A client "I
paid" is ignored.
**Single writer.** One payments domain is the only writer of the ledger. Public webhooks
(Robokassa/VK) terminate at the edge (Caddy/gateway) and proxy into payments; TG
`successful_payment` reaches the bot, which forwards into payments. One place credits and
dedupes.
**Order-flow.** The server pre-creates an `order(pending)` with account / platform / pack /
expected amount / origin. The `order_id` is threaded to the provider (Robokassa `InvId` / TG
`invoice_payload` / VK `item` — confirm the exact VK field at integration). The callback
matches by `order_id` (never by amount, so equal-amount collisions cannot happen), verifies
the amount, credits, marks `paid`. **Idempotency:** dedup by `(provider, provider_payment_id)`.
**Pending is invisible** to the user; it auto-expires on a timeout (~30 min, DB hygiene). A
valid callback is **always** honoured, even on an expired order (`expired` ≠ cancellation —
the money is real, the chips are owed). The user sees only successful purchases.
**TG bot outbox.** `successful_payment` reaches the bot only (Bot API, not the Mini App), and
the bot host is weak and can lose connectivity, so the bot is a durable link. Store-and-
forward on **SQLite** on the bot's disk: receive → store → ack the Telegram update → forward
to payments (idempotent, dedup by `telegram_payment_charge_id`) → ack → mark `forwarded`.
Retries with backoff; re-drives undelivered on restart. At-least-once delivery + idempotent
intake = credited exactly once.
**Events.** The payments domain writes `payment_events` (succeeded / failed / refunded); a
dispatcher fans out over channels — the live gRPC stream if the user is in-app, else the
existing `botlink` push / email relay. "Payment failed" (an **active** provider decline, not
an abandoned pending) is surfaced to the user; "payment succeeded" is a hook (email / bot
message).
**Refunds.** ToS is **non-refundable** — we do not offer refunds to the user. An admin may
issue a **manual** refund (edge case: a user demands one shortly after paying / closes their
account — tie into `accountdelete`, which already preserves messages). **External** refunds
(chargeback / store decision / TG / VK) are honoured: the system takes the `refunded` event,
**best-effort** revokes the benefit (never going negative; if chips were already spent, it
records the loss + an abuse flag), and writes to the ledger. The ledger is **export-ready**
for future tax reporting and Robokassa reconciliation (reconciliation itself is not built
yet; the schema stays compatible).
## 10. Ads
**Launch scope: VK only** for video (rewarded payout in RUB, ОРД automatic, API ready).
web/native/TG keep the existing house **text banner** only; video is deferred until a
ruble-paying in-app network exists. The ad provider is behind an **abstraction** so a future
network for other platforms slots in without rework. Crypto-payout networks (AdsGram/AdMob)
are rejected — no legal ruble income for a self-employed (НПД) developer.
**Rewarded** (voluntary video for chips) credits chips through payments on the network's
**server verify callback** (client not believed, like a payment). On-launch anti-fraud is
the provider's verify only (no own daily cap yet; the abstraction allows adding caps later).
**Interstitial** (post-move fullscreen), configurable server-side values:
- Global cooldown **per user, across all games**, default **5 min**.
- **`vs_ai` — 30 min** (aligned with the hint cooldown, so it does not scare casual players).
- Applying a **hint** triggers a post-move interstitial **independently** of the main
cooldown, with its own **1-min** cooldown.
- Offline — banner only.
- Respect VK's own frequency caps.
**No ads offline** beyond the house banner. The `no-ads` benefit suppresses the banner via
the existing `ads.Eligible` (`backend/internal/ads/ads.go`), which is extended to gate on
the **origin benefit applicable in the current context** rather than one global flag.
## 11. Admin, audit, reporting
**Append-only ledger + materialized balance.** The operations ledger is **INSERT-only**
(never UPDATE/DELETE — full audit). Segment balances `(account, source)` and benefits
`(account, origin)` are a fast **materialized** cache, updated **in the same transaction** as
the ledger write, and recomputable from the ledger for reconciliation.
**In-process read cache.** On top of the materialized tables, the payments package keeps an
in-process, account-keyed, write-through cache of each account's segments and benefits, so the
hot read paths — the ad-eligibility check, hint availability, the wallet view, the spend gate
— issue **no** query to the `payments` schema on the steady-state path. It is invalidated on
every payments mutation (spend / grant / fund / refund / merge) and re-read from the
materialized tables on a miss (the same write-through pattern the account-suspension gate
uses). Single-instance, matching the deployment; a multi-instance backend would need a shared
cache. Identity-presence (which segments are awake, §6) is supplied by the caller, not cached
here, so unlink/re-link takes effect immediately.
**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
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
chips — full audit of rewards.
**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
(`UserDetailView`, `handlers_admin_console.go`). Plus a ledger export.
## 12. Taxes and compliance
Receipts are automatic **through the provider**, and differ by rail:
- **Robokassa** (direct) — self-employed НПД receipt on payment.
- **VK** — VK processes Votes through the tax authority itself; nothing to do.
- **TG Stars** — no tax side (for a RU self-employed, Stars are not legally withdrawable = not
НПД income; accepted, no receipt issued).
**ОРД** (ad marking) for VK ads is handled on VK's side. (Not legal advice — the owner
confirms the exact НПД scheme with a tax advisor.)
## 13. Distribution (native Android)
- **RuStore** — Robokassa/external gate allowed (0%); native = clean `direct` context.
- **Google Play** — direct purchases are **hidden**; the Wallet shows a stub ("install the
RuStore build to make purchases"). Rewarded ads and spending already-earned chips still
work. Confirm Google's current in-app-currency rules before the GP release.
## 14. Data model (schema `payments`)
The payments domain lives in its **own schema `payments`** in the shared Postgres instance,
with its own DB role (rights limited to `payments`) and a domain package behind a hard
interface. There is **no cross-schema foreign key** to `backend.accounts` — an account id is a
plain value here, kept consistent in code and joined to the tombstoned account /
retained-identities dossier by the stable id. That keeps the chip↔benefit spend atomic
**within `payments`** and the domain extractable into its own database. Durability is **PITR**
(continuous WAL archive), independent of DB topology.
Core tables (final names/columns fixed in `PLAN.md`):
- **ledger** — append-only operations: fund / spend / `admin_grant` / refund; `(provider,
provider_payment_id)` unique for idempotency; export-ready.
- **balances** — materialized `(account_id, source) → chips`.
- **benefits** — materialized `(account_id, origin)` → no-ads `paid_until`/`forever` +
hints count.
- **catalog** — atoms + products (soft-deletable), per-method prices, chip-purchase rates,
rewarded payout.
- **orders** — pending purchases, `order_id`, expected amount, origin, status
(pending/paid/expired).
- **payment_events** — succeeded/failed/refunded for the dispatcher.
Legacy `accounts.hint_balance` and `accounts.paid_account` are **deprecated** in favour of the
segmented model and dropped in a later **contract-phase** migration (expand-contract, after the
currency core flips reads and Release 2 — image rollback stays DB-safe); neither was ever set in
prod (no purchase flow existed), so legacy values are zeroed.
## 15. Glossary
- **Фишка / chip** — the in-game currency; uniform unit, segmented by `source`.
- **source** — where chips were funded (`vk`/`telegram`/`direct`).
- **origin** — where a value was bought; governs where the benefit applies.
- **value / benefit** — what chips buy (no-ads, hints, tournament fee).
- **gate** — the one-directional store-compliance rule (§4).
- **ledger** — the append-only record of all money/value operations.
- **rail** — a payment provider (Robokassa / VK Votes / TG Stars).

Some files were not shown because too many files have changed in this diff Show More