Compare commits
248 Commits
d81d117b54
...
v1.20.0
| Author | SHA1 | Date | |
|---|---|---|---|
| aaf162de40 | |||
| 7cc2b50d23 | |||
| eb7fa98426 | |||
| 81d5383c42 | |||
| b6af682381 | |||
| ed79c30d2a | |||
| 471fadc6a4 | |||
| 564abdcf88 | |||
| c522e77599 | |||
| 28afcff551 | |||
| c34e2a8dbf | |||
| 25381f70a3 | |||
| 5ce9f7e9b4 | |||
| 522beec82e | |||
| 3b485883ee | |||
| efeed17abc | |||
| cc34622630 | |||
| f6d256215a | |||
| fe5a3d6d3b | |||
| 7e142d471c | |||
| dbe2c4cb94 | |||
| 8c5b659ddf | |||
| a067a2fd01 | |||
| 8becdb45e4 | |||
| 25b5ed5516 | |||
| de5ab9186c | |||
| 7df078f5ed | |||
| 1d77ee83b3 | |||
| 5a4c460268 | |||
| 807edff327 | |||
| 0f3b4dbcff | |||
| 3456a0b3ff | |||
| 93ccc8c449 | |||
| e0360c98e2 | |||
| a41281c495 | |||
| 11f89f6477 | |||
| 2d1fadb50c | |||
| 09e05eef18 | |||
| 73baf58002 | |||
| eec225c4ee | |||
| ca2c6487cf | |||
| e7cb60c996 | |||
| 49c53794f4 | |||
| 4a0689a4ac | |||
| 0eb72ba955 | |||
| e077258567 | |||
| a035edfb54 | |||
| bcd5a1d02d | |||
| a57fd355ba | |||
| 2ea91a8354 | |||
| de003e862a | |||
| aaf2825260 | |||
| 0f5db0ee91 | |||
| 53b33073ac | |||
| e3c5cff7b7 | |||
| 0c5e40c509 | |||
| edaf2dfd9e | |||
| 516ffbe5f0 | |||
| 3ce460f72a | |||
| 4ba9da6721 | |||
| ad1cc361e9 | |||
| 2c2316fb5e | |||
| bb71e7b1c7 | |||
| 5c1f64c7d1 | |||
| b3cf024e9e | |||
| 6e120bdaa7 | |||
| 40acbcccdd | |||
| b54371845f | |||
| b6c2598710 | |||
| 6badc20078 | |||
| a241e43d79 | |||
| 8ce986922a | |||
| 3469278260 | |||
| c66bf1eceb | |||
| 7b4d2421e2 | |||
| bf46b9492d | |||
| 1e1117c28e | |||
| 77a690fcf6 | |||
| 0ca01133b5 | |||
| 2683103fc1 | |||
| 2d2dd2bc47 | |||
| 45f0b34881 | |||
| df9eace09f | |||
| 0a0a9e5a8d | |||
| 0c9678c42b | |||
| e40adfb0c7 | |||
| 3306a016a0 | |||
| e45167041f | |||
| ed53e25e57 | |||
| 2e5136b22a | |||
| 1bf612a087 | |||
| ec5c6afa23 | |||
| 82648a4398 | |||
| d2d6955cbf | |||
| c3eecf16b3 | |||
| 0036b55618 | |||
| aabb32081e | |||
| e089a0a997 | |||
| 00d1bd33e3 | |||
| 7ce8101cfa | |||
| b5c8a04f0b | |||
| 2ce80c241d | |||
| 13be7c3d9a | |||
| e08d3301bd | |||
| 9acf6ab3b4 | |||
| dbd76d53e8 | |||
| 68c937f3b6 | |||
| 21fb14facf | |||
| ee9924c313 | |||
| e10f3beed5 | |||
| 55dbb1005e | |||
| 219fc7c827 | |||
| 6e03ce0131 | |||
| 5612bb624d | |||
| 8230253142 | |||
| 890a887257 | |||
| e33b9864c8 | |||
| f6aa0ba4b0 | |||
| f48ebf2151 | |||
| 3bb9f10fad | |||
| 3e0763463f | |||
| 96adf98e1d | |||
| 3367cc2bf1 | |||
| 04435a3283 | |||
| be0e4995f3 | |||
| 936a70ab94 | |||
| 4f6c22d669 | |||
| 2a6dc5a304 | |||
| a66a5bfa08 | |||
| 36a5730e52 | |||
| 7860efce48 | |||
| ca60025fbe | |||
| 0bdc034f7a | |||
| 625fc3135a | |||
| a118c63411 | |||
| 7123ba439d | |||
| 18785efc8c | |||
| 850884d077 | |||
| a58f2ce0e4 | |||
| e922f5f3b9 | |||
| 04976a64e8 | |||
| 4aa6174968 | |||
| 41f4fd3497 | |||
| 1c06d1d0d1 | |||
| 711fabe9cc | |||
| 92633f935e | |||
| 07815c5a30 | |||
| bab57343e1 | |||
| ce8b5026c1 | |||
| d9bc7596c6 | |||
| 3ad3b1dd7f | |||
| 4d1f389cf1 | |||
| 4c7bc7baa9 | |||
| 780ff68ec2 | |||
| ca48b3e18e | |||
| a7f483792f | |||
| 80cc2a76e8 | |||
| 1e087be90a | |||
| 84d0385c95 | |||
| 0ed34c7720 | |||
| 52d0c559f9 | |||
| beda6ccd3d | |||
| c1ac77bc6a | |||
| cafe67e9c8 | |||
| 3adc4e32c9 | |||
| 903de7d41d | |||
| baa9961c3e | |||
| 66278e34b7 | |||
| 452a686e07 | |||
| 4ddc690c38 | |||
| 6216359c6f | |||
| 8c67d679d9 | |||
| 69673e7727 | |||
| d7f3d93c6c | |||
| 16cd3d0411 | |||
| ef8a32bb82 | |||
| 53311cbc95 | |||
| 7fc1301b31 | |||
| ee7ff06403 | |||
| 537a265409 | |||
| 8c991429c6 | |||
| 359af83c34 | |||
| ff486c80f8 | |||
| 42a6308261 | |||
| 08792dad3d | |||
| e9f4cb0178 | |||
| 8fbbb3c5ef | |||
| 30770a759b | |||
| 05c445e4da | |||
| d43a740eb6 | |||
| 350013acd9 | |||
| 3ad66f49c7 | |||
| 09c5a5e72e | |||
| fffc6030ce | |||
| fc8143758a | |||
| e0d28733ff | |||
| ccd65f61b8 | |||
| 5bc2ad3b6d | |||
| 2a4b0fb25e | |||
| 2a7c632840 | |||
| 3a72bc29ba | |||
| 020742fad3 | |||
| fd225564a3 | |||
| fdf14d5897 | |||
| 1a456c4847 | |||
| 2a045a5b37 | |||
| 19e7ea5da0 | |||
| 54d701fd8a | |||
| ec0c13bebc | |||
| 5643c8be10 | |||
| 2f70ef1b85 | |||
| ef832b823d | |||
| 99f0a545be | |||
| 1a95a5f2cb | |||
| 8abd8b2ae6 | |||
| 2f867b8e6c | |||
| a692024b4e | |||
| 8349e222fc | |||
| 68ecd881d9 | |||
| d80d28a402 | |||
| 1654131904 | |||
| 9ecbe480db | |||
| 32298595f2 | |||
| 2847412b5b | |||
| 4fa77bf82c | |||
| afa44d41b4 | |||
| e4cf143e9f | |||
| 543cbe56b9 | |||
| a9c8f1ecfe | |||
| 57ff2d03f8 | |||
| a9d0986e74 | |||
| 45957bdcd6 | |||
| 829e29a726 | |||
| 399508f2f0 | |||
| 3a18e683ca | |||
| 93d086a8a3 | |||
| 8fe1bdba6b | |||
| 7923b3cc09 | |||
| 4891216749 | |||
| f1b8769c89 | |||
| b6f28a2423 | |||
| e32ee9ce68 | |||
| dc946a1faf | |||
| 384bd143d0 | |||
| c5d22fceca | |||
| deaa7a29c5 | |||
| 24017bcb7f | |||
| 2c4f4b10dc |
@@ -0,0 +1,242 @@
|
||||
# Agent field notes — scrabble-game
|
||||
|
||||
Non-obvious, hard-won project knowledge that is **not** in the main docs (`CLAUDE.md`, `docs/*`,
|
||||
`deploy/README.md`). Kept in the repo so it travels with a clone to any host. These are working
|
||||
memory, not a spec — **verify any named file / flag / function against the current code before acting
|
||||
on it**, and prefer the authoritative docs where they overlap. Add to this file as new gotchas turn up.
|
||||
|
||||
## Codegen & build
|
||||
|
||||
- **jetgen churns everything.** `backend/cmd/jetgen` regenerates go-jet code for *all* tables and may
|
||||
reorder output. After running it, revert the churn on tables you did not touch and commit only your
|
||||
table's change.
|
||||
- **flatc is version-pinned** (`pkg/Makefile`, `REQUIRED_FLATC = 23.5.26`, hard-checked). A different
|
||||
flatc silently churns the generated wire code and can flip wire defaults. Never regenerate FBS with
|
||||
another flatc version.
|
||||
- **gopls lags codegen.** Right after an FBS/jet regen, gopls shows phantom "undefined" errors. Trust
|
||||
`go build` / `go test`, not the editor squiggles.
|
||||
- **go-jet type mapping:** SQL `numeric` → `float64`, `interval` → `string`. Never store money as
|
||||
`numeric` (float precision loss) — use **bigint minor units + a `Money` type**; store durations as
|
||||
**int seconds**, not `interval`.
|
||||
- **`go mod tidy` chokes on dot-free local module paths** (`scrabble/...`). Hand-edit `go.mod` when a
|
||||
bump is needed. The solver (`../scrabble-solver`) is consumed via `go.work` replace locally, but a
|
||||
prod bump goes through a solver **PR → master + a published tag**, not a local replace.
|
||||
- **Run the whole CI suite locally before pushing** — unit + integration (`//go:build integration`,
|
||||
Postgres) + the UI job + codegen check. Do not lean on CI to catch what a local run would.
|
||||
- **CI runner shares this host's `/tmp` as a different user.** In workflow steps, write artifacts to
|
||||
`${GITHUB_WORKSPACE}`, never a fixed `/tmp/...` path (cross-user permission failures otherwise).
|
||||
- **pnpm corepack pre-flight flakes.** `pnpm exec` / `pnpm check` occasionally abort on a corepack
|
||||
pre-flight. Dodge it by invoking the tool directly: `node_modules/.bin/<tool>`.
|
||||
|
||||
## Native Android build (Capacitor)
|
||||
|
||||
The native Android app is a Capacitor 8 wrapper of the `ui` SPA, scaffolded under
|
||||
`ui/` (a Node project outside `go.work`). Hard-won bring-up facts — verify against current code:
|
||||
|
||||
- **Capacitor 8 needs JDK 21, not 17.** `@capacitor/android` compiles at `VERSION_21`; a JDK 17 Gradle
|
||||
run dies with `error: invalid source release: 21`. Install sudo-free via the Homebrew **formula**
|
||||
`brew install openjdk@21` (the `temurin@21` **cask** wants sudo for a system `.pkg`, unusable
|
||||
non-interactively) + a user symlink into `~/Library/Java/JavaVirtualMachines/` so
|
||||
`/usr/libexec/java_home -v 21` finds it. JDK 17 stays fine for `sdkmanager`/`avdmanager`.
|
||||
- **Cap 8 SDK pins:** compileSdk/targetSdk **36**, minSdk **24**, Gradle **8.14.3**, AGP **8.13.0**
|
||||
(`ui/android/variables.gradle`). Install `platforms;android-36` — Android Studio's newer
|
||||
`android-36.1` does NOT satisfy compileSdk 36.
|
||||
- **SDK is Android Studio's** at `~/Library/Android/sdk` (no `cmdline-tools` by default → `brew install
|
||||
--cask android-commandlinetools`, then `sdkmanager`/`avdmanager --sdk_root=$HOME/Library/Android/sdk`).
|
||||
Gradle finds it via **`ANDROID_HOME`** (`local.properties` is gitignored, machine-specific).
|
||||
- **Build recipe:** `cd ui && pnpm build && node_modules/.bin/cap sync android`; then `cd ui/android &&
|
||||
ANDROID_HOME=~/Library/Android/sdk JAVA_HOME=$(/usr/libexec/java_home -v 21) ./gradlew assembleDebug`
|
||||
→ `ui/android/app/build/outputs/apk/debug/app-debug.apk`. Prefer the local `ui/node_modules/.bin/cap`
|
||||
(dodges the corepack flake).
|
||||
- **Emulator smoke:** existing AVDs `Pixel_10` / `Pixel_4_Android_10_API_29` / `Pixel_Android_9`;
|
||||
`emulator -avd <name>`, `adb install -r <apk>`, `adb shell monkey -p ru.eruditgame.app -c
|
||||
android.intent.category.LAUNCHER 1`, `adb exec-out screencap -p > x.png`. The boot wait needs `sleep`
|
||||
→ run it as a **background** Bash task (foreground `sleep` is blocked). Browsers/WebView are cached, so a
|
||||
full offline vs_ai turn is drivable via `adb shell input tap` (tap through the first-run coachmark tour —
|
||||
each tap advances one step — then Hint places a suggested word; commit → the robot replies).
|
||||
- **Android 15+ edge-to-edge safe-area (WebView-version-dependent, bit me on API 37).** targetSdk 36 forces
|
||||
edge-to-edge — the WebView draws behind the status bar (top) and the gesture-nav home indicator (bottom).
|
||||
On Android **WebView < 140**, `env(safe-area-inset-*)` wrongly reports **0**, so chrome relying on it draws
|
||||
under the bars and is untappable: the top nav under the clock, and the game's bottom action bar's **centre**
|
||||
button (Hint) under the home-indicator pill (side buttons still work; `navigation_mode`=2 is gesture nav).
|
||||
Fix is CSS-only, in TWO parts: (1) Capacitor 8's **SystemBars** plugin (built into `@capacitor/core`,
|
||||
`insetsHandling:'css'` default — no dep, no config) injects correct `--safe-area-inset-*`; consume them as
|
||||
`--tg-safe-*: var(--safe-area-inset-*, env(safe-area-inset-*, 0px))` (`ui/src/app.css`) — fixes any consumer
|
||||
that ALREADY applies the token (the bottom bars: `Game.svelte`/`Screen.svelte` `--tg-safe-bottom`).
|
||||
(2) But a consumer that never applied the top inset on the native path is NOT fixed by the token alone — the
|
||||
**header's** top inset was Telegram-fullscreen-scoped only, so the native header sat under the status bar on
|
||||
EVERY WebView; it needed its own `.bar { padding-top: calc(var(--safe-area-inset-top, 0px) + 5px) }`
|
||||
(`Header.svelte`, native-only via the plugin var; tg-fullscreen still overrides via specificity). **Measure
|
||||
per element, don't eyeball** — a centred title at y=11 under a 54px bar reads as "fine" in a screenshot but
|
||||
is overlapping; an emulator WebView auto-updated to ≥140 (Chrome 149) also hides the `env()`=0 half (so the
|
||||
BOTTOM looks fine there while the user's < 140 device overlaps). **Inspect a live debug WebView** over CDP:
|
||||
`adb forward tcp:9222 localabstract:$(adb shell cat /proc/net/unix | grep -o 'webview_devtools_remote_[0-9]*'
|
||||
| head -1)`, then Playwright `chromium.connectOverCDP('http://localhost:9222')` → `page.evaluate` (read each
|
||||
element's `getBoundingClientRect().top`, and `--safe-area-inset-*` vs `env(...)`).
|
||||
- **`sharp` is whitelisted** in `ui/pnpm-workspace.yaml` (`allowBuilds: sharp: true`) — `@capacitor/assets`
|
||||
uses it for `pnpm android:assets` (launcher icon/splash); else pnpm 11 raises `ERR_PNPM_IGNORED_BUILDS`.
|
||||
- **Bundled offline dicts come from the `scrabble-dictionary` release** (`scrabble-dawg-<DICT_VERSION>.tar.gz`,
|
||||
keyed on the `DICT_VERSION` Gitea var — the same source the backend image + CI `curl`), NOT
|
||||
`scrabble-solver/dawg` (those are the solver's pinned test fixtures).
|
||||
|
||||
## Wire / schema evolution (client ↔ gateway ↔ backend)
|
||||
|
||||
- **FBS is additive-only.** Add **trailing** fields ("added trailing — backward-compatible"). Never
|
||||
delete or reorder a mid-table field — **deprecate** it (`(deprecated)`); deleting shifts field IDs
|
||||
and breaks older readers.
|
||||
- **Retiring a domain field must also retire the WIRE field it fed** — by deprecation, not deletion,
|
||||
and do not just zero it: a dead `0` the client dutifully syncs will clobber the real value.
|
||||
- **The gateway transcodes FBS ↔ backend JSON DTOs in lockstep.** A wire change usually means editing
|
||||
both the FBS schema and the backend DTO.
|
||||
- **Seat display name is built in two places** — `game.Service` live events **and** the server REST
|
||||
DTOs. Change both or they drift.
|
||||
- **A per-viewer flag is computed only in the per-viewer REST DTO**; the client seeds it from REST,
|
||||
then bumps it from the live event. Don't expect it on the shared broadcast.
|
||||
|
||||
## UI / Svelte 5
|
||||
|
||||
- **Never name a `$state` variable `state`.** `svelte-check` then misreads `$state` as a store
|
||||
subscription. Rename (e.g. `view`).
|
||||
- **Svelte trims literal edge whitespace** in markup. To keep a separator space, emit an expression:
|
||||
`{' : '}`.
|
||||
- **No global `.btn` / `.ghost` button classes.** Style buttons per-component with scoped CSS + design
|
||||
tokens (mirror `NewGame`'s `.invite`).
|
||||
- **A `$state` proxy fails structured-clone** when persisted to IndexedDB. Snapshot at the call site
|
||||
with `$state.snapshot(...)` before storing.
|
||||
|
||||
## Platform / WebView quirks (Telegram, VK, iOS, native, old Android)
|
||||
|
||||
- **Telegram `showPopup` eats the user-activation.** Share / clipboard called from inside a
|
||||
`showPopup` callback fail (no gesture). Use your own `Modal` for gesture-gated Web APIs.
|
||||
- **iOS Telegram `<a download blob:>` navigates away** and strands the SPA. Deliver files by **Web
|
||||
Share on mobile**, and only use a `<a download>` Blob path on **desktop**.
|
||||
- **Android TG/VK WebViews** lack `navigator.share` and ignore `<a download>`. Deliver a
|
||||
client-generated file by **copying it to the clipboard**.
|
||||
- **VK Android WebView ignores `target=_blank`.** Open external links through `lib/links.ts`
|
||||
(routes to `vk.com/away.php`). Verify on-device.
|
||||
- **Per-platform file-delivery last hop differs** (TG / VK / plain browser) — there is a delivery
|
||||
matrix; do not re-litigate it without new on-device facts.
|
||||
- **Old Android System WebView floor ≈ Chrome 67.** The bundle targets **es2019** (esbuild lowers
|
||||
syntax), a conditional `core-js` polyfill loads only on old engines, and `index.html` has a boot
|
||||
gate (BigInt / Proxy are the hard block → the unsupported-engine screen). There's also a `vmin`
|
||||
glyph fallback for old rendering.
|
||||
- **iOS WKWebView overscroll and Telegram swipe-to-close are not reproducible in Playwright.** Verify
|
||||
those live on the deployed contour, not in the e2e.
|
||||
- **Telegram Desktop Mini App shows a persistent bottom-right loader** — that's the long-lived
|
||||
Subscribe stream, cosmetic, deliberately left as-is.
|
||||
|
||||
## Testing
|
||||
|
||||
- **UI test layers:** vitest (node env, pure logic, **no jsdom**) + Playwright **mock** e2e. The mock
|
||||
e2e **bypasses the codec**, so wire/codec bugs need **codec unit tests**, not e2e coverage.
|
||||
- **Mock overlay blocks e2e.** The cold-load overlay must be instant under the mock build or it
|
||||
intercepts Playwright taps.
|
||||
- **Mock tile pools lack a blank `'?'`.** The seeded game `G1` hard-codes one; flip `G1`'s variant to
|
||||
eyeball per-variant tiles.
|
||||
- **Durable Playwright MCP servers** (chromium + webkit) exist for UI inspection (there's a
|
||||
plugin-config gotcha in wiring them).
|
||||
- **The `playwright test` runner can't *fetch* browsers in this sandbox** — `playwright install` dies
|
||||
with `EBADF` against the Playwright CDN (blocked network), even with the sandbox off. Run the e2e in
|
||||
**CI** (the `ui` job installs chromium+webkit), or drive a state live through the **Playwright MCP**
|
||||
browser against a local `vite --mode mock` server for visual verification. **But if chromium/webkit are
|
||||
already cached** in `~/Library/Caches/ms-playwright/`, `playwright test` runs locally fine (only the CDN
|
||||
fetch is blocked) — the native offline-first e2e was run green locally this way (chromium + webkit),
|
||||
its dawgs from the sibling `../../scrabble-solver/dawg` via the webServer's `bundle-dicts.mjs` fallback.
|
||||
- **A native (Capacitor) e2e must inject `window.androidBridge`, NOT `window.Capacitor.getPlatform`.**
|
||||
`@capacitor/core` (pulled in during boot by `initNativeShell`'s dynamic `@capacitor/app` import)
|
||||
**replaces** any pre-set `window.Capacitor` with its own shim and derives the platform from
|
||||
`window.androidBridge` (android) / `window.webkit.messageHandlers` (ios) — so a bare injected
|
||||
`Capacitor.getPlatform: () => 'android'` is clobbered to `web` and the boot falls to `/login`. Inject
|
||||
`window.androidBridge = { postMessage(){} }` in an `addInitScript` (see `e2e/native.spec.ts` `simulateNative`);
|
||||
`initNativeShell` is written to tolerate the stub bridge (`try/catch` round the `@capacitor/app` addListener).
|
||||
- **`docker run -p ...` boot tests fail from the shell** (published ports unreachable in this env).
|
||||
Use **testcontainers** for container-backed tests.
|
||||
- **Distroless images run as UID 65532 (nonroot).** Bind-mounted TLS keys must be **0644** (not 0600)
|
||||
or the service crash-loops on start.
|
||||
|
||||
## Deploy / test contour (operational)
|
||||
|
||||
- **The TEST contour runs on THIS dev host.** Inspect it via `docker` / Prometheus. A host-side
|
||||
`curl` to caddy **hangs** (NAT hairpin) — don't debug the edge that way.
|
||||
- **The contour's client IP is the home-router SNAT address**, not the real external IP. Correct in
|
||||
prod, not a bug — which is why the IP ban / blocklist are **prod-only**.
|
||||
- **The contour is one shared env, last-deploy-wins.** Keep a multi-PR batch a **linear stack** (one
|
||||
PR, one deploy) so deploys don't clobber each other.
|
||||
- **A schema/wire PR breaks the contour** until a `DROP SCHEMA` + backend restart. Note such a
|
||||
prerelease step in `PRERELEASE.md`.
|
||||
- **A contour DB wipe resets the account but not the client's stored locale**; the reconciler then
|
||||
syncs the stale locale, masking a fresh TG `language_code` seed. Clear client prefs too when testing
|
||||
locale.
|
||||
- **`DNS=` in `TEST_AWG_CONF` pins the VPN netns to 1.1.1.1** → internal names go NXDOMAIN → the
|
||||
bot-link silently dies. Diagnose from inside the netns.
|
||||
- **Swap one contour service to a local image** without deploy secrets via a busybox-in-the-netns
|
||||
socket-inspect trick (single-service recreate).
|
||||
- **A rolling deploy did NOT recreate caddy on a config-only change.** Force `--force-recreate` for
|
||||
caddy; don't trust "deploy green" for an edge-config change.
|
||||
- **A new gateway edge route MUST be added to the Caddyfile `@gateway` matcher** or it falls through
|
||||
to the landing catch-all. Add a CI probe for the route.
|
||||
- **Prod caddy logs warnings only, no access log.** Trace a request via the backend "http request"
|
||||
telemetry log or Tempo, not caddy.
|
||||
- **Confirm a release is live without SSH** by grepping the served SPA: `__APP_VERSION__` inside
|
||||
`/assets/main-*.js`.
|
||||
- **"App hangs on load" was a dead HTTP/3 advert:** caddy sent `Alt-Svc: h3` with no UDP/443 open;
|
||||
clients cache it ~30 days. Fix with `Alt-Svc: clear`.
|
||||
- **Config-poison deploy loop:** a crash-looping config-mounted service + a missing bind source
|
||||
produces a root-owned directory that then fails deploys. Break it by removing the root-owned dir.
|
||||
- **Maintenance-window contract** (planned-deploy 503): a marker header, `/_gm` exempt, the flag spans
|
||||
the whole roll, the SPA overlay reloads on recovery. Don't break these invariants.
|
||||
- **A new dictionary goes live via a `/_gm/dictionary` upload, NOT a redeploy.** In-flight games keep
|
||||
their pinned version. The **owner** does the upload.
|
||||
- **Renderer deploy job flakes** on the skia-canvas GitHub binary download when its lockfile changes —
|
||||
re-run, it's not your code.
|
||||
|
||||
## Repo workflow
|
||||
|
||||
- **PR-based, zero issues.** Work is tracked via PRs + `PRERELEASE.md`. "заведи задачу" means *do it*
|
||||
/ add a plan line — not file a tracker issue.
|
||||
- **`tea` CLI for all Gitea ops** (PRs, secrets, variables, dispatch). `gh` does **not** work here. The
|
||||
agent **cannot self-approve** a PR but **can merge** it after the owner approves. Watch the
|
||||
stale-mergeable trap (re-check mergeability right before merging).
|
||||
- **Watch every push/merge/deploy to green** with `python3 ~/.claude/bin/gitea-ci-watch.py`, launched
|
||||
**bare** under a background task. It polls run-level conclusions; its `ALL GREEN` already covers the
|
||||
gated deploy job. Pass `--no-runs 600` when the runner is busy. A **merge** is the most-forgotten
|
||||
case — watch the post-merge runs too.
|
||||
- **After a merge, switch to the merged-into branch** (`development`/`master`), pull, and prune the
|
||||
local feature branch.
|
||||
- **The contour deploy probe checks the backend `/readyz`**; a PR deploy builds the PR's own code; a
|
||||
wedged contour can be recovered by recreating the host container set.
|
||||
|
||||
## Domain semantics (not obvious from the code)
|
||||
|
||||
- **Account deletion must NOT delete any user messages** — including feedback / support. Interview the
|
||||
owner on every deletion point before wiring it.
|
||||
- **`account.time_zone` is `NOT NULL DEFAULT 'UTC'`, seeded from a detected ±HH:MM offset.** An email
|
||||
account row is created at the **code-request** step, not at confirmation.
|
||||
- **The robot has two distinct time windows:** a **sleep window** (~00:00–07:00, gates its moves and
|
||||
nudges) vs the **player away window** (turn-timeout only). "окно отсутствия" in dialogue = the
|
||||
**sleep** window.
|
||||
|
||||
## Production topology
|
||||
|
||||
- **Two prod hosts.** `main` — full stack + ACME/edge (Selectel); `tg` — Telegram bot only (vdsina).
|
||||
SSH aliases `scrabble-main-ops` / `scrabble-tg-ops`. Deploy is **manual dispatch only**, rolling +
|
||||
health-gated + auto-rollback. Hosts are provisioned by `deploy/ansible/` (inventory + vars there —
|
||||
the source of truth for IPs/roles).
|
||||
- **The agent has targeted root SSH** to the hosts (and may run the prod Ansible). Surface every
|
||||
owner-side step **before** a deploy, not after.
|
||||
|
||||
## Feature-area pointers (state lives in the linked docs/plans, not here)
|
||||
|
||||
- **Monetization** — «Фишка» currency, per-platform wallets, ads. Agreements in
|
||||
`docs/PAYMENTS_DECISIONS_ru.md`; a phased plan (E0–E9). go-jet money rule above applies.
|
||||
- **PITR** — pgBackRest → Selectel S3 (encrypted, 30-day), currently **gated off**; runbook in
|
||||
`deploy/README.md`. Gotcha: run pgBackRest via docker-exec **as the `postgres` user** with
|
||||
`--pg1-user=scrabble`.
|
||||
- **Offline mode** — the robot brain, move generator/validator/scorer and DAWG reader are **JS ports**
|
||||
of the Go engine, bundled client-side and **parity-pinned** by golden tests. Multi-phase; native
|
||||
offline-first bundles the dictionaries.
|
||||
- **VK ID web login** — raw OAuth 2.1 against `id.vk.com`, a **separate VK "Web" app** from the Mini
|
||||
App, server-side confidential code exchange.
|
||||
- **Email relay** — Selectel SMTP + confirm / link / unlink / deletion codes + alerts.
|
||||
- **Native Android** — Capacitor bundle model, client-version gate, offline-first, RuStore; the
|
||||
design lives in `docs/ARCHITECTURE.md` (§2 gate, §3 identity, §13 native build).
|
||||
@@ -0,0 +1,172 @@
|
||||
# Manual signed-APK build for the standalone Android app (RuStore). Runs ONLY from master, ONLY on
|
||||
# workflow_dispatch with confirm=build — the same deliberate-manual shape as prod-deploy.yaml, never on
|
||||
# a PR. It builds the native-flavoured SPA, bundles the offline dictionaries into the APK assets, and
|
||||
# assembles a release APK, uploaded as a run artifact (RuStore upload stays manual for the MVP).
|
||||
#
|
||||
# Signing degrades gracefully: with the ANDROID_KEYSTORE_* secrets present the APK is signed; without
|
||||
# them build.gradle produces an UNSIGNED release APK (so a dry run still proves the whole pipeline).
|
||||
# The keystore is a publication prerequisite — see deploy/README.md (Android build/release runbook).
|
||||
# The toolchain is self-provisioned here (JDK 21 + a cached Android SDK), so the runner host needs
|
||||
# nothing pre-installed.
|
||||
name: android-build
|
||||
run-name: "android build ${{ github.sha }}"
|
||||
|
||||
on:
|
||||
workflow_dispatch:
|
||||
inputs:
|
||||
confirm:
|
||||
description: 'Type "build" to confirm an APK build from master.'
|
||||
required: true
|
||||
default: ""
|
||||
|
||||
permissions:
|
||||
contents: read
|
||||
|
||||
env:
|
||||
NO_COLOR: "1"
|
||||
# The dictionary release, one source of truth (same Gitea variable the backend image + CI use). It
|
||||
# both fetches the DAWGs and labels the bundled files (VITE_DICT_VERSION must equal __DICT_VERSION__).
|
||||
DICT_VERSION: ${{ vars.DICT_VERSION }}
|
||||
VITE_DICT_VERSION: ${{ vars.DICT_VERSION }}
|
||||
# Hide in-app purchases in the RuStore MVP (RuStore, not Google Play — VITE_GP_BUILD stays unset).
|
||||
VITE_PAYMENTS_DISABLED: "1"
|
||||
# The update overlay's store target; empty until publication (the button no-ops, and the version gate
|
||||
# is dormant in the MVP so it never fires). Set the ANDROID_RUSTORE_URL variable when the app is live.
|
||||
VITE_RUSTORE_URL: ${{ vars.ANDROID_RUSTORE_URL }}
|
||||
# Host-executor runner: the Android SDK is pre-installed on the host. Override ANDROID_SDK_DIR if it
|
||||
# lives elsewhere (the default matches deploy/README.md's install path).
|
||||
ANDROID_HOME: ${{ vars.ANDROID_SDK_DIR || '/opt/android-sdk' }}
|
||||
ANDROID_SDK_ROOT: ${{ vars.ANDROID_SDK_DIR || '/opt/android-sdk' }}
|
||||
|
||||
jobs:
|
||||
build:
|
||||
if: ${{ github.ref == 'refs/heads/master' && inputs.confirm == 'build' }}
|
||||
runs-on: ubuntu-latest
|
||||
defaults:
|
||||
run:
|
||||
shell: bash
|
||||
steps:
|
||||
- uses: actions/checkout@v4
|
||||
with:
|
||||
fetch-depth: 0
|
||||
|
||||
# Host-executor runner: the Android SDK is pre-installed on the host (JDK 21 comes from setup-java
|
||||
# below). Fail fast + legibly if the runner user cannot read/execute it or a needed package is
|
||||
# missing — this doubles as the runner-access check (deploy/README.md).
|
||||
- name: Verify the host Android SDK
|
||||
run: |
|
||||
sm="$ANDROID_HOME/cmdline-tools/latest/bin/sdkmanager"
|
||||
if [ ! -x "$sm" ]; then
|
||||
echo "::error::sdkmanager not found/executable at $sm — set the ANDROID_SDK_DIR variable if the SDK lives elsewhere, or grant the runner user read+exec: sudo chmod -R a+rX \"$ANDROID_HOME\""; exit 1
|
||||
fi
|
||||
for pkg in "platforms/android-36" "build-tools"; do
|
||||
if [ ! -d "$ANDROID_HOME/$pkg" ]; then
|
||||
echo "::error::missing $ANDROID_HOME/$pkg — run: \"$sm\" 'platforms;android-36' 'build-tools;36.0.0'"; exit 1
|
||||
fi
|
||||
done
|
||||
echo "Android SDK OK at $ANDROID_HOME"; "$sm" --version
|
||||
|
||||
- name: Compute version + native build env
|
||||
id: prep
|
||||
env:
|
||||
PUBLIC_BASE_URL: ${{ vars.PROD_PUBLIC_BASE_URL }}
|
||||
run: |
|
||||
# A store release must sit on an exact vMAJOR.MINOR.PATCH tag (G tags before dispatch) so the
|
||||
# versionCode is deterministic and strictly increasing across uploads. Refuse anything else
|
||||
# rather than derive a versionCode from a "-N-gSHA" describe.
|
||||
desc="$(git describe --tags --exact-match 2>/dev/null || true)"
|
||||
case "$desc" in
|
||||
v[0-9]*.[0-9]*.[0-9]*) ;;
|
||||
*) echo "::error::HEAD is not on a clean vX.Y.Z tag (git describe --exact-match = '${desc:-none}'); tag the release first"; exit 1 ;;
|
||||
esac
|
||||
v="${desc#v}"
|
||||
IFS=. read -r MA MI PA <<< "$v"
|
||||
# 10# forces base-10 so a zero-padded part is never read as octal.
|
||||
code=$(( 10#$MA * 1000000 + 10#$MI * 1000 + 10#$PA ))
|
||||
# The native SPA talks to the production origin (reuse the prod public base URL); strip any
|
||||
# trailing slash so the Connect endpoint never doubles it.
|
||||
gateway="${PUBLIC_BASE_URL%/}"
|
||||
{
|
||||
echo "tag=$desc"
|
||||
echo "name=$v"
|
||||
echo "code=$code"
|
||||
echo "gateway=$gateway"
|
||||
} >> "$GITHUB_OUTPUT"
|
||||
echo "release $desc -> versionName $v versionCode $code, gateway $gateway"
|
||||
|
||||
# Same release + fetch as the Go/UI jobs in ci.yaml — the bundled dicts come from this tarball,
|
||||
# NOT the scrabble-solver sibling (ui is a Node project outside go.work).
|
||||
- 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: Set up Node
|
||||
uses: actions/setup-node@v4
|
||||
with:
|
||||
node-version: 22
|
||||
|
||||
- name: Install pnpm
|
||||
run: npm install -g pnpm@11.0.9
|
||||
|
||||
- name: Install deps
|
||||
working-directory: ui
|
||||
run: pnpm install --frozen-lockfile
|
||||
|
||||
- name: Build the SPA (native flavour)
|
||||
working-directory: ui
|
||||
env:
|
||||
VITE_GATEWAY_URL: ${{ steps.prep.outputs.gateway }}
|
||||
VITE_APP_VERSION: ${{ steps.prep.outputs.tag }}
|
||||
run: pnpm run build
|
||||
|
||||
# Copy the release DAWGs into dist/dict/<variant>@<version>.dawg for the offline-first bundled tier
|
||||
# (after the build, before cap sync copies dist/ into the native assets).
|
||||
- name: Bundle the dictionaries
|
||||
working-directory: ui
|
||||
env:
|
||||
DICT_DIR: ${{ github.workspace }}/dawg
|
||||
run: node scripts/bundle-dicts.mjs
|
||||
|
||||
- name: Set up JDK 21
|
||||
uses: actions/setup-java@v4
|
||||
with:
|
||||
distribution: temurin
|
||||
java-version: "21"
|
||||
|
||||
# Local cap binary (dodges the corepack pre-flight flake); syncs dist/ (incl. dict/) + native deps.
|
||||
- name: Sync the native project
|
||||
working-directory: ui
|
||||
run: node_modules/.bin/cap sync android
|
||||
|
||||
- name: Decode the release keystore
|
||||
id: keystore
|
||||
env:
|
||||
ANDROID_KEYSTORE_BASE64: ${{ secrets.ANDROID_KEYSTORE_BASE64 }}
|
||||
run: |
|
||||
if [ -n "$ANDROID_KEYSTORE_BASE64" ]; then
|
||||
printf '%s' "$ANDROID_KEYSTORE_BASE64" | base64 -d > "${GITHUB_WORKSPACE}/release.jks"
|
||||
echo "file=${GITHUB_WORKSPACE}/release.jks" >> "$GITHUB_OUTPUT"
|
||||
echo "keystore decoded -> signed release build"
|
||||
else
|
||||
echo "file=" >> "$GITHUB_OUTPUT"
|
||||
echo "::warning::ANDROID_KEYSTORE_BASE64 is not set — building an UNSIGNED release APK (not installable/publishable)"
|
||||
fi
|
||||
|
||||
- name: Assemble the release APK
|
||||
working-directory: ui/android
|
||||
env:
|
||||
ANDROID_KEYSTORE_FILE: ${{ steps.keystore.outputs.file }}
|
||||
ANDROID_KEYSTORE_PASSWORD: ${{ secrets.ANDROID_KEYSTORE_PASSWORD }}
|
||||
ANDROID_KEY_ALIAS: ${{ secrets.ANDROID_KEY_ALIAS }}
|
||||
ANDROID_KEY_PASSWORD: ${{ secrets.ANDROID_KEY_PASSWORD }}
|
||||
run: ./gradlew assembleRelease -PversionCode=${{ steps.prep.outputs.code }} -PversionName=${{ steps.prep.outputs.name }} --console=plain
|
||||
|
||||
- name: Upload the APK artifact
|
||||
uses: actions/upload-artifact@v4
|
||||
with:
|
||||
name: erudit-${{ steps.prep.outputs.name }}-apk
|
||||
path: ui/android/app/build/outputs/apk/release/*.apk
|
||||
if-no-files-found: error
|
||||
@@ -214,9 +214,21 @@ jobs:
|
||||
run: pnpm exec playwright install chromium webkit
|
||||
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)
|
||||
run: pnpm run test:e2e
|
||||
timeout-minutes: 5
|
||||
env:
|
||||
E2E_DICT_DIR: ${{ github.workspace }}/dawg
|
||||
|
||||
# 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:
|
||||
@@ -252,6 +264,7 @@ jobs:
|
||||
run: |
|
||||
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/movegen -dawg-dir "${GITHUB_WORKSPACE}/dawg" -out "${GITHUB_WORKSPACE}/movegold"
|
||||
|
||||
- name: Set up Node
|
||||
uses: actions/setup-node@v4
|
||||
@@ -271,6 +284,7 @@ jobs:
|
||||
DICT_DAWG_DIR: ${{ github.workspace }}/dawg
|
||||
DICT_GOLD_DIR: /tmp/dictgold
|
||||
DICT_VALID_DIR: /tmp/validgold
|
||||
DICT_MOVEGEN_DIR: ${{ github.workspace }}/movegold
|
||||
run: pnpm exec vitest run src/lib/dict/
|
||||
|
||||
# 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.
|
||||
# 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
|
||||
# some test jobs were path-skipped.
|
||||
needs: [gate]
|
||||
if: ${{ (github.event_name == 'push' && github.ref == 'refs/heads/development') || (github.event_name == 'pull_request' && github.base_ref == 'development') }}
|
||||
# some test jobs were path-skipped. Skipped entirely when neither the Go nor the
|
||||
# UI side changed (e.g. a docs-only change): the contour image is unchanged, so
|
||||
# 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
|
||||
defaults:
|
||||
run:
|
||||
@@ -335,6 +353,11 @@ jobs:
|
||||
# for the server-side confidential code exchange — a SEPARATE VK app from the Mini
|
||||
# App above. One VK ID "Web" app serves every contour -> unprefixed secret.
|
||||
GATEWAY_VK_ID_CLIENT_SECRET: ${{ secrets.GATEWAY_VK_ID_CLIENT_SECRET }}
|
||||
# Client-version gate (ARCHITECTURE.md §2): one plain (unprefixed) variable serves every
|
||||
# contour. In the test contour the stamped client version is a commit hash (unparseable ⇒
|
||||
# fail-open), so setting these only enforces on real semver prod builds. Empty ⇒ dormant.
|
||||
GATEWAY_MIN_CLIENT_VERSION: ${{ vars.GATEWAY_MIN_CLIENT_VERSION }}
|
||||
GATEWAY_RECOMMENDED_CLIENT_VERSION: ${{ vars.GATEWAY_RECOMMENDED_CLIENT_VERSION }}
|
||||
# Planted honeytoken bearer: presenting it flags the caller (logs + a ban metric on
|
||||
# test where the IP ban is off; a 24h IP ban on prod). Per-contour secret; empty = trap off.
|
||||
GATEWAY_HONEYTOKEN: ${{ secrets.TEST_GATEWAY_HONEYTOKEN }}
|
||||
@@ -348,6 +371,13 @@ jobs:
|
||||
SMTP_RELAY_HOST: ${{ vars.SMTP_RELAY_HOST }}
|
||||
SMTP_RELAY_PORT: ${{ vars.SMTP_RELAY_PORT }}
|
||||
SMTP_RELAY_TLS: ${{ vars.SMTP_RELAY_TLS }}
|
||||
# Direct-rail (Robokassa) sandbox intake on the contour: the test shop's merchant login +
|
||||
# Password1/Password2. IsTest is forced to 1 below so the contour can never take real money
|
||||
# (independent of the shop's own mode). Empty login leaves the direct rail off.
|
||||
ROBOKASSA_MERCHANT_LOGIN: ${{ secrets.TEST_BACKEND_ROBOKASSA_MERCHANT_LOGIN }}
|
||||
ROBOKASSA_PASSWORD1: ${{ secrets.TEST_BACKEND_ROBOKASSA_PASSWORD1 }}
|
||||
ROBOKASSA_PASSWORD2: ${{ secrets.TEST_BACKEND_ROBOKASSA_PASSWORD2 }}
|
||||
ROBOKASSA_TEST: "1"
|
||||
SMTP_RELAY_FROM: ${{ vars.TEST_SMTP_RELAY_FROM }}
|
||||
# Operator alerts: backend admin emails (new feedback / complaints) + Grafana
|
||||
# infra alerts. Distinct senders + recipients; Grafana uses the relay's STARTTLS
|
||||
@@ -382,6 +412,9 @@ jobs:
|
||||
# the VK ID redirect URL is derived from PUBLIC_BASE_URL in the run step below.
|
||||
VITE_VK_APP_LINK: ${{ vars.VITE_VK_APP_LINK }}
|
||||
VITE_VK_APP_ID: ${{ vars.VITE_VK_APP_ID }}
|
||||
# Rewarded-ad test stub: set TEST_VITE_ADS_STUB=1 to swap real ads for a toast on the
|
||||
# contour (empty = real ads, for capturing the real VK ad result). Prod never sets it.
|
||||
VITE_ADS_STUB: ${{ vars.TEST_VITE_ADS_STUB }}
|
||||
# VITE_GATEWAY_URL omitted: the SPA is served same-origin, so it stays the
|
||||
# compose ":-" empty default. Other unset vars likewise fall to their defaults.
|
||||
POSTGRES_DB: ${{ vars.TEST_POSTGRES_DB }}
|
||||
@@ -479,6 +512,83 @@ jobs:
|
||||
docker logs --tail 50 scrabble-backend || true
|
||||
exit 1
|
||||
|
||||
- name: Probe the /offer/ public offer page is served
|
||||
run: |
|
||||
set -u
|
||||
# /offer/ is rendered by the render sidecar: it splices the live catalog price list
|
||||
# (fetched from the backend's internal endpoint) into the committed ui/legal/offer_ru.md.
|
||||
# If the @offer caddy route is missing, the request falls to the landing shell (also 200),
|
||||
# and if the backend fetch fails the sidecar returns 502 — so assert offer-specific content
|
||||
# (the seller INN, §11) AND that the pricing marker was substituted (proves the fetch +
|
||||
# splice ran), never just the status. Data-independent: an empty catalog still substitutes
|
||||
# the marker with nothing, so "pricing_template" must be absent either way.
|
||||
# Full body is needed (the INN is in §11 and the marker check spans the page), so this
|
||||
# cannot be a head-only probe like the legal one. Retry with a timeout instead: the offer is
|
||||
# now bilingual (RU + EN, larger), and a single-shot fetch can race the renderer's restart in
|
||||
# the same deploy.
|
||||
ok=
|
||||
for i in $(seq 1 20); do
|
||||
out="$(docker run --rm --network edge alpine:3.20 wget -q -T 10 -O - http://scrabble/offer/ 2>&1 || true)"
|
||||
if echo "$out" | grep -q "290210610742" && ! echo "$out" | grep -q "pricing_template"; then
|
||||
echo "ok: /offer/ serves the rendered offer with the price list spliced in"
|
||||
ok=1
|
||||
break
|
||||
fi
|
||||
sleep 3
|
||||
done
|
||||
if [ -z "$ok" ]; then
|
||||
echo "FAIL: /offer/ did not serve the rendered offer (route fell through, or the price splice failed)"
|
||||
docker logs --tail 50 scrabble-renderer || true
|
||||
docker logs --tail 50 scrabble-backend || true
|
||||
docker logs --tail 50 scrabble-landing || true
|
||||
exit 1
|
||||
fi
|
||||
|
||||
- name: Probe the /privacy/ and /eula/ legal pages are served
|
||||
run: |
|
||||
set -u
|
||||
# /privacy/ and /eula/ are static legal markdown rendered by the render sidecar. If the
|
||||
# @legal caddy route is missing they fall to the landing shell (also 200, canonical
|
||||
# erudit-game.ru/), so assert the page-specific canonical URL — it uniquely identifies the
|
||||
# rendered legal doc reaching the edge, not the landing catch-all. Read only the <head>
|
||||
# (head -c 4096; the canonical link sits in the first bytes): the check is then
|
||||
# size-independent, so the large EULA page does not exceed the timeout while the monitoring
|
||||
# stack is still booting post-deploy and starving the CPU-capped renderer. Retry like the
|
||||
# landing/gateway/backend probe to ride out that window.
|
||||
for route in privacy eula; do
|
||||
ok=
|
||||
for i in $(seq 1 20); do
|
||||
head_out="$(docker run --rm --network edge alpine:3.20 sh -c "wget -q -T 10 -O - http://scrabble/${route}/ 2>/dev/null | head -c 4096" || true)"
|
||||
if printf '%s' "$head_out" | grep -qF "erudit-game.ru/${route}/"; then
|
||||
echo "ok: /${route}/ serves the rendered legal page"
|
||||
ok=1
|
||||
break
|
||||
fi
|
||||
sleep 3
|
||||
done
|
||||
if [ -z "$ok" ]; then
|
||||
echo "FAIL: /${route}/ did not serve the rendered legal page (@legal route missing?)"
|
||||
docker logs --tail 50 scrabble-renderer || true
|
||||
exit 1
|
||||
fi
|
||||
done
|
||||
|
||||
- name: Probe the /pay/ callback route reaches the gateway
|
||||
run: |
|
||||
set -u
|
||||
# /pay/robokassa/result must reach the gateway, not fall to the landing catch-all. An
|
||||
# unsigned probe is rejected downstream, so the gateway answers a 4xx/5xx (never a 200 or
|
||||
# a 404 landing.html), which proves the edge route is wired.
|
||||
out="$(docker run --rm --network edge alpine:3.20 wget -S -q -O /dev/null http://scrabble/pay/robokassa/result 2>&1 || true)"
|
||||
echo "$out" | grep -E "HTTP/" || true
|
||||
if echo "$out" | grep -qE "HTTP/1\.1 (4|5)[0-9][0-9]"; then
|
||||
echo "ok: /pay/ reaches the gateway (non-landing response)"
|
||||
else
|
||||
echo "FAIL: /pay/robokassa/result did not reach the gateway (landing catch-all?)"
|
||||
docker logs --tail 50 scrabble-gateway || true
|
||||
exit 1
|
||||
fi
|
||||
|
||||
- name: Probe the /dict edge route reaches the gateway
|
||||
run: |
|
||||
set -u
|
||||
|
||||
@@ -77,7 +77,7 @@ jobs:
|
||||
# The main-stack images via compose (reuses the build args, incl. VERSION);
|
||||
# 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 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 push "$REGISTRY/scrabble-telegram-bot:$TAG"
|
||||
|
||||
@@ -99,14 +99,33 @@ jobs:
|
||||
GRAFANA_ADMIN_PASSWORD: ${{ secrets.PROD_GRAFANA_ADMIN_PASSWORD }}
|
||||
TELEGRAM_BOT_TOKEN: ${{ secrets.PROD_TELEGRAM_BOT_TOKEN }}
|
||||
GATEWAY_VK_APP_SECRET: ${{ secrets.GATEWAY_VK_APP_SECRET }}
|
||||
# Robokassa direct-rail (backend BACKEND_ROBOKASSA_*): the prod shop login + the pass phrases
|
||||
# that sign the launch request / verify the Result callback, and the test-mode flag — a var so
|
||||
# go-live is a flag flip, not a secret redeploy ("1" runs test payments against the test
|
||||
# passwords; empty/"0" is live). An empty login leaves the direct rail disabled.
|
||||
ROBOKASSA_MERCHANT_LOGIN: ${{ secrets.PROD_BACKEND_ROBOKASSA_MERCHANT_LOGIN }}
|
||||
ROBOKASSA_PASSWORD1: ${{ secrets.PROD_BACKEND_ROBOKASSA_PASSWORD1 }}
|
||||
ROBOKASSA_PASSWORD2: ${{ secrets.PROD_BACKEND_ROBOKASSA_PASSWORD2 }}
|
||||
ROBOKASSA_TEST: ${{ vars.PROD_BACKEND_ROBOKASSA_TEST }}
|
||||
# VK ID web login: the "Web" app id (the gateway reuses it as GATEWAY_VK_ID_APP_ID at
|
||||
# runtime) + the app's protected key. Both shared across contours. The redirect URL is
|
||||
# derived from PUBLIC_BASE_URL in deploy/write-prod-env.sh.
|
||||
VITE_VK_APP_ID: ${{ vars.VITE_VK_APP_ID }}
|
||||
GATEWAY_VK_ID_CLIENT_SECRET: ${{ secrets.GATEWAY_VK_ID_CLIENT_SECRET }}
|
||||
# Client-version gate (ARCHITECTURE.md §2): plain (unprefixed) vars, shared across contours
|
||||
# (empty ⇒ dormant). On prod the stamped client version is a real semver, so the gate enforces
|
||||
# here — set GATEWAY_MIN_CLIENT_VERSION to the release in the same rollout that ships a breaking
|
||||
# wire change; bump GATEWAY_RECOMMENDED_CLIENT_VERSION (≥ min) to nudge upgrades softly.
|
||||
GATEWAY_MIN_CLIENT_VERSION: ${{ vars.GATEWAY_MIN_CLIENT_VERSION }}
|
||||
GATEWAY_RECOMMENDED_CLIENT_VERSION: ${{ vars.GATEWAY_RECOMMENDED_CLIENT_VERSION }}
|
||||
# Planted honeytoken bearer: presenting it earns a 24h IP ban + a high-severity alarm.
|
||||
# Per-contour secret; empty = trap off. Rendered by deploy/write-prod-env.sh.
|
||||
GATEWAY_HONEYTOKEN: ${{ secrets.PROD_GATEWAY_HONEYTOKEN }}
|
||||
# Community IP blocklist (Spamhaus DROP): opt-in. Set _ENABLED=true + _URL (the feed) once
|
||||
# verified; _ALLOW is a comma-separated never-block set (own infra). Empty ⇒ off.
|
||||
GATEWAY_BLOCKLIST_ENABLED: ${{ vars.PROD_GATEWAY_BLOCKLIST_ENABLED }}
|
||||
GATEWAY_BLOCKLIST_URL: ${{ vars.PROD_GATEWAY_BLOCKLIST_URL }}
|
||||
GATEWAY_BLOCKLIST_ALLOW: ${{ vars.PROD_GATEWAY_BLOCKLIST_ALLOW }}
|
||||
# Signs the finished-game export download URLs (backend BACKEND_EXPORT_SIGN_KEY).
|
||||
EXPORT_SIGN_KEY: ${{ secrets.PROD_EXPORT_SIGN_KEY }}
|
||||
# Transactional email via the shared Selectel relay (confirm-codes): one account for
|
||||
@@ -135,6 +154,18 @@ jobs:
|
||||
DICT_VERSION: ${{ vars.DICT_VERSION }}
|
||||
POSTGRES_DB: ${{ vars.PROD_POSTGRES_DB }}
|
||||
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
|
||||
# deploy/write-prod-env.sh, not stored variables.
|
||||
steps:
|
||||
|
||||
@@ -61,9 +61,19 @@ jobs:
|
||||
# the SAME env.sh (email / VK login / Grafana alerts survive a rollback). TELEGRAM_MINIAPP_URL
|
||||
# and GRAFANA_ROOT_URL are derived from PUBLIC_BASE_URL in deploy/write-prod-env.sh.
|
||||
GATEWAY_VK_APP_SECRET: ${{ secrets.GATEWAY_VK_APP_SECRET }}
|
||||
# Robokassa direct-rail: the rollback re-renders the same runtime env (write-prod-env.sh), so
|
||||
# it must carry the same credentials or the direct rail goes dark after a rollback.
|
||||
ROBOKASSA_MERCHANT_LOGIN: ${{ secrets.PROD_BACKEND_ROBOKASSA_MERCHANT_LOGIN }}
|
||||
ROBOKASSA_PASSWORD1: ${{ secrets.PROD_BACKEND_ROBOKASSA_PASSWORD1 }}
|
||||
ROBOKASSA_PASSWORD2: ${{ secrets.PROD_BACKEND_ROBOKASSA_PASSWORD2 }}
|
||||
ROBOKASSA_TEST: ${{ vars.PROD_BACKEND_ROBOKASSA_TEST }}
|
||||
VITE_VK_APP_ID: ${{ vars.VITE_VK_APP_ID }}
|
||||
GATEWAY_VK_ID_CLIENT_SECRET: ${{ secrets.GATEWAY_VK_ID_CLIENT_SECRET }}
|
||||
GATEWAY_HONEYTOKEN: ${{ secrets.PROD_GATEWAY_HONEYTOKEN }}
|
||||
# Community IP blocklist — rendered on rollback too so a rollback keeps the same edge policy.
|
||||
GATEWAY_BLOCKLIST_ENABLED: ${{ vars.PROD_GATEWAY_BLOCKLIST_ENABLED }}
|
||||
GATEWAY_BLOCKLIST_URL: ${{ vars.PROD_GATEWAY_BLOCKLIST_URL }}
|
||||
GATEWAY_BLOCKLIST_ALLOW: ${{ vars.PROD_GATEWAY_BLOCKLIST_ALLOW }}
|
||||
EXPORT_SIGN_KEY: ${{ secrets.PROD_EXPORT_SIGN_KEY }}
|
||||
SMTP_RELAY_USER: ${{ secrets.SMTP_RELAY_USER }}
|
||||
SMTP_RELAY_PASS: ${{ secrets.SMTP_RELAY_PASS }}
|
||||
@@ -77,6 +87,16 @@ jobs:
|
||||
SERVICE_EMAIL: ${{ vars.PROD_SERVICE_EMAIL }}
|
||||
GRAFANA_SMTP_PORT: ${{ vars.GRAFANA_SMTP_PORT }}
|
||||
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 }}
|
||||
steps:
|
||||
- uses: actions/checkout@v4
|
||||
|
||||
@@ -156,3 +156,11 @@ The `ui` module is a Node project (pnpm), **not** in `go.work`; it is the `ui` j
|
||||
the single `.gitea/workflows/ci.yaml`. Committed edge codegen under `ui/src/gen/`
|
||||
(regenerate with `pnpm codegen`); pnpm build-script approval lives in
|
||||
`ui/pnpm-workspace.yaml` (`allowBuilds: esbuild: true`).
|
||||
|
||||
## Agent field notes
|
||||
|
||||
Non-obvious, hard-won knowledge the agent has accumulated that is **not** captured in the docs above,
|
||||
kept in the repo so it travels with a clone. Verify any named file/flag against current code before
|
||||
acting on it.
|
||||
|
||||
@.claude/CLAUDE.md
|
||||
|
||||
@@ -0,0 +1,909 @@
|
||||
# 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 (E0–E9) 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 | DONE |
|
||||
| E5 | Payment intake | 2 | DONE |
|
||||
| E6 | Ads | 2 | DONE |
|
||||
| E7 | Admin, reports & catalog | 2 | DONE |
|
||||
| E8 | Guest limits | — | DONE |
|
||||
| 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:** DONE — armed + restore-drilled on prod (v1.13.0, 2026-07-09). · **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 (completed 2026-07-09 with the v1.13.0 release).** Owner created the Selectel S3
|
||||
bucket (`erudite`, ru-6) + the `PROD_PGBACKREST_*` secrets/variables (incl. `ARCHIVE_MODE=on`);
|
||||
promoted `development → master` → `prod-deploy` (archive_mode on behind the maintenance window),
|
||||
then `stanza-create` + first base backup (31.9 MB cluster → 3.7 MB in the repo) + `check`, the
|
||||
Ansible `-e pitr_enabled=true` timer, and a restore drill on an isolated one-shot target (data
|
||||
intact, then wiped). 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 (met).** WAL archiving live on prod PG (v1.13.0); a restore verified on an
|
||||
isolated one-shot target; 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. Manual/timer pgBackRest runs use `docker exec -u postgres … --pg1-user=scrabble`
|
||||
(docker exec is root; the DB superuser role is `scrabble`, not `postgres`) — the systemd timer
|
||||
+ the runbook carry this. Enabling `archive_mode` restarts postgres (rode the prod-deploy
|
||||
maintenance window). Migrations stay expand-contract so image rollback remains DB-safe with PITR.
|
||||
|
||||
---
|
||||
|
||||
## E5 — Payment intake
|
||||
|
||||
**Status:** DONE · **Release 2** · depends on: E0, E1, E2, E4 · mechanics: PAYMENTS §9, §12.
|
||||
|
||||
**Delivery & baked decisions.** Shipped as a linear PR stack (owner's choice), Robokassa first.
|
||||
Resolved: match the order by a Robokassa **`Shp_order`** custom parameter, not the numeric `InvId`
|
||||
(an order id is a uuid); idempotency key = the order id (`provider_payment_id = order_id`); the НПД
|
||||
receipt is formed **shop-side in the Robokassa cabinet**, so no `Receipt` parameter is sent; a
|
||||
chargeback **never drives the balance negative** (D27 stands, `balances_chips_chk` kept), so E5 is
|
||||
**schema-free** (no migration, no contour wipe). Delivered on `feature/payment-intake-robokassa`:
|
||||
the offer page (`/offer/`), the order/`fund` engine (idempotent, honours an expired order), the
|
||||
`internal/robokassa` adapter, the `POST /wallet/order` + internal Result-callback handlers (a D36
|
||||
confirmed-email gate on `direct`), the pending reaper, the `wallet.order` edge wire + the public
|
||||
`/pay/*` routes, the Wallet purchase CTA, the contour deploy env (IsTest forced), and the
|
||||
`payment_events` dispatcher — an in-app wallet-refresh push (KindNotification `"payment"`) with a
|
||||
self-closing provider-return page (the payment opens in a separate window) and a return-focus
|
||||
refetch fallback. The **VK Votes rail** is delivered too: the client opens
|
||||
`VKWebAppShowOrderBox({item: order_id})`; a two-phase signed server callback (`get_item` → the pack
|
||||
title + vote price; a chargeable `order_status_change` → the same `Fund` with source=`vk`, idempotent
|
||||
on VK's own order id) is verified at the gateway with the app protected key (`GATEWAY_VK_APP_SECRET`,
|
||||
already deployed) and proxied to the backend intake. The **Telegram Stars rail** is delivered on
|
||||
`feature/payment-intake-tg-stars`: only the bot reaches Telegram, so the **invoice is minted by the
|
||||
bot** — on the `wallet.order` path the gateway sends a new `CreateInvoice` command over the reverse
|
||||
bot-link and the bot returns the `createInvoiceLink` (XTR) in its Ack, handed to the client's
|
||||
`WebApp.openInvoice`. The bot answers `pre_checkout_query` via a new bot→gateway **`ValidatePreCheckout`**
|
||||
unary (backed by the backend: the order must exist, be still creditable and **not already paid** — the
|
||||
reusable-invoice double-pay guard — with a matching amount); the decline reason is localised to the
|
||||
order account's language. A completed `successful_payment` is persisted to a pure-Go **SQLite outbox**
|
||||
(`modernc.org/sqlite`) then forwarded by a new bot→gateway **`ForwardPayment`** unary into the same
|
||||
`Fund` (source=`telegram`, idempotent on `telegram_payment_charge_id`, honours an expired order),
|
||||
re-driven at startup and every 30 s. The rail is wired by `TELEGRAM_STARS_OUTBOX_DIR` (defaults to the
|
||||
bot `/data` volume) but stays **inert until a chip pack carries an XTR price**, so seeding a Stars price
|
||||
in the admin is the go-live. Finally **refunds** are delivered on `feature/payment-intake-refunds`: a
|
||||
single `Refund` engine (`internal/payments`) reverses a paid order best-effort, exactly once —
|
||||
idempotent on `(provider, provider_refund_id)`, revoking the funded chips **floored at 0** (never
|
||||
negative, D27), and recording the unrecoverable remainder (chips already spent) as a per-account
|
||||
**loss + abuse flag** in the new additive `payments.account_risk` table (read by the E7 report). The
|
||||
refund ledger row's chip delta is what was actually reclaimed (the ledger stays reconcilable); the
|
||||
full reversal rides in its snapshot; the order stays `paid`. **No rail pushes an unsolicited refund**
|
||||
— all are admin-triggered (E7): Robokassa refund API / cabinet (auto-polling deferred — a worker not
|
||||
worth it at low chargeback volume), VK via support, Telegram `refundStarPayment`. `failed` events are
|
||||
not wired (no rail signals a hard post-charge server decline). The migration is **additive** (a new
|
||||
table only), so E5 stays rollback-safe / no contour wipe. That closes E5. Deferred to a later stage:
|
||||
hiding the ad banner on a no-ads purchase (a spend-path `NotifyBanner`, with the owner's agreement).
|
||||
|
||||
**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. A **SQLite**
|
||||
store on the bot's disk (`internal/outbox`): on receipt, persist → forward over the reverse
|
||||
mTLS bot-link to the **gateway** (a `ForwardPayment` unary; the bot cannot dial the backend
|
||||
directly) → the gateway proxies to the backend payments-intake REST → on a durable response,
|
||||
mark `forwarded`. Re-drive undelivered on startup and on a 30 s tick. Backend intake dedups by
|
||||
`telegram_payment_charge_id`.
|
||||
- Invoice creation (Stars) is minted by the bot (`createInvoiceLink`, XTR) on the gateway's
|
||||
`CreateInvoice` bot-link command, returned to the Mini App as the `openInvoice` link.
|
||||
|
||||
**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. **All refunds are admin-triggered** (E7): no rail pushes an
|
||||
unsolicited refund — Robokassa refund API / cabinet (auto-polling deferred as a low-value worker),
|
||||
VK via support, Telegram `refundStarPayment`. One `Refund` engine reverses a paid order best-effort,
|
||||
exactly once (idempotent on `(provider, provider_refund_id)`): revoke floored at 0 (never negative),
|
||||
unrecoverable remainder → per-account loss + abuse flag (`payments.account_risk`), a `refund` ledger
|
||||
row (chip delta = revoked, full reversal in the snapshot). `failed` events are not wired (no rail
|
||||
signals a hard post-charge server decline). 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:** DONE · **Release 2** · depends on: E2 (chips), E5 (rewarded credits via intake) ·
|
||||
mechanics: PAYMENTS §10.
|
||||
|
||||
**Delivery & baked decisions.** Shipped as a linear PR stack (owner's choice): **rewarded first**,
|
||||
then interstitial. Baked: the interstitial cooldowns already exist in `payments.config` (E0) and the
|
||||
per-origin banner suppression is already done (E2 `AdFree`), so E6 is the two ad DISPLAY paths + the
|
||||
rewarded credit. **VK reality (checked live in the VK docs via Playwright):** VK Mini App ads
|
||||
(`VKWebAppShowNativeAds`, both `reward` and `interstitial`) expose **only a client-side `data.result`
|
||||
boolean** — no server verify, no signature. So **D29 is amended**: rewarded is **client-attested**,
|
||||
guarded by a server **daily + hourly cap** (config `reward_daily_cap` / `reward_hourly_cap`, default
|
||||
50 / 10) that is both anti-abuse and an economic conversion lever (limits free chips so players buy);
|
||||
the cooldown state for the interstitial is **client-mirrored** (owner's pick). Delivered on
|
||||
`feature/ads-rewarded` (the **rewarded** slice): the ads-network abstraction (`ui/src/lib/ads.ts`, VK
|
||||
impl) + the VK bridge (`vkRewardedReady` / `vkShowRewarded`), the backend `CreditReward` (VK-only,
|
||||
order-less, idempotent on a client nonce, floored by the caps, payout from config
|
||||
`rewarded_payout_chips` default 0 = off), the `wallet.reward` edge op returning the updated wallet
|
||||
(with `reward_chips` gating the "watch for chips" CTA), and a **contour test stub** (`VITE_ADS_STUB` →
|
||||
a toast instead of a real ad; prod always real). A temporary diagnostic confirmed on the contour that
|
||||
VK returns **only `{result:true}`** (no token/signature) — client-attested is final, no hardening
|
||||
possible; the diagnostic is removed. The slice also **corrects the VK-iOS freeze to purchase-only**
|
||||
(rewarded on VK-iOS earns chips, which the old blanket "spend freeze" then blocked from spending —
|
||||
Apple forbids only *buying* in-app values, not spending or earning them; `vkFrozen()` now gates only
|
||||
`CreateOrder`, not `spendableSources`, so VK-wallet chips spend on VK-iOS). Delivered on
|
||||
`feature/ads-interstitial` (the **interstitial + D31** slice): the post-move fullscreen interstitial
|
||||
as a **client-mirrored** gate — the backend `adsFor` puts the config cooldowns + a `suppressed` flag
|
||||
(the no-ads / `no_banner` gate, same as the banner) on the profile (`Profile.ads`), and
|
||||
`ui/src/lib/ads.ts` `maybeShowInterstitial` self-gates on the last-shown time per kind in
|
||||
`localStorage`, showing a VK interstitial (`vkShowInterstitial`) after a **confirmed play or a hint
|
||||
only** (never a pass / exchange / resign), VK-only, offline banner-only, with the same `VITE_ADS_STUB`
|
||||
toast on the contour. The slice also lands **D31 step 1 (contract-code)**: the domain no longer reads
|
||||
or writes the deprecated `accounts.hint_balance` / `paid_account` columns — the `Account` fields, the
|
||||
dead `account.SpendHint`, `account.GrantHints` and the admin **grant-hints** action are removed, and
|
||||
the in-game hint display now comes wholly from the payments benefit (`HintsAvailable`). The **columns
|
||||
stay** (no migration → image rollback is DB-safe); a later contract-PR does the `DROP` once E6 is
|
||||
stable on prod.
|
||||
|
||||
**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 is **client-mirrored**
|
||||
(the chosen option): the server sends the cooldowns + `suppressed` on the profile and the
|
||||
client self-gates on a per-kind last-shown time in `localStorage` — no per-move round-trip.
|
||||
- **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 & catalog
|
||||
|
||||
**Status:** DONE · **Release 2** · depends on: E2 (ledger/grant/spend), E5 (payments/refunds),
|
||||
E6 (D31 retired the legacy `hint_balance`/`paid_account`) · mechanics: PAYMENTS §11, §12, D32.
|
||||
|
||||
**Delivery & baked decisions (this planning round).** A linear PR stack into `development`.
|
||||
|
||||
- **Archived product = the existing `product.active` flag** (no new column, no migration):
|
||||
`active=false` **is** "archived". Its three behaviours already hold — hidden from the user
|
||||
storefront (`store_catalog.go` filters `active`), an **in-flight external payment still
|
||||
credits** (the `fund` credit path resolves the order and never re-checks `active`; only order
|
||||
*creation* / chip *spend* require `active`), and a product with any order/ledger row **cannot
|
||||
be hard-deleted** (FK `orders→product` / `ledger→product` are RESTRICT). The admin toggle is
|
||||
labelled **Archive / Unarchive**.
|
||||
- **Delete vs archive:** the editor offers a hard **Delete** only for a product with **no
|
||||
orders and no ledger rows** (never transacted — the FK is the DB backstop); a transacted
|
||||
product is **archive-only**.
|
||||
- **Admin grant = raw atoms + by-product.** Keep the quick raw-atom grant (N hints / no-ads
|
||||
days / forever) AND add grant-by-product: pick a defined product (including archived "reward"
|
||||
bundles) and grant its atoms. Both write an `admin_grant` ledger row; by-product records
|
||||
`product_id` + the snapshot. **Both refuse any set containing `chips`** (admin never grants
|
||||
currency) **or `tournament`** (no credit target until E9) — an explicit refusal, never a
|
||||
silent no-op.
|
||||
- **Manual refund = full order only** for now (the E5 `Refund` engine takes an amount; partial
|
||||
is a later add if needed).
|
||||
- **Tournament stays atom-only (E0); its entry economy is E9.** The catalog editor can compose
|
||||
products carrying the `tournament` atom (archived templates for E9), but granting/spending a
|
||||
`tournament` atom is refused until E9 designs the storage (recurring types, each its own
|
||||
benefit + price) — see E9. Adding a `benefits.tournament` counter now was rejected: the model
|
||||
is multi-type, so a single column would be wrong and force a second DB break.
|
||||
- The admin grant **no longer mirrors `grant-hints`** (E6/D31 removed that action); it is a
|
||||
fresh action on `payments.Grant`.
|
||||
|
||||
**Goal.** The admin console financial surface — per-user report, admin grant, manual refund,
|
||||
ledger export — plus the **configurable product catalog editor** (D32).
|
||||
|
||||
**Work (`/_gm`, `handlers_admin_console.go` + `adminconsole/`, `internal/payments/`).**
|
||||
|
||||
- **Per-user financial panel** on the user card (`consoleUserDetail`, `UserDetailView`): segment
|
||||
chip balances `(account, source)`, benefits `(account, origin)` (hints, no-ads until/forever),
|
||||
and the full append-only ledger (fund/spend/admin_grant/refund — amount, origin, product,
|
||||
provider, snapshot) from the ledger + materialized cache. Replaces the retired
|
||||
`PaidAccount`/`HintBalance` fields with the segmented view.
|
||||
- **Catalog editor** (`/_gm/catalog`): list every product (active + archived) with its atoms and
|
||||
per-method/currency prices; create/edit (title, atom items `atom_type→quantity`, price rows
|
||||
`method+currency→amount`); **Archive/Unarchive** (`active`); **Delete** (never-transacted
|
||||
only). Enforce the projection's shape: a **pack** (carries `chips`) needs a money price per
|
||||
method; a **value** (no `chips`) needs a single `CHIP` price. A `tournament`-bearing product is
|
||||
allowed in composition but cannot be activated for sale until E9.
|
||||
- **Admin grant** action: raw atoms (hints / no-ads days / forever) and by-product (a value
|
||||
product, incl. archived); **origin picker**; refuses `chips`/`tournament`; `admin_grant` ledger
|
||||
row (+ `product_id`/snapshot for by-product) via `payments.Grant`.
|
||||
- **Manual refund** action: refund a specific paid order **in full** — a `refund` ledger row +
|
||||
best-effort floor-0 benefit revoke (E5 `Refund`).
|
||||
- **Ledger export**: CSV/JSON for tax + future Robokassa reconciliation (export-ready;
|
||||
reconciliation itself not built).
|
||||
- Auth unchanged: gateway Basic-Auth in front of `/_gm` + backend same-origin CSRF on POSTs.
|
||||
|
||||
**Tests.**
|
||||
|
||||
- unit: panel view assembly; catalog pack/value shape projection; grant refuses chips + tournament;
|
||||
editor validation (pack⇒money price, value⇒CHIP price); export shape.
|
||||
- integration: grant (raw + by-product) writes the right `admin_grant` row + benefit; refund writes
|
||||
a `refund` row + floor-0 revoke; catalog CRUD round-trips (create→edit→archive→delete-if-clean);
|
||||
delete refused on a transacted product; panel reflects balances + benefits + history.
|
||||
|
||||
**Done-criteria.** An operator can: see a user's full financial picture; create/edit/archive
|
||||
products + prices and delete only never-transacted ones; grant concrete values raw or by-product
|
||||
(origin-picked, never chips/tournament); refund an order in full; export the ledger.
|
||||
|
||||
**PR stack (linear into `development`, all merged).**
|
||||
|
||||
1. ~~**Per-user financial panel**~~ (#230) — read-only ledger/segments/benefits on the user card;
|
||||
retired the `PaidAccount`/`HintBalance` display.
|
||||
2. ~~**Catalog editor**~~ (#231) — product/atom/price CRUD + archive/unarchive + delete-if-clean +
|
||||
shape validation.
|
||||
3. ~~**Admin grant**~~ (#232, + the D31 hint-wallet wire cleanup that surfaced there) — raw +
|
||||
by-product, refuse chips/tournament, origin-picked, `admin_grant` + snapshot.
|
||||
4. ~~**Manual refund** (full order via E5 `Refund`) + **ledger CSV export**~~ — `RefundOrderFull`
|
||||
(idempotent, floor-0 revoke) on each fund row; `/_gm/ledger.csv`.
|
||||
|
||||
**Notes/risks.** High-blast-radius (money, ledger — append-only, trigger-enforced). No mixed-in
|
||||
refactors. The catalog editor becomes the source of truth for products; the contour SQL seeds
|
||||
become bootstrap-only.
|
||||
|
||||
---
|
||||
|
||||
## E8 — Guest limits
|
||||
|
||||
**Status:** DONE · **standalone** (game-behaviour change) · depends on: none · mechanics: PAYMENTS §6.
|
||||
|
||||
**Goal.** A registration funnel: cap what a guest can do, enforced **server-side** (today UI-only),
|
||||
with configurable per-tier × per-kind limits so the pressure tunes without a release.
|
||||
|
||||
**Finding (verified).** Two gaps. (a) The friend/invitation paths do **not** check `is_guest` on the
|
||||
server — only the UI hides them: `social/friends.go`, `robotfriends.go`, `friendcodes.go`,
|
||||
`lobby/invitations.go`. A guest with a valid `X-User-ID` can call them. (b) A **pre-existing** flat
|
||||
cap already existed: `game.MaxActiveQuickGames`=10 — a **combined** cap on `active`+`open` quick
|
||||
games (vs_ai+random together, friend games excluded), counted by `CountActiveQuickGames`, enforced at
|
||||
the handler (`ensureUnderGameLimit`) with **409 `game_limit_reached`**, surfaced as `at_game_limit`.
|
||||
E8's per-tier × per-kind config **subsumes and replaces** it (decided: option A).
|
||||
|
||||
**Delivery & baked decisions (this planning round).** A linear PR stack; E8 is game-behaviour, no
|
||||
payments mixed in.
|
||||
|
||||
- **`games.game_kind smallint DEFAULT 0`** (0=unknown — pre-E8 games, never gated; 1=vs_ai; 2=random;
|
||||
3=friends), set on creation (`StartVsAI`=1, `Enqueue`=2, a friend invitation=3). Existing games
|
||||
stay 0 and fall outside the gate.
|
||||
- **Limits are per-tier × per-kind**, in a **new single-row `backend.config`** table:
|
||||
`guest_{vs_ai,random,friends}_limit` + `durable_{vs_ai,random,friends}_limit` (smallint,
|
||||
**`-1`=unlimited**), seeded `(1,1,0, 10,10,10)`. A guest is capped at 1 vs_ai + 1 random (friends
|
||||
is moot — the guest gate blocks friend games); a durable account is **10 per kind** — the old flat
|
||||
MaxActiveQuickGames=10, now split per kind. Editable in the admin.
|
||||
- **The old flat cap is removed** (option A, owner-agreed): `MaxActiveQuickGames`,
|
||||
`CountActiveQuickGames`, `atGameLimit` deleted; the per-tier/kind config is the single mechanism,
|
||||
enforced at the **same handler gate** (`ensureUnderGameLimit(kind)` for `enqueue`
|
||||
random/vs_ai) plus the durable **friends cap** inside `CreateInvitation`. The gate stays out of the
|
||||
game domain — `game.Service.AtGameLimit(account, kind)` only resolves tier + counts.
|
||||
- **A hot in-memory cache** fronts the config (read once on start, invalidated on the admin edit —
|
||||
mirrors the payments read-cache) so a login / game-create never queries it.
|
||||
- **"Active" = `open` + `active`** (an open, unmatched random game holds a slot); the limit is on
|
||||
**creation** — an existing game is grandfathered, never interrupted.
|
||||
- **Limits ride the wire (forward-compat, no double break):** `Profile.game_limits`
|
||||
(`GameLimits{vs_ai, random, friends}` — the caller's tier resolved server-side) + `GameView.kind`,
|
||||
so the client counts active games **per kind** from its lobby and locks the right start button. On
|
||||
a guest → durable upgrade (register / link) the client **re-fetches the profile** so the new
|
||||
(durable) limits apply. The client picks the lock's message by tier: a **guest** → the login funnel;
|
||||
a **durable** account at its cap → a plain "finish a current game first" notice.
|
||||
|
||||
**Work.**
|
||||
|
||||
- ~~**PR1 — backend + admin (server-side)** — DONE.~~ The migration (`game_kind` + `backend.config`,
|
||||
seeded `1,1,0 / 10,10,10` + jetgen); `game_kind` set on creation and projected onto `game.Game`;
|
||||
the **guest gate** (`ErrGuestForbidden`, mapped to **403 `guest_forbidden`**) on friend-request /
|
||||
redeem-code / befriend-in-game / invitation-create; the **active-limit enforce** — the old flat
|
||||
`MaxActiveQuickGames` mechanism removed and replaced by `ensureUnderGameLimit(kind)` on `enqueue`
|
||||
(random/vs_ai) plus the durable friends cap in `CreateInvitation`, keyed off
|
||||
`game.Service.AtGameLimit`; the **`internal/gamelimits` config + hot cache** (loaded at boot,
|
||||
invalidated on edit); the admin **kind column** in both game lists (`/_gm/games` + the user card)
|
||||
and a **config editor** (`/_gm/limits`) for the six limits.
|
||||
- ~~**PR2 — wire + client** — DONE.~~ `Profile.game_limits` (the caller's tier) + `GameView.kind` (FBS
|
||||
+ gateway transcode + client codec, committed regen); the client counts active games per kind from
|
||||
the lobby cache (`gamelimits.ts`) and locks a capped new-game start — an **outline 🔒** button that
|
||||
opens `GameLimitModal` instead of a game, native (Telegram `showPopup`) or the in-app `Modal`
|
||||
elsewhere, with two messages by tier: a **guest** sign-in funnel ("Войдите или создайте учётную
|
||||
запись…", Отмена / Вход→`/settings`) and, for a **durable** account at its cap, a plain notice
|
||||
("Вы достигли лимита одновременных игр, сначала завершите текущие", ОК). The lock lifts via the
|
||||
existing profile re-fetch after a guest→durable upgrade.
|
||||
- **Decision (owner-agreed): the lobby's old `at_game_limit` New-Game tab-disable + notice is
|
||||
removed.** The `at_game_limit` flag (now the random-kind cap) conflicted with the per-kind lock —
|
||||
it hid the New-Game screen where the lock lives, and wrongly blocked starting an unfulfilled kind.
|
||||
The tab is always enabled; the per-kind lock on the start button is the only gate. The wire field
|
||||
`GameList.at_game_limit` stays (unused by the client) for a later cleanup.
|
||||
|
||||
**Tests.**
|
||||
|
||||
- integration (PR1, done): `game_kind` persisted per path; guest refused friend/redeem/invitation
|
||||
(domain + HTTP 403); guest blocked from a 2nd vs_ai / 2nd random (+ `at_game_limit`); durable on the
|
||||
higher tier; the durable friends cap + config cache reflecting an admin edit; accept stays exempt.
|
||||
- unit (PR1, done): the per-tier/kind limit resolution (`Cap`, `LimitsFor`).
|
||||
- unit + UI (PR2, done): the client lock logic (`gamelimits.ts` — count/cap/lock), the codec kind +
|
||||
game_limits roundtrip, the gateway transcode game_limits encode, the popup builders, and a mock e2e
|
||||
(`gamelimit.spec.ts`) — a capped start shows 🔒, opens the modal without navigating, and the lock
|
||||
clears when the profile refetch lifts the cap.
|
||||
|
||||
**Done-criteria.** A guest is server-capped (default 1 vs_ai + 1 random, configurable) and cannot
|
||||
friend/invite; a durable account is capped at 10 per kind (configurable); the client shows the lock +
|
||||
the tier-appropriate modal and the cap lifts on registration; durable flows otherwise unchanged.
|
||||
|
||||
**Notes/risks.** Game-behaviour change — own PRs, own tests, no payments mixed in. The limit is on
|
||||
creation (existing games grandfathered). The config cache is single-instance (matching the deploy).
|
||||
|
||||
---
|
||||
|
||||
## E9 — Tournament fee (future)
|
||||
|
||||
**Status:** TODO · **future** · depends on: E0 (atom provisioned), tournament feature ·
|
||||
mechanics: PAYMENTS §5, §7.
|
||||
|
||||
**Goal.** A chip entry economy for tournaments. The `tournament` atom is provisioned (E0) and
|
||||
E7's catalog editor can compose tournament products; E7 deliberately **defers the entry storage
|
||||
+ credit/spend** here, to avoid guessing the schema.
|
||||
|
||||
**Design to settle here (not before — avoid a double DB break).** Tournaments are expected in
|
||||
**several recurring types** (daily / weekly / monthly …), each its **own benefit with its own
|
||||
price** — so a single `benefits.tournament` counter is wrong. Model the entry store as
|
||||
**per-tournament-type** (e.g. a `tournament_type` catalog + a per-`(account, type)` entry
|
||||
balance), design the pricing, then:
|
||||
|
||||
- lift E7's **refusal** of granting/spending the `tournament` atom (admin grant by-product +
|
||||
chip spend);
|
||||
- add the **spend** (charge an entry) reusing E2 `Spend`;
|
||||
- wire the coupling to the actual tournament feature (out of scope until it exists).
|
||||
|
||||
**Done-criteria.** Deferred — revisit when tournaments are built; this stage owns the
|
||||
tournament-entry storage + pricing design.
|
||||
|
||||
---
|
||||
|
||||
## 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.
|
||||
@@ -1,41 +1,18 @@
|
||||
# Erudit — site icons + Open Graph card
|
||||
# Icons
|
||||
|
||||
The favicon set and the `og:image` link-preview card for the public landing
|
||||
(`ui/landing.html`) and the SPA shell (`ui/index.html`). Same design language as the
|
||||
[VK loading-screen logo](../vk/README.md): the wooden Erudit «Э» tile (score `8`),
|
||||
with the wordmark on the app's dark board green (`ui/src/app.css` tokens).
|
||||
|
||||
| Output (committed to `ui/public/`) | Purpose |
|
||||
|------|---------|
|
||||
| `favicon.svg` | Vector favicon, transparent; tile + «Э» only (the score is illegible below ~32 px). |
|
||||
| `favicon.ico` | 32×32 PNG-in-ICO fallback (also answers the browsers' blind `/favicon.ico` probe). |
|
||||
| `apple-touch-icon.png` | 180×180 opaque full-bleed tile; iOS masks its own corners. |
|
||||
| `og-image.png` | 1200×630 card: tile + «Эрудит / Скрэббл — игра в слова». Referenced absolutely as `https://erudit-game.ru/og-image.png`. |
|
||||
|
||||
## How it works
|
||||
|
||||
`build/extract.js` extracts the needed glyph outlines (tile glyphs + every wordmark
|
||||
character) from LiberationSans (Arial-metric, the game's font stack) with their
|
||||
advance widths into `build/glyphs.json` (committed). `build/generate.js` composes
|
||||
plain SVG from those outlines — no font is needed at generation time — writes
|
||||
`favicon.svg` and rasterises the PNG/ICO outputs by screenshotting the SVGs with the
|
||||
`ui` package's Playwright chromium (`@playwright/test`); the `.ico` container is
|
||||
assembled in-script (a single PNG entry). Raster bytes therefore depend on the
|
||||
installed chromium version; the SVG sources are deterministic.
|
||||
|
||||
## Regenerate
|
||||
|
||||
Requirements: Node ≥ 18, `ui` installed (`pnpm install`, provides Playwright).
|
||||
`extract.js` additionally needs `opentype.js` (`npm i opentype.js`); **`generate.js`
|
||||
needs no extra packages**.
|
||||
The whole shipped icon set is sliced from **one master** — see
|
||||
[`brand/`](brand/) for the reference generator, the pinned font and the committed
|
||||
outputs, and [`../../docs/ICON_BRANDBOOK.md`](../../docs/ICON_BRANDBOOK.md) for how the
|
||||
mark is constructed. The per-platform target map (which file goes where) is in
|
||||
[`../../docs/ICONS.md`](../../docs/ICONS.md).
|
||||
|
||||
```sh
|
||||
cd assets/icons
|
||||
|
||||
# 1. (optional) re-extract the glyphs — only if the font or the wordmark changes:
|
||||
# default font: /usr/share/fonts/truetype/liberation/LiberationSans-Regular.ttf
|
||||
node build/extract.js [/path/to/font.ttf] # -> build/glyphs.json
|
||||
|
||||
# 2. regenerate everything in ui/public/:
|
||||
node build/generate.js
|
||||
cd brand
|
||||
npm i opentype.js
|
||||
node build-set.mjs # web (ui/public) + Capacitor layers (ui/assets) + vk/ tg/ store/
|
||||
node build-android-res.mjs # Android launcher resources (all densities + monochrome)
|
||||
```
|
||||
|
||||
> The earlier `build/` pipeline (LiberationSans glyph outlines → favicon/apple-touch/OG)
|
||||
> was replaced by `brand/` when the icon was rebranded onto the Spectral «Э» master.
|
||||
> The separate VK loading-screen animation lives in [`../vk/`](../vk/) and is unrelated.
|
||||
|
||||
@@ -0,0 +1,2 @@
|
||||
node_modules/
|
||||
package-lock.json
|
||||
@@ -0,0 +1,32 @@
|
||||
# Erudit icon — brand master
|
||||
|
||||
The reference construction of the Erudit app icon. The authoritative, human-readable
|
||||
spec lives in [`docs/ICON_BRANDBOOK.md`](../../../docs/ICON_BRANDBOOK.md); this folder
|
||||
holds the machine reproduction of exactly what that document describes.
|
||||
|
||||
| File | What |
|
||||
|------|------|
|
||||
| `build-icon.mjs` | reference generator — every dimension is a fraction of the side, matching the brand book |
|
||||
| `render-png.mjs` | rasterises an icon SVG to PNG via the `ui` package's Playwright chromium |
|
||||
| `Spectral-Bold.ttf` | the «Э» typeface (SIL OFL, `Spectral-OFL.txt`) — pinned so the letter never drifts |
|
||||
| `icon-light.svg` / `.png` | the light master (1024) |
|
||||
| `icon-dark.svg` / `.png` | the dark master (1024) |
|
||||
| `icon-construction.svg` / `.png` | the percent-annotated construction scheme (figure in the brand book) |
|
||||
|
||||
## Regenerate
|
||||
|
||||
```sh
|
||||
cd assets/icons/brand
|
||||
npm i opentype.js # the only extra dep; render uses ui's chromium
|
||||
|
||||
node build-icon.mjs --variant light > icon-light.svg
|
||||
node build-icon.mjs --variant dark > icon-dark.svg
|
||||
node build-icon.mjs --variant light --scheme > icon-construction.svg
|
||||
|
||||
node render-png.mjs icon-light.svg icon-light.png 1024
|
||||
node render-png.mjs icon-dark.svg icon-dark.png 1024
|
||||
node render-png.mjs icon-construction.svg icon-construction.png 2048
|
||||
```
|
||||
|
||||
The SVGs are resolution-free; render at any size. Downstream slicing (favicon / PWA /
|
||||
iOS / Android) is a separate step — see [`docs/ICONS.md`](../../../docs/ICONS.md).
|
||||
@@ -0,0 +1,93 @@
|
||||
Copyright 2017 The Spectral Project Authors (https://github.com/productiontype/Spectral)
|
||||
|
||||
This Font Software is licensed under the SIL Open Font License, Version 1.1.
|
||||
This license is copied below, and is also available with a FAQ at:
|
||||
https://openfontlicense.org
|
||||
|
||||
|
||||
-----------------------------------------------------------
|
||||
SIL OPEN FONT LICENSE Version 1.1 - 26 February 2007
|
||||
-----------------------------------------------------------
|
||||
|
||||
PREAMBLE
|
||||
The goals of the Open Font License (OFL) are to stimulate worldwide
|
||||
development of collaborative font projects, to support the font creation
|
||||
efforts of academic and linguistic communities, and to provide a free and
|
||||
open framework in which fonts may be shared and improved in partnership
|
||||
with others.
|
||||
|
||||
The OFL allows the licensed fonts to be used, studied, modified and
|
||||
redistributed freely as long as they are not sold by themselves. The
|
||||
fonts, including any derivative works, can be bundled, embedded,
|
||||
redistributed and/or sold with any software provided that any reserved
|
||||
names are not used by derivative works. The fonts and derivatives,
|
||||
however, cannot be released under any other type of license. The
|
||||
requirement for fonts to remain under this license does not apply
|
||||
to any document created using the fonts or their derivatives.
|
||||
|
||||
DEFINITIONS
|
||||
"Font Software" refers to the set of files released by the Copyright
|
||||
Holder(s) under this license and clearly marked as such. This may
|
||||
include source files, build scripts and documentation.
|
||||
|
||||
"Reserved Font Name" refers to any names specified as such after the
|
||||
copyright statement(s).
|
||||
|
||||
"Original Version" refers to the collection of Font Software components as
|
||||
distributed by the Copyright Holder(s).
|
||||
|
||||
"Modified Version" refers to any derivative made by adding to, deleting,
|
||||
or substituting -- in part or in whole -- any of the components of the
|
||||
Original Version, by changing formats or by porting the Font Software to a
|
||||
new environment.
|
||||
|
||||
"Author" refers to any designer, engineer, programmer, technical
|
||||
writer or other person who contributed to the Font Software.
|
||||
|
||||
PERMISSION & CONDITIONS
|
||||
Permission is hereby granted, free of charge, to any person obtaining
|
||||
a copy of the Font Software, to use, study, copy, merge, embed, modify,
|
||||
redistribute, and sell modified and unmodified copies of the Font
|
||||
Software, subject to the following conditions:
|
||||
|
||||
1) Neither the Font Software nor any of its individual components,
|
||||
in Original or Modified Versions, may be sold by itself.
|
||||
|
||||
2) Original or Modified Versions of the Font Software may be bundled,
|
||||
redistributed and/or sold with any software, provided that each copy
|
||||
contains the above copyright notice and this license. These can be
|
||||
included either as stand-alone text files, human-readable headers or
|
||||
in the appropriate machine-readable metadata fields within text or
|
||||
binary files as long as those fields can be easily viewed by the user.
|
||||
|
||||
3) No Modified Version of the Font Software may use the Reserved Font
|
||||
Name(s) unless explicit written permission is granted by the corresponding
|
||||
Copyright Holder. This restriction only applies to the primary font name as
|
||||
presented to the users.
|
||||
|
||||
4) The name(s) of the Copyright Holder(s) or the Author(s) of the Font
|
||||
Software shall not be used to promote, endorse or advertise any
|
||||
Modified Version, except to acknowledge the contribution(s) of the
|
||||
Copyright Holder(s) and the Author(s) or with their explicit written
|
||||
permission.
|
||||
|
||||
5) The Font Software, modified or unmodified, in part or in whole,
|
||||
must be distributed entirely under this license, and must not be
|
||||
distributed under any other license. The requirement for fonts to
|
||||
remain under this license does not apply to any document created
|
||||
using the Font Software.
|
||||
|
||||
TERMINATION
|
||||
This license becomes null and void if any of the above conditions are
|
||||
not met.
|
||||
|
||||
DISCLAIMER
|
||||
THE FONT SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND,
|
||||
EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO ANY WARRANTIES OF
|
||||
MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT
|
||||
OF COPYRIGHT, PATENT, TRADEMARK, OR OTHER RIGHT. IN NO EVENT SHALL THE
|
||||
COPYRIGHT HOLDER BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY,
|
||||
INCLUDING ANY GENERAL, SPECIAL, INDIRECT, INCIDENTAL, OR CONSEQUENTIAL
|
||||
DAMAGES, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING
|
||||
FROM, OUT OF THE USE OR INABILITY TO USE THE FONT SOFTWARE OR FROM
|
||||
OTHER DEALINGS IN THE FONT SOFTWARE.
|
||||
|
After Width: | Height: | Size: 23 KiB |
@@ -0,0 +1,47 @@
|
||||
'use strict';
|
||||
// Generate the Android launcher resources from the committed layers, at every
|
||||
// density, with NO inset (our layers already fill the 108 dp canvas) and a
|
||||
// monochrome (themed) layer. Run build-set.mjs first (it writes the layer PNGs).
|
||||
//
|
||||
// npm i opentype.js # (only build-icon/build-set need it; this reads PNGs)
|
||||
// node build-set.mjs && node build-android-res.mjs
|
||||
//
|
||||
// Writes ui/android/app/src/main/res/mipmap-*/ic_launcher{,_round,_foreground,
|
||||
// _background,_monochrome}.png. The two mipmap-anydpi-v26/*.xml are committed by hand.
|
||||
import { readFileSync, writeFileSync } from 'node:fs';
|
||||
import { fileURLToPath } from 'node:url';
|
||||
import { dirname, join } from 'node:path';
|
||||
|
||||
const DIR = dirname(fileURLToPath(import.meta.url));
|
||||
const ROOT = join(DIR, '..', '..', '..');
|
||||
const RES = join(ROOT, 'ui', 'android', 'app', 'src', 'main', 'res');
|
||||
const uri = p => 'data:image/png;base64,' + readFileSync(p).toString('base64');
|
||||
const BG = uri(join(ROOT, 'ui', 'assets', 'icon-background.png'));
|
||||
const FG = uri(join(ROOT, 'ui', 'assets', 'icon-foreground.png'));
|
||||
const MONO = uri(join(DIR, 'android-monochrome.png'));
|
||||
|
||||
// density: [adaptive-layer px (108 dp), legacy launcher px (48 dp)]
|
||||
const D = { ldpi: [81, 36], mdpi: [108, 48], hdpi: [162, 72], xhdpi: [216, 96], xxhdpi: [324, 144], xxxhdpi: [432, 192] };
|
||||
|
||||
const pw = await import(join(ROOT, 'ui', 'node_modules', '@playwright', 'test', 'index.js'));
|
||||
const { chromium } = pw.default ?? pw;
|
||||
const browser = await chromium.launch();
|
||||
const page = await browser.newPage({ deviceScaleFactor: 1 });
|
||||
const im = (u, s) => `<img src="${u}" width="${s}" height="${s}" style="display:block">`;
|
||||
async function shot(html, s, transparent) {
|
||||
await page.setViewportSize({ width: s, height: s });
|
||||
await page.setContent(`<!doctype html><meta charset=utf8><style>*{margin:0;padding:0}#r{width:${s}px;height:${s}px}</style><div id=r>${html}</div>`, { waitUntil: 'networkidle' });
|
||||
return page.locator('#r').screenshot({ omitBackground: !!transparent });
|
||||
}
|
||||
for (const [d, [fg, lg]] of Object.entries(D)) {
|
||||
const dir = join(RES, `mipmap-${d}`);
|
||||
writeFileSync(join(dir, 'ic_launcher_background.png'), await shot(im(BG, fg), fg, false));
|
||||
writeFileSync(join(dir, 'ic_launcher_foreground.png'), await shot(im(FG, fg), fg, true));
|
||||
writeFileSync(join(dir, 'ic_launcher_monochrome.png'), await shot(im(MONO, fg), fg, true));
|
||||
const comp = `<div style="position:relative;width:${lg}px;height:${lg}px">${im(BG, lg)}<div style="position:absolute;inset:0">${im(FG, lg)}</div></div>`;
|
||||
writeFileSync(join(dir, 'ic_launcher.png'), await shot(comp, lg, false));
|
||||
const round = `<div style="width:${lg}px;height:${lg}px;border-radius:50%;overflow:hidden;position:relative">${im(BG, lg)}<div style="position:absolute;inset:0">${im(FG, lg)}</div></div>`;
|
||||
writeFileSync(join(dir, 'ic_launcher_round.png'), await shot(round, lg, true));
|
||||
console.log('mipmap-' + d, 'ok');
|
||||
}
|
||||
await browser.close();
|
||||
@@ -0,0 +1,159 @@
|
||||
'use strict';
|
||||
// Erudit app-icon — the reference generator. Every dimension below is a FRACTION
|
||||
// of the icon side, so this file reads the same as docs/ICON_BRANDBOOK.md and the
|
||||
// icon reproduces at any resolution. Emits one variant/layer's SVG to stdout.
|
||||
//
|
||||
// node build-icon.mjs --variant light|dark [--layer full|background|foreground] [--scheme] > icon.svg
|
||||
//
|
||||
// Layers (for Android adaptive icons):
|
||||
// full the standalone master (rounded tile + mark) — iOS / PWA / favicon
|
||||
// background wood + grain + light/shadow, full-bleed square (the OS applies the mask)
|
||||
// foreground only «Э» + ✻, transparent, re-placed for the round mask (see FG_* below)
|
||||
//
|
||||
// Deps: opentype.js (npm i opentype.js). Font: ./Spectral-Bold.ttf (OFL, bundled).
|
||||
import { readFileSync } from 'node:fs';
|
||||
import { fileURLToPath } from 'node:url';
|
||||
import { dirname, join } from 'node:path';
|
||||
import opentype from 'opentype.js';
|
||||
|
||||
const DIR = dirname(fileURLToPath(import.meta.url));
|
||||
const args = process.argv.slice(2);
|
||||
const has = f => args.includes('--' + f);
|
||||
const val = (f, d) => { const i = args.indexOf('--' + f); return i >= 0 ? args[i + 1] : d; };
|
||||
|
||||
// ---- the design, in fractions of the side ----
|
||||
const S = 1024; // reference side (the icon is resolution-free)
|
||||
const RADIUS = 0.17; // tile corner radius (full master)
|
||||
const STAR_BULB = 0.22; // spoke-end (and hub) radius, as fraction of star radius
|
||||
const STAR_SPOKES = 6;
|
||||
const GRAIN_COLS = 6, GRAIN_W = 1 / 32, GRAIN_SHIFT = 1 / 16, GRAIN_TILT = 4;
|
||||
const SHEEN = 0.15; // white, transparent bottom-left -> this alpha top-right
|
||||
const SHADE = 0.15; // black, this alpha bottom-left -> transparent top-right
|
||||
|
||||
// The mark, placed for the standalone master (bottom-right biased, full-bleed):
|
||||
const MASTER = { lh: 0.6055, lc: [0.4814, 0.4922], sd: 0.1729, sc: [0.7881, 0.7881] };
|
||||
// The mark, placed for the Android foreground layer (centred-ish inside the 61% safe
|
||||
// zone, nudged right 8% / down 3% for optical balance under the round mask; smaller ✻):
|
||||
const FOREGROUND = { lh: 0.5391, lc: [0.5234, 0.4951], sd: 0.1318, sc: [0.7627, 0.7266] };
|
||||
|
||||
const PALETTE = {
|
||||
light: { wood: '#e6b053', ink: '#5a3a12', grain: '#d8a54e' },
|
||||
dark: { wood: '#4a3316', ink: '#f2d9a0', grain: '#432e14' },
|
||||
};
|
||||
|
||||
// Layers:
|
||||
// full rounded tile + master mark (favicon.svg — browser rounds nothing)
|
||||
// flat square tile + master mark (apple-touch, PWA "any" — OS rounds)
|
||||
// background square tile, no mark (Android adaptive background)
|
||||
// foreground mark only, transparent (Android adaptive foreground)
|
||||
// maskable square tile + foreground mark (PWA maskable — safe-zone aware)
|
||||
// monochrome foreground mark only, flat colour (Android 13+ themed layer)
|
||||
const LAYERS = ['full', 'flat', 'background', 'foreground', 'maskable', 'monochrome'];
|
||||
const variant = val('variant', 'light');
|
||||
const layer = val('layer', 'full');
|
||||
const P = PALETTE[variant];
|
||||
if (!P) { console.error(`unknown --variant ${variant} (light|dark)`); process.exit(1); }
|
||||
if (!LAYERS.includes(layer)) { console.error(`unknown --layer ${layer} (${LAYERS.join('|')})`); process.exit(1); }
|
||||
|
||||
const usesForegroundPlacement = ['foreground', 'maskable', 'monochrome'].includes(layer);
|
||||
const drawBg = ['full', 'flat', 'background', 'maskable'].includes(layer);
|
||||
const drawMark = layer !== 'background';
|
||||
const MARK = usesForegroundPlacement ? FOREGROUND : MASTER;
|
||||
const inkColour = layer === 'monochrome' ? val('mono', '#ffffff') : P.ink;
|
||||
const RX = layer === 'full' ? RADIUS * S : 0; // only the standalone master keeps rounded corners
|
||||
const font = opentype.parse(readFileSync(join(DIR, 'Spectral-Bold.ttf')).buffer);
|
||||
|
||||
// «Э» outline scaled so its ink bounding box is MARK.lh of the side, box centred on MARK.lc.
|
||||
function letterSvg() {
|
||||
const g = font.charToGlyph('Э');
|
||||
const h0 = (() => { const b = g.getPath(0, 0, 1000).getBoundingBox(); return b.y2 - b.y1; })();
|
||||
const scale = (MARK.lh * S) / h0;
|
||||
const p = g.getPath(0, 0, 1000 * scale);
|
||||
const bb = p.getBoundingBox();
|
||||
const w = bb.x2 - bb.x1, h = bb.y2 - bb.y1;
|
||||
const tx = MARK.lc[0] * S - (bb.x1 + w / 2);
|
||||
const ty = MARK.lc[1] * S - (bb.y1 + h / 2);
|
||||
return { svg: `<path transform="translate(${tx.toFixed(2)} ${ty.toFixed(2)})" d="${p.toPathData(2)}" fill="${inkColour}"/>`,
|
||||
box: { x: bb.x1 + tx, y: bb.y1 + ty, w, h } };
|
||||
}
|
||||
|
||||
// Teardrop-spoked asterisk ✻: each spoke tapers to a point at the centre and ends
|
||||
// in a round bulb; a central disc equal to the bulb fuses them.
|
||||
function starPath() {
|
||||
const cx = MARK.sc[0] * S, cy = MARK.sc[1] * S, Rout = MARK.sd * S / 2;
|
||||
const rb = Rout * STAR_BULB, R = Rout - rb;
|
||||
const L = Math.sqrt(R * R - rb * rb), theta = Math.asin(rb / R);
|
||||
const rot = (v, t) => [v[0] * Math.cos(t) - v[1] * Math.sin(t), v[0] * Math.sin(t) + v[1] * Math.cos(t)];
|
||||
let d = '';
|
||||
for (let k = 0; k < STAR_SPOKES; k++) {
|
||||
const a = -Math.PI / 2 + k * 2 * Math.PI / STAR_SPOKES;
|
||||
const u = [Math.cos(a), Math.sin(a)];
|
||||
const d1 = rot(u, theta), d2 = rot(u, -theta);
|
||||
const T1 = [cx + L * d1[0], cy + L * d1[1]], T2 = [cx + L * d2[0], cy + L * d2[1]];
|
||||
d += `M${cx.toFixed(2)} ${cy.toFixed(2)} L${T1[0].toFixed(2)} ${T1[1].toFixed(2)} A${rb.toFixed(2)} ${rb.toFixed(2)} 0 1 0 ${T2[0].toFixed(2)} ${T2[1].toFixed(2)} Z `;
|
||||
}
|
||||
d += `M${cx} ${cy} m${-rb} 0 a${rb} ${rb} 0 1 0 ${2 * rb} 0 a${rb} ${rb} 0 1 0 ${-2 * rb} 0 Z`;
|
||||
return `<path d="${d}" fill="${inkColour}"/>`;
|
||||
}
|
||||
|
||||
function grain() {
|
||||
const colW = S / GRAIN_COLS, sw = GRAIN_W * S, shift = GRAIN_SHIFT * S;
|
||||
const yTop = -0.2 * S, yBot = 1.2 * S, cy = S / 2;
|
||||
let s = '';
|
||||
for (let i = 1; i <= GRAIN_COLS; i++) {
|
||||
const xr = i * colW - shift, x = xr - sw, cx = xr - sw / 2;
|
||||
s += `<rect x="${x.toFixed(2)}" y="${yTop.toFixed(2)}" width="${sw.toFixed(2)}" height="${(yBot - yTop).toFixed(2)}" fill="${P.grain}" transform="rotate(${GRAIN_TILT} ${cx.toFixed(2)} ${cy.toFixed(2)})"/>`;
|
||||
}
|
||||
return `<g clip-path="url(#tile)">${s}</g>`;
|
||||
}
|
||||
|
||||
let body = '';
|
||||
const L = letterSvg();
|
||||
if (drawBg) { // tile + grain + light/shadow (the background)
|
||||
body += `<rect width="${S}" height="${S}" rx="${RX}" fill="${P.wood}"/>`
|
||||
+ `<defs><clipPath id="tile"><rect width="${S}" height="${S}" rx="${RX}"/></clipPath>`
|
||||
+ `<linearGradient id="sheen" gradientUnits="userSpaceOnUse" x1="0" y1="${S}" x2="${S}" y2="0"><stop offset="0" stop-color="#fff" stop-opacity="0"/><stop offset="1" stop-color="#fff" stop-opacity="${SHEEN}"/></linearGradient>`
|
||||
+ `<linearGradient id="shade" gradientUnits="userSpaceOnUse" x1="0" y1="${S}" x2="${S}" y2="0"><stop offset="0" stop-color="#000" stop-opacity="${SHADE}"/><stop offset="1" stop-color="#000" stop-opacity="0"/></linearGradient>`
|
||||
+ `</defs>`
|
||||
+ grain()
|
||||
+ `<rect width="${S}" height="${S}" fill="url(#sheen)" clip-path="url(#tile)"/>`
|
||||
+ `<rect width="${S}" height="${S}" fill="url(#shade)" clip-path="url(#tile)"/>`;
|
||||
}
|
||||
if (drawMark) body += L.svg + starPath(); // the mark
|
||||
if (has('scheme')) body += scheme(L);
|
||||
|
||||
process.stdout.write(`<svg xmlns="http://www.w3.org/2000/svg" width="${S}" height="${S}" viewBox="0 0 ${S} ${S}">${body}</svg>\n`);
|
||||
|
||||
// Percent-based construction overlay for the brand book (full master only).
|
||||
function scheme(L) {
|
||||
const pc = n => (100 * n / S).toFixed(1) + '%';
|
||||
const GC = '#0f172a', BX = '#d81b60', AC = '#0d9488';
|
||||
const halo = 'paint-order="stroke" stroke="#fff" stroke-width="4"';
|
||||
const cx = MARK.sc[0] * S, cy = MARK.sc[1] * S, sd = MARK.sd * S;
|
||||
let g = `<g font-family="'Helvetica Neue',Arial,sans-serif">`;
|
||||
for (let f = 1; f <= 3; f++) {
|
||||
const p = f * S / 4;
|
||||
g += `<line x1="${p}" y1="0" x2="${p}" y2="${S}" stroke="${GC}" stroke-opacity="0.18" stroke-width="1.2"/>`;
|
||||
g += `<line x1="0" y1="${p}" x2="${S}" y2="${p}" stroke="${GC}" stroke-opacity="0.18" stroke-width="1.2"/>`;
|
||||
g += `<text x="${p + 4}" y="20" font-size="17" font-weight="700" fill="${GC}" ${halo}>${f * 25}%</text>`;
|
||||
g += `<text x="4" y="${p - 4}" font-size="17" font-weight="700" fill="${GC}" ${halo}>${f * 25}%</text>`;
|
||||
}
|
||||
g += `<line x1="${0.14 * S}" y1="${0.86 * S}" x2="${0.86 * S}" y2="${0.14 * S}" stroke="${AC}" stroke-width="3" stroke-dasharray="3 8" stroke-linecap="round"/>`;
|
||||
g += `<circle cx="${0.86 * S}" cy="${0.14 * S}" r="6" fill="${AC}"/><circle cx="${0.14 * S}" cy="${0.86 * S}" r="6" fill="${AC}"/>`;
|
||||
g += `<text x="${0.86 * S - 6}" y="${0.14 * S - 12}" text-anchor="end" font-size="18" font-weight="700" fill="${AC}" ${halo}>light: white 0%→15%</text>`;
|
||||
g += `<text x="${0.14 * S + 10}" y="${0.86 * S + 26}" font-size="18" font-weight="700" fill="${AC}" ${halo}>shadow: black 15%→0%</text>`;
|
||||
const { x, y, w, h } = L.box;
|
||||
g += `<rect x="${x.toFixed(1)}" y="${y.toFixed(1)}" width="${w.toFixed(1)}" height="${h.toFixed(1)}" fill="none" stroke="${BX}" stroke-width="2.5"/>`;
|
||||
g += `<line x1="${MARK.lc[0] * S}" y1="${MARK.lc[1] * S - 16}" x2="${MARK.lc[0] * S}" y2="${MARK.lc[1] * S + 16}" stroke="${BX}" stroke-width="2.5"/><line x1="${MARK.lc[0] * S - 16}" y1="${MARK.lc[1] * S}" x2="${MARK.lc[0] * S + 16}" y2="${MARK.lc[1] * S}" stroke="${BX}" stroke-width="2.5"/>`;
|
||||
g += `<text x="${x.toFixed(1)}" y="${(y - 10).toFixed(1)}" font-size="19" font-weight="700" fill="${BX}" ${halo}>«Э» Spectral Bold · box H ${pc(h)} · W ${pc(w)}</text>`;
|
||||
g += `<text x="${x.toFixed(1)}" y="${(y + h + 24).toFixed(1)}" font-size="18" font-weight="700" fill="${BX}" ${halo}>box center ${pc(MARK.lc[0] * S)} / ${pc(MARK.lc[1] * S)}</text>`;
|
||||
g += `<rect x="${cx - sd / 2}" y="${cy - sd / 2}" width="${sd}" height="${sd}" fill="none" stroke="${BX}" stroke-width="2.5"/>`;
|
||||
g += `<line x1="${cx}" y1="${cy - 14}" x2="${cx}" y2="${cy + 14}" stroke="${BX}" stroke-width="2.5"/><line x1="${cx - 14}" y1="${cy}" x2="${cx + 14}" y2="${cy}" stroke="${BX}" stroke-width="2.5"/>`;
|
||||
g += `<text x="${(cx + sd / 2).toFixed(1)}" y="${(cy - sd / 2 - 10).toFixed(1)}" text-anchor="end" font-size="18" font-weight="700" fill="${BX}" ${halo}>✻ Ø ${pc(sd)} · center ${pc(cx)} / ${pc(cy)}</text>`;
|
||||
g += `<text x="${RX + 14}" y="${RX - 6}" font-size="17" font-weight="700" fill="${GC}" ${halo}>corner r = 17%</text>`;
|
||||
g += `<path d="M4 ${RX} A ${RX} ${RX} 0 0 1 ${RX} 4" fill="none" stroke="${GC}" stroke-width="2" stroke-dasharray="5 5"/>`;
|
||||
g += `<rect x="${0.03 * S}" y="${S - 96}" width="${0.94 * S}" height="74" rx="12" fill="#0f172a" fill-opacity="0.82"/>`;
|
||||
g += `<text x="${0.055 * S}" y="${S - 64}" font-size="18" font-weight="600" fill="#fff">Wood grain: 6 equal columns; a vertical stripe on each column's right edge,</text>`;
|
||||
g += `<text x="${0.055 * S}" y="${S - 40}" font-size="18" font-weight="600" fill="#fff">width 1/32 of side; the whole set shifted left 1/16; every stripe tilted 4°.</text>`;
|
||||
return g + `</g>`;
|
||||
}
|
||||
@@ -0,0 +1,105 @@
|
||||
'use strict';
|
||||
// Slice the whole shipped icon set from the brand master (build-icon.mjs). Renders
|
||||
// with the ui package's Playwright chromium. See docs/ICONS.md for the target map.
|
||||
//
|
||||
// npm i opentype.js && node build-set.mjs
|
||||
//
|
||||
// Writes: ui/public/* (web), ui/assets/* (Capacitor layers), ./vk/* and ./store/*
|
||||
// (manual-upload art). Wiring (manifest, html, Android res) is done separately.
|
||||
import { execFileSync } from 'node:child_process';
|
||||
import { readFileSync, writeFileSync, mkdirSync } from 'node:fs';
|
||||
import { fileURLToPath } from 'node:url';
|
||||
import { dirname, join } from 'node:path';
|
||||
|
||||
const DIR = dirname(fileURLToPath(import.meta.url));
|
||||
const UI = join(DIR, '..', '..', '..', 'ui');
|
||||
const PUB = join(UI, 'public'), ASSETS = join(UI, 'assets');
|
||||
const VK = join(DIR, 'vk'), STORE = join(DIR, 'store'), TG = join(DIR, 'tg');
|
||||
[VK, STORE, TG].forEach(d => mkdirSync(d, { recursive: true }));
|
||||
|
||||
// one SVG string for a variant+layer from the reference generator
|
||||
const svgFor = (variant, layer) =>
|
||||
execFileSync('node', [join(DIR, 'build-icon.mjs'), '--variant', variant, '--layer', layer], { encoding: 'utf8' });
|
||||
|
||||
const pw = await import(join(UI, 'node_modules', '@playwright', 'test', 'index.js'));
|
||||
const { chromium } = pw.default ?? pw;
|
||||
const browser = await chromium.launch();
|
||||
const page = await browser.newPage();
|
||||
|
||||
async function pngFromSvg(svg, size, transparent) {
|
||||
await page.setViewportSize({ width: size, height: size });
|
||||
await page.setContent(`<!doctype html><meta charset=utf8><style>*{margin:0;padding:0}svg{display:block;width:${size}px;height:${size}px}</style>${svg}`, { waitUntil: 'networkidle' });
|
||||
return page.locator('svg').screenshot({ omitBackground: !!transparent });
|
||||
}
|
||||
async function shootHtml(html, w, h) {
|
||||
await page.setViewportSize({ width: w, height: h });
|
||||
await page.setContent(html, { waitUntil: 'networkidle' });
|
||||
return page.screenshot();
|
||||
}
|
||||
function icoFromPNG(png, sizePx) {
|
||||
const h = Buffer.alloc(22);
|
||||
h.writeUInt16LE(0, 0); h.writeUInt16LE(1, 2); h.writeUInt16LE(1, 4);
|
||||
h.writeUInt8(sizePx, 6); h.writeUInt8(sizePx, 7);
|
||||
h.writeUInt16LE(1, 10); h.writeUInt16LE(32, 12);
|
||||
h.writeUInt32LE(png.length, 14); h.writeUInt32LE(22, 18);
|
||||
return Buffer.concat([h, png]);
|
||||
}
|
||||
const write = (p, buf) => { writeFileSync(p, buf); console.log('wrote', p.replace(join(DIR, '..', '..', '..'), '.'), buf.length, 'b'); };
|
||||
|
||||
// ---- pre-render the SVG sources we need ----
|
||||
const S = {
|
||||
fullLight: svgFor('light', 'full'), fullDark: svgFor('dark', 'full'),
|
||||
flatLight: svgFor('light', 'flat'), flatDark: svgFor('dark', 'flat'),
|
||||
maskLight: svgFor('light', 'maskable'), maskDark: svgFor('dark', 'maskable'),
|
||||
bgLight: svgFor('light', 'background'), fgLight: svgFor('light', 'foreground'),
|
||||
monoLight: svgFor('light', 'monochrome'),
|
||||
};
|
||||
|
||||
// ---- favicon.svg: light + dark in one file, toggled by prefers-color-scheme ----
|
||||
const inner = svg => svg.replace(/^<svg[^>]*>/, '').replace(/<\/svg>\s*$/, '');
|
||||
const faviconSvg =
|
||||
`<svg xmlns="http://www.w3.org/2000/svg" width="1024" height="1024" viewBox="0 0 1024 1024">`
|
||||
+ `<style>.d{display:none}@media(prefers-color-scheme:dark){.l{display:none}.d{display:inline}}</style>`
|
||||
+ `<g class="l">${inner(S.fullLight)}</g><g class="d">${inner(S.fullDark)}</g></svg>\n`;
|
||||
write(join(PUB, 'favicon.svg'), Buffer.from(faviconSvg));
|
||||
|
||||
// ---- web rasters ----
|
||||
write(join(PUB, 'favicon.ico'), icoFromPNG(await pngFromSvg(S.flatLight, 32, false), 32));
|
||||
write(join(PUB, 'apple-touch-icon.png'), await pngFromSvg(S.flatLight, 180, false));
|
||||
write(join(PUB, 'icon-192.png'), await pngFromSvg(S.flatLight, 192, false));
|
||||
write(join(PUB, 'icon-512.png'), await pngFromSvg(S.flatLight, 512, false));
|
||||
write(join(PUB, 'icon-192-dark.png'), await pngFromSvg(S.flatDark, 192, false));
|
||||
write(join(PUB, 'icon-512-dark.png'), await pngFromSvg(S.flatDark, 512, false));
|
||||
write(join(PUB, 'icon-maskable-512.png'), await pngFromSvg(S.maskLight, 512, false));
|
||||
write(join(PUB, 'icon-maskable-512-dark.png'), await pngFromSvg(S.maskDark, 512, false));
|
||||
|
||||
// ---- Capacitor layers (ui/assets) ----
|
||||
write(join(ASSETS, 'icon.png'), await pngFromSvg(S.flatLight, 1024, false)); // legacy single
|
||||
write(join(ASSETS, 'icon-foreground.png'), await pngFromSvg(S.fgLight, 1024, true)); // adaptive fg
|
||||
write(join(ASSETS, 'icon-background.png'), await pngFromSvg(S.bgLight, 1024, false)); // adaptive bg
|
||||
write(join(DIR, 'android-monochrome.png'), await pngFromSvg(S.monoLight, 1024, true)); // themed layer (wired manually)
|
||||
|
||||
// ---- VK (no rounding — flat light) ----
|
||||
for (const px of [576, 278, 150, 32]) write(join(VK, `vk-${px}.png`), await pngFromSvg(S.flatLight, px, false));
|
||||
|
||||
// ---- Telegram bot: square, no rounding (TG crops the avatar to a circle) ----
|
||||
write(join(TG, 'tg-512.png'), await pngFromSvg(S.flatLight, 512, false)); // bot avatar + Mini App icon
|
||||
|
||||
// ---- store art ----
|
||||
write(join(STORE, 'rustore-512.png'), await pngFromSvg(S.flatLight, 512, false));
|
||||
|
||||
// ---- wordmark banner (board-green bg + master tile + «Эрудит» / Игра в слова) ----
|
||||
const b64 = readFileSync(join(DIR, 'Spectral-Bold.ttf')).toString('base64');
|
||||
const banner = (w, h, tile, t1, t2, gap, pad) => `<!doctype html><meta charset=utf8><style>
|
||||
@font-face{font-family:Spectral;src:url(data:font/ttf;base64,${b64});font-weight:700}
|
||||
*{margin:0;padding:0;box-sizing:border-box}
|
||||
body{width:${w}px;height:${h}px;background:#2a3330;display:flex;align-items:center;gap:${gap}px;padding:0 ${pad}px;font-family:Spectral}
|
||||
.tile{width:${tile}px;height:${tile}px;flex:none}.tile svg{width:${tile}px;height:${tile}px;display:block}
|
||||
.wm{color:#e7ece8;text-align:center}.t1{font-weight:700;font-size:${t1}px;line-height:1.02;letter-spacing:-2px}
|
||||
.t2{font-weight:700;font-size:${t2}px;line-height:1.1;color:#cdd6cf;margin-top:6px}
|
||||
</style><div class=tile>${S.fullLight}</div><div class=wm><div class=t1>«Эрудит»</div><div class=t2>Игра в слова</div></div>`;
|
||||
write(join(PUB, 'og-image.png'), await shootHtml(banner(1200, 630, 300, 150, 74, 64, 96), 1200, 630));
|
||||
write(join(TG, 'tg-demo-640x360.png'), await shootHtml(banner(640, 360, 150, 72, 38, 30, 44), 640, 360));
|
||||
|
||||
await browser.close();
|
||||
console.log('done');
|
||||
|
After Width: | Height: | Size: 477 KiB |
|
After Width: | Height: | Size: 6.5 KiB |
|
After Width: | Height: | Size: 288 KiB |
@@ -0,0 +1 @@
|
||||
<svg xmlns="http://www.w3.org/2000/svg" width="1024" height="1024" viewBox="0 0 1024 1024"><rect width="1024" height="1024" rx="0" fill="#4a3316"/><defs><clipPath id="tile"><rect width="1024" height="1024" rx="0"/></clipPath><linearGradient id="sheen" gradientUnits="userSpaceOnUse" x1="0" y1="1024" x2="1024" y2="0"><stop offset="0" stop-color="#fff" stop-opacity="0"/><stop offset="1" stop-color="#fff" stop-opacity="0.15"/></linearGradient><linearGradient id="shade" gradientUnits="userSpaceOnUse" x1="0" y1="1024" x2="1024" y2="0"><stop offset="0" stop-color="#000" stop-opacity="0.15"/><stop offset="1" stop-color="#000" stop-opacity="0"/></linearGradient></defs><g clip-path="url(#tile)"><rect x="74.67" y="-204.80" width="32.00" height="1433.60" fill="#432e14" transform="rotate(4 90.67 512.00)"/><rect x="245.33" y="-204.80" width="32.00" height="1433.60" fill="#432e14" transform="rotate(4 261.33 512.00)"/><rect x="416.00" y="-204.80" width="32.00" height="1433.60" fill="#432e14" transform="rotate(4 432.00 512.00)"/><rect x="586.67" y="-204.80" width="32.00" height="1433.60" fill="#432e14" transform="rotate(4 602.67 512.00)"/><rect x="757.33" y="-204.80" width="32.00" height="1433.60" fill="#432e14" transform="rotate(4 773.33 512.00)"/><rect x="928.00" y="-204.80" width="32.00" height="1433.60" fill="#432e14" transform="rotate(4 944.00 512.00)"/></g><rect width="1024" height="1024" fill="url(#sheen)" clip-path="url(#tile)"/><rect width="1024" height="1024" fill="url(#shade)" clip-path="url(#tile)"/></svg>
|
||||
|
After Width: | Height: | Size: 1.5 KiB |
|
After Width: | Height: | Size: 29 KiB |
@@ -0,0 +1 @@
|
||||
<svg xmlns="http://www.w3.org/2000/svg" width="1024" height="1024" viewBox="0 0 1024 1024"><path transform="translate(237.62 774.88)" d="M250.04-34.91Q326.35-34.91 368.57-91.33Q410.78-147.75 410.78-243.55Q410.78-249.23 410.78-254.91L254.10-254.91L208.64-185.91L195.65-185.91L195.65-368.57L208.64-368.57L254.10-299.56L407.53-299.56Q396.98-392.92 351.52-447.72Q306.06-502.52 241.11-502.52Q217.57-502.52 198.90-498.46Q180.22-494.40 162.36-482.22L83.62-354.77L69-354.77L77.93-505.76Q112.84-522.81 154.25-533.37Q195.65-543.92 259.78-543.92Q319.86-543.92 371.41-523.22Q422.96-502.52 460.71-465.58Q498.46-428.64 519.57-378.31Q540.67-327.98 540.67-267.90Q540.67-188.34 504.55-125.83Q468.42-63.32 405.10-27.60Q341.78 8.12 259.78 8.12Q200.52 8.12 150.59-3.25Q100.67-14.61 67.38-34.10L56.02-171.29L69.82-171.29L142.88-63.32Q165.61-50.33 191.59-42.62Q217.57-34.91 250.04-34.91" fill="#f2d9a0"/><path d="M781.00 744.04 L795.25 695.59 A14.85 14.85 0 1 0 766.76 695.59 Z M781.00 744.04 L830.08 732.15 A14.85 14.85 0 1 0 815.84 707.48 Z M781.00 744.04 L815.84 780.60 A14.85 14.85 0 1 0 830.08 755.93 Z M781.00 744.04 L766.76 792.49 A14.85 14.85 0 1 0 795.25 792.49 Z M781.00 744.04 L731.93 755.93 A14.85 14.85 0 1 0 746.17 780.60 Z M781.00 744.04 L746.17 707.48 A14.85 14.85 0 1 0 731.93 732.15 Z M781.0048 744.0384 m-14.845952 0 a14.845952 14.845952 0 1 0 29.691904 0 a14.845952 14.845952 0 1 0 -29.691904 0 Z" fill="#f2d9a0"/></svg>
|
||||
|
After Width: | Height: | Size: 1.4 KiB |
|
After Width: | Height: | Size: 405 KiB |
@@ -0,0 +1 @@
|
||||
<svg xmlns="http://www.w3.org/2000/svg" width="1024" height="1024" viewBox="0 0 1024 1024"><rect width="1024" height="1024" rx="174.08" fill="#4a3316"/><defs><clipPath id="tile"><rect width="1024" height="1024" rx="174.08"/></clipPath><linearGradient id="sheen" gradientUnits="userSpaceOnUse" x1="0" y1="1024" x2="1024" y2="0"><stop offset="0" stop-color="#fff" stop-opacity="0"/><stop offset="1" stop-color="#fff" stop-opacity="0.15"/></linearGradient><linearGradient id="shade" gradientUnits="userSpaceOnUse" x1="0" y1="1024" x2="1024" y2="0"><stop offset="0" stop-color="#000" stop-opacity="0.15"/><stop offset="1" stop-color="#000" stop-opacity="0"/></linearGradient></defs><g clip-path="url(#tile)"><rect x="74.67" y="-204.80" width="32.00" height="1433.60" fill="#432e14" transform="rotate(4 90.67 512.00)"/><rect x="245.33" y="-204.80" width="32.00" height="1433.60" fill="#432e14" transform="rotate(4 261.33 512.00)"/><rect x="416.00" y="-204.80" width="32.00" height="1433.60" fill="#432e14" transform="rotate(4 432.00 512.00)"/><rect x="586.67" y="-204.80" width="32.00" height="1433.60" fill="#432e14" transform="rotate(4 602.67 512.00)"/><rect x="757.33" y="-204.80" width="32.00" height="1433.60" fill="#432e14" transform="rotate(4 773.33 512.00)"/><rect x="928.00" y="-204.80" width="32.00" height="1433.60" fill="#432e14" transform="rotate(4 944.00 512.00)"/></g><rect width="1024" height="1024" fill="url(#sheen)" clip-path="url(#tile)"/><rect width="1024" height="1024" fill="url(#shade)" clip-path="url(#tile)"/><path transform="translate(157.86 804.91)" d="M280.84-39.21Q366.55-39.21 413.96-102.58Q461.38-165.95 461.38-273.54Q461.38-279.93 461.38-286.31L285.40-286.31L234.34-208.80L219.75-208.80L219.75-413.96L234.34-413.96L285.40-336.46L457.73-336.46Q445.88-441.32 394.81-502.86Q343.75-564.41 270.81-564.41Q244.37-564.41 223.39-559.85Q202.42-555.29 182.36-541.62L93.92-398.46L77.50-398.46L87.53-568.06Q126.74-587.21 173.24-599.06Q219.75-610.91 291.78-610.91Q359.25-610.91 417.15-587.66Q475.05-564.41 517.45-522.92Q559.85-481.44 583.56-424.90Q607.27-368.37 607.27-300.90Q607.27-211.54 566.69-141.33Q526.12-71.12 454.99-31Q383.87 9.12 291.78 9.12Q225.22 9.12 169.14-3.65Q113.06-16.41 75.68-38.30L62.92-192.39L78.42-192.39L160.48-71.12Q186.01-56.53 215.19-47.87Q244.37-39.21 280.84-39.21" fill="#f2d9a0"/><path d="M807.01 807.01 L825.70 743.46 A19.48 19.48 0 1 0 788.33 743.46 Z M807.01 807.01 L871.40 791.42 A19.48 19.48 0 1 0 852.71 759.05 Z M807.01 807.01 L852.71 854.97 A19.48 19.48 0 1 0 871.40 822.61 Z M807.01 807.01 L788.33 870.57 A19.48 19.48 0 1 0 825.70 870.57 Z M807.01 807.01 L742.63 822.61 A19.48 19.48 0 1 0 761.32 854.97 Z M807.01 807.01 L761.32 759.05 A19.48 19.48 0 1 0 742.63 791.42 Z M807.0144 807.0144 m-19.475456 0 a19.475456 19.475456 0 1 0 38.950912 0 a19.475456 19.475456 0 1 0 -38.950912 0 Z" fill="#f2d9a0"/></svg>
|
||||
|
After Width: | Height: | Size: 2.8 KiB |
|
After Width: | Height: | Size: 290 KiB |
@@ -0,0 +1 @@
|
||||
<svg xmlns="http://www.w3.org/2000/svg" width="1024" height="1024" viewBox="0 0 1024 1024"><rect width="1024" height="1024" rx="0" fill="#e6b053"/><defs><clipPath id="tile"><rect width="1024" height="1024" rx="0"/></clipPath><linearGradient id="sheen" gradientUnits="userSpaceOnUse" x1="0" y1="1024" x2="1024" y2="0"><stop offset="0" stop-color="#fff" stop-opacity="0"/><stop offset="1" stop-color="#fff" stop-opacity="0.15"/></linearGradient><linearGradient id="shade" gradientUnits="userSpaceOnUse" x1="0" y1="1024" x2="1024" y2="0"><stop offset="0" stop-color="#000" stop-opacity="0.15"/><stop offset="1" stop-color="#000" stop-opacity="0"/></linearGradient></defs><g clip-path="url(#tile)"><rect x="74.67" y="-204.80" width="32.00" height="1433.60" fill="#d8a54e" transform="rotate(4 90.67 512.00)"/><rect x="245.33" y="-204.80" width="32.00" height="1433.60" fill="#d8a54e" transform="rotate(4 261.33 512.00)"/><rect x="416.00" y="-204.80" width="32.00" height="1433.60" fill="#d8a54e" transform="rotate(4 432.00 512.00)"/><rect x="586.67" y="-204.80" width="32.00" height="1433.60" fill="#d8a54e" transform="rotate(4 602.67 512.00)"/><rect x="757.33" y="-204.80" width="32.00" height="1433.60" fill="#d8a54e" transform="rotate(4 773.33 512.00)"/><rect x="928.00" y="-204.80" width="32.00" height="1433.60" fill="#d8a54e" transform="rotate(4 944.00 512.00)"/></g><rect width="1024" height="1024" fill="url(#sheen)" clip-path="url(#tile)"/><rect width="1024" height="1024" fill="url(#shade)" clip-path="url(#tile)"/></svg>
|
||||
|
After Width: | Height: | Size: 1.5 KiB |
|
After Width: | Height: | Size: 30 KiB |
@@ -0,0 +1 @@
|
||||
<svg xmlns="http://www.w3.org/2000/svg" width="1024" height="1024" viewBox="0 0 1024 1024"><path transform="translate(237.62 774.88)" d="M250.04-34.91Q326.35-34.91 368.57-91.33Q410.78-147.75 410.78-243.55Q410.78-249.23 410.78-254.91L254.10-254.91L208.64-185.91L195.65-185.91L195.65-368.57L208.64-368.57L254.10-299.56L407.53-299.56Q396.98-392.92 351.52-447.72Q306.06-502.52 241.11-502.52Q217.57-502.52 198.90-498.46Q180.22-494.40 162.36-482.22L83.62-354.77L69-354.77L77.93-505.76Q112.84-522.81 154.25-533.37Q195.65-543.92 259.78-543.92Q319.86-543.92 371.41-523.22Q422.96-502.52 460.71-465.58Q498.46-428.64 519.57-378.31Q540.67-327.98 540.67-267.90Q540.67-188.34 504.55-125.83Q468.42-63.32 405.10-27.60Q341.78 8.12 259.78 8.12Q200.52 8.12 150.59-3.25Q100.67-14.61 67.38-34.10L56.02-171.29L69.82-171.29L142.88-63.32Q165.61-50.33 191.59-42.62Q217.57-34.91 250.04-34.91" fill="#5a3a12"/><path d="M781.00 744.04 L795.25 695.59 A14.85 14.85 0 1 0 766.76 695.59 Z M781.00 744.04 L830.08 732.15 A14.85 14.85 0 1 0 815.84 707.48 Z M781.00 744.04 L815.84 780.60 A14.85 14.85 0 1 0 830.08 755.93 Z M781.00 744.04 L766.76 792.49 A14.85 14.85 0 1 0 795.25 792.49 Z M781.00 744.04 L731.93 755.93 A14.85 14.85 0 1 0 746.17 780.60 Z M781.00 744.04 L746.17 707.48 A14.85 14.85 0 1 0 731.93 732.15 Z M781.0048 744.0384 m-14.845952 0 a14.845952 14.845952 0 1 0 29.691904 0 a14.845952 14.845952 0 1 0 -29.691904 0 Z" fill="#5a3a12"/></svg>
|
||||
|
After Width: | Height: | Size: 1.4 KiB |
|
After Width: | Height: | Size: 411 KiB |
@@ -0,0 +1 @@
|
||||
<svg xmlns="http://www.w3.org/2000/svg" width="1024" height="1024" viewBox="0 0 1024 1024"><rect width="1024" height="1024" rx="174.08" fill="#e6b053"/><defs><clipPath id="tile"><rect width="1024" height="1024" rx="174.08"/></clipPath><linearGradient id="sheen" gradientUnits="userSpaceOnUse" x1="0" y1="1024" x2="1024" y2="0"><stop offset="0" stop-color="#fff" stop-opacity="0"/><stop offset="1" stop-color="#fff" stop-opacity="0.15"/></linearGradient><linearGradient id="shade" gradientUnits="userSpaceOnUse" x1="0" y1="1024" x2="1024" y2="0"><stop offset="0" stop-color="#000" stop-opacity="0.15"/><stop offset="1" stop-color="#000" stop-opacity="0"/></linearGradient></defs><g clip-path="url(#tile)"><rect x="74.67" y="-204.80" width="32.00" height="1433.60" fill="#d8a54e" transform="rotate(4 90.67 512.00)"/><rect x="245.33" y="-204.80" width="32.00" height="1433.60" fill="#d8a54e" transform="rotate(4 261.33 512.00)"/><rect x="416.00" y="-204.80" width="32.00" height="1433.60" fill="#d8a54e" transform="rotate(4 432.00 512.00)"/><rect x="586.67" y="-204.80" width="32.00" height="1433.60" fill="#d8a54e" transform="rotate(4 602.67 512.00)"/><rect x="757.33" y="-204.80" width="32.00" height="1433.60" fill="#d8a54e" transform="rotate(4 773.33 512.00)"/><rect x="928.00" y="-204.80" width="32.00" height="1433.60" fill="#d8a54e" transform="rotate(4 944.00 512.00)"/></g><rect width="1024" height="1024" fill="url(#sheen)" clip-path="url(#tile)"/><rect width="1024" height="1024" fill="url(#shade)" clip-path="url(#tile)"/><path transform="translate(157.86 804.91)" d="M280.84-39.21Q366.55-39.21 413.96-102.58Q461.38-165.95 461.38-273.54Q461.38-279.93 461.38-286.31L285.40-286.31L234.34-208.80L219.75-208.80L219.75-413.96L234.34-413.96L285.40-336.46L457.73-336.46Q445.88-441.32 394.81-502.86Q343.75-564.41 270.81-564.41Q244.37-564.41 223.39-559.85Q202.42-555.29 182.36-541.62L93.92-398.46L77.50-398.46L87.53-568.06Q126.74-587.21 173.24-599.06Q219.75-610.91 291.78-610.91Q359.25-610.91 417.15-587.66Q475.05-564.41 517.45-522.92Q559.85-481.44 583.56-424.90Q607.27-368.37 607.27-300.90Q607.27-211.54 566.69-141.33Q526.12-71.12 454.99-31Q383.87 9.12 291.78 9.12Q225.22 9.12 169.14-3.65Q113.06-16.41 75.68-38.30L62.92-192.39L78.42-192.39L160.48-71.12Q186.01-56.53 215.19-47.87Q244.37-39.21 280.84-39.21" fill="#5a3a12"/><path d="M807.01 807.01 L825.70 743.46 A19.48 19.48 0 1 0 788.33 743.46 Z M807.01 807.01 L871.40 791.42 A19.48 19.48 0 1 0 852.71 759.05 Z M807.01 807.01 L852.71 854.97 A19.48 19.48 0 1 0 871.40 822.61 Z M807.01 807.01 L788.33 870.57 A19.48 19.48 0 1 0 825.70 870.57 Z M807.01 807.01 L742.63 822.61 A19.48 19.48 0 1 0 761.32 854.97 Z M807.01 807.01 L761.32 759.05 A19.48 19.48 0 1 0 742.63 791.42 Z M807.0144 807.0144 m-19.475456 0 a19.475456 19.475456 0 1 0 38.950912 0 a19.475456 19.475456 0 1 0 -38.950912 0 Z" fill="#5a3a12"/></svg>
|
||||
|
After Width: | Height: | Size: 2.8 KiB |
@@ -0,0 +1,12 @@
|
||||
{
|
||||
"name": "erudit-icon-brand",
|
||||
"private": true,
|
||||
"type": "module",
|
||||
"description": "Reference generator for the Erudit app-icon set (see docs/ICON_BRANDBOOK.md).",
|
||||
"scripts": {
|
||||
"build": "node build-set.mjs && node build-android-res.mjs"
|
||||
},
|
||||
"dependencies": {
|
||||
"opentype.js": "^1.3.4"
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,21 @@
|
||||
'use strict';
|
||||
// Rasterise an icon SVG to PNG at a given size, using the `ui` package's cached
|
||||
// Playwright chromium (no extra install). Usage:
|
||||
// node render-png.mjs <in.svg> <out.png> [size=1024]
|
||||
import { readFileSync, writeFileSync } from 'node:fs';
|
||||
import { fileURLToPath } from 'node:url';
|
||||
import { dirname, join } from 'node:path';
|
||||
|
||||
const DIR = dirname(fileURLToPath(import.meta.url));
|
||||
const pw = await import(join(DIR, '..', '..', '..', 'ui', 'node_modules', '@playwright', 'test', 'index.js'));
|
||||
const { chromium } = pw.default ?? pw;
|
||||
|
||||
const [, , svgPath, pngPath, size] = process.argv;
|
||||
const S = Number(size) || 1024;
|
||||
const svg = readFileSync(svgPath, 'utf8');
|
||||
const b = await chromium.launch();
|
||||
const pg = await b.newPage({ viewport: { width: S, height: S }, deviceScaleFactor: 1 });
|
||||
await pg.setContent(`<!doctype html><meta charset=utf8><style>*{margin:0;padding:0}</style>${svg}`, { waitUntil: 'networkidle' });
|
||||
writeFileSync(pngPath, await pg.locator('svg').screenshot({ omitBackground: true }));
|
||||
await b.close();
|
||||
console.log('wrote', pngPath, S + 'px');
|
||||
|
After Width: | Height: | Size: 103 KiB |
|
After Width: | Height: | Size: 103 KiB |
|
After Width: | Height: | Size: 32 KiB |
|
After Width: | Height: | Size: 15 KiB |
|
After Width: | Height: | Size: 38 KiB |
|
After Width: | Height: | Size: 1.6 KiB |
|
After Width: | Height: | Size: 122 KiB |
@@ -1,78 +0,0 @@
|
||||
'use strict';
|
||||
// Extract the glyph outlines the site icons and the og-image wordmark need from a
|
||||
// grotesque font (LiberationSans = Arial-metric, matching the game's system-ui/Arial
|
||||
// stack) and emit cubic-bezier contours per character, baseline at y=0, y-down,
|
||||
// plus the advance width so generate.js can lay out words without the font.
|
||||
// Same outline conversion as ../../vk/build/extract.js, generalised to a char set.
|
||||
const opentype = require('opentype.js');
|
||||
const fs = require('fs');
|
||||
|
||||
const FONT = process.argv[2] || '/usr/share/fonts/truetype/liberation/LiberationSans-Regular.ttf';
|
||||
const b = fs.readFileSync(FONT);
|
||||
const font = opentype.parse(b.buffer.slice(b.byteOffset, b.byteOffset + b.byteLength));
|
||||
const FS = 1000; // em scale
|
||||
|
||||
// The tile glyphs («Э», «8») + every character of the og-image wordmark lines
|
||||
// («Эрудит», «Скрэббл — игра в слова»). The space carries only an advance.
|
||||
const CHARS = [...new Set('Э8рудитСкэббл—игра в слова')];
|
||||
|
||||
function glyphData(ch) {
|
||||
const g = font.charToGlyph(ch);
|
||||
const p = g.getPath(0, 0, FS); // baseline at y=0, y-down
|
||||
const contours = [];
|
||||
let cur = null, prev = null;
|
||||
for (const c of p.commands) {
|
||||
if (c.type === 'M') {
|
||||
if (cur) contours.push(cur);
|
||||
cur = [{ v: [c.x, c.y], i: [c.x, c.y], o: [c.x, c.y] }];
|
||||
prev = { x: c.x, y: c.y };
|
||||
} else if (c.type === 'L') {
|
||||
cur.push({ v: [c.x, c.y], i: [c.x, c.y], o: [c.x, c.y] });
|
||||
prev = { x: c.x, y: c.y };
|
||||
} else if (c.type === 'C') {
|
||||
cur[cur.length - 1].o = [c.x1, c.y1];
|
||||
cur.push({ v: [c.x, c.y], i: [c.x2, c.y2], o: [c.x, c.y] });
|
||||
prev = { x: c.x, y: c.y };
|
||||
} else if (c.type === 'Q') {
|
||||
const c1 = [prev.x + 2 / 3 * (c.x1 - prev.x), prev.y + 2 / 3 * (c.y1 - prev.y)];
|
||||
const c2 = [c.x + 2 / 3 * (c.x1 - c.x), c.y + 2 / 3 * (c.y1 - c.y)];
|
||||
cur[cur.length - 1].o = c1;
|
||||
cur.push({ v: [c.x, c.y], i: c2, o: [c.x, c.y] });
|
||||
prev = { x: c.x, y: c.y };
|
||||
} else if (c.type === 'Z') {
|
||||
if (cur && cur.length > 1) {
|
||||
const last = cur[cur.length - 1], first = cur[0];
|
||||
if (Math.hypot(last.v[0] - first.v[0], last.v[1] - first.v[1]) < 1e-3) {
|
||||
first.i = last.i; // fold the duplicate closing point into the first
|
||||
cur.pop();
|
||||
}
|
||||
}
|
||||
if (cur) { contours.push(cur); cur = null; }
|
||||
}
|
||||
}
|
||||
if (cur) contours.push(cur);
|
||||
|
||||
let minx = Infinity, miny = Infinity, maxx = -Infinity, maxy = -Infinity;
|
||||
const out = contours.map(ct => {
|
||||
const v = [], i = [], o = [];
|
||||
ct.forEach(pt => {
|
||||
v.push(pt.v);
|
||||
i.push([pt.i[0] - pt.v[0], pt.i[1] - pt.v[1]]);
|
||||
o.push([pt.o[0] - pt.v[0], pt.o[1] - pt.v[1]]);
|
||||
minx = Math.min(minx, pt.v[0]); maxx = Math.max(maxx, pt.v[0]);
|
||||
miny = Math.min(miny, pt.v[1]); maxy = Math.max(maxy, pt.v[1]);
|
||||
});
|
||||
return { i, o, v, c: true };
|
||||
});
|
||||
const bbox = out.length
|
||||
? { x: minx, y: miny, w: maxx - minx, h: maxy - miny }
|
||||
: { x: 0, y: 0, w: 0, h: 0 }; // the space has no outline
|
||||
return { adv: g.advanceWidth * (FS / font.unitsPerEm), bbox, contours: out };
|
||||
}
|
||||
|
||||
const glyphs = {};
|
||||
for (const ch of CHARS) glyphs[ch] = glyphData(ch);
|
||||
|
||||
const out = __dirname + '/glyphs.json';
|
||||
fs.writeFileSync(out, JSON.stringify({ em: FS, glyphs }));
|
||||
console.log('wrote', out, fs.statSync(out).size, 'bytes;', CHARS.length, 'glyphs:', CHARS.join(''));
|
||||
@@ -1,128 +0,0 @@
|
||||
'use strict';
|
||||
// Site icons + the Open Graph card for the public landing, drawn from the same
|
||||
// design as ../../vk (the wooden Erudit tile: face «Э», score «8») and the app's
|
||||
// board palette (ui/src/app.css). Everything is composed as SVG from the committed
|
||||
// glyph outlines (build/extract.js -> glyphs.json), so no font is needed at build
|
||||
// time; the PNG/ICO rasters are screenshots taken with the ui package's Playwright
|
||||
// chromium (@playwright/test re-exports the browser API). Outputs go straight to
|
||||
// ui/public/:
|
||||
// favicon.svg 96 viewBox, transparent, tile + «Э» (the score is illegible small)
|
||||
// favicon.ico 32x32 PNG-in-ICO render of the same
|
||||
// apple-touch-icon.png 180x180 opaque full-bleed tile (iOS masks its own corners)
|
||||
// og-image.png 1200x630 card: tile + wordmark on the board green
|
||||
const fs = require('fs');
|
||||
const path = require('path');
|
||||
|
||||
const G = JSON.parse(fs.readFileSync(path.join(__dirname, 'glyphs.json'), 'utf8'));
|
||||
const UI = path.resolve(__dirname, '../../../ui');
|
||||
const OUT = path.join(UI, 'public');
|
||||
|
||||
// ---- palette (vk loader tile + app.css board tokens) ------------------------
|
||||
const FACE = '#D9B978', BORDER = '#B49559', GLYPH = '#1A1A1A';
|
||||
const BOARD_DARK = '#2a3330', TEXT = '#e7ece8', TEXT_MUTED = '#cdd6cf';
|
||||
|
||||
// ---- glyph outlines -> SVG path data ----------------------------------------
|
||||
const r2 = n => Math.round(n * 100) / 100;
|
||||
// pathD renders one glyph's contours scaled by sc and translated by (tx, ty).
|
||||
function pathD(g, sc, tx, ty) {
|
||||
const pt = (v, d) => `${r2(v[0] * sc + tx + d[0] * sc)} ${r2(v[1] * sc + ty + d[1] * sc)}`;
|
||||
const Z = [0, 0];
|
||||
return g.contours.map(ct => {
|
||||
const n = ct.v.length;
|
||||
let d = `M${pt(ct.v[0], Z)}`;
|
||||
for (let k = 1; k <= n; k++) {
|
||||
const a = k - 1, b = k % n;
|
||||
d += `C${pt(ct.v[a], ct.o[a])} ${pt(ct.v[b], ct.i[b])} ${pt(ct.v[b], Z)}`;
|
||||
}
|
||||
return d + 'Z';
|
||||
}).join('');
|
||||
}
|
||||
// glyphAt centres a glyph's bbox at (cx, cy) with the given pixel cap height, the
|
||||
// same placement rule as the vk loader's glyph(). stroke fattens it slightly.
|
||||
function glyphAt(ch, capPx, cx, cy, stroke, colour) {
|
||||
const g = G.glyphs[ch], sc = capPx / g.bbox.h;
|
||||
const d = pathD(g, sc, cx - (g.bbox.x + g.bbox.w / 2) * sc, cy - (g.bbox.y + g.bbox.h / 2) * sc);
|
||||
return `<path d="${d}" fill="${colour}" stroke="${colour}" stroke-width="${r2(stroke)}"/>`;
|
||||
}
|
||||
// textLine lays out a string on a baseline from the per-glyph advances; capPx sets
|
||||
// the capital height (measured on «Э»). Returns the combined path + the width.
|
||||
function textLine(str, capPx, x, y, colour) {
|
||||
const sc = capPx / G.glyphs['Э'].bbox.h;
|
||||
let d = '', w = 0;
|
||||
for (const ch of str) {
|
||||
const g = G.glyphs[ch];
|
||||
if (g.contours.length) d += pathD(g, sc, x + w, y);
|
||||
w += g.adv * sc;
|
||||
}
|
||||
return { svg: `<path d="${d}" fill="${colour}"/>`, width: w };
|
||||
}
|
||||
// tile draws the rounded wooden tile centred at (cx, cy): size px wide/high, with
|
||||
// the vk loader's corner (6/52) and rim proportions, «Э» and optionally the «8».
|
||||
function tile(cx, cy, size, withScore) {
|
||||
const h = size / 2, rx = size * (6 / 52), rim = size * (1.8 / 52);
|
||||
let s = `<rect x="${r2(cx - h + rim / 2)}" y="${r2(cy - h + rim / 2)}" width="${r2(size - rim)}" height="${r2(size - rim)}" rx="${r2(rx)}" fill="${FACE}" stroke="${BORDER}" stroke-width="${r2(rim)}"/>`;
|
||||
s += glyphAt('Э', size * (32 / 52), cx, cy - size * (1 / 52), size * (1 / 52), GLYPH);
|
||||
if (withScore) s += glyphAt('8', size * (8.5 / 52), cx + size * (19.5 / 52), cy + size * (18.5 / 52), size * (0.5 / 52), GLYPH);
|
||||
return s;
|
||||
}
|
||||
const svg = (w, h, body) => `<svg xmlns="http://www.w3.org/2000/svg" width="${w}" height="${h}" viewBox="0 0 ${w} ${h}">${body}</svg>`;
|
||||
|
||||
// ---- favicon.svg (the committed vector master) -------------------------------
|
||||
const favicon = svg(96, 96, tile(48, 48, 88, false)) + '\n';
|
||||
|
||||
// ---- apple-touch-icon: opaque full bleed, iOS applies its own corner mask ----
|
||||
const appleTouch = svg(180, 180,
|
||||
`<rect width="180" height="180" fill="${FACE}"/>` +
|
||||
`<rect x="8" y="8" width="164" height="164" rx="18" fill="none" stroke="${BORDER}" stroke-width="4"/>` +
|
||||
glyphAt('Э', 100, 90, 88, 3, GLYPH) +
|
||||
glyphAt('8', 26, 146, 142, 1.5, GLYPH));
|
||||
|
||||
// ---- og-image: tile + wordmark, centred as one group on the board green ------
|
||||
function ogImage() {
|
||||
const W = 1200, H = 630, tileSize = 340, gap = 84;
|
||||
const l1 = textLine('Эрудит', 112, 0, 0, TEXT);
|
||||
const l2 = textLine('Скрэббл — игра в слова', 44, 0, 0, TEXT_MUTED);
|
||||
const textW = Math.max(l1.width, l2.width);
|
||||
const left = (W - (tileSize + gap + textW)) / 2;
|
||||
const tx = left + tileSize + gap;
|
||||
// Two baselines around the vertical centre; the tile centre sits between them.
|
||||
const b1 = 295, b2 = 408;
|
||||
const body =
|
||||
`<rect width="${W}" height="${H}" fill="${BOARD_DARK}"/>` +
|
||||
tile(left + tileSize / 2, H / 2, tileSize, true) +
|
||||
textLine('Эрудит', 112, tx, b1, TEXT).svg +
|
||||
textLine('Скрэббл — игра в слова', 44, tx, b2, TEXT_MUTED).svg;
|
||||
console.log(`og-image: text ${Math.round(textW)}px wide, group left ${Math.round(left)}px`);
|
||||
return svg(W, H, body);
|
||||
}
|
||||
|
||||
// ---- rasterisation (Playwright chromium from ui/node_modules) ----------------
|
||||
async function shoot(page, markup, w, h, transparent) {
|
||||
await page.setViewportSize({ width: w, height: h });
|
||||
await page.setContent(`<body style="margin:0">${markup}</body>`);
|
||||
return page.screenshot({ omitBackground: transparent });
|
||||
}
|
||||
// icoFromPNG wraps one PNG as a single-entry .ico (ICONDIR + ICONDIRENTRY + PNG).
|
||||
function icoFromPNG(png, sizePx) {
|
||||
const h = Buffer.alloc(22);
|
||||
h.writeUInt16LE(0, 0); h.writeUInt16LE(1, 2); h.writeUInt16LE(1, 4); // icon, 1 image
|
||||
h.writeUInt8(sizePx, 6); h.writeUInt8(sizePx, 7); // 32x32
|
||||
h.writeUInt16LE(1, 10); h.writeUInt16LE(32, 12); // planes, 32bpp
|
||||
h.writeUInt32LE(png.length, 14); h.writeUInt32LE(22, 18); // size, offset
|
||||
return Buffer.concat([h, png]);
|
||||
}
|
||||
|
||||
(async () => {
|
||||
fs.writeFileSync(path.join(OUT, 'favicon.svg'), favicon);
|
||||
const { chromium } = require(path.join(UI, 'node_modules', '@playwright/test'));
|
||||
const browser = await chromium.launch();
|
||||
const page = await browser.newPage();
|
||||
const fav32 = await shoot(page, svg(32, 32, tile(16, 16, 29.33, false)), 32, 32, true);
|
||||
fs.writeFileSync(path.join(OUT, 'favicon.ico'), icoFromPNG(fav32, 32));
|
||||
fs.writeFileSync(path.join(OUT, 'apple-touch-icon.png'), await shoot(page, appleTouch, 180, 180, false));
|
||||
fs.writeFileSync(path.join(OUT, 'og-image.png'), await shoot(page, ogImage(), 1200, 630, false));
|
||||
await browser.close();
|
||||
for (const f of ['favicon.svg', 'favicon.ico', 'apple-touch-icon.png', 'og-image.png']) {
|
||||
console.log('wrote', path.join(OUT, f), fs.statSync(path.join(OUT, f)).size, 'bytes');
|
||||
}
|
||||
})();
|
||||
@@ -33,11 +33,16 @@ real game seating the caller with an **empty opponent seat** (status `open`) or,
|
||||
another player already waits for the same variant and per-turn word rule, seats the
|
||||
caller into that open game and starts it — and
|
||||
friend-game invitations (invite → accept, starting a 2–4 player game once every
|
||||
invitee accepts). A **simultaneous-game cap** (`game.MaxActiveQuickGames` = 10) limits a
|
||||
player's active quick games — status `active`/`open`, excluding invitation-linked friend
|
||||
games (`game.Service.CountActiveQuickGames`); the server refuses `lobby/enqueue` and
|
||||
`invitations` creation with **409 `game_limit_reached`** at the cap (accepting an invitation
|
||||
is exempt), and the `games.list` response carries an `at_game_limit` flag for the lobby.
|
||||
invitee accepts). **Per-tier, per-kind active-game caps** limit a player's simultaneous
|
||||
unfinished games by kind — `vs_ai`, `random` (quick auto-match), `friends` — with separate
|
||||
guest and durable-account tiers held in the single-row `backend.config` table (a `-1` means
|
||||
unlimited), read through an in-memory cache (`internal/gamelimits`) and tuned live in the admin
|
||||
(`/_gm/limits`, no redeploy). Each game is tagged with its `games.game_kind` on creation;
|
||||
`game.Service.AtGameLimit` counts the account's `active`/`open` games of a kind against the
|
||||
tier's cap. The server refuses `lobby/enqueue` (random/vs_ai) with **409 `game_limit_reached`**
|
||||
at the cap, and the `invitations` (friends) path enforces the durable friends cap and refuses a
|
||||
**guest** outright (guests cannot use friends); accepting an invitation is exempt. The
|
||||
`games.list` response carries an `at_game_limit` flag (the random-kind cap) for the lobby.
|
||||
`internal/social` owns the friend graph (request/accept),
|
||||
per-user blocks, and per-game chat with nudges folded in as a message kind; chat
|
||||
messages are length-capped, content-filtered (no links/emails/phone numbers,
|
||||
@@ -100,7 +105,10 @@ state, lobby enqueue, chat). The social/account/history operations under
|
||||
`/api/v1/user`: `friends/*` (request/respond/cancel/unfriend,
|
||||
list/incoming, the one-time `code` issue/redeem), `blocks/*`, `invitations/*`
|
||||
(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
|
||||
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
|
||||
@@ -132,21 +140,38 @@ so `/internal/push-target` returns the recipient's `preferred_language` as the r
|
||||
language for out-of-app push; no per-bot routing remains. The console also manages the **advertising banner** (`/_gm/banners` +
|
||||
`/_gm/banner-settings`, `internal/ads`): operator campaigns with a percent weight, an optional
|
||||
window and bilingual messages, plus the global display timings. `GET /api/v1/user/profile` attaches
|
||||
the resolved, weighted campaign feed for an **eligible** viewer (`!paid_account && hint_balance == 0
|
||||
&& !no_banner` role, the message language picked by `preferred_language`); changing those inputs
|
||||
publishes a `notify` `banner` re-poll signal so the client shows/hides it in place. The shared wire
|
||||
the resolved, weighted campaign feed for an **eligible** viewer (no active **no-ads** benefit
|
||||
applicable in the current context and no **`no_banner`** role; the message language picked by
|
||||
`preferred_language`); changing those inputs
|
||||
publishes a `notify` `banner` re-poll signal so the client shows/hides it in place.
|
||||
The same gate drives the post-move interstitial config (`Profile.ads`, `adsFor`). The user card
|
||||
also carries a **finance panel** (`payments.AccountStatement`): the account's chip balances per
|
||||
funding segment, benefits per origin, the recorded refund risk, and the append-only ledger history
|
||||
(newest first) — read straight from the payments tables, uncached. The **catalog editor**
|
||||
(`/_gm/catalog`, `handlers_admin_catalog.go`) is the source of truth for products (D32): create /
|
||||
edit / archive-unarchive (the `product.active` flag) products, their atoms and per-rail prices, and
|
||||
hard-delete only a **never-transacted** product (an order/ledger reference forces archive-only,
|
||||
backed by the FK); a `tournament`-bearing product is composable but not sellable yet. The user card
|
||||
also carries an admin **grant** panel: grant raw benefit atoms (hints / no-ads days / forever) or a
|
||||
defined **value product** (a reward bundle, including an archived one), origin-picked; both write an
|
||||
`admin_grant` ledger row via `payments.Grant` / `GrantProduct` and **refuse** a chips or `tournament`
|
||||
atom (never grant currency; no tournament target yet). Each fund row in the panel carries a **Refund**
|
||||
action (`payments.RefundOrderFull`): a full-order refund the operator records after refunding on the
|
||||
rail — a `refund` ledger row + a floor-0 chip revoke, idempotent. A **ledger CSV export**
|
||||
(`/_gm/ledger.csv`, `payments.LedgerExport`) dumps the whole append-only ledger for tax +
|
||||
reconciliation. The shared wire
|
||||
contracts live in the sibling [`../pkg`](../pkg) module.
|
||||
|
||||
**Account linking & merge** (`/api/v1/user/link/*`). `internal/link`
|
||||
orchestrates it: an email confirm-code or a gateway-validated Telegram identity is
|
||||
attached to the current account, and when the identity already has its own account
|
||||
the two are merged in one transaction (`internal/accountmerge`) — stats and the hint
|
||||
wallet summed, `paid_account` ORed, identities/games/chat/complaints transferred,
|
||||
the two are merged in one transaction (`internal/accountmerge`) — stats summed,
|
||||
identities/games/chat/complaints transferred,
|
||||
friends/blocks de-duplicated, the secondary kept as a `merged_into` tombstone (so a
|
||||
shared finished game's foreign keys hold); a shared **active** game blocks the merge.
|
||||
The current account is primary, except a guest initiator whose linked identity has a
|
||||
durable owner — then the durable account wins and a fresh session is minted for it.
|
||||
The `accounts.paid_account`/`merged_into`/`merged_at` columns back this. This supersedes the
|
||||
The `accounts.merged_into`/`merged_at` columns back this. This supersedes the
|
||||
former `email.bind.*` edge surface (the `RequestCode`/`ConfirmCode` primitives stay).
|
||||
|
||||
Rate-limit observability: the gateway posts its periodic rejection
|
||||
|
||||
@@ -28,9 +28,11 @@ import (
|
||||
"scrabble/backend/internal/engine"
|
||||
"scrabble/backend/internal/feedback"
|
||||
"scrabble/backend/internal/game"
|
||||
"scrabble/backend/internal/gamelimits"
|
||||
"scrabble/backend/internal/link"
|
||||
"scrabble/backend/internal/lobby"
|
||||
"scrabble/backend/internal/notify"
|
||||
"scrabble/backend/internal/payments"
|
||||
"scrabble/backend/internal/postgres"
|
||||
"scrabble/backend/internal/pushgrpc"
|
||||
"scrabble/backend/internal/ratewatch"
|
||||
@@ -155,6 +157,17 @@ func run(ctx context.Context, cfg config.Config, logger *zap.Logger) error {
|
||||
logger.Info("active dictionary version", zap.String("version", games.ActiveVersion()))
|
||||
games.SetNotifier(hub)
|
||||
games.SetMetrics(tel.MeterProvider().Meter("scrabble/backend/game"))
|
||||
|
||||
// Active-game limit config: the per-tier, per-kind caps in backend.config, read once into an
|
||||
// in-memory cache at boot and refreshed when the admin edits them. A boot-time load fails fast if
|
||||
// the single config row is missing; the game domain reads the cache on every new-game gate.
|
||||
gameLimits := gamelimits.NewService(gamelimits.NewStore(db))
|
||||
if err := gameLimits.Load(ctx); err != nil {
|
||||
return fmt.Errorf("load game-limit config: %w", err)
|
||||
}
|
||||
games.SetGameLimits(gameLimits)
|
||||
logger.Info("game-limit config loaded")
|
||||
|
||||
go games.RunSweeper(ctx, cfg.Game.TimeoutSweepInterval)
|
||||
logger.Info("game turn-timeout sweeper started",
|
||||
zap.Duration("interval", cfg.Game.TimeoutSweepInterval))
|
||||
@@ -194,7 +207,8 @@ func run(ctx context.Context, cfg config.Config, logger *zap.Logger) error {
|
||||
emails.SetSendLimiter(account.NewSendLimiter(time.Minute, 5))
|
||||
// Account linking & merge: the orchestrator over the account, merge and
|
||||
// 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.SetNotifier(hub)
|
||||
socialSvc.SetMetrics(tel.MeterProvider().Meter("scrabble/backend/social"))
|
||||
@@ -260,6 +274,28 @@ func run(ctx context.Context, cfg config.Config, logger *zap.Logger) error {
|
||||
// block and the banner admin console section.
|
||||
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")
|
||||
|
||||
// Warm the public-offer price list cache so /offer/ serves the current catalog from the first
|
||||
// request; it is reprojected lazily thereafter on any catalog edit. Non-fatal — a transient
|
||||
// failure here only defers the projection to the first read.
|
||||
if _, err := paymentsSvc.OfferPricing(ctx); err != nil {
|
||||
logger.Warn("offer pricing warm failed; will project on first request", zap.Error(err))
|
||||
}
|
||||
|
||||
// Wire the payments surface into the domains that consume it: the online-game hint wallet
|
||||
// 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
|
||||
// download answers 404) when BACKEND_RENDERER_URL is unset.
|
||||
var renderer *render.Client
|
||||
@@ -287,9 +323,12 @@ func run(ctx context.Context, cfg config.Config, logger *zap.Logger) error {
|
||||
RateWatch: rateWatch,
|
||||
BanView: banView,
|
||||
Ads: adsSvc,
|
||||
Payments: paymentsSvc,
|
||||
GameLimits: gameLimits,
|
||||
Notifier: hub,
|
||||
ExportSignKey: cfg.ExportSignKey,
|
||||
Renderer: renderer,
|
||||
Robokassa: cfg.Robokassa,
|
||||
})
|
||||
pushSrv := pushgrpc.NewServer(cfg.GRPCAddr, hub, logger)
|
||||
|
||||
@@ -298,6 +337,13 @@ func run(ctx context.Context, cfg config.Config, logger *zap.Logger) error {
|
||||
logger.Info("servers starting",
|
||||
zap.String("http_addr", cfg.HTTPAddr),
|
||||
zap.String("grpc_addr", cfg.GRPCAddr))
|
||||
// Sweep expired pending payment orders on a cadence (cosmetic hygiene; a late valid callback
|
||||
// still credits). Runs until ctx is cancelled.
|
||||
go runOrderReaper(ctx, paymentsSvc, logger)
|
||||
// Deliver pending payment_events to connected clients as an in-app wallet-refresh push (the
|
||||
// credit already landed in the ledger; a return-focus poll is the client-side fallback).
|
||||
go runPaymentDispatcher(ctx, paymentsSvc, hub, logger)
|
||||
|
||||
errc := make(chan error, 2)
|
||||
go func() { errc <- pushSrv.Run(ctx) }()
|
||||
go func() { errc <- srv.Run(ctx) }()
|
||||
@@ -307,6 +353,52 @@ func run(ctx context.Context, cfg config.Config, logger *zap.Logger) error {
|
||||
return err
|
||||
}
|
||||
|
||||
// runOrderReaper periodically expires pending payment orders past their configured lifetime, until
|
||||
// ctx is cancelled. Expiry is cosmetic: a later valid provider callback still credits an expired
|
||||
// order.
|
||||
func runOrderReaper(ctx context.Context, p *payments.Service, log *zap.Logger) {
|
||||
t := time.NewTicker(5 * time.Minute)
|
||||
defer t.Stop()
|
||||
for {
|
||||
select {
|
||||
case <-ctx.Done():
|
||||
return
|
||||
case <-t.C:
|
||||
if n, err := p.ExpireOrders(ctx); err != nil {
|
||||
log.Warn("order reaper: sweep failed", zap.Error(err))
|
||||
} else if n > 0 {
|
||||
log.Info("order reaper: expired pending orders", zap.Int("count", n))
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// runPaymentDispatcher delivers pending payment_events to connected clients as a wallet-refresh
|
||||
// signal (KindNotification / "payment"), marking each delivered, until ctx is cancelled. The credit
|
||||
// already committed to the ledger; this is only the in-app push so an open wallet updates in place.
|
||||
func runPaymentDispatcher(ctx context.Context, p *payments.Service, pub notify.Publisher, log *zap.Logger) {
|
||||
t := time.NewTicker(3 * time.Second)
|
||||
defer t.Stop()
|
||||
for {
|
||||
select {
|
||||
case <-ctx.Done():
|
||||
return
|
||||
case <-t.C:
|
||||
evs, err := p.UndispatchedEvents(ctx, 50)
|
||||
if err != nil {
|
||||
log.Warn("payment dispatcher: read failed", zap.Error(err))
|
||||
continue
|
||||
}
|
||||
for _, e := range evs {
|
||||
pub.Publish(notify.Notification(e.AccountID, notify.NotifyPayment))
|
||||
if err := p.MarkEventDispatched(ctx, e.EventID); err != nil {
|
||||
log.Warn("payment dispatcher: mark failed", zap.String("event", e.EventID.String()), zap.Error(err))
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// newMailer builds the confirm-code mailer: an SMTP relay when a host is
|
||||
// configured, otherwise the development log mailer (the code is logged, not sent).
|
||||
func newMailer(cfg account.SMTPConfig, logger *zap.Logger) account.Mailer {
|
||||
|
||||
@@ -9,7 +9,8 @@
|
||||
// 1. start a postgres:17-alpine container via testcontainers-go
|
||||
// 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
|
||||
// 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
|
||||
|
||||
import (
|
||||
@@ -36,6 +37,7 @@ const (
|
||||
superuserPassword = "scrabble"
|
||||
superuserDatabase = "scrabble_backend"
|
||||
backendSchema = "backend"
|
||||
paymentsSchema = "payments"
|
||||
containerStartup = 90 * time.Second
|
||||
jetOutputDirSuffix = "internal/postgres/jet"
|
||||
)
|
||||
@@ -105,11 +107,16 @@ func run(ctx context.Context) error {
|
||||
return fmt.Errorf("drop goose_db_version: %w", err)
|
||||
}
|
||||
|
||||
if err := jetpostgres.GenerateDB(db, backendSchema, outputDir); err != nil {
|
||||
return fmt.Errorf("jet generate: %w", err)
|
||||
// Each schema generates into its own jet/<schema>/ subdirectory (the
|
||||
// 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
|
||||
}
|
||||
|
||||
|
||||
@@ -19,11 +19,13 @@
|
||||
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"
|
||||
@@ -108,12 +110,20 @@ type genCase struct {
|
||||
|
||||
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),
|
||||
@@ -172,6 +182,78 @@ func buildSample(out, tag string, rs *rules.Ruleset, words []string, cases []gen
|
||||
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) {
|
||||
@@ -284,6 +366,46 @@ func russianRack(letters string, blanks int) genRack {
|
||||
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 {
|
||||
|
||||
@@ -43,9 +43,7 @@ var ErrNotFound = errors.New("account: not found")
|
||||
// local-time window (in TimeZone) during which the player is asleep, so the
|
||||
// turn-timeout sweeper does not auto-resign them inside it. (The robot opponent's
|
||||
// own sleep is anchored to its human opponent's timezone with a per-game drift,
|
||||
// computed in internal/robot, not from a robot account's away window.) HintBalance
|
||||
// is the player's wallet of purchasable hints, spent after a game's per-seat
|
||||
// allowance.
|
||||
// computed in internal/robot, not from a robot account's away window.)
|
||||
type Account struct {
|
||||
ID uuid.UUID
|
||||
DisplayName string
|
||||
@@ -53,7 +51,6 @@ type Account struct {
|
||||
TimeZone string
|
||||
AwayStart time.Time
|
||||
AwayEnd time.Time
|
||||
HintBalance int
|
||||
BlockChat bool
|
||||
BlockFriendRequests bool
|
||||
// VariantPreferences is the set of game variants (engine.Variant stable labels:
|
||||
@@ -69,10 +66,6 @@ type Account struct {
|
||||
// true (the default): the platform side-service skips out-of-app push for the
|
||||
// account.
|
||||
NotificationsInAppOnly bool
|
||||
// PaidAccount marks a lifetime one-time-payment account. It is a service field
|
||||
// (no purchase flow yet); an account linking & merge ORs it so a paid status is
|
||||
// never lost when accounts are consolidated.
|
||||
PaidAccount bool
|
||||
// MergedInto is the primary account a retired (merged) secondary points at, or
|
||||
// uuid.Nil for a live account. A tombstone keeps the row so the no-cascade
|
||||
// foreign keys of a shared finished game stay valid.
|
||||
@@ -393,6 +386,21 @@ func (s *Store) Identities(ctx context.Context, accountID uuid.UUID) ([]Identity
|
||||
return out, nil
|
||||
}
|
||||
|
||||
// HasConfirmedEmail reports whether the account owns a confirmed email identity — the direct-rail
|
||||
// recovery anchor a first purchase requires (D36).
|
||||
func (s *Store) HasConfirmedEmail(ctx context.Context, accountID uuid.UUID) (bool, error) {
|
||||
ids, err := s.Identities(ctx, accountID)
|
||||
if err != nil {
|
||||
return false, err
|
||||
}
|
||||
for _, id := range ids {
|
||||
if id.Kind == "email" && id.Confirmed {
|
||||
return true, nil
|
||||
}
|
||||
}
|
||||
return false, nil
|
||||
}
|
||||
|
||||
// ListAccounts returns accounts for the admin user list, newest first, paginated
|
||||
// by limit and offset.
|
||||
func (s *Store) ListAccounts(ctx context.Context, limit, offset int) ([]Account, error) {
|
||||
@@ -548,52 +556,6 @@ func (s *Store) ProvisionGuest(ctx context.Context, browserTZ string) (Account,
|
||||
return modelToAccount(row), nil
|
||||
}
|
||||
|
||||
// SpendHint atomically decrements the account's hint wallet by one, returning
|
||||
// true when a hint was spent and false when the balance was already empty. The
|
||||
// guarded UPDATE keeps it safe under concurrent spends across the player's games.
|
||||
func (s *Store) SpendHint(ctx context.Context, id uuid.UUID) (bool, error) {
|
||||
stmt := table.Accounts.
|
||||
UPDATE(table.Accounts.HintBalance, table.Accounts.UpdatedAt).
|
||||
SET(table.Accounts.HintBalance.SUB(postgres.Int(1)), postgres.TimestampzT(time.Now().UTC())).
|
||||
WHERE(
|
||||
table.Accounts.AccountID.EQ(postgres.UUID(id)).
|
||||
AND(table.Accounts.HintBalance.GT(postgres.Int(0))),
|
||||
)
|
||||
res, err := stmt.ExecContext(ctx, s.db)
|
||||
if err != nil {
|
||||
return false, fmt.Errorf("account: spend hint %s: %w", id, err)
|
||||
}
|
||||
n, err := res.RowsAffected()
|
||||
if err != nil {
|
||||
return false, fmt.Errorf("account: spend hint rows %s: %w", id, err)
|
||||
}
|
||||
return n > 0, nil
|
||||
}
|
||||
|
||||
// GrantHints adds n hints to the account's wallet and returns the new balance. n must be
|
||||
// positive: the additive update can only raise the balance, never lower it, so it enforces the
|
||||
// admin console's raise-only rule by construction and stays correct under a concurrent SpendHint.
|
||||
// It returns ErrNotFound when no account matches.
|
||||
func (s *Store) GrantHints(ctx context.Context, id uuid.UUID, n int) (int, error) {
|
||||
if n <= 0 {
|
||||
return 0, fmt.Errorf("account: grant hints %s: n must be positive, got %d", id, n)
|
||||
}
|
||||
stmt := table.Accounts.
|
||||
UPDATE(table.Accounts.HintBalance, table.Accounts.UpdatedAt).
|
||||
SET(table.Accounts.HintBalance.ADD(postgres.Int(int64(n))), postgres.TimestampzT(time.Now().UTC())).
|
||||
WHERE(table.Accounts.AccountID.EQ(postgres.UUID(id))).
|
||||
RETURNING(table.Accounts.HintBalance)
|
||||
|
||||
var row model.Accounts
|
||||
if err := stmt.QueryContext(ctx, s.db, &row); err != nil {
|
||||
if errors.Is(err, qrm.ErrNoRows) {
|
||||
return 0, ErrNotFound
|
||||
}
|
||||
return 0, fmt.Errorf("account: grant hints %s: %w", id, err)
|
||||
}
|
||||
return int(row.HintBalance), nil
|
||||
}
|
||||
|
||||
// FlagHighRate stamps the soft "suspected high-rate" marker with at, only when
|
||||
// the account is not already flagged — the first sustained episode wins, and a
|
||||
// re-flag after an operator clear starts a fresh timestamp. An infra marker, not
|
||||
@@ -649,12 +611,10 @@ func modelToAccount(row model.Accounts) Account {
|
||||
TimeZone: row.TimeZone,
|
||||
AwayStart: row.AwayStart,
|
||||
AwayEnd: row.AwayEnd,
|
||||
HintBalance: int(row.HintBalance),
|
||||
BlockChat: row.BlockChat,
|
||||
BlockFriendRequests: row.BlockFriendRequests,
|
||||
IsGuest: row.IsGuest,
|
||||
NotificationsInAppOnly: row.NotificationsInAppOnly,
|
||||
PaidAccount: row.PaidAccount,
|
||||
MergedInto: mergedInto,
|
||||
FlaggedHighRateAt: flaggedHighRateAt,
|
||||
CreatedAt: row.CreatedAt,
|
||||
|
||||
@@ -51,8 +51,24 @@ var ErrSameAccount = errors.New("accountmerge: primary and secondary are the sam
|
||||
type Merger struct {
|
||||
db *sql.DB
|
||||
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.
|
||||
func NewMerger(db *sql.DB) *Merger {
|
||||
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
|
||||
}
|
||||
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 {
|
||||
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 {
|
||||
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)
|
||||
})
|
||||
}); 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
|
||||
@@ -248,25 +277,16 @@ func mergeBestMoves(ctx context.Context, tx *sql.Tx, primary, secondary uuid.UUI
|
||||
return nil
|
||||
}
|
||||
|
||||
// mergeAccountFields adds secondary's hint wallet to primary and ORs the paid flag;
|
||||
// all other profile fields stay the primary's.
|
||||
func mergeAccountFields(ctx context.Context, tx *sql.Tx, primary, secondary uuid.UUID, now time.Time) error {
|
||||
var sec model.Accounts
|
||||
if err := postgres.SELECT(table.Accounts.AllColumns).
|
||||
FROM(table.Accounts).
|
||||
WHERE(table.Accounts.AccountID.EQ(postgres.UUID(secondary))).
|
||||
QueryContext(ctx, tx, &sec); err != nil {
|
||||
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)))
|
||||
// mergeAccountFields bumps the primary account's updated_at to reflect the merge. The former
|
||||
// hint-wallet and paid-flag merge moved to the payments domain, where segments and benefits
|
||||
// merge by origin (see the payments MergeTx step and docs/PAYMENTS.md §6); the legacy
|
||||
// accounts.hint_balance / paid_account columns are deprecated and no longer read or written.
|
||||
func mergeAccountFields(ctx context.Context, tx *sql.Tx, primary, _ uuid.UUID, now time.Time) error {
|
||||
upd := table.Accounts.UPDATE(table.Accounts.UpdatedAt).
|
||||
SET(postgres.TimestampzT(now)).
|
||||
WHERE(table.Accounts.AccountID.EQ(postgres.UUID(primary)))
|
||||
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
|
||||
}
|
||||
|
||||
@@ -15,12 +15,14 @@
|
||||
<a href="/_gm/"{{if eq .ActiveNav "dashboard"}} class="active"{{end}}>Dashboard</a>
|
||||
<a href="/_gm/users"{{if eq .ActiveNav "users"}} class="active"{{end}}>Users</a>
|
||||
<a href="/_gm/games"{{if eq .ActiveNav "games"}} class="active"{{end}}>Games</a>
|
||||
<a href="/_gm/limits"{{if eq .ActiveNav "limits"}} class="active"{{end}}>Limits</a>
|
||||
<a href="/_gm/complaints"{{if eq .ActiveNav "complaints"}} class="active"{{end}}>Complaints</a>
|
||||
<a href="/_gm/feedback"{{if eq .ActiveNav "feedback"}} class="active"{{end}}>Feedback</a>
|
||||
<a href="/_gm/messages"{{if eq .ActiveNav "messages"}} class="active"{{end}}>Messages</a>
|
||||
<a href="/_gm/throttled"{{if eq .ActiveNav "throttled"}} class="active"{{end}}>Throttled</a>
|
||||
<a href="/_gm/reasons"{{if eq .ActiveNav "reasons"}} class="active"{{end}}>Reasons</a>
|
||||
<a href="/_gm/banners"{{if eq .ActiveNav "banners"}} class="active"{{end}}>Banners</a>
|
||||
<a href="/_gm/catalog"{{if eq .ActiveNav "catalog"}} class="active"{{end}}>Catalog</a>
|
||||
<a href="/_gm/dictionary"{{if eq .ActiveNav "dictionary"}} class="active"{{end}}>Dictionary</a>
|
||||
<a href="/_gm/broadcast"{{if eq .ActiveNav "broadcast"}} class="active"{{end}}>Broadcast</a>
|
||||
<a href="/_gm/grafana/">Grafana ↗</a>
|
||||
|
||||
@@ -0,0 +1,54 @@
|
||||
{{define "content" -}}
|
||||
<h1>Product catalog</h1>
|
||||
{{with .Data}}
|
||||
<p class="note">A <strong>pack</strong> funds chips (a money price per rail — RUB via direct, VOTE via vk, XTR via telegram); a <strong>value</strong> buys benefits with chips (a CHIP price). Archived products are hidden from players but still credit an in-flight payment and can be granted. A product with transactions can only be archived, not deleted. Amounts are in minor units (RUB kopecks; VOTE/XTR/CHIP whole). The <code>tournament</code> atom is not sellable yet — keep such a product archived.</p>
|
||||
<section class="panel"><h2>Add product</h2>
|
||||
<form class="form col" method="post" action="/_gm/catalog">
|
||||
<label>Title <input type="text" name="title" maxlength="120" required></label>
|
||||
<fieldset><legend>Atoms (quantity; blank = none)</legend>
|
||||
<label>Chips <input type="number" name="chips" min="0"></label>
|
||||
<label>Hints <input type="number" name="hints" min="0"></label>
|
||||
<label>No-ads days <input type="number" name="noads" min="0"></label>
|
||||
<label>Tournament <input type="number" name="tournament" min="0"></label>
|
||||
</fieldset>
|
||||
<fieldset><legend>Prices (minor units; blank = none)</legend>
|
||||
<label>RUB — direct (kopecks) <input type="number" name="price_rub" min="0"></label>
|
||||
<label>VOTE — vk <input type="number" name="price_vote" min="0"></label>
|
||||
<label>XTR — telegram <input type="number" name="price_star" min="0"></label>
|
||||
<label>CHIP — value <input type="number" name="price_chip" min="0"></label>
|
||||
</fieldset>
|
||||
<label><input type="checkbox" name="active" value="true"> Active (on sale)</label>
|
||||
<div><button type="submit">Add</button></div>
|
||||
</form>
|
||||
</section>
|
||||
<section class="panel"><h2>Rewarded ads (watch for chips)</h2>
|
||||
<p class="note">The chips a player earns per rewarded-video view (VK only), plus the per-day and per-hour caps that bound free chips — <strong>0 payout turns rewarded off</strong>. This is the shared reward config, not a product.</p>
|
||||
<form class="form col" method="post" action="/_gm/catalog/reward">
|
||||
<label>Chips per view <input type="number" name="reward_payout" min="0" value="{{.RewardPayout}}"></label>
|
||||
<label>Daily cap <input type="number" name="reward_daily" min="0" value="{{.RewardDailyCap}}"></label>
|
||||
<label>Hourly cap <input type="number" name="reward_hourly" min="0" value="{{.RewardHourlyCap}}"></label>
|
||||
<div><button type="submit">Save</button></div>
|
||||
</form>
|
||||
</section>
|
||||
<section class="panel"><h2>Products</h2>
|
||||
<p class="note">Showing {{if .ShowAll}}<strong>all</strong> products (active + archived) — <a href="/_gm/catalog">active only</a>{{else}}<strong>active</strong> products — <a href="/_gm/catalog?all=1">show all (incl. archived)</a>{{end}}.</p>
|
||||
<table class="list">
|
||||
<thead><tr><th>Title</th><th>Status</th><th>Atoms</th><th>Prices</th><th></th></tr></thead>
|
||||
<tbody>
|
||||
{{range .Products}}
|
||||
<tr>
|
||||
<td><a href="/_gm/catalog/{{.ID}}">{{.Title}}</a></td>
|
||||
<td>{{if .Active}}<span class="ok">active</span>{{else}}<span class="warn">archived</span>{{end}}{{if .Transacted}} <span class="pill">transacted</span>{{end}}</td>
|
||||
<td>{{range .Atoms}}<code>{{.Atom}}×{{.Quantity}}</code> {{end}}</td>
|
||||
<td>{{range .Prices}}<code>{{.Currency}}{{if .Method}}/{{.Method}}{{end}} {{.Amount}}</code> {{end}}</td>
|
||||
<td class="row-actions">
|
||||
<form class="form" method="post" action="/_gm/catalog/{{.ID}}/archive"><input type="hidden" name="active" value="{{if .Active}}false{{else}}true{{end}}"><button type="submit">{{if .Active}}Archive{{else}}Unarchive{{end}}</button></form>
|
||||
{{if not .Transacted}}<form class="form" method="post" action="/_gm/catalog/{{.ID}}/delete" onsubmit="return confirm('Delete this product? It has never been transacted, so this is safe and permanent.')"><button type="submit">Delete</button></form>{{end}}
|
||||
</td>
|
||||
</tr>
|
||||
{{else}}<tr><td colspan="5"><span class="note">no products</span></td></tr>{{end}}
|
||||
</tbody>
|
||||
</table>
|
||||
</section>
|
||||
{{end}}
|
||||
{{- end}}
|
||||
@@ -8,6 +8,7 @@
|
||||
<li><b>Dictionary</b> {{.DictVersion}}</li>
|
||||
<li><b>Status</b> {{.Status}}{{if .EndReason}} ({{.EndReason}}){{end}}</li>
|
||||
<li><b>AI game</b> {{if .VsAI}}🤖 yes{{else}}no{{end}}</li>
|
||||
<li><b>Word rule</b> {{if .MultipleWordsPerTurn}}multiple words per turn{{else}}single word per turn{{end}}</li>
|
||||
<li><b>Players</b> {{.Players}}</li>
|
||||
<li><b>To move</b> seat {{.ToMove}}</li>
|
||||
<li><b>Moves</b> {{.MoveCount}}</li>
|
||||
|
||||
@@ -8,11 +8,11 @@
|
||||
<a href="/_gm/games?status=finished"{{if eq .Status "finished"}} class="active"{{end}}>finished</a>
|
||||
</nav>
|
||||
<table class="list">
|
||||
<thead><tr><th>Game</th><th>Variant</th><th>Status</th><th>🤖</th><th class="num">Players</th><th>Updated</th></tr></thead>
|
||||
<thead><tr><th>Game</th><th>Variant</th><th>Kind</th><th>Status</th><th>🤖</th><th class="num">Players</th><th>Updated</th></tr></thead>
|
||||
<tbody>
|
||||
{{range .Items}}
|
||||
<tr><td><a href="/_gm/games/{{.ID}}">{{.ID}}</a></td><td>{{.Variant}}</td><td>{{.Status}}</td><td>{{if .VsAI}}🤖{{end}}</td><td class="num">{{.Players}}</td><td>{{.UpdatedAt}}</td></tr>
|
||||
{{else}}<tr><td colspan="6"><span class="note">no games</span></td></tr>{{end}}
|
||||
<tr><td><a href="/_gm/games/{{.ID}}">{{.ID}}</a></td><td>{{.Variant}}</td><td>{{.Kind}}</td><td>{{.Status}}</td><td>{{if .VsAI}}🤖{{end}}</td><td class="num">{{.Players}}</td><td>{{.UpdatedAt}}</td></tr>
|
||||
{{else}}<tr><td colspan="7"><span class="note">no games</span></td></tr>{{end}}
|
||||
</tbody>
|
||||
</table>
|
||||
<nav class="pager">
|
||||
|
||||
@@ -0,0 +1,19 @@
|
||||
{{define "content" -}}
|
||||
<h1>Active-game limits</h1>
|
||||
{{with .Data}}
|
||||
<p class="note">Per-tier, per-kind caps on a player's simultaneous unfinished games. <strong>-1</strong> = unlimited, <strong>0</strong> = the kind is blocked, a positive number caps concurrent games of that kind. Guests are additionally blocked from friend games outright. Changes apply immediately (no redeploy); games already in progress are never affected.</p>
|
||||
<section class="panel">
|
||||
<form class="form col" method="post" action="/_gm/limits">
|
||||
<h2>Guest</h2>
|
||||
<label>vs AI <input type="number" name="guest_vs_ai" min="-1" value="{{.GuestVsAI}}" required></label>
|
||||
<label>Random <input type="number" name="guest_random" min="-1" value="{{.GuestRandom}}" required></label>
|
||||
<label>Friends <input type="number" name="guest_friends" min="-1" value="{{.GuestFriends}}" required></label>
|
||||
<h2>Durable account</h2>
|
||||
<label>vs AI <input type="number" name="durable_vs_ai" min="-1" value="{{.DurableVsAI}}" required></label>
|
||||
<label>Random <input type="number" name="durable_random" min="-1" value="{{.DurableRandom}}" required></label>
|
||||
<label>Friends <input type="number" name="durable_friends" min="-1" value="{{.DurableFriends}}" required></label>
|
||||
<div><button type="submit">Save</button></div>
|
||||
</form>
|
||||
</section>
|
||||
{{end}}
|
||||
{{- end}}
|
||||
@@ -0,0 +1,25 @@
|
||||
{{define "content" -}}
|
||||
{{with .Data}}
|
||||
<p class="note"><a href="/_gm/catalog">← all products</a></p>
|
||||
<h1>{{.Title}} {{if .Active}}<span class="ok">active</span>{{else}}<span class="warn">archived</span>{{end}}{{if .Transacted}} <span class="pill">transacted</span>{{end}}</h1>
|
||||
<section class="panel"><h2>Edit</h2>
|
||||
<p class="note">A zero quantity / blank price removes that atom / price. Amounts are in minor units. Saving revalidates the sellable shape when the product is active. Archive / unarchive from the <a href="/_gm/catalog">catalog list</a>.</p>
|
||||
<form class="form col" method="post" action="/_gm/catalog/{{.ID}}">
|
||||
<label>Title <input type="text" name="title" value="{{.Title}}" maxlength="120" required></label>
|
||||
<fieldset><legend>Atoms (quantity; 0 = none)</legend>
|
||||
<label>Chips <input type="number" name="chips" min="0" value="{{.Chips}}"></label>
|
||||
<label>Hints <input type="number" name="hints" min="0" value="{{.Hints}}"></label>
|
||||
<label>No-ads days <input type="number" name="noads" min="0" value="{{.NoAds}}"></label>
|
||||
<label>Tournament <input type="number" name="tournament" min="0" value="{{.Tournament}}"></label>
|
||||
</fieldset>
|
||||
<fieldset><legend>Prices (minor units; 0 = none)</legend>
|
||||
<label>RUB — direct (kopecks) <input type="number" name="price_rub" min="0" value="{{.PriceRUB}}"></label>
|
||||
<label>VOTE — vk <input type="number" name="price_vote" min="0" value="{{.PriceVote}}"></label>
|
||||
<label>XTR — telegram <input type="number" name="price_star" min="0" value="{{.PriceStar}}"></label>
|
||||
<label>CHIP — value <input type="number" name="price_chip" min="0" value="{{.PriceChip}}"></label>
|
||||
</fieldset>
|
||||
<div><button type="submit">Save</button></div>
|
||||
</form>
|
||||
</section>
|
||||
{{end}}
|
||||
{{- end}}
|
||||
@@ -10,8 +10,6 @@
|
||||
<li><b>Timezone</b> {{.TimeZone}}</li>
|
||||
<li><b>Guest</b> {{if .Guest}}yes{{else}}no{{end}}</li>
|
||||
<li><b>Push</b> {{if .NotificationsInAppOnly}}in-app only{{else}}out-of-app{{end}}</li>
|
||||
<li><b>Paid</b> {{if .PaidAccount}}yes{{else}}no{{end}}</li>
|
||||
<li><b>Hint wallet</b> {{.HintBalance}}</li>
|
||||
{{if .MergedInto}}<li><b>Merged into</b> {{.MergedInto}}</li>{{end}}
|
||||
{{if .FlaggedHighRateAt}}<li><b>High-rate flag</b> <span class="warn">{{.FlaggedHighRateAt}}</span></li>{{end}}
|
||||
<li><b>Created</b> {{.CreatedAt}}</li>
|
||||
@@ -21,10 +19,6 @@
|
||||
<button type="submit">Clear high-rate flag</button>
|
||||
</form>
|
||||
{{end}}
|
||||
<form class="form" method="post" action="/_gm/users/{{.ID}}/grant-hints">
|
||||
<label>Add hints <input type="number" name="amount" min="1" max="{{.HintGrantMax}}" value="1"></label>
|
||||
<button type="submit">Grant</button>
|
||||
</form>
|
||||
</section>
|
||||
<section class="panel"><h2>Statistics</h2>
|
||||
{{if .HasStats}}
|
||||
@@ -69,6 +63,51 @@
|
||||
<div><button type="submit">{{if .Suspension.Blocked}}Re-block{{else}}Block{{end}}</button></div>
|
||||
</form>
|
||||
</section>
|
||||
<section class="panel"><h2>Finance</h2>
|
||||
{{if .Finance.Present}}
|
||||
{{if or .Finance.Segments .Finance.Benefits .Finance.Abuse .Finance.Loss}}
|
||||
<ul class="kv">
|
||||
{{range .Finance.Segments}}<li><b>Chips ({{.Source}})</b> {{.Chips}}</li>{{end}}
|
||||
{{range .Finance.Benefits}}<li><b>Benefits ({{.Origin}})</b> {{.Hints}} hints{{if .Forever}} · no-ads forever{{else if .AdsUntil}} · no-ads until {{.AdsUntil}} (UTC){{end}}</li>{{end}}
|
||||
{{if or .Finance.Abuse .Finance.Loss}}<li><b>Refund risk</b> <span class="warn">{{if .Finance.Abuse}}abuse-flagged{{end}}{{if .Finance.Loss}} · loss {{.Finance.Loss}} chips{{end}}</span></li>{{end}}
|
||||
</ul>
|
||||
{{else}}<p class="note">no balances or benefits</p>{{end}}
|
||||
<h3>Ledger</h3>
|
||||
{{$uid := .ID}}
|
||||
{{if .Finance.Ledger}}
|
||||
<table class="list">
|
||||
<thead><tr><th>Time</th><th>Kind</th><th>Source</th><th>Origin</th><th>Chips</th><th>Order</th><th>Provider</th><th>Detail</th><th></th></tr></thead>
|
||||
<tbody>
|
||||
{{range .Finance.Ledger}}
|
||||
<tr><td>{{.At}}</td><td>{{.Kind}}</td><td>{{.Source}}</td><td>{{.Origin}}</td><td>{{.ChipsDelta}}</td><td>{{if .Order}}<code>{{.Order}}</code>{{end}}</td><td>{{.Provider}}</td><td>{{if .Snapshot}}<code>{{.Snapshot}}</code>{{end}}</td>
|
||||
<td>{{if and (eq .Kind "fund") .Order}}<form class="form" method="post" action="/_gm/users/{{$uid}}/refund" onsubmit="return confirm('Refund this order in full? Record the money refund on the rail first; this revokes the chips (floored at 0).')"><input type="hidden" name="order_id" value="{{.Order}}"><button type="submit">Refund</button></form>{{end}}</td></tr>
|
||||
{{end}}
|
||||
</tbody>
|
||||
</table>
|
||||
<p class="note"><a href="/_gm/ledger.csv">Export the full ledger (CSV)</a> — all accounts, for tax + reconciliation.</p>
|
||||
{{else}}<p class="note">no ledger entries</p>{{end}}
|
||||
{{else}}<p class="note">payments not enabled</p>{{end}}
|
||||
</section>
|
||||
<section class="panel"><h2>Grant benefits</h2>
|
||||
{{if .Grant.Present}}
|
||||
<p class="note">A zero-price admin sale of a value — <strong>never chips</strong>. The origin is your compliance choice. The by-product grant applies a defined bundle, including an archived reward product.</p>
|
||||
<form class="form col" method="post" action="/_gm/users/{{.ID}}/grant">
|
||||
<label>Origin <select name="origin">{{range .Grant.Origins}}<option value="{{.}}">{{.}}</option>{{end}}</select></label>
|
||||
<label>Hints <input type="number" name="hints" min="0" value="0"></label>
|
||||
<label>No-ads days <input type="number" name="noads" min="0" value="0"></label>
|
||||
<label><input type="checkbox" name="forever" value="true"> No-ads forever</label>
|
||||
<div><button type="submit">Grant</button></div>
|
||||
</form>
|
||||
{{if .Grant.Products}}
|
||||
<h3>Grant a product</h3>
|
||||
<form class="form col" method="post" action="/_gm/users/{{.ID}}/grant-product">
|
||||
<label>Origin <select name="origin">{{range .Grant.Origins}}<option value="{{.}}">{{.}}</option>{{end}}</select></label>
|
||||
<label>Product <select name="product_id">{{range .Grant.Products}}<option value="{{.ID}}">{{.Title}} ({{.Summary}}){{if .Archived}} — archived{{end}}</option>{{end}}</select></label>
|
||||
<div><button type="submit">Grant product</button></div>
|
||||
</form>
|
||||
{{else}}<p class="note">no grantable products — create a value product in the <a href="/_gm/catalog">catalog</a></p>{{end}}
|
||||
{{else}}<p class="note">payments not enabled</p>{{end}}
|
||||
</section>
|
||||
<section class="panel"><h2>Roles</h2>
|
||||
{{$id := .ID}}
|
||||
{{if .Roles}}
|
||||
@@ -172,11 +211,11 @@
|
||||
{{end}}
|
||||
<section class="panel"><h2>Games</h2>
|
||||
<table class="list">
|
||||
<thead><tr><th>Game</th><th>Variant</th><th>Status</th><th class="num">Players</th><th>Updated</th></tr></thead>
|
||||
<thead><tr><th>Game</th><th>Variant</th><th>Kind</th><th>Status</th><th class="num">Players</th><th>Updated</th></tr></thead>
|
||||
<tbody>
|
||||
{{range .Games}}
|
||||
<tr><td><a href="/_gm/games/{{.ID}}">{{.ID}}</a></td><td>{{.Variant}}</td><td>{{.Status}}</td><td class="num">{{.Players}}</td><td>{{.UpdatedAt}}</td></tr>
|
||||
{{else}}<tr><td colspan="5"><span class="note">no games</span></td></tr>{{end}}
|
||||
<tr><td><a href="/_gm/games/{{.ID}}">{{.ID}}</a></td><td>{{.Variant}}</td><td>{{.Kind}}</td><td>{{.Status}}</td><td class="num">{{.Players}}</td><td>{{.UpdatedAt}}</td></tr>
|
||||
{{else}}<tr><td colspan="6"><span class="note">no games</span></td></tr>{{end}}
|
||||
</tbody>
|
||||
</table>
|
||||
</section>
|
||||
|
||||
@@ -149,7 +149,6 @@ type UserDetailView struct {
|
||||
TimeZone string
|
||||
Guest bool
|
||||
NotificationsInAppOnly bool
|
||||
PaidAccount bool
|
||||
// MergedInto is the primary account id when this account has been retired by a
|
||||
// merge, or empty for a live account.
|
||||
MergedInto string
|
||||
@@ -165,14 +164,10 @@ type UserDetailView struct {
|
||||
// FlaggedHighRateAt is the pre-formatted soft high-rate marker timestamp,
|
||||
// empty for an unflagged account; the card shows it with the Clear action.
|
||||
FlaggedHighRateAt string
|
||||
HintBalance int
|
||||
// HintGrantMax is the per-grant cap the operator's "add hints" form enforces (it mirrors the
|
||||
// server's maxHintGrant), passed through so the policy value lives in one place.
|
||||
HintGrantMax int
|
||||
CreatedAt string
|
||||
HasStats bool
|
||||
Stats StatsRow
|
||||
Identities []IdentityRow
|
||||
CreatedAt string
|
||||
HasStats bool
|
||||
Stats StatsRow
|
||||
Identities []IdentityRow
|
||||
// HasEmail gates the "Erase email" action; set when the account carries an email identity.
|
||||
HasEmail bool
|
||||
Games []GameRow
|
||||
@@ -200,6 +195,55 @@ type UserDetailView struct {
|
||||
Blocks []RelationRow
|
||||
BlockedBy []RelationRow
|
||||
Friends []RelationRow
|
||||
// Finance is the account's payments picture (balances, benefits, refund risk, ledger). Present
|
||||
// is false when the payments domain is unwired.
|
||||
Finance FinanceView
|
||||
// Grant is the admin-grant panel (origin picker + grantable products). Present is false when the
|
||||
// payments domain is unwired.
|
||||
Grant GrantFormView
|
||||
}
|
||||
|
||||
// FinanceView is the account's payments picture on the user card: chip balances per funding
|
||||
// segment, benefits per origin, the recorded refund risk, and the append-only ledger history
|
||||
// (newest first). Present is false when the payments domain is unwired.
|
||||
type FinanceView struct {
|
||||
Present bool
|
||||
Segments []SegmentRow
|
||||
Benefits []BenefitRow
|
||||
// Abuse is the refund abuse flag; Loss is the unrecoverable chip loss from floor-0 refunds.
|
||||
Abuse bool
|
||||
Loss int
|
||||
Ledger []LedgerRow
|
||||
}
|
||||
|
||||
// SegmentRow is one funding segment's chip balance.
|
||||
type SegmentRow struct {
|
||||
Source string
|
||||
Chips int
|
||||
}
|
||||
|
||||
// BenefitRow is one origin's benefit: the hint wallet, the ad-free expiry (pre-formatted, empty
|
||||
// when none) and the lifetime ad-free flag.
|
||||
type BenefitRow struct {
|
||||
Origin string
|
||||
Hints int
|
||||
AdsUntil string
|
||||
Forever bool
|
||||
}
|
||||
|
||||
// LedgerRow is one append-only ledger entry: its kind, funding source / benefit origin, signed chip
|
||||
// delta, the product / order / provider it references (empty when none), the raw snapshot JSON and
|
||||
// the pre-formatted time.
|
||||
type LedgerRow struct {
|
||||
Kind string
|
||||
Source string
|
||||
Origin string
|
||||
ChipsDelta int
|
||||
Product string
|
||||
Order string
|
||||
Provider string
|
||||
Snapshot string
|
||||
At string
|
||||
}
|
||||
|
||||
// RelationRow is one cross-linked account in the user card's blocks / blocked-by / friends
|
||||
@@ -268,6 +312,8 @@ type GameRow struct {
|
||||
UpdatedAt string
|
||||
// VsAI marks an honest-AI game (rendered as 🤖 in the list's AI column).
|
||||
VsAI bool
|
||||
// Kind is the game's origin tag label: vs_ai / random / friends / unknown.
|
||||
Kind string
|
||||
}
|
||||
|
||||
// GamesView is the paginated games list, optionally filtered by status.
|
||||
@@ -277,6 +323,17 @@ type GamesView struct {
|
||||
Pager Pager
|
||||
}
|
||||
|
||||
// GameLimitsView is the per-tier, per-kind active-game limit form: each field is a cap where -1
|
||||
// is unlimited, 0 blocks the kind, and a positive value caps concurrent games of that kind.
|
||||
type GameLimitsView struct {
|
||||
GuestVsAI int
|
||||
GuestRandom int
|
||||
GuestFriends int
|
||||
DurableVsAI int
|
||||
DurableRandom int
|
||||
DurableFriends int
|
||||
}
|
||||
|
||||
// GameDetailView is one game with its seats.
|
||||
type GameDetailView struct {
|
||||
ID string
|
||||
@@ -291,8 +348,12 @@ type GameDetailView struct {
|
||||
UpdatedAt string
|
||||
FinishedAt string
|
||||
// VsAI marks an honest-AI game (shown as a 🤖 flag in the summary).
|
||||
VsAI bool
|
||||
Seats []SeatRow
|
||||
VsAI bool
|
||||
// MultipleWordsPerTurn is the game's cross-word rule: true = standard Scrabble (every cross-word
|
||||
// is validated and scored), false = the single-word rule (only the main word along the play
|
||||
// direction counts). Shown in the summary so an operator can tell the rule at a glance.
|
||||
MultipleWordsPerTurn bool
|
||||
Seats []SeatRow
|
||||
// HasRobot is true when any seat is a robot, gating the robot-target caption;
|
||||
// RobotTargetPct is the configured global play-to-win rate, in percent.
|
||||
HasRobot bool
|
||||
@@ -612,3 +673,78 @@ type FeedbackDetailView struct {
|
||||
UserTZ string
|
||||
Banned bool
|
||||
}
|
||||
|
||||
// CatalogView is the product-catalog list page.
|
||||
type CatalogView struct {
|
||||
Products []ProductRow
|
||||
// ShowAll reports whether archived products are listed alongside active ones (the active/all
|
||||
// toggle); it drives the toggle link and the list heading.
|
||||
ShowAll bool
|
||||
// RewardPayout / RewardDailyCap / RewardHourlyCap are the rewarded-video config (chips earned per
|
||||
// view and the per-day / per-hour anti-abuse caps), shown in and edited from the page's
|
||||
// rewarded-ads form. A 0 payout means rewarded is inert.
|
||||
RewardPayout int
|
||||
RewardDailyCap int
|
||||
RewardHourlyCap int
|
||||
}
|
||||
|
||||
// ProductRow is one product in the catalog list: its composition, prices, the archived flag
|
||||
// (Active) and the transacted flag (which forbids a hard delete).
|
||||
type ProductRow struct {
|
||||
ID string
|
||||
Title string
|
||||
Active bool
|
||||
Atoms []AtomRow
|
||||
Prices []PriceRow
|
||||
Transacted bool
|
||||
}
|
||||
|
||||
// AtomRow is one atom line of a product row.
|
||||
type AtomRow struct {
|
||||
Atom string
|
||||
Quantity int
|
||||
}
|
||||
|
||||
// PriceRow is one price of a product: the method ("" for a value's CHIP price), the currency, and
|
||||
// the amount in that currency's minor units.
|
||||
type PriceRow struct {
|
||||
Method string
|
||||
Currency string
|
||||
Amount int64
|
||||
}
|
||||
|
||||
// ProductFormView is the product edit form, pre-filled from the current composition. Atom quantities
|
||||
// and prices are flattened to the fixed fields the form offers (0 = absent); Transacted disables the
|
||||
// delete action.
|
||||
type ProductFormView struct {
|
||||
ID string
|
||||
Title string
|
||||
Active bool
|
||||
Chips int
|
||||
Hints int
|
||||
NoAds int
|
||||
Tournament int
|
||||
PriceRUB int64
|
||||
PriceVote int64
|
||||
PriceStar int64
|
||||
PriceChip int64
|
||||
Transacted bool
|
||||
}
|
||||
|
||||
// GrantFormView is the admin-grant panel on the user card: the origin picker and the grantable
|
||||
// products (value bundles — hints / no-ads days — including archived ones; chips and tournament
|
||||
// products are excluded). Present is false when the payments domain is unwired.
|
||||
type GrantFormView struct {
|
||||
Present bool
|
||||
Origins []string
|
||||
Products []GrantProductOption
|
||||
}
|
||||
|
||||
// GrantProductOption is one grantable product in the by-product picker: its id, title, an atom
|
||||
// summary, and whether it is archived (the common case for a non-public reward bundle).
|
||||
type GrantProductOption struct {
|
||||
ID string
|
||||
Title string
|
||||
Summary string
|
||||
Archived bool
|
||||
}
|
||||
|
||||
@@ -100,14 +100,6 @@ type ActiveCampaign struct {
|
||||
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
|
||||
// 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
|
||||
|
||||
@@ -6,30 +6,6 @@ import (
|
||||
"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) {
|
||||
now := time.Date(2026, 6, 15, 12, 0, 0, 0, time.UTC)
|
||||
past := now.Add(-24 * time.Hour)
|
||||
|
||||
@@ -14,6 +14,7 @@ import (
|
||||
"scrabble/backend/internal/lobby"
|
||||
"scrabble/backend/internal/postgres"
|
||||
"scrabble/backend/internal/ratewatch"
|
||||
"scrabble/backend/internal/robokassa"
|
||||
"scrabble/backend/internal/robot"
|
||||
"scrabble/backend/internal/telemetry"
|
||||
)
|
||||
@@ -64,6 +65,9 @@ type Config struct {
|
||||
// RendererURL is the base URL of the internal image-render sidecar (e.g.
|
||||
// http://renderer:8090). Empty disables the PNG export artifact.
|
||||
RendererURL string
|
||||
// Robokassa configures the direct-rail (RUB) payment provider. An empty MerchantLogin
|
||||
// leaves the direct order and Result-callback endpoints unregistered.
|
||||
Robokassa robokassa.Config
|
||||
}
|
||||
|
||||
// Defaults applied when the corresponding environment variable is unset.
|
||||
@@ -153,6 +157,13 @@ func Load() (Config, error) {
|
||||
AdminTo: os.Getenv("BACKEND_ADMIN_EMAIL"),
|
||||
}
|
||||
|
||||
robo := robokassa.Config{
|
||||
MerchantLogin: os.Getenv("BACKEND_ROBOKASSA_MERCHANT_LOGIN"),
|
||||
Password1: os.Getenv("BACKEND_ROBOKASSA_PASSWORD1"),
|
||||
Password2: os.Getenv("BACKEND_ROBOKASSA_PASSWORD2"),
|
||||
IsTest: os.Getenv("BACKEND_ROBOKASSA_TEST") == "1",
|
||||
}
|
||||
|
||||
c := Config{
|
||||
HTTPAddr: envOr("BACKEND_HTTP_ADDR", defaultHTTPAddr),
|
||||
GRPCAddr: envOr("BACKEND_GRPC_ADDR", defaultGRPCAddr),
|
||||
@@ -170,6 +181,7 @@ func Load() (Config, error) {
|
||||
GuestRetention: guestRetention,
|
||||
ExportSignKey: os.Getenv("BACKEND_EXPORT_SIGN_KEY"),
|
||||
RendererURL: os.Getenv("BACKEND_RENDERER_URL"),
|
||||
Robokassa: robo,
|
||||
}
|
||||
if err := c.validate(); err != nil {
|
||||
return Config{}, err
|
||||
@@ -222,6 +234,9 @@ func (c Config) validate() error {
|
||||
return fmt.Errorf("config: BACKEND_PUBLIC_BASE_URL %q must be an absolute URL (scheme://host)", c.PublicBaseURL)
|
||||
}
|
||||
}
|
||||
if c.Robokassa.MerchantLogin != "" && (c.Robokassa.Password1 == "" || c.Robokassa.Password2 == "") {
|
||||
return fmt.Errorf("config: BACKEND_ROBOKASSA_PASSWORD1 and BACKEND_ROBOKASSA_PASSWORD2 must be set when BACKEND_ROBOKASSA_MERCHANT_LOGIN is")
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
|
||||
@@ -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))
|
||||
}
|
||||
@@ -52,6 +52,7 @@ func gameSummary(g Game, names []string) notify.GameSummary {
|
||||
TurnTimeoutSecs: int(g.TurnTimeout.Seconds()),
|
||||
MultipleWordsPerTurn: g.MultipleWordsPerTurn,
|
||||
VsAI: g.VsAI,
|
||||
Kind: int(g.Kind),
|
||||
MoveCount: g.MoveCount,
|
||||
EndReason: g.EndReason,
|
||||
Seats: seats,
|
||||
@@ -74,7 +75,6 @@ func playerState(v StateView, names []string, includeAlphabet bool) (notify.Play
|
||||
Rack: rack,
|
||||
BagLen: v.BagLen,
|
||||
HintsRemaining: v.HintsRemaining,
|
||||
WalletBalance: v.WalletBalance,
|
||||
}
|
||||
if includeAlphabet {
|
||||
tab, err := engine.AlphabetTable(v.Game.Variant)
|
||||
|
||||
@@ -55,16 +55,39 @@ func TestPayloadExchangeRoundTrip(t *testing.T) {
|
||||
}
|
||||
|
||||
func TestHintsRemaining(t *testing.T) {
|
||||
cases := []struct{ allowance, used, wallet, want int }{
|
||||
{1, 0, 3, 4},
|
||||
{1, 1, 3, 3},
|
||||
{1, 2, 3, 3}, // used past allowance clamps to 0
|
||||
{0, 0, 5, 5},
|
||||
{2, 1, 0, 1},
|
||||
cases := []struct{ allowance, used, want int }{
|
||||
{1, 0, 1},
|
||||
{1, 1, 0},
|
||||
{1, 2, 0}, // used past allowance clamps to 0
|
||||
{3, 1, 2},
|
||||
{0, 0, 0},
|
||||
}
|
||||
for _, c := range cases {
|
||||
if got := hintsRemaining(c.allowance, c.used, c.wallet); got != c.want {
|
||||
t.Errorf("hintsRemaining(%d,%d,%d) = %d, want %d", c.allowance, c.used, c.wallet, got, c.want)
|
||||
if got := hintsRemaining(c.allowance, c.used); got != c.want {
|
||||
t.Errorf("hintsRemaining(%d,%d) = %d, want %d", c.allowance, c.used, got, c.want)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
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)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -17,7 +17,10 @@ import (
|
||||
|
||||
"scrabble/backend/internal/account"
|
||||
"scrabble/backend/internal/engine"
|
||||
"scrabble/backend/internal/gamelimits"
|
||||
"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
|
||||
@@ -50,6 +53,14 @@ type Service struct {
|
||||
// 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.
|
||||
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
|
||||
// limits is the per-tier active-game cap config (cached). Set by SetGameLimits during wiring;
|
||||
// when nil, no active-game limit is enforced (a game is always creatable).
|
||||
limits *gamelimits.Service
|
||||
// clearNudges, when set, marks the actor's pending nudges in a game read once they
|
||||
// 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.
|
||||
@@ -105,6 +116,70 @@ func (svc *Service) SetAITrigger(trigger func(gameID uuid.UUID)) {
|
||||
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
|
||||
}
|
||||
|
||||
// SetGameLimits installs the active-game limit config (cached), enabling the per-tier caps. When
|
||||
// unset (nil), a game is always creatable.
|
||||
func (svc *Service) SetGameLimits(l *gamelimits.Service) {
|
||||
svc.limits = l
|
||||
}
|
||||
|
||||
// AtGameLimit reports whether accountID has reached its tier's active-game cap for kind — the
|
||||
// per-tier, per-kind limits held in backend.config, read from the in-memory cache. It resolves
|
||||
// the caller's tier (guest vs durable) from the account, then counts its open+active games of that
|
||||
// kind. It reports false (not at the limit) when the limits config is not wired, the account is nil,
|
||||
// or the resolved cap is gamelimits.Unlimited. It backs the new-game gate (the handler aborts 409
|
||||
// game_limit_reached) and the lobby's at-limit flag.
|
||||
func (svc *Service) AtGameLimit(ctx context.Context, accountID uuid.UUID, kind gamelimits.Kind) (bool, error) {
|
||||
if svc.limits == nil || accountID == uuid.Nil {
|
||||
return false, nil
|
||||
}
|
||||
acc, err := svc.accounts.GetByID(ctx, accountID)
|
||||
if err != nil {
|
||||
return false, err
|
||||
}
|
||||
limit := svc.limits.LimitsFor(acc.IsGuest).Cap(kind)
|
||||
if limit == gamelimits.Unlimited {
|
||||
return false, nil
|
||||
}
|
||||
n, err := svc.store.CountActiveByKind(ctx, accountID, kind)
|
||||
if err != nil {
|
||||
return false, err
|
||||
}
|
||||
return n >= limit, nil
|
||||
}
|
||||
|
||||
// walletContext resolves the payments gate inputs for an account on the current request: the
|
||||
// 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
|
||||
// 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.
|
||||
@@ -298,6 +373,7 @@ func (svc *Service) Create(ctx context.Context, params CreateParams) (Game, erro
|
||||
dropoutTiles: params.DropoutTiles.String(),
|
||||
multipleWordsPerTurn: params.MultipleWordsPerTurn,
|
||||
vsAI: params.VsAI,
|
||||
kind: params.Kind,
|
||||
}
|
||||
if err := svc.store.CreateGame(ctx, ins, seats, seeding.draws); err != nil {
|
||||
return Game{}, err
|
||||
@@ -361,6 +437,7 @@ func (svc *Service) OpenOrJoin(ctx context.Context, accountID uuid.UUID, params
|
||||
multipleWordsPerTurn: params.MultipleWordsPerTurn,
|
||||
status: StatusOpen,
|
||||
openDeadline: &deadline,
|
||||
kind: gamelimits.KindRandom,
|
||||
}
|
||||
// Decide the first move now by the official draw, with the not-yet-arrived opponent as a
|
||||
// synthetic placeholder (uuid.Nil): the draw fixes who sits at seat 0 — and so moves
|
||||
@@ -1058,10 +1135,31 @@ func (svc *Service) MarkChangesApplied(ctx context.Context, variant engine.Varia
|
||||
return svc.store.MarkChangesApplied(ctx, variant.String(), version)
|
||||
}
|
||||
|
||||
// Hint reveals the top-scoring legal play for the requesting player on their
|
||||
// turn, spending one hint from their per-game allowance and then their profile
|
||||
// wallet. It returns ErrHintsDisabled, ErrNoHintsLeft or ErrNoHintAvailable as
|
||||
// appropriate.
|
||||
// hintIdleWindow is how long a vs_ai player must be stuck on a turn (since the robot's last move)
|
||||
// before the idle hint unlocks. Mirrors the offline client (lib/hints HINT_GATE_MS = 30 min).
|
||||
const hintIdleWindow = 30 * time.Minute
|
||||
|
||||
// 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) {
|
||||
pre, err := svc.store.GetGame(ctx, gameID)
|
||||
if err != nil {
|
||||
@@ -1080,13 +1178,40 @@ func (svc *Service) Hint(ctx context.Context, gameID, accountID uuid.UUID) (Hint
|
||||
if !pre.HintsAllowed {
|
||||
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 {
|
||||
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
|
||||
fromAllowance := used < pre.HintsPerPlayer
|
||||
if !fromAllowance && acc.HintBalance <= 0 {
|
||||
if !fromAllowance && wallet <= 0 {
|
||||
return HintResult{}, ErrNoHintsLeft
|
||||
}
|
||||
|
||||
@@ -1101,9 +1226,9 @@ func (svc *Service) Hint(ctx context.Context, gameID, accountID uuid.UUID) (Hint
|
||||
return HintResult{}, ErrNoHintAvailable
|
||||
}
|
||||
|
||||
walletAfter := acc.HintBalance
|
||||
walletAfter := wallet
|
||||
if !fromAllowance {
|
||||
spent, err := svc.accounts.SpendHint(ctx, accountID)
|
||||
spent, err := svc.hintWallet.SpendHint(ctx, accountID, cxt, present)
|
||||
if err != nil {
|
||||
return HintResult{}, err
|
||||
}
|
||||
@@ -1119,7 +1244,7 @@ func (svc *Service) Hint(ctx context.Context, gameID, accountID uuid.UUID) (Hint
|
||||
return HintResult{}, err
|
||||
}
|
||||
used++
|
||||
return HintResult{Move: move, HintsRemaining: hintsRemaining(pre.HintsPerPlayer, used, walletAfter), WalletBalance: walletAfter}, nil
|
||||
return HintResult{Move: move, HintsRemaining: hintsRemaining(pre.HintsPerPlayer, used), WalletBalance: walletAfter}, nil
|
||||
}
|
||||
|
||||
// Candidates returns the to-move player's legal plays for a seated player on
|
||||
@@ -1184,10 +1309,6 @@ func (svc *Service) GameState(ctx context.Context, gameID, accountID uuid.UUID)
|
||||
if !ok {
|
||||
return StateView{}, ErrNotAPlayer
|
||||
}
|
||||
acc, err := svc.accounts.GetByID(ctx, accountID)
|
||||
if err != nil {
|
||||
return StateView{}, err
|
||||
}
|
||||
|
||||
unlock := svc.locks.lock(gameID)
|
||||
defer unlock()
|
||||
@@ -1202,12 +1323,15 @@ func (svc *Service) GameState(ctx context.Context, gameID, accountID uuid.UUID)
|
||||
}
|
||||
}
|
||||
return StateView{
|
||||
Game: pre,
|
||||
Seat: seat,
|
||||
Rack: g.Hand(seat),
|
||||
BagLen: g.BagLen(),
|
||||
HintsRemaining: hintsRemaining(pre.HintsPerPlayer, pre.Seats[seat].HintsUsed, acc.HintBalance),
|
||||
WalletBalance: acc.HintBalance,
|
||||
Game: pre,
|
||||
Seat: seat,
|
||||
Rack: g.Hand(seat),
|
||||
BagLen: g.BagLen(),
|
||||
// HintsRemaining is the per-seat allowance only; the purchasable wallet lives on the profile
|
||||
// (payments) and the client adds it (lib/hints.hintsLeft).
|
||||
HintsRemaining: hintsRemaining(pre.HintsPerPlayer, pre.Seats[seat].HintsUsed),
|
||||
// vs_ai idle-hint gate (seconds left; 0 for a human game / first move / not your turn).
|
||||
HintUnlockLeftSeconds: hintUnlockLeftSeconds(pre, seat, svc.clock()),
|
||||
}, nil
|
||||
}
|
||||
|
||||
@@ -1299,14 +1423,6 @@ func (svc *Service) ListForLobby(ctx context.Context, accountID uuid.UUID) ([]Ga
|
||||
return kept, nil
|
||||
}
|
||||
|
||||
// CountActiveQuickGames reports how many in-progress quick games the account holds —
|
||||
// the count the simultaneous-game limit (MaxActiveQuickGames) is checked against. It
|
||||
// counts active and still-open quick games (including honest-AI ones) and excludes
|
||||
// friend games created by invitation and finished games. See Store.CountActiveQuickGames.
|
||||
func (svc *Service) CountActiveQuickGames(ctx context.Context, accountID uuid.UUID) (int, error) {
|
||||
return svc.store.CountActiveQuickGames(ctx, accountID)
|
||||
}
|
||||
|
||||
// HideGame hides a finished game from accountID's own lobby (it stays visible to the other
|
||||
// players); it is irreversible by design. Only a player of a finished game may hide it
|
||||
// (ErrNotAPlayer / ErrGameActive otherwise); hiding an already-hidden game is a no-op.
|
||||
@@ -1686,10 +1802,10 @@ func (svc *Service) DictBytes(variant engine.Variant, version string) ([]byte, e
|
||||
return svc.registry.DictBytes(variant, version)
|
||||
}
|
||||
|
||||
// hintsRemaining is a player's remaining hint budget: the unspent per-game
|
||||
// allowance plus the profile wallet.
|
||||
func hintsRemaining(allowance, used, wallet int) int {
|
||||
return max(0, allowance-used) + wallet
|
||||
// hintsRemaining is the unspent per-game hint allowance. The purchasable wallet is separate,
|
||||
// carried on the profile (payments), and the client adds it (lib/hints.hintsLeft).
|
||||
func hintsRemaining(allowance, used int) int {
|
||||
return max(0, allowance-used)
|
||||
}
|
||||
|
||||
// allowedTimeout reports whether d is one of the offered move clocks.
|
||||
|
||||
@@ -15,6 +15,7 @@ import (
|
||||
|
||||
"scrabble/backend/internal/account"
|
||||
"scrabble/backend/internal/engine"
|
||||
"scrabble/backend/internal/gamelimits"
|
||||
"scrabble/backend/internal/postgres/jet/backend/model"
|
||||
"scrabble/backend/internal/postgres/jet/backend/table"
|
||||
)
|
||||
@@ -45,6 +46,8 @@ type gameInsert struct {
|
||||
multipleWordsPerTurn bool
|
||||
// vsAI marks an honest-AI game (games.vs_ai).
|
||||
vsAI bool
|
||||
// kind tags the game's origin (games.game_kind) for the active-game limits.
|
||||
kind gamelimits.Kind
|
||||
// status is the lifecycle state to create the game in: StatusActive for a normal
|
||||
// seated game, StatusOpen for an auto-match game still awaiting an opponent. An
|
||||
// empty string defaults to StatusActive.
|
||||
@@ -138,6 +141,20 @@ func (s *Store) CreateGame(ctx context.Context, ins gameInsert, seats []seatInse
|
||||
})
|
||||
}
|
||||
|
||||
// CountActiveByKind counts the account's active (open or in-progress) games of the given kind — the
|
||||
// per-tier active-game limit is checked against it before a new game of that kind is created.
|
||||
func (s *Store) CountActiveByKind(ctx context.Context, accountID uuid.UUID, kind gamelimits.Kind) (int, error) {
|
||||
var n int
|
||||
if err := s.db.QueryRowContext(ctx,
|
||||
`SELECT count(*) FROM backend.games g
|
||||
JOIN backend.game_players p ON p.game_id = g.game_id
|
||||
WHERE p.account_id = $1 AND g.game_kind = $2 AND g.status IN ('open', 'active')`,
|
||||
accountID, int16(kind)).Scan(&n); err != nil {
|
||||
return 0, fmt.Errorf("game: count active by kind %s: %w", accountID, err)
|
||||
}
|
||||
return n, nil
|
||||
}
|
||||
|
||||
// insertGameTx inserts the games row and one game_players row per seat (seat 0
|
||||
// first) on tx, stamping each seat's display-name snapshot. A seat whose account id is
|
||||
// uuid.Nil is written with a NULL account_id (and an empty snapshot) — the still-empty
|
||||
@@ -155,9 +172,9 @@ func insertGameTx(ctx context.Context, tx *sql.Tx, ins gameInsert, seats []seatI
|
||||
table.Games.GameID, table.Games.Variant, table.Games.DictVersion, table.Games.Seed,
|
||||
table.Games.Status, table.Games.Players, table.Games.TurnTimeoutSecs,
|
||||
table.Games.HintsAllowed, table.Games.HintsPerPlayer, table.Games.OpenDeadlineAt,
|
||||
table.Games.DropoutTiles, table.Games.MultipleWordsPerTurn, table.Games.VsAi,
|
||||
table.Games.DropoutTiles, table.Games.MultipleWordsPerTurn, table.Games.VsAi, table.Games.GameKind,
|
||||
).VALUES(ins.id, ins.variant, ins.dictVersion, ins.seed, status, ins.players,
|
||||
ins.turnTimeoutSecs, ins.hintsAllowed, ins.hintsPerPlayer, deadline, ins.dropoutTiles, ins.multipleWordsPerTurn, ins.vsAI)
|
||||
ins.turnTimeoutSecs, ins.hintsAllowed, ins.hintsPerPlayer, deadline, ins.dropoutTiles, ins.multipleWordsPerTurn, ins.vsAI, int16(ins.kind))
|
||||
if _, err := gi.ExecContext(ctx, tx); err != nil {
|
||||
return fmt.Errorf("insert game: %w", err)
|
||||
}
|
||||
@@ -501,28 +518,6 @@ func (s *Store) ListGamesForAccount(ctx context.Context, accountID uuid.UUID) ([
|
||||
return out, nil
|
||||
}
|
||||
|
||||
// CountActiveQuickGames counts the account's in-progress quick games — the ones the
|
||||
// simultaneous-game limit (MaxActiveQuickGames) is checked against. It includes both
|
||||
// active and still-open (awaiting-opponent) games, the honest-AI ones among them, and
|
||||
// excludes friend games (those linked to a game_invitations row) and finished games.
|
||||
// A hidden game still occupies a slot, so this is a dedicated count rather than a
|
||||
// filter over ListGamesForAccount (which drops hidden games). Joining on the account's
|
||||
// own seat counts each game once (an open game's empty opponent seat has no account).
|
||||
func (s *Store) CountActiveQuickGames(ctx context.Context, accountID uuid.UUID) (int, error) {
|
||||
// The status literals are game.StatusActive / game.StatusOpen, matching the
|
||||
// games.status CHECK in the baseline migration.
|
||||
const q = `
|
||||
SELECT COUNT(*) FROM backend.games g
|
||||
JOIN backend.game_players gp ON gp.game_id = g.game_id
|
||||
LEFT JOIN backend.game_invitations gi ON gi.game_id = g.game_id
|
||||
WHERE gp.account_id = $1 AND g.status IN ('active', 'open') AND gi.game_id IS NULL`
|
||||
var n int
|
||||
if err := s.db.QueryRowContext(ctx, q, accountID).Scan(&n); err != nil {
|
||||
return 0, fmt.Errorf("game: count active quick games: %w", err)
|
||||
}
|
||||
return n, nil
|
||||
}
|
||||
|
||||
// HideGame hides a game from the account's own lobby list (idempotent). The caller validates the
|
||||
// game is finished and the account is a player.
|
||||
func (s *Store) HideGame(ctx context.Context, accountID, gameID uuid.UUID) error {
|
||||
@@ -1361,6 +1356,7 @@ func projectGame(g model.Games, seats []model.GamePlayers) (Game, error) {
|
||||
}
|
||||
out.MultipleWordsPerTurn = g.MultipleWordsPerTurn
|
||||
out.VsAI = g.VsAi
|
||||
out.Kind = gamelimits.Kind(g.GameKind)
|
||||
if g.EndReason != nil {
|
||||
out.EndReason = *g.EndReason
|
||||
}
|
||||
|
||||
@@ -7,6 +7,7 @@ import (
|
||||
"github.com/google/uuid"
|
||||
|
||||
"scrabble/backend/internal/engine"
|
||||
"scrabble/backend/internal/gamelimits"
|
||||
)
|
||||
|
||||
// Status values persisted in the games.status column.
|
||||
@@ -65,6 +66,10 @@ var (
|
||||
ErrNoHintsLeft = errors.New("game: no hints remaining")
|
||||
// ErrNoHintAvailable is returned when the player has no legal play to reveal.
|
||||
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
|
||||
// simultaneous quick games and tries to create another game (quick or by invitation).
|
||||
ErrGameLimitReached = errors.New("game: simultaneous game limit reached")
|
||||
@@ -90,15 +95,6 @@ const DefaultTurnTimeout = 24 * time.Hour
|
||||
// one of AllowedTurnTimeouts (never offered in the creation UI).
|
||||
const AIInactivityTimeout = 7 * 24 * time.Hour
|
||||
|
||||
// MaxActiveQuickGames is the cap on a player's simultaneous quick games (human
|
||||
// auto-match and honest-AI), counting both in-progress (StatusActive) and
|
||||
// still-open, awaiting-opponent (StatusOpen) games. Friend games created by
|
||||
// invitation are not counted. At the cap the backend refuses to create a new game
|
||||
// of any kind — quick or by invitation — and the lobby disables "New Game";
|
||||
// accepting an incoming invitation is always allowed. See
|
||||
// Store.CountActiveQuickGames.
|
||||
const MaxActiveQuickGames = 10
|
||||
|
||||
// aiPlayerName labels the robot seat in an honest-AI game's GCG export, so a downloaded
|
||||
// game file shows a clean "AI" rather than the robot's human-like pool name (the in-app
|
||||
// UI shows 🤖 from the game's vs_ai flag).
|
||||
@@ -121,6 +117,10 @@ type CreateParams struct {
|
||||
// robot is seated at once, the move clock is AIInactivityTimeout, and chat/nudge
|
||||
// and finish-time statistics are suppressed. Set by the lobby's AI-match path.
|
||||
VsAI bool
|
||||
// Kind tags the game's origin (games.game_kind) for the active-game limits: vs_ai / random /
|
||||
// friends. The lobby sets it; a zero (unknown) kind is never gated. The active-game limit itself
|
||||
// is enforced at the new-game handler (Server.ensureUnderGameLimit), not here.
|
||||
Kind gamelimits.Kind
|
||||
}
|
||||
|
||||
// Game is the persisted state of a match: the games row joined with its seats.
|
||||
@@ -147,6 +147,9 @@ type Game struct {
|
||||
// VsAI is true for an honest-AI game (games.vs_ai): the opponent is a robot the
|
||||
// player knowingly chose, shown as 🤖, with chat/nudge disabled and no statistics.
|
||||
VsAI bool
|
||||
// Kind is the game's origin tag (games.game_kind) for the active-game limits: vs_ai / random /
|
||||
// friends, or unknown for an untagged game. Read-only projection; set once on creation.
|
||||
Kind gamelimits.Kind
|
||||
}
|
||||
|
||||
// Seat is one player's standing in a game.
|
||||
@@ -207,10 +210,9 @@ type MoveResult struct {
|
||||
BagLen int
|
||||
}
|
||||
|
||||
// HintResult is a revealed hint and the requesting player's remaining hint
|
||||
// budget (per-seat allowance plus profile wallet) after spending one. WalletBalance is
|
||||
// the global wallet alone, so the client can keep its live wallet authoritative and
|
||||
// re-derive the per-game allowance (HintsRemaining - WalletBalance).
|
||||
// HintResult is a revealed hint with the per-seat allowance remaining (HintsRemaining) and the
|
||||
// purchasable hint wallet after spending one (WalletBalance, from payments). The client adopts
|
||||
// WalletBalance into the profile so the badge stays live across games (lib/hints).
|
||||
type HintResult struct {
|
||||
Move engine.MoveRecord
|
||||
HintsRemaining int
|
||||
@@ -237,9 +239,11 @@ type StateView struct {
|
||||
Rack []string
|
||||
BagLen int
|
||||
HintsRemaining int
|
||||
// WalletBalance is the player's global hint-wallet balance alone (HintsRemaining folds
|
||||
// it in with the per-game allowance), so the client keeps the wallet live across games.
|
||||
WalletBalance int
|
||||
// HintUnlockLeftSeconds is, for a vs_ai game on the requesting player's turn, the seconds left
|
||||
// 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.
|
||||
|
||||
@@ -0,0 +1,149 @@
|
||||
// Package gamelimits holds the per-tier, per-kind active-game caps (a guest funnel) with a hot
|
||||
// in-memory cache over the single-row backend.config, so a login or a game-create never queries it.
|
||||
package gamelimits
|
||||
|
||||
import (
|
||||
"context"
|
||||
"database/sql"
|
||||
"fmt"
|
||||
"sync"
|
||||
|
||||
"github.com/go-jet/jet/v2/postgres"
|
||||
|
||||
"scrabble/backend/internal/postgres/jet/backend/model"
|
||||
"scrabble/backend/internal/postgres/jet/backend/table"
|
||||
)
|
||||
|
||||
// Kind is a game's kind, mirroring backend.games.game_kind. 0 (unknown) is an untagged game, never gated.
|
||||
type Kind int16
|
||||
|
||||
const (
|
||||
KindUnknown Kind = 0
|
||||
KindVsAI Kind = 1
|
||||
KindRandom Kind = 2
|
||||
KindFriends Kind = 3
|
||||
)
|
||||
|
||||
// String returns the kind's label for admin game lists and logs.
|
||||
func (k Kind) String() string {
|
||||
switch k {
|
||||
case KindVsAI:
|
||||
return "vs_ai"
|
||||
case KindRandom:
|
||||
return "random"
|
||||
case KindFriends:
|
||||
return "friends"
|
||||
default:
|
||||
return "unknown"
|
||||
}
|
||||
}
|
||||
|
||||
// Unlimited is the limit sentinel meaning no cap.
|
||||
const Unlimited = -1
|
||||
|
||||
// Limits is a tier's active-game caps per kind (-1 = unlimited).
|
||||
type Limits struct {
|
||||
VsAI int
|
||||
Random int
|
||||
Friends int
|
||||
}
|
||||
|
||||
// Cap returns the limit for kind; the unknown kind is never capped.
|
||||
func (l Limits) Cap(kind Kind) int {
|
||||
switch kind {
|
||||
case KindVsAI:
|
||||
return l.VsAI
|
||||
case KindRandom:
|
||||
return l.Random
|
||||
case KindFriends:
|
||||
return l.Friends
|
||||
default:
|
||||
return Unlimited
|
||||
}
|
||||
}
|
||||
|
||||
// Config is the per-tier limit config (the single backend.config row).
|
||||
type Config struct {
|
||||
Guest Limits
|
||||
Durable Limits
|
||||
}
|
||||
|
||||
// Store reads and writes the single-row backend.config.
|
||||
type Store struct{ db *sql.DB }
|
||||
|
||||
// NewStore constructs a Store over db.
|
||||
func NewStore(db *sql.DB) *Store { return &Store{db: db} }
|
||||
|
||||
func (s *Store) load(ctx context.Context) (Config, error) {
|
||||
var c model.Config
|
||||
if err := postgres.SELECT(table.Config.AllColumns).FROM(table.Config).LIMIT(1).
|
||||
QueryContext(ctx, s.db, &c); err != nil {
|
||||
return Config{}, fmt.Errorf("gamelimits: load config: %w", err)
|
||||
}
|
||||
return Config{
|
||||
Guest: Limits{VsAI: int(c.GuestVsAiLimit), Random: int(c.GuestRandomLimit), Friends: int(c.GuestFriendsLimit)},
|
||||
Durable: Limits{VsAI: int(c.DurableVsAiLimit), Random: int(c.DurableRandomLimit), Friends: int(c.DurableFriendsLimit)},
|
||||
}, nil
|
||||
}
|
||||
|
||||
func (s *Store) save(ctx context.Context, c Config) error {
|
||||
if _, err := s.db.ExecContext(ctx,
|
||||
`UPDATE backend.config SET guest_vs_ai_limit=$1, guest_random_limit=$2, guest_friends_limit=$3,
|
||||
durable_vs_ai_limit=$4, durable_random_limit=$5, durable_friends_limit=$6 WHERE only_row`,
|
||||
c.Guest.VsAI, c.Guest.Random, c.Guest.Friends, c.Durable.VsAI, c.Durable.Random, c.Durable.Friends); err != nil {
|
||||
return fmt.Errorf("gamelimits: save config: %w", err)
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
// Service fronts the config with an in-memory cache (single-instance, matching the deploy). Load
|
||||
// once at startup; Update after an admin edit refreshes it in place.
|
||||
type Service struct {
|
||||
store *Store
|
||||
mu sync.RWMutex
|
||||
cfg Config
|
||||
}
|
||||
|
||||
// NewService constructs a Service over store. Call Load before serving.
|
||||
func NewService(store *Store) *Service { return &Service{store: store} }
|
||||
|
||||
// Load reads the config into the cache. Call once at startup.
|
||||
func (s *Service) Load(ctx context.Context) error {
|
||||
c, err := s.store.load(ctx)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
s.set(c)
|
||||
return nil
|
||||
}
|
||||
|
||||
func (s *Service) set(c Config) {
|
||||
s.mu.Lock()
|
||||
s.cfg = c
|
||||
s.mu.Unlock()
|
||||
}
|
||||
|
||||
// Get returns the cached config.
|
||||
func (s *Service) Get() Config {
|
||||
s.mu.RLock()
|
||||
defer s.mu.RUnlock()
|
||||
return s.cfg
|
||||
}
|
||||
|
||||
// LimitsFor returns the cached limits for the account tier (guest or durable).
|
||||
func (s *Service) LimitsFor(isGuest bool) Limits {
|
||||
c := s.Get()
|
||||
if isGuest {
|
||||
return c.Guest
|
||||
}
|
||||
return c.Durable
|
||||
}
|
||||
|
||||
// Update saves the config and refreshes the cache in place (the admin edit).
|
||||
func (s *Service) Update(ctx context.Context, c Config) error {
|
||||
if err := s.store.save(ctx, c); err != nil {
|
||||
return err
|
||||
}
|
||||
s.set(c)
|
||||
return nil
|
||||
}
|
||||
@@ -0,0 +1,44 @@
|
||||
package gamelimits
|
||||
|
||||
import "testing"
|
||||
|
||||
// TestLimitsCap checks Cap maps each kind to its field and leaves the unknown kind uncapped, so a
|
||||
// untagged game (game_kind 0) is never gated.
|
||||
func TestLimitsCap(t *testing.T) {
|
||||
l := Limits{VsAI: 1, Random: 2, Friends: 3}
|
||||
for _, tc := range []struct {
|
||||
kind Kind
|
||||
want int
|
||||
}{
|
||||
{KindVsAI, 1},
|
||||
{KindRandom, 2},
|
||||
{KindFriends, 3},
|
||||
{KindUnknown, Unlimited},
|
||||
{Kind(99), Unlimited},
|
||||
} {
|
||||
if got := l.Cap(tc.kind); got != tc.want {
|
||||
t.Errorf("Cap(%d) = %d, want %d", tc.kind, got, tc.want)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// TestServiceLimitsForTier checks LimitsFor selects the guest or durable tier from the cached config.
|
||||
func TestServiceLimitsForTier(t *testing.T) {
|
||||
svc := NewService(nil)
|
||||
svc.set(Config{
|
||||
Guest: Limits{VsAI: 1, Random: 1, Friends: 0},
|
||||
Durable: Limits{VsAI: 10, Random: 10, Friends: 10},
|
||||
})
|
||||
|
||||
if got := svc.LimitsFor(true); got != (Limits{VsAI: 1, Random: 1, Friends: 0}) {
|
||||
t.Errorf("guest limits = %+v, want {1 1 0}", got)
|
||||
}
|
||||
if got := svc.LimitsFor(false); got != (Limits{VsAI: 10, Random: 10, Friends: 10}) {
|
||||
t.Errorf("durable limits = %+v, want {10 10 10}", got)
|
||||
}
|
||||
// The unlimited sentinel resolves through the tier too.
|
||||
svc.set(Config{Durable: Limits{VsAI: Unlimited, Random: Unlimited, Friends: Unlimited}})
|
||||
if got := svc.LimitsFor(false).Cap(KindVsAI); got != Unlimited {
|
||||
t.Errorf("durable vs_ai cap = %d, want unlimited (-1)", got)
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,79 @@
|
||||
//go:build integration
|
||||
|
||||
package inttest
|
||||
|
||||
import (
|
||||
"context"
|
||||
"net/http"
|
||||
"strings"
|
||||
"testing"
|
||||
|
||||
"github.com/google/uuid"
|
||||
|
||||
"scrabble/backend/internal/payments"
|
||||
)
|
||||
|
||||
// benefitFor returns the account's benefit on the given origin from its statement.
|
||||
func benefitFor(t *testing.T, pay *payments.Service, id uuid.UUID, origin payments.Source) payments.OriginBenefit {
|
||||
t.Helper()
|
||||
stmt, err := pay.AccountStatement(context.Background(), id)
|
||||
if err != nil {
|
||||
t.Fatalf("statement: %v", err)
|
||||
}
|
||||
for _, b := range stmt.Benefits {
|
||||
if b.Origin == origin {
|
||||
return b
|
||||
}
|
||||
}
|
||||
return payments.OriginBenefit{}
|
||||
}
|
||||
|
||||
// TestConsoleAdminGrant drives the admin grant: a raw benefit grant, a by-product grant of a reward
|
||||
// bundle, and a refusal to grant a chips pack; the create is CSRF-guarded.
|
||||
func TestConsoleAdminGrant(t *testing.T) {
|
||||
ctx := context.Background()
|
||||
srv, _, pay := bannerServer(t)
|
||||
h := srv.Handler()
|
||||
id := provisionAccount(t)
|
||||
const origin = "http://admin.test"
|
||||
base := "http://admin.test/_gm/users/" + id.String()
|
||||
|
||||
// CSRF: a grant without the origin header is refused.
|
||||
if code, _ := consoleDo(h, http.MethodPost, base+"/grant", "origin=direct&hints=1", ""); code != http.StatusForbidden {
|
||||
t.Fatalf("grant without origin = %d, want 403", code)
|
||||
}
|
||||
|
||||
// Raw grant: 5 hints + 30 no-ads days to the direct origin.
|
||||
if code, body := consoleDo(h, http.MethodPost, base+"/grant", "origin=direct&hints=5&noads=30", origin); code != http.StatusOK || !strings.Contains(body, "Granted") {
|
||||
t.Fatalf("raw grant = %d, has 'Granted' = %v", code, strings.Contains(body, "Granted"))
|
||||
}
|
||||
if b := benefitFor(t, pay, id, payments.SourceDirect); b.Hints != 5 || b.AdsPaidUntil.IsZero() {
|
||||
t.Fatalf("after raw grant: hints=%d adsUntil-zero=%v, want 5 hints + a no-ads term", b.Hints, b.AdsPaidUntil.IsZero())
|
||||
}
|
||||
|
||||
// By-product grant: an archived reward bundle (3 hints) to vk.
|
||||
reward, err := pay.CreateProduct(ctx, payments.ProductInput{
|
||||
Title: "reward-3-hints", Atoms: []payments.AtomLine{{Atom: "hints", Quantity: 3}},
|
||||
}, false)
|
||||
if err != nil {
|
||||
t.Fatalf("create reward: %v", err)
|
||||
}
|
||||
if code, body := consoleDo(h, http.MethodPost, base+"/grant-product", "origin=vk&product_id="+reward.String(), origin); code != http.StatusOK || !strings.Contains(body, "Granted") {
|
||||
t.Fatalf("product grant = %d, has 'Granted' = %v", code, strings.Contains(body, "Granted"))
|
||||
}
|
||||
if b := benefitFor(t, pay, id, payments.SourceVK); b.Hints != 3 {
|
||||
t.Fatalf("after product grant: vk hints=%d, want 3", b.Hints)
|
||||
}
|
||||
|
||||
// A chips pack cannot be granted.
|
||||
pack, err := pay.CreateProduct(ctx, payments.ProductInput{
|
||||
Title: "grant-pack", Atoms: []payments.AtomLine{{Atom: "chips", Quantity: 100}},
|
||||
Prices: []payments.PriceLine{{Method: "direct", Currency: payments.CurrencyRUB, Amount: 14900}},
|
||||
}, true)
|
||||
if err != nil {
|
||||
t.Fatalf("create pack: %v", err)
|
||||
}
|
||||
if code, body := consoleDo(h, http.MethodPost, base+"/grant-product", "origin=direct&product_id="+pack.String(), origin); code != http.StatusOK || !strings.Contains(body, "cannot grant chips") {
|
||||
t.Fatalf("chips grant = %d, has 'cannot grant chips' = %v", code, strings.Contains(body, "cannot grant chips"))
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,77 @@
|
||||
//go:build integration
|
||||
|
||||
package inttest
|
||||
|
||||
import (
|
||||
"context"
|
||||
"net/http"
|
||||
"strings"
|
||||
"testing"
|
||||
|
||||
"scrabble/backend/internal/payments"
|
||||
)
|
||||
|
||||
// TestConsoleRefundAndExport drives the manual refund and the ledger CSV export: a funded order is
|
||||
// refunded in full (chips revoked, a refund row), the refund is idempotent, and the export carries
|
||||
// the fund + refund rows; the refund POST is CSRF-guarded.
|
||||
func TestConsoleRefundAndExport(t *testing.T) {
|
||||
ctx := context.Background()
|
||||
srv, _, pay := bannerServer(t)
|
||||
h := srv.Handler()
|
||||
id := provisionAccount(t)
|
||||
const origin = "http://admin.test"
|
||||
base := "http://admin.test/_gm/users/" + id.String()
|
||||
|
||||
// Fund a pack: 100 chips + a fund ledger row.
|
||||
prod := seedPackProduct(t, 100, methodPrice{method: "direct", currency: "RUB", amount: 14900})
|
||||
res, err := pay.CreateOrder(ctx, id, payments.NewContext("direct", "web"), []payments.Source{payments.SourceDirect}, prod, "robokassa")
|
||||
if err != nil {
|
||||
t.Fatalf("order: %v", err)
|
||||
}
|
||||
paid, _ := payments.MoneyFromMinor(14900, payments.CurrencyRUB)
|
||||
if _, err := pay.Fund(ctx, res.OrderID, "robokassa", res.OrderID.String(), paid); err != nil {
|
||||
t.Fatalf("fund: %v", err)
|
||||
}
|
||||
|
||||
// CSRF: a refund without the origin header is refused.
|
||||
if code, _ := consoleDo(h, http.MethodPost, base+"/refund", "order_id="+res.OrderID.String(), ""); code != http.StatusForbidden {
|
||||
t.Fatalf("refund without origin = %d, want 403", code)
|
||||
}
|
||||
|
||||
// Refund the order in full → 100 chips revoked (none spent).
|
||||
if code, body := consoleDo(h, http.MethodPost, base+"/refund", "order_id="+res.OrderID.String(), origin); code != http.StatusOK || !strings.Contains(body, "revoked 100 chips") {
|
||||
t.Fatalf("refund = %d, has 'revoked 100 chips' = %v", code, strings.Contains(body, "revoked 100 chips"))
|
||||
}
|
||||
stmt, err := pay.AccountStatement(ctx, id)
|
||||
if err != nil {
|
||||
t.Fatalf("statement: %v", err)
|
||||
}
|
||||
for _, sg := range stmt.Segments {
|
||||
if sg.Source == payments.SourceDirect && sg.Chips != 0 {
|
||||
t.Errorf("after refund: direct chips = %d, want 0", sg.Chips)
|
||||
}
|
||||
}
|
||||
kinds := map[string]int{}
|
||||
for _, e := range stmt.Ledger {
|
||||
kinds[e.Kind]++
|
||||
}
|
||||
if kinds["fund"] != 1 || kinds["refund"] != 1 {
|
||||
t.Errorf("ledger kinds = %v, want one fund + one refund", kinds)
|
||||
}
|
||||
|
||||
// A second refund of the same order is idempotent.
|
||||
if code, body := consoleDo(h, http.MethodPost, base+"/refund", "order_id="+res.OrderID.String(), origin); code != http.StatusOK || !strings.Contains(body, "Already refunded") {
|
||||
t.Fatalf("second refund = %d, has 'Already refunded' = %v", code, strings.Contains(body, "Already refunded"))
|
||||
}
|
||||
|
||||
// Export the whole ledger as CSV: the header, this account, and its fund + refund rows.
|
||||
code, body := consoleDo(h, http.MethodGet, "http://admin.test/_gm/ledger.csv", "", "")
|
||||
if code != http.StatusOK {
|
||||
t.Fatalf("export = %d, want 200", code)
|
||||
}
|
||||
for _, want := range []string{"created_at,account_id,kind", id.String(), ",fund,", ",refund,"} {
|
||||
if !strings.Contains(body, want) {
|
||||
t.Errorf("ledger CSV missing %q", want)
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -4,7 +4,6 @@ package inttest
|
||||
|
||||
import (
|
||||
"context"
|
||||
"errors"
|
||||
"net/http"
|
||||
"net/http/httptest"
|
||||
"strings"
|
||||
@@ -280,76 +279,6 @@ func TestConsoleThrottledViewAndFlagClear(t *testing.T) {
|
||||
}
|
||||
}
|
||||
|
||||
// TestConsoleGrantHints drives the admin hint-wallet grant end to end: the card shows the form,
|
||||
// the action is CSRF-guarded, a same-origin grant adds to the wallet, a second grant adds again
|
||||
// (rather than replacing), the inclusive per-grant cap is accepted, and an out-of-range or
|
||||
// non-numeric amount is refused without changing the balance.
|
||||
func TestConsoleGrantHints(t *testing.T) {
|
||||
ctx := context.Background()
|
||||
accounts := account.NewStore(testDB)
|
||||
id := provisionAccount(t)
|
||||
srv := server.New(":0", server.Deps{
|
||||
Logger: zap.NewNop(), Accounts: accounts, Games: newGameService(), Registry: testRegistry, DictDir: dictDir(),
|
||||
})
|
||||
h := srv.Handler()
|
||||
base := "http://admin.test/_gm/users/" + id.String()
|
||||
|
||||
if code, body := consoleDo(h, http.MethodGet, base, "", ""); code != http.StatusOK || !strings.Contains(body, "Add hints") {
|
||||
t.Fatalf("user card = %d, has grant form = %v", code, strings.Contains(body, "Add hints"))
|
||||
}
|
||||
// The grant POST is CSRF-guarded like every console action.
|
||||
if code, _ := consoleDo(h, http.MethodPost, base+"/grant-hints", "amount=5", ""); code != http.StatusForbidden {
|
||||
t.Fatalf("grant without origin = %d, want 403", code)
|
||||
}
|
||||
// A same-origin grant adds to the wallet.
|
||||
if code, body := consoleDo(h, http.MethodPost, base+"/grant-hints", "amount=5", "http://admin.test"); code != http.StatusOK || !strings.Contains(body, "now 5") {
|
||||
t.Fatalf("grant 5 = %d, body has 'now 5' = %v", code, strings.Contains(body, "now 5"))
|
||||
}
|
||||
if acc, err := accounts.GetByID(ctx, id); err != nil || acc.HintBalance != 5 {
|
||||
t.Fatalf("after grant 5: balance=%d err=%v, want 5", acc.HintBalance, err)
|
||||
}
|
||||
// A second grant adds again rather than replacing.
|
||||
if code, body := consoleDo(h, http.MethodPost, base+"/grant-hints", "amount=3", "http://admin.test"); code != http.StatusOK || !strings.Contains(body, "now 8") {
|
||||
t.Fatalf("grant 3 = %d, body has 'now 8' = %v", code, strings.Contains(body, "now 8"))
|
||||
}
|
||||
// An out-of-range or non-numeric amount is refused; the balance is left untouched.
|
||||
for _, bad := range []string{"0", "-1", "101", "x", ""} {
|
||||
if code, body := consoleDo(h, http.MethodPost, base+"/grant-hints", "amount="+bad, "http://admin.test"); code != http.StatusOK || !strings.Contains(body, "Invalid amount") {
|
||||
t.Fatalf("grant %q = %d, has 'Invalid amount' = %v", bad, code, strings.Contains(body, "Invalid amount"))
|
||||
}
|
||||
}
|
||||
if acc, err := accounts.GetByID(ctx, id); err != nil || acc.HintBalance != 8 {
|
||||
t.Fatalf("after invalid grants: balance=%d err=%v, want 8", acc.HintBalance, err)
|
||||
}
|
||||
// The inclusive per-grant cap (100) is accepted.
|
||||
if code, _ := consoleDo(h, http.MethodPost, base+"/grant-hints", "amount=100", "http://admin.test"); code != http.StatusOK {
|
||||
t.Fatalf("grant 100 = %d, want 200", code)
|
||||
}
|
||||
if acc, err := accounts.GetByID(ctx, id); err != nil || acc.HintBalance != 108 {
|
||||
t.Fatalf("after grant 100: balance=%d err=%v, want 108", acc.HintBalance, err)
|
||||
}
|
||||
}
|
||||
|
||||
// TestGrantHintsStore covers the wallet store method directly: an additive grant raises the
|
||||
// balance, a non-positive grant is rejected, and an unknown account yields ErrNotFound.
|
||||
func TestGrantHintsStore(t *testing.T) {
|
||||
ctx := context.Background()
|
||||
accounts := account.NewStore(testDB)
|
||||
id := provisionAccount(t)
|
||||
if bal, err := accounts.GrantHints(ctx, id, 4); err != nil || bal != 4 {
|
||||
t.Fatalf("grant 4 = (%d, %v), want (4, nil)", bal, err)
|
||||
}
|
||||
if bal, err := accounts.GrantHints(ctx, id, 6); err != nil || bal != 10 {
|
||||
t.Fatalf("grant 6 = (%d, %v), want (10, nil)", bal, err)
|
||||
}
|
||||
if _, err := accounts.GrantHints(ctx, id, 0); err == nil {
|
||||
t.Error("grant 0 should be rejected (non-positive)")
|
||||
}
|
||||
if _, err := accounts.GrantHints(ctx, uuid.New(), 1); !errors.Is(err, account.ErrNotFound) {
|
||||
t.Errorf("grant unknown account = %v, want ErrNotFound", err)
|
||||
}
|
||||
}
|
||||
|
||||
// consoleDo issues a request to h, optionally with an Origin header, and returns
|
||||
// the status and body. Form bodies are sent as application/x-www-form-urlencoded.
|
||||
func consoleDo(h http.Handler, method, target, body, origin string) (int, string) {
|
||||
|
||||
@@ -15,13 +15,15 @@ import (
|
||||
|
||||
"scrabble/backend/internal/account"
|
||||
"scrabble/backend/internal/ads"
|
||||
"scrabble/backend/internal/payments"
|
||||
"scrabble/backend/internal/server"
|
||||
)
|
||||
|
||||
// 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()
|
||||
adsSvc := ads.NewService(ads.NewStore(testDB))
|
||||
paySvc := newPaymentsService()
|
||||
srv := server.New(":0", server.Deps{
|
||||
Logger: zap.NewNop(),
|
||||
Accounts: account.NewStore(testDB),
|
||||
@@ -29,8 +31,9 @@ func bannerServer(t *testing.T) (*server.Server, *ads.Service) {
|
||||
Registry: testRegistry,
|
||||
DictDir: dictDir(),
|
||||
Ads: adsSvc,
|
||||
Payments: paySvc,
|
||||
})
|
||||
return srv, adsSvc
|
||||
return srv, adsSvc, paySvc
|
||||
}
|
||||
|
||||
// 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.
|
||||
func TestBannerConsoleCRUD(t *testing.T) {
|
||||
ctx := context.Background()
|
||||
srv, adsSvc := bannerServer(t)
|
||||
srv, adsSvc, _ := bannerServer(t)
|
||||
h := srv.Handler()
|
||||
base := "http://admin.test/_gm"
|
||||
const origin = "http://admin.test"
|
||||
@@ -151,7 +154,7 @@ func TestBannerConsoleCRUD(t *testing.T) {
|
||||
// unrelated campaign's URL must be refused.
|
||||
func TestBannerMessageOwnership(t *testing.T) {
|
||||
ctx := context.Background()
|
||||
srv, adsSvc := bannerServer(t)
|
||||
srv, adsSvc, _ := bannerServer(t)
|
||||
h := srv.Handler()
|
||||
base := "http://admin.test/_gm"
|
||||
const origin = "http://admin.test"
|
||||
@@ -188,7 +191,7 @@ func TestBannerMessageOwnership(t *testing.T) {
|
||||
}
|
||||
}
|
||||
|
||||
// profileBanner is the banner block of the profile.get JSON response.
|
||||
// profileBanner is the banner (and interstitial-ad) block of the profile.get JSON response.
|
||||
type profileBanner struct {
|
||||
Banner *struct {
|
||||
Campaigns []struct {
|
||||
@@ -200,6 +203,12 @@ type profileBanner struct {
|
||||
HoldMs int `json:"hold_ms"`
|
||||
} `json:"timings"`
|
||||
} `json:"banner"`
|
||||
Ads *struct {
|
||||
CooldownGlobalS int `json:"cooldown_global_s"`
|
||||
CooldownVsAiS int `json:"cooldown_vs_ai_s"`
|
||||
CooldownHintS int `json:"cooldown_hint_s"`
|
||||
Suppressed bool `json:"suppressed"`
|
||||
} `json:"ads"`
|
||||
}
|
||||
|
||||
// TestBannerProfileEligibility checks the profile.get banner block follows
|
||||
@@ -207,10 +216,11 @@ type profileBanner struct {
|
||||
// wallet each suppress it.
|
||||
func TestBannerProfileEligibility(t *testing.T) {
|
||||
ctx := context.Background()
|
||||
srv, _ := bannerServer(t)
|
||||
srv, _, pay := bannerServer(t)
|
||||
accounts := account.NewStore(testDB)
|
||||
id := provisionAccount(t)
|
||||
|
||||
// getBanner fetches the profile banner with no platform header (an untrusted context).
|
||||
getBanner := func() profileBanner {
|
||||
t.Helper()
|
||||
rec := userGet(t, srv, "/api/v1/user/profile", id)
|
||||
@@ -223,10 +233,27 @@ func TestBannerProfileEligibility(t *testing.T) {
|
||||
}
|
||||
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.
|
||||
p := getBanner()
|
||||
if p.Banner == nil || len(p.Banner.Campaigns) == 0 || p.Banner.Timings.HoldMs <= 0 {
|
||||
// A free account with no role and no benefit is eligible.
|
||||
if p := getBanner(); p.Banner == nil || len(p.Banner.Campaigns) == 0 || p.Banner.Timings.HoldMs <= 0 {
|
||||
t.Fatalf("eligible account: banner=%v", p.Banner)
|
||||
}
|
||||
|
||||
@@ -241,19 +268,106 @@ func TestBannerProfileEligibility(t *testing.T) {
|
||||
t.Fatalf("revoke role: %v", err)
|
||||
}
|
||||
|
||||
// A non-empty hint wallet suppresses it.
|
||||
if _, err := accounts.GrantHints(ctx, id, 5); err != nil {
|
||||
// A hint wallet no longer suppresses the banner — hints and no-ads are distinct benefits now.
|
||||
if err := pay.Grant(ctx, id, payments.SourceTelegram, 5, 0, false); err != nil {
|
||||
t.Fatalf("grant hints: %v", err)
|
||||
}
|
||||
if p := getBanner(); p.Banner != nil {
|
||||
t.Fatal("a non-empty hint wallet still shows the banner")
|
||||
if p := getBannerTG(); p.Banner == nil {
|
||||
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)")
|
||||
}
|
||||
}
|
||||
|
||||
// TestProfileAdsConfig checks the profile.get interstitial-ad block: the seeded config cooldowns are
|
||||
// carried through, and Suppressed follows the same no-ads / no_banner gate as the banner (the client
|
||||
// self-gates VK-only + online on top). The no_banner role is context-independent; the no-ads benefit
|
||||
// applies only in a trusted context where its segment is present.
|
||||
func TestProfileAdsConfig(t *testing.T) {
|
||||
ctx := context.Background()
|
||||
srv, _, pay := bannerServer(t)
|
||||
accounts := account.NewStore(testDB)
|
||||
id := provisionAccount(t)
|
||||
|
||||
get := func() profileBanner {
|
||||
t.Helper()
|
||||
rec := userGet(t, srv, "/api/v1/user/profile", id)
|
||||
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
|
||||
}
|
||||
getTG := 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: the ads block carries the seeded config cooldowns and is not suppressed.
|
||||
p := get()
|
||||
if p.Ads == nil {
|
||||
t.Fatal("free account: no ads block")
|
||||
}
|
||||
if p.Ads.CooldownGlobalS != 300 || p.Ads.CooldownVsAiS != 1800 || p.Ads.CooldownHintS != 60 {
|
||||
t.Fatalf("cooldowns = %d/%d/%d, want 300/1800/60", p.Ads.CooldownGlobalS, p.Ads.CooldownVsAiS, p.Ads.CooldownHintS)
|
||||
}
|
||||
if p.Ads.Suppressed {
|
||||
t.Fatal("free account: interstitials must not be suppressed")
|
||||
}
|
||||
|
||||
// The no_banner role suppresses interstitials too (context-independent).
|
||||
if err := accounts.GrantRole(ctx, id, account.RoleNoBanner); err != nil {
|
||||
t.Fatalf("grant role: %v", err)
|
||||
}
|
||||
if p := get(); p.Ads == nil || !p.Ads.Suppressed {
|
||||
t.Fatalf("no_banner role: ads=%v, want suppressed", p.Ads)
|
||||
}
|
||||
if err := accounts.RevokeRole(ctx, id, account.RoleNoBanner); err != nil {
|
||||
t.Fatalf("revoke role: %v", err)
|
||||
}
|
||||
|
||||
// An active no-ads benefit suppresses interstitials in a trusted context; an untrusted context
|
||||
// does not apply it (fail-closed to eligible, as for the banner).
|
||||
if err := pay.Grant(ctx, id, payments.SourceTelegram, 0, 30, false); err != nil {
|
||||
t.Fatalf("grant no-ads: %v", err)
|
||||
}
|
||||
if p := getTG(); p.Ads == nil || !p.Ads.Suppressed {
|
||||
t.Fatalf("no-ads benefit (trusted): ads=%v, want suppressed", p.Ads)
|
||||
}
|
||||
if p := get(); p.Ads == nil || p.Ads.Suppressed {
|
||||
t.Fatalf("no-ads benefit (untrusted): ads=%v, want NOT suppressed", p.Ads)
|
||||
}
|
||||
}
|
||||
|
||||
// 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.
|
||||
func TestBannerSurvivesProfileUpdate(t *testing.T) {
|
||||
srv, _ := bannerServer(t)
|
||||
srv, _, _ := bannerServer(t)
|
||||
id := provisionAccount(t)
|
||||
|
||||
body := `{"display_name":"Tester","preferred_language":"ru","time_zone":"UTC","away_start":"00:00",` +
|
||||
@@ -283,7 +397,7 @@ func TestBannerSurvivesProfileUpdate(t *testing.T) {
|
||||
// default campaign stays plain (colours + urgent are ignored for it).
|
||||
func TestBannerConsoleColorsAndUrgent(t *testing.T) {
|
||||
ctx := context.Background()
|
||||
srv, adsSvc := bannerServer(t)
|
||||
srv, adsSvc, _ := bannerServer(t)
|
||||
h := srv.Handler()
|
||||
base := "http://admin.test/_gm"
|
||||
const origin = "http://admin.test"
|
||||
@@ -351,7 +465,7 @@ func TestBannerConsoleColorsAndUrgent(t *testing.T) {
|
||||
// otherwise suppress the banner.
|
||||
func TestBannerUrgentBypassesEligibility(t *testing.T) {
|
||||
ctx := context.Background()
|
||||
srv, adsSvc := bannerServer(t)
|
||||
srv, adsSvc, _ := bannerServer(t)
|
||||
accounts := account.NewStore(testDB)
|
||||
id := provisionAccount(t)
|
||||
|
||||
@@ -407,10 +521,4 @@ func TestBannerUrgentBypassesEligibility(t *testing.T) {
|
||||
if err := accounts.RevokeRole(ctx, id, account.RoleNoBanner); err != nil {
|
||||
t.Fatalf("revoke role: %v", err)
|
||||
}
|
||||
|
||||
// A non-empty hint wallet no longer suppresses it either.
|
||||
if _, err := accounts.GrantHints(ctx, id, 5); err != nil {
|
||||
t.Fatalf("grant hints: %v", err)
|
||||
}
|
||||
assertUrgentOnly("hints", get())
|
||||
}
|
||||
|
||||
@@ -0,0 +1,153 @@
|
||||
//go:build integration
|
||||
|
||||
package inttest
|
||||
|
||||
import (
|
||||
"context"
|
||||
"net/http"
|
||||
"strings"
|
||||
"testing"
|
||||
|
||||
"github.com/google/uuid"
|
||||
|
||||
"scrabble/backend/internal/payments"
|
||||
)
|
||||
|
||||
// productByTitle finds a catalog product by its (unique in the test) title.
|
||||
func productByTitle(t *testing.T, pay *payments.Service, title string) payments.AdminProduct {
|
||||
t.Helper()
|
||||
all, err := pay.AdminCatalog(context.Background(), true)
|
||||
if err != nil {
|
||||
t.Fatalf("admin catalog: %v", err)
|
||||
}
|
||||
for _, p := range all {
|
||||
if p.Title == title {
|
||||
return p
|
||||
}
|
||||
}
|
||||
t.Fatalf("product %q not found", title)
|
||||
return payments.AdminProduct{}
|
||||
}
|
||||
|
||||
// TestConsoleCatalogEditor drives the catalog editor end to end: create is CSRF-guarded; a value and
|
||||
// a pack are created and edited; an invalid active product is refused; archive hides it; a
|
||||
// never-transacted product deletes; a transacted product is refused deletion (archive only).
|
||||
func TestConsoleCatalogEditor(t *testing.T) {
|
||||
ctx := context.Background()
|
||||
srv, _, pay := bannerServer(t)
|
||||
h := srv.Handler()
|
||||
const origin = "http://admin.test"
|
||||
const catalog = "http://admin.test/_gm/catalog"
|
||||
|
||||
// CSRF: a create without the origin header is refused.
|
||||
if code, _ := consoleDo(h, http.MethodPost, catalog, "title=x&hints=1&price_chip=10&active=true", ""); code != http.StatusForbidden {
|
||||
t.Fatalf("create without origin = %d, want 403", code)
|
||||
}
|
||||
|
||||
// Create a value product: 5 hints for 50 chips, active.
|
||||
if code, body := consoleDo(h, http.MethodPost, catalog, "title=cat-hints-5&hints=5&price_chip=50&active=true", origin); code != http.StatusOK || !strings.Contains(body, "Created") {
|
||||
t.Fatalf("create value = %d, has 'Created' = %v", code, strings.Contains(body, "Created"))
|
||||
}
|
||||
val := productByTitle(t, pay, "cat-hints-5")
|
||||
if !val.Active || len(val.Atoms) != 1 || val.Atoms[0].Atom != "hints" || val.Atoms[0].Quantity != 5 {
|
||||
t.Fatalf("created value = %+v", val)
|
||||
}
|
||||
|
||||
// An invalid active pack (chips, no money price) is refused.
|
||||
if code, body := consoleDo(h, http.MethodPost, catalog, "title=cat-bad&chips=100&active=true", origin); code != http.StatusOK || !strings.Contains(body, "Invalid product") {
|
||||
t.Fatalf("bad pack = %d, has 'Invalid product' = %v", code, strings.Contains(body, "Invalid product"))
|
||||
}
|
||||
if _, err := pay.AdminCatalog(ctx, true); err != nil {
|
||||
t.Fatalf("catalog: %v", err)
|
||||
}
|
||||
|
||||
// Edit the value: raise to 8 hints.
|
||||
base := catalog + "/" + val.ID.String()
|
||||
if code, _ := consoleDo(h, http.MethodPost, base, "title=cat-hints-8&hints=8&price_chip=50&active=true", origin); code != http.StatusOK {
|
||||
t.Fatalf("edit = %d", code)
|
||||
}
|
||||
if got := productByTitle(t, pay, "cat-hints-8"); len(got.Atoms) != 1 || got.Atoms[0].Quantity != 8 {
|
||||
t.Fatalf("after edit atoms = %+v, want 8 hints", got.Atoms)
|
||||
}
|
||||
|
||||
// Archive it → hidden from the storefront (the user Catalog filters active).
|
||||
if code, _ := consoleDo(h, http.MethodPost, base+"/archive", "active=false", origin); code != http.StatusOK {
|
||||
t.Fatalf("archive = %d", code)
|
||||
}
|
||||
if productByTitle(t, pay, "cat-hints-8").Active {
|
||||
t.Error("product still active after archive")
|
||||
}
|
||||
|
||||
// Delete the never-transacted product.
|
||||
if code, body := consoleDo(h, http.MethodPost, base+"/delete", "", origin); code != http.StatusOK || !strings.Contains(body, "Deleted") {
|
||||
t.Fatalf("delete clean = %d, has 'Deleted' = %v", code, strings.Contains(body, "Deleted"))
|
||||
}
|
||||
|
||||
// A transacted product cannot be deleted — only archived.
|
||||
if code, _ := consoleDo(h, http.MethodPost, catalog, "title=cat-pack&chips=100&price_rub=14900&active=true", origin); code != http.StatusOK {
|
||||
t.Fatal("create pack failed")
|
||||
}
|
||||
pack := productByTitle(t, pay, "cat-pack")
|
||||
if _, err := pay.CreateOrder(ctx, uuid.New(), payments.NewContext("direct", "web"), []payments.Source{payments.SourceDirect}, pack.ID, "robokassa"); err != nil {
|
||||
t.Fatalf("create order: %v", err)
|
||||
}
|
||||
if code, body := consoleDo(h, http.MethodPost, catalog+"/"+pack.ID.String()+"/delete", "", origin); code != http.StatusOK || !strings.Contains(body, "Cannot delete") {
|
||||
t.Fatalf("delete transacted = %d, has 'Cannot delete' = %v", code, strings.Contains(body, "Cannot delete"))
|
||||
}
|
||||
}
|
||||
|
||||
// TestConsoleCatalogActiveToggle checks the catalog console lists only active products by default and
|
||||
// includes the archived ones under ?all=1 (the active/all toggle).
|
||||
func TestConsoleCatalogActiveToggle(t *testing.T) {
|
||||
srv, _, _ := bannerServer(t)
|
||||
h := srv.Handler()
|
||||
const origin = "http://admin.test"
|
||||
const catalog = "http://admin.test/_gm/catalog"
|
||||
|
||||
// An active value and an archived one (created without the active flag).
|
||||
if code, _ := consoleDo(h, http.MethodPost, catalog, "title=toggle-active&hints=5&price_chip=50&active=true", origin); code != http.StatusOK {
|
||||
t.Fatal("create active failed")
|
||||
}
|
||||
if code, _ := consoleDo(h, http.MethodPost, catalog, "title=toggle-archived&hints=9&price_chip=90", origin); code != http.StatusOK {
|
||||
t.Fatal("create archived failed")
|
||||
}
|
||||
|
||||
// Default view: active only.
|
||||
if _, body := consoleDo(h, http.MethodGet, catalog, "", ""); !strings.Contains(body, "toggle-active") || strings.Contains(body, "toggle-archived") {
|
||||
t.Errorf("default view: active listed=%v, archived listed=%v (want true/false)",
|
||||
strings.Contains(body, "toggle-active"), strings.Contains(body, "toggle-archived"))
|
||||
}
|
||||
|
||||
// ?all=1: active and archived.
|
||||
if _, body := consoleDo(h, http.MethodGet, catalog+"?all=1", "", ""); !strings.Contains(body, "toggle-active") || !strings.Contains(body, "toggle-archived") {
|
||||
t.Errorf("all view: active listed=%v, archived listed=%v (want true/true)",
|
||||
strings.Contains(body, "toggle-active"), strings.Contains(body, "toggle-archived"))
|
||||
}
|
||||
}
|
||||
|
||||
// TestConsoleRewardConfig checks the rewarded-ads config form on the catalog page: a POST updates the
|
||||
// payout and caps, the page pre-fills the current values, and a negative value is refused.
|
||||
func TestConsoleRewardConfig(t *testing.T) {
|
||||
srv, _, pay := bannerServer(t)
|
||||
h := srv.Handler()
|
||||
const origin = "http://admin.test"
|
||||
const reward = "http://admin.test/_gm/catalog/reward"
|
||||
|
||||
// Set the payout and caps.
|
||||
if code, body := consoleDo(h, http.MethodPost, reward, "reward_payout=7&reward_daily=40&reward_hourly=9", origin); code != http.StatusOK || !strings.Contains(body, "Saved") {
|
||||
t.Fatalf("set reward = %d, has 'Saved' = %v", code, strings.Contains(body, "Saved"))
|
||||
}
|
||||
if payout, daily, hourly, err := pay.RewardConfig(context.Background()); err != nil || payout != 7 || daily != 40 || hourly != 9 {
|
||||
t.Fatalf("reward config = %d/%d/%d (err %v), want 7/40/9", payout, daily, hourly, err)
|
||||
}
|
||||
|
||||
// The catalog page renders the form pre-filled with the current payout.
|
||||
if _, body := consoleDo(h, http.MethodGet, "http://admin.test/_gm/catalog", "", ""); !strings.Contains(body, "reward_payout") || !strings.Contains(body, `value="7"`) {
|
||||
t.Error("catalog page did not render the reward form with the current payout")
|
||||
}
|
||||
|
||||
// A negative value is refused (the service validates non-negativity).
|
||||
if code, body := consoleDo(h, http.MethodPost, reward, "reward_payout=-1", origin); code != http.StatusOK || !strings.Contains(body, "non-negative") {
|
||||
t.Errorf("negative payout = %d, has 'non-negative' = %v", code, strings.Contains(body, "non-negative"))
|
||||
}
|
||||
}
|
||||
@@ -5,6 +5,7 @@ package inttest
|
||||
import (
|
||||
"context"
|
||||
"encoding/json"
|
||||
"errors"
|
||||
"fmt"
|
||||
"net/http"
|
||||
"net/http/httptest"
|
||||
@@ -18,59 +19,119 @@ import (
|
||||
"scrabble/backend/internal/account"
|
||||
"scrabble/backend/internal/engine"
|
||||
"scrabble/backend/internal/game"
|
||||
"scrabble/backend/internal/gamelimits"
|
||||
"scrabble/backend/internal/lobby"
|
||||
"scrabble/backend/internal/server"
|
||||
"scrabble/backend/internal/social"
|
||||
)
|
||||
|
||||
// The game-limit suite covers the simultaneous quick-game cap (game.MaxActiveQuickGames):
|
||||
// the counting rule (Store/Service.CountActiveQuickGames) and the HTTP gate that refuses a
|
||||
// new game once the cap is reached, while accepting an incoming invitation stays allowed.
|
||||
// The game-limit suite covers the per-tier, per-kind active-game caps (backend.config): the
|
||||
// game_kind tag persisted per creation path, the tier resolution + counting rule
|
||||
// (game.Service.AtGameLimit), the HTTP gate + lobby at_game_limit flag, the guest gate on friend
|
||||
// actions, and the config hot-cache reflecting an admin edit. Accepting an invitation stays exempt.
|
||||
|
||||
// TestCountActiveQuickGames checks the count includes active and open quick games (the
|
||||
// honest-AI ones among them) and excludes finished games, friend games (created by
|
||||
// invitation) and games the account is not seated in.
|
||||
func TestCountActiveQuickGames(t *testing.T) {
|
||||
// newGameLimits builds a gamelimits service over the shared pool and loads the seeded config
|
||||
// (guest 1/1/0, durable 10/10/10).
|
||||
func newGameLimits(t *testing.T) *gamelimits.Service {
|
||||
t.Helper()
|
||||
gl := gamelimits.NewService(gamelimits.NewStore(testDB))
|
||||
if err := gl.Load(context.Background()); err != nil {
|
||||
t.Fatalf("load game limits: %v", err)
|
||||
}
|
||||
return gl
|
||||
}
|
||||
|
||||
// restoreDefaultLimits resets the single config row to the migration seed on cleanup, so a test that
|
||||
// edited it never leaks its values into another (the row is a shared singleton).
|
||||
func restoreDefaultLimits(t *testing.T, gl *gamelimits.Service) {
|
||||
t.Helper()
|
||||
t.Cleanup(func() {
|
||||
_ = gl.Update(context.Background(), gamelimits.Config{
|
||||
Guest: gamelimits.Limits{VsAI: 1, Random: 1, Friends: 0},
|
||||
Durable: gamelimits.Limits{VsAI: 10, Random: 10, Friends: 10},
|
||||
})
|
||||
})
|
||||
}
|
||||
|
||||
// gameKind reads a game's persisted game_kind tag.
|
||||
func gameKind(t *testing.T, gameID uuid.UUID) int16 {
|
||||
t.Helper()
|
||||
var k int16
|
||||
if err := testDB.QueryRowContext(context.Background(),
|
||||
`SELECT game_kind FROM backend.games WHERE game_id=$1`, gameID).Scan(&k); err != nil {
|
||||
t.Fatalf("read game_kind: %v", err)
|
||||
}
|
||||
return k
|
||||
}
|
||||
|
||||
// mustCreateKind creates an active game seating the accounts, tagged with kind, and returns its id.
|
||||
func mustCreateKind(t *testing.T, games *game.Service, seats []uuid.UUID, kind gamelimits.Kind) uuid.UUID {
|
||||
t.Helper()
|
||||
g, err := games.Create(context.Background(), game.CreateParams{
|
||||
Variant: engine.VariantEnglish, Seats: seats, TurnTimeout: 24 * time.Hour, Kind: kind,
|
||||
})
|
||||
if err != nil {
|
||||
t.Fatalf("create game (kind=%d): %v", kind, err)
|
||||
}
|
||||
return g.ID
|
||||
}
|
||||
|
||||
// startFriendGameID has inviter invite invitee to a friend game and the invitee accept, returning
|
||||
// the started game's id.
|
||||
func startFriendGameID(t *testing.T, inv *lobby.InvitationService, inviter, invitee uuid.UUID) uuid.UUID {
|
||||
t.Helper()
|
||||
ctx := context.Background()
|
||||
invitation, err := inv.CreateInvitation(ctx, inviter, []uuid.UUID{invitee}, englishInvite())
|
||||
if err != nil {
|
||||
t.Fatalf("create invitation: %v", err)
|
||||
}
|
||||
got, err := inv.RespondInvitation(ctx, invitation.ID, invitee, true)
|
||||
if err != nil {
|
||||
t.Fatalf("accept invitation: %v", err)
|
||||
}
|
||||
if got.GameID == nil {
|
||||
t.Fatal("accepted invitation has no game id")
|
||||
}
|
||||
return *got.GameID
|
||||
}
|
||||
|
||||
// TestGameKindPersisted checks each creation path stamps the right game_kind: random (auto-match)
|
||||
// =2, vs_ai =1, friend (by invitation) =3.
|
||||
func TestGameKindPersisted(t *testing.T) {
|
||||
ctx := context.Background()
|
||||
clearOpenGames(t)
|
||||
games := newGameService()
|
||||
human := provisionAccount(t)
|
||||
opp := provisionAccount(t)
|
||||
inv := newInvitationService()
|
||||
human, opp := provisionAccount(t), provisionAccount(t)
|
||||
|
||||
if n := mustCount(t, games, human); n != 0 {
|
||||
t.Fatalf("fresh account count = %d, want 0", n)
|
||||
rnd, _, err := games.OpenOrJoin(ctx, human, game.CreateParams{Variant: engine.VariantEnglish, TurnTimeout: 24 * time.Hour}, time.Now().Add(time.Minute), nil)
|
||||
if err != nil {
|
||||
t.Fatalf("open random game: %v", err)
|
||||
}
|
||||
if k := gameKind(t, rnd.ID); k != int16(gamelimits.KindRandom) {
|
||||
t.Errorf("random game_kind = %d, want %d", k, gamelimits.KindRandom)
|
||||
}
|
||||
|
||||
// An open (awaiting-opponent) quick game counts.
|
||||
if _, _, err := games.OpenOrJoin(ctx, human, game.CreateParams{
|
||||
Variant: engine.VariantEnglish, TurnTimeout: 24 * time.Hour,
|
||||
}, time.Now().Add(time.Minute), nil); err != nil {
|
||||
t.Fatalf("open quick game: %v", err)
|
||||
ai := mustCreateKind(t, games, []uuid.UUID{human, opp}, gamelimits.KindVsAI)
|
||||
if k := gameKind(t, ai); k != int16(gamelimits.KindVsAI) {
|
||||
t.Errorf("vs_ai game_kind = %d, want %d", k, gamelimits.KindVsAI)
|
||||
}
|
||||
// An active quick game and an honest-AI quick game both count (neither has an invitation row).
|
||||
mustCreateQuick(t, games, []uuid.UUID{human, opp}, false, 1)
|
||||
mustCreateQuick(t, games, []uuid.UUID{human, opp}, true, 2)
|
||||
|
||||
// A finished quick game does NOT count.
|
||||
fin := mustCreateQuick(t, games, []uuid.UUID{human, opp}, false, 3)
|
||||
if _, err := testDB.ExecContext(ctx, `UPDATE backend.games SET status='finished' WHERE game_id=$1`, fin); err != nil {
|
||||
t.Fatalf("finish game: %v", err)
|
||||
}
|
||||
// A game the human is not seated in does NOT count.
|
||||
mustCreateQuick(t, games, []uuid.UUID{opp, provisionAccount(t)}, false, 4)
|
||||
// A friend game (created by invitation) does NOT count, even though it is active.
|
||||
startFriendGame(t, human)
|
||||
|
||||
if n := mustCount(t, games, human); n != 3 {
|
||||
t.Fatalf("active quick count = %d, want 3 (active + AI + open)", n)
|
||||
friend := startFriendGameID(t, inv, human, opp)
|
||||
if k := gameKind(t, friend); k != int16(gamelimits.KindFriends) {
|
||||
t.Errorf("friend game_kind = %d, want %d", k, gamelimits.KindFriends)
|
||||
}
|
||||
}
|
||||
|
||||
// TestGameLimitGate drives the cap through the assembled HTTP server: under the cap the lobby
|
||||
// reports at_game_limit false; at the cap it flips to true and both new-game endpoints (quick
|
||||
// enqueue and invitation creation) are refused with 409 game_limit_reached, while accepting an
|
||||
// incoming invitation is still allowed.
|
||||
func TestGameLimitGate(t *testing.T) {
|
||||
// TestGuestActiveGameLimitHTTP drives the guest random cap through the assembled server: the first
|
||||
// auto-match opens a game, the lobby then flags at_game_limit, and a second enqueue is refused 409
|
||||
// game_limit_reached. It also checks the guest vs_ai and friends caps resolve at the domain level.
|
||||
func TestGuestActiveGameLimitHTTP(t *testing.T) {
|
||||
ctx := context.Background()
|
||||
clearOpenGames(t)
|
||||
gl := newGameLimits(t)
|
||||
games := newGameService()
|
||||
games.SetGameLimits(gl)
|
||||
srv := server.New(":0", server.Deps{
|
||||
Logger: zaptest.NewLogger(t),
|
||||
DB: testDB,
|
||||
@@ -78,88 +139,110 @@ func TestGameLimitGate(t *testing.T) {
|
||||
Games: games,
|
||||
Matchmaker: newMatchmaker(t, newRobotService(t, newGameService()), time.Minute, 0),
|
||||
Invitations: newInvitationService(),
|
||||
GameLimits: gl,
|
||||
})
|
||||
|
||||
human := provisionAccount(t)
|
||||
guest := provisionGuest(t)
|
||||
if gamesListAtLimit(t, srv, guest) {
|
||||
t.Fatal("a fresh guest must be under the random game limit")
|
||||
}
|
||||
// The first auto-match opens a random game (guest random cap = 1).
|
||||
if rec := userPost(t, srv, "/api/v1/user/lobby/enqueue", guest, `{"variant":"erudit_ru"}`); rec.Code != http.StatusOK {
|
||||
t.Fatalf("first enqueue = %d (%s), want 200", rec.Code, rec.Body.String())
|
||||
}
|
||||
// At the cap: the lobby flags it and a second enqueue is refused with the stable code.
|
||||
if !gamesListAtLimit(t, srv, guest) {
|
||||
t.Fatal("after one random game the guest must be at the limit")
|
||||
}
|
||||
if rec := userPost(t, srv, "/api/v1/user/lobby/enqueue", guest, `{"variant":"erudit_ru"}`); rec.Code != http.StatusConflict || errorCode(t, rec) != "game_limit_reached" {
|
||||
t.Fatalf("second enqueue = (%d, %q), want (409, game_limit_reached)", rec.Code, errorCode(t, rec))
|
||||
}
|
||||
|
||||
// A guest is refused the friends flow outright at the HTTP edge (403 guest_forbidden).
|
||||
opp := provisionAccount(t)
|
||||
|
||||
// Under the cap: the lobby reports the player is not limited.
|
||||
if gamesListAtLimit(t, srv, human) {
|
||||
t.Fatal("a fresh account must be under the game limit")
|
||||
}
|
||||
|
||||
// Reach the cap with active quick games seating the human (no invitation row → quick).
|
||||
for i := 0; i < game.MaxActiveQuickGames; i++ {
|
||||
mustCreateQuick(t, games, []uuid.UUID{human, opp}, false, int64(i+1))
|
||||
}
|
||||
|
||||
// At the cap: the lobby flags it and both create paths are refused with the stable code.
|
||||
if !gamesListAtLimit(t, srv, human) {
|
||||
t.Fatalf("at %d games at_game_limit must be true", game.MaxActiveQuickGames)
|
||||
}
|
||||
// erudit_ru is in the default variant preferences, so the variant gate passes and the
|
||||
// game-limit gate is what fires here.
|
||||
if rec := userPost(t, srv, "/api/v1/user/lobby/enqueue", human, `{"variant":"erudit_ru"}`); rec.Code != http.StatusConflict || errorCode(t, rec) != "game_limit_reached" {
|
||||
t.Fatalf("enqueue at limit = (%d, %q), want (409, game_limit_reached)", rec.Code, errorCode(t, rec))
|
||||
}
|
||||
invBody := fmt.Sprintf(`{"variant":"erudit_ru","invitee_ids":[%q]}`, opp.String())
|
||||
if rec := userPost(t, srv, "/api/v1/user/invitations", human, invBody); rec.Code != http.StatusConflict || errorCode(t, rec) != "game_limit_reached" {
|
||||
t.Fatalf("invitation at limit = (%d, %q), want (409, game_limit_reached)", rec.Code, errorCode(t, rec))
|
||||
if rec := userPost(t, srv, "/api/v1/user/invitations", guest, invBody); rec.Code != http.StatusForbidden || errorCode(t, rec) != "guest_forbidden" {
|
||||
t.Fatalf("guest invitation = (%d, %q), want (403, guest_forbidden)", rec.Code, errorCode(t, rec))
|
||||
}
|
||||
|
||||
// Accepting an incoming invitation is never blocked, even at the cap: another player invites
|
||||
// the capped human, who accepts over HTTP and the game starts (friend games do not count).
|
||||
inviter := provisionAccount(t)
|
||||
inv := newInvitationService()
|
||||
invitation, err := inv.CreateInvitation(ctx, inviter, []uuid.UUID{human}, englishInvite())
|
||||
if err != nil {
|
||||
t.Fatalf("create invitation: %v", err)
|
||||
// The guest vs_ai cap is 1: one vs_ai game puts the guest at the vs_ai limit.
|
||||
mustCreateKind(t, games, []uuid.UUID{guest, opp}, gamelimits.KindVsAI)
|
||||
if at, err := games.AtGameLimit(ctx, guest, gamelimits.KindVsAI); err != nil || !at {
|
||||
t.Fatalf("guest vs_ai at-limit = (%v, %v), want (true, nil)", at, err)
|
||||
}
|
||||
if rec := userPost(t, srv, "/api/v1/user/invitations/"+invitation.ID.String()+"/accept", human, ""); rec.Code != http.StatusOK {
|
||||
t.Fatalf("accept at limit = %d (%s), want 200 — accept must bypass the cap", rec.Code, rec.Body.String())
|
||||
// The guest friends cap is 0: a guest is always at the friends limit (the 403 gate blocks first).
|
||||
if at, err := games.AtGameLimit(ctx, guest, gamelimits.KindFriends); err != nil || !at {
|
||||
t.Fatalf("guest friends at-limit = (%v, %v), want (true, nil)", at, err)
|
||||
}
|
||||
}
|
||||
|
||||
// mustCount returns the account's active-quick-game count, failing on error.
|
||||
func mustCount(t *testing.T, games *game.Service, id uuid.UUID) int {
|
||||
t.Helper()
|
||||
n, err := games.CountActiveQuickGames(context.Background(), id)
|
||||
if err != nil {
|
||||
t.Fatalf("count active quick games: %v", err)
|
||||
}
|
||||
return n
|
||||
}
|
||||
|
||||
// mustCreateQuick creates an active quick game (no invitation) seating the given accounts and
|
||||
// returns its id; vsAI flags an honest-AI game (the service then applies the 7-day clock).
|
||||
func mustCreateQuick(t *testing.T, games *game.Service, seats []uuid.UUID, vsAI bool, seed int64) uuid.UUID {
|
||||
t.Helper()
|
||||
p := game.CreateParams{Variant: engine.VariantEnglish, Seats: seats, TurnTimeout: 24 * time.Hour, Seed: seed}
|
||||
if vsAI {
|
||||
p.VsAI = true
|
||||
p.TurnTimeout = 0 // the service applies the 7-day AI inactivity clock for vs_ai games
|
||||
}
|
||||
g, err := games.Create(context.Background(), p)
|
||||
if err != nil {
|
||||
t.Fatalf("create quick game (vsAI=%v): %v", vsAI, err)
|
||||
}
|
||||
return g.ID
|
||||
}
|
||||
|
||||
// startFriendGame has a fresh inviter invite the given account to a friend game and the invitee
|
||||
// accept it, so the (active) friend game exists with its game_invitations row.
|
||||
func startFriendGame(t *testing.T, invitee uuid.UUID) {
|
||||
t.Helper()
|
||||
// TestDurableTierHigherLimit checks a durable account resolves the durable tier, not the guest one:
|
||||
// one random game leaves it well under the durable cap (10).
|
||||
func TestDurableTierHigherLimit(t *testing.T) {
|
||||
ctx := context.Background()
|
||||
gl := newGameLimits(t)
|
||||
games := newGameService()
|
||||
games.SetGameLimits(gl)
|
||||
durable, opp := provisionAccount(t), provisionAccount(t)
|
||||
|
||||
mustCreateKind(t, games, []uuid.UUID{durable, opp}, gamelimits.KindRandom)
|
||||
if at, err := games.AtGameLimit(ctx, durable, gamelimits.KindRandom); err != nil || at {
|
||||
t.Fatalf("durable random at-limit after one game = (%v, %v), want (false, nil)", at, err)
|
||||
}
|
||||
}
|
||||
|
||||
// TestGuestForbiddenFriendActions checks a guest is refused every friend action server-side (the UI
|
||||
// hides them; this is the source of truth): creating an invitation, sending a friend request, and
|
||||
// redeeming a friend code.
|
||||
func TestGuestForbiddenFriendActions(t *testing.T) {
|
||||
ctx := context.Background()
|
||||
guest := provisionGuest(t)
|
||||
other := provisionAccount(t)
|
||||
|
||||
inv := newInvitationService()
|
||||
if _, err := inv.CreateInvitation(ctx, guest, []uuid.UUID{other}, englishInvite()); !errors.Is(err, lobby.ErrGuestForbidden) {
|
||||
t.Fatalf("guest CreateInvitation err = %v, want ErrGuestForbidden", err)
|
||||
}
|
||||
|
||||
soc := newSocialService()
|
||||
if err := soc.SendFriendRequest(ctx, guest, other); !errors.Is(err, social.ErrGuestForbidden) {
|
||||
t.Fatalf("guest SendFriendRequest err = %v, want ErrGuestForbidden", err)
|
||||
}
|
||||
if _, err := soc.RedeemFriendCode(ctx, guest, "000000"); !errors.Is(err, social.ErrGuestForbidden) {
|
||||
t.Fatalf("guest RedeemFriendCode err = %v, want ErrGuestForbidden", err)
|
||||
}
|
||||
}
|
||||
|
||||
// TestDurableFriendsCapAndConfigCache lowers the durable friends cap to 1 through the service (the
|
||||
// admin-edit path), checks a durable inviter is refused a second friend game with 409, and that
|
||||
// accepting an incoming invitation is still exempt from the cap.
|
||||
func TestDurableFriendsCapAndConfigCache(t *testing.T) {
|
||||
ctx := context.Background()
|
||||
gl := newGameLimits(t)
|
||||
restoreDefaultLimits(t, gl)
|
||||
cfg := gl.Get()
|
||||
cfg.Durable.Friends = 1
|
||||
if err := gl.Update(ctx, cfg); err != nil {
|
||||
t.Fatalf("update durable friends cap: %v", err)
|
||||
}
|
||||
games := newGameService()
|
||||
games.SetGameLimits(gl)
|
||||
inv := lobby.NewInvitationService(lobby.NewStore(testDB), games, account.NewStore(testDB), newSocialService())
|
||||
|
||||
inviter := provisionAccount(t)
|
||||
invitation, err := inv.CreateInvitation(ctx, inviter, []uuid.UUID{invitee}, englishInvite())
|
||||
if err != nil {
|
||||
t.Fatalf("create invitation: %v", err)
|
||||
d2, d3 := provisionAccount(t), provisionAccount(t)
|
||||
|
||||
// The inviter's first friend game reaches the (lowered) cap of 1.
|
||||
startFriendGameID(t, inv, inviter, d2)
|
||||
if at, err := games.AtGameLimit(ctx, inviter, gamelimits.KindFriends); err != nil || !at {
|
||||
t.Fatalf("inviter friends at-limit = (%v, %v), want (true, nil)", at, err)
|
||||
}
|
||||
if _, err := inv.RespondInvitation(ctx, invitation.ID, invitee, true); err != nil {
|
||||
t.Fatalf("accept invitation: %v", err)
|
||||
// A second invitation is refused: the cache reflects the edit.
|
||||
if _, err := inv.CreateInvitation(ctx, inviter, []uuid.UUID{d3}, englishInvite()); !errors.Is(err, game.ErrGameLimitReached) {
|
||||
t.Fatalf("second invitation err = %v, want ErrGameLimitReached", err)
|
||||
}
|
||||
// Accept stays exempt: someone else invites the capped inviter, who accepts and the game starts.
|
||||
startFriendGameID(t, inv, d3, inviter)
|
||||
}
|
||||
|
||||
// gamesListAtLimit fetches /api/v1/user/games and returns its at_game_limit flag.
|
||||
|
||||
@@ -14,6 +14,8 @@ import (
|
||||
"scrabble/backend/internal/account"
|
||||
"scrabble/backend/internal/engine"
|
||||
"scrabble/backend/internal/game"
|
||||
"scrabble/backend/internal/payments"
|
||||
"scrabble/backend/internal/session"
|
||||
)
|
||||
|
||||
// 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
|
||||
// disabled switch.
|
||||
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()
|
||||
pay := newPaymentsService()
|
||||
svc.SetHintWallet(pay) // share the handle so a grant invalidates the wallet the game reads
|
||||
seats := []uuid.UUID{provisionAccount(t), provisionAccount(t)}
|
||||
seed := openingSeed(t)
|
||||
g, err := svc.Create(ctx, game.CreateParams{
|
||||
@@ -417,25 +424,30 @@ func TestHintPolicy(t *testing.T) {
|
||||
t.Fatalf("first hint: %v", err)
|
||||
}
|
||||
// The allowance is spent before the wallet: with an empty wallet, the state now reports no
|
||||
// hints left and a zero wallet, so the per-game allowance (HintsRemaining-WalletBalance) is 0.
|
||||
// per-game allowance left (HintsRemaining is the allowance alone; the wallet lives on the profile).
|
||||
st, err := svc.GameState(ctx, g.ID, seats[0])
|
||||
if err != nil {
|
||||
t.Fatalf("state: %v", err)
|
||||
}
|
||||
if st.HintsRemaining != 0 || st.WalletBalance != 0 {
|
||||
t.Errorf("after allowance hint: hints=%d wallet=%d, want 0/0", st.HintsRemaining, st.WalletBalance)
|
||||
if st.HintsRemaining != 0 {
|
||||
t.Errorf("after allowance hint: hints=%d, want 0", st.HintsRemaining)
|
||||
}
|
||||
if _, err := svc.Hint(ctx, g.ID, seats[0]); !errors.Is(err, game.ErrNoHintsLeft) {
|
||||
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
|
||||
if err != nil {
|
||||
t.Fatalf("wallet hint: %v", err)
|
||||
}
|
||||
// The allowance stays exhausted; the wallet dropped 2->1, and WalletBalance carries it alone.
|
||||
if res.HintsRemaining != 1 || res.WalletBalance != 1 {
|
||||
t.Errorf("wallet hint: hints=%d wallet=%d, want 1/1", res.HintsRemaining, res.WalletBalance)
|
||||
// The allowance stays exhausted (HintsRemaining is the allowance alone, so 0); the wallet dropped
|
||||
// 2->1 and WalletBalance carries it (the client adopts it into the profile).
|
||||
if res.HintsRemaining != 0 || res.WalletBalance != 1 {
|
||||
t.Errorf("wallet hint: hints=%d wallet=%d, want 0/1", res.HintsRemaining, res.WalletBalance)
|
||||
}
|
||||
// game_players.hints_used counts BOTH hints (1 allowance + 1 wallet) — the per-game total
|
||||
// that feeds the player's lifetime hint statistics, not just the allowance.
|
||||
@@ -603,14 +615,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 {
|
||||
if len(a) != len(b) {
|
||||
return false
|
||||
|
||||
@@ -17,6 +17,7 @@ import (
|
||||
"scrabble/backend/internal/engine"
|
||||
"scrabble/backend/internal/game"
|
||||
"scrabble/backend/internal/lobby"
|
||||
"scrabble/backend/internal/payments"
|
||||
"scrabble/backend/internal/robot"
|
||||
"scrabble/backend/internal/social"
|
||||
)
|
||||
@@ -42,9 +43,42 @@ func newGameService() *game.Service {
|
||||
zap.NewNop(),
|
||||
)
|
||||
svc.SetFirstMoveEntropy(seatZeroFirstMove)
|
||||
svc.SetHintWallet(newPaymentsService())
|
||||
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
|
||||
// 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
|
||||
|
||||
@@ -58,14 +58,6 @@ func bestMoveCount(t *testing.T, id uuid.UUID) int {
|
||||
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) {
|
||||
t.Helper()
|
||||
if _, err := testDB.ExecContext(context.Background(),
|
||||
@@ -131,6 +123,7 @@ func TestAccountMergeCore(t *testing.T) {
|
||||
ctx := context.Background()
|
||||
store := account.NewStore(testDB)
|
||||
merger := accountmerge.NewMerger(testDB)
|
||||
merger.SetPayments(newPaymentsService())
|
||||
|
||||
primary := provisionAccount(t)
|
||||
secondary := provisionAccount(t)
|
||||
@@ -140,8 +133,8 @@ func TestAccountMergeCore(t *testing.T) {
|
||||
setStats(t, secondary, 3, 1, 2, 400, 80)
|
||||
setStatsCounts(t, primary, 100, 5)
|
||||
setStatsCounts(t, secondary, 50, 8)
|
||||
setWallet(t, primary, 2, false)
|
||||
setWallet(t, secondary, 5, true)
|
||||
seedBenefit(t, primary, "telegram", 2, false)
|
||||
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
|
||||
// scrabble_ru (30) is new to primary and carried over.
|
||||
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")
|
||||
}
|
||||
|
||||
acc, err := store.GetByID(ctx, primary)
|
||||
if err != nil {
|
||||
t.Fatalf("get primary: %v", err)
|
||||
// The payments wallet merged by origin: hints sum (2+5) and the forever flag OR-s; the
|
||||
// deprecated accounts.hint_balance / paid_account columns are no longer touched by a merge.
|
||||
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 {
|
||||
t.Errorf("hint balance = %d, want 7", acc.HintBalance)
|
||||
}
|
||||
if !acc.PaidAccount {
|
||||
t.Error("paid_account should be true (ORed from secondary)")
|
||||
if hints, _ := readBenefit(t, secondary, "telegram"); hints != 0 {
|
||||
t.Errorf("secondary benefit should be cleared after merge, got hints %d", hints)
|
||||
}
|
||||
|
||||
if owner, ok, _ := store.AccountIDByIdentity(ctx, account.KindEmail, email); !ok || owner != primary {
|
||||
|
||||
@@ -0,0 +1,155 @@
|
||||
//go:build integration
|
||||
|
||||
package inttest
|
||||
|
||||
import (
|
||||
"context"
|
||||
"strings"
|
||||
"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")
|
||||
}
|
||||
}
|
||||
|
||||
// TestOfferPricingReflectsCatalogEdits verifies the public-offer price list (§4.4) is projected from
|
||||
// the live catalog and its cache is invalidated on a catalog mutation: a newly created pack appears
|
||||
// with its per-rail prices, and archiving it through the service drops it from the next read.
|
||||
func TestOfferPricingReflectsCatalogEdits(t *testing.T) {
|
||||
svc := newPaymentsService()
|
||||
ctx := context.Background()
|
||||
title := "OfferTest " + uuid.NewString()
|
||||
id, err := svc.CreateProduct(ctx, payments.ProductInput{
|
||||
Title: title,
|
||||
Atoms: []payments.AtomLine{{Atom: "chips", Quantity: 50}},
|
||||
Prices: []payments.PriceLine{
|
||||
{Method: "direct", Currency: payments.CurrencyRUB, Amount: 20000},
|
||||
{Method: "vk", Currency: payments.CurrencyVote, Amount: 30},
|
||||
{Method: "telegram", Currency: payments.CurrencyStar, Amount: 100},
|
||||
},
|
||||
}, true)
|
||||
if err != nil {
|
||||
t.Fatalf("create pack: %v", err)
|
||||
}
|
||||
|
||||
md, err := svc.OfferPricing(ctx)
|
||||
if err != nil {
|
||||
t.Fatalf("offer pricing: %v", err)
|
||||
}
|
||||
if row := "| " + title + " | 200.00 | 30 | 100 |"; !strings.Contains(md, row) {
|
||||
t.Fatalf("offer pricing missing the new pack row %q\n%s", row, md)
|
||||
}
|
||||
|
||||
// Archiving through the service marks the cache stale; the next read must reproject without it.
|
||||
if err := svc.SetProductActive(ctx, id, false); err != nil {
|
||||
t.Fatalf("archive: %v", err)
|
||||
}
|
||||
md, err = svc.OfferPricing(ctx)
|
||||
if err != nil {
|
||||
t.Fatalf("offer pricing after archive: %v", err)
|
||||
}
|
||||
if strings.Contains(md, title) {
|
||||
t.Errorf("archived pack must drop from the offer pricing:\n%s", md)
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,563 @@
|
||||
//go:build integration
|
||||
|
||||
package inttest
|
||||
|
||||
import (
|
||||
"context"
|
||||
"database/sql"
|
||||
"errors"
|
||||
"testing"
|
||||
|
||||
"github.com/google/uuid"
|
||||
|
||||
"scrabble/backend/internal/payments"
|
||||
)
|
||||
|
||||
// orderStatus reads an order's status.
|
||||
func orderStatus(t *testing.T, orderID uuid.UUID) string {
|
||||
t.Helper()
|
||||
var status string
|
||||
if err := testDB.QueryRowContext(context.Background(),
|
||||
`SELECT status FROM payments.orders WHERE order_id=$1`, orderID).Scan(&status); err != nil {
|
||||
t.Fatalf("read order status: %v", err)
|
||||
}
|
||||
return status
|
||||
}
|
||||
|
||||
// readRisk reads an account's payment-risk row (abuse flag + accumulated loss), or (false, 0) when
|
||||
// none exists.
|
||||
func readRisk(t *testing.T, acc uuid.UUID) (abuse bool, loss int64) {
|
||||
t.Helper()
|
||||
err := testDB.QueryRowContext(context.Background(),
|
||||
`SELECT abuse, loss_chips FROM payments.account_risk WHERE account_id=$1`, acc).Scan(&abuse, &loss)
|
||||
if errors.Is(err, sql.ErrNoRows) {
|
||||
return false, 0
|
||||
}
|
||||
if err != nil {
|
||||
t.Fatalf("read risk: %v", err)
|
||||
}
|
||||
return abuse, loss
|
||||
}
|
||||
|
||||
// TestPaymentsOrderFundCreditsOnce verifies the intake path over Postgres: creating an order then
|
||||
// funding it credits the funded segment exactly once, and a replayed callback (the same order)
|
||||
// credits nothing more — the ledger idempotency index holds.
|
||||
func TestPaymentsOrderFundCreditsOnce(t *testing.T) {
|
||||
ctx := context.Background()
|
||||
svc := newPaymentsService()
|
||||
acc := uuid.New()
|
||||
prod := seedPackProduct(t, 100, methodPrice{method: "direct", currency: "RUB", amount: 14900}) // 149.00 RUB funds 100 chips
|
||||
|
||||
res, err := svc.CreateOrder(ctx, acc, payments.NewContext("direct", "web"), []payments.Source{payments.SourceDirect}, prod, "robokassa")
|
||||
if err != nil {
|
||||
t.Fatalf("create order: %v", err)
|
||||
}
|
||||
if res.Amount.Minor() != 14900 || res.Amount.Currency() != payments.CurrencyRUB {
|
||||
t.Fatalf("order amount = %s, want 149.00 RUB", res.Amount)
|
||||
}
|
||||
if orderStatus(t, res.OrderID) != "pending" {
|
||||
t.Errorf("new order status = %s, want pending", orderStatus(t, res.OrderID))
|
||||
}
|
||||
|
||||
paid, _ := payments.MoneyFromMinor(14900, payments.CurrencyRUB)
|
||||
out, err := svc.Fund(ctx, res.OrderID, "robokassa", res.OrderID.String(), paid)
|
||||
if err != nil {
|
||||
t.Fatalf("fund: %v", err)
|
||||
}
|
||||
if out.AlreadyCredited || out.Chips != 100 || out.Source != payments.SourceDirect {
|
||||
t.Fatalf("fund outcome = %+v, want 100 chips to direct, not already-credited", out)
|
||||
}
|
||||
if got := readBalance(t, acc, "direct"); got != 100 {
|
||||
t.Errorf("balance after fund = %d, want 100", got)
|
||||
}
|
||||
if ledgerRows(t, acc, "fund") != 1 {
|
||||
t.Errorf("fund ledger rows = %d, want 1", ledgerRows(t, acc, "fund"))
|
||||
}
|
||||
if orderStatus(t, res.OrderID) != "paid" {
|
||||
t.Errorf("order status after fund = %s, want paid", orderStatus(t, res.OrderID))
|
||||
}
|
||||
|
||||
// A replayed callback for the same order is rejected by the unique index: no second credit.
|
||||
out2, err := svc.Fund(ctx, res.OrderID, "robokassa", res.OrderID.String(), paid)
|
||||
if err != nil {
|
||||
t.Fatalf("duplicate fund: %v", err)
|
||||
}
|
||||
if !out2.AlreadyCredited {
|
||||
t.Error("duplicate callback not flagged AlreadyCredited")
|
||||
}
|
||||
if got := readBalance(t, acc, "direct"); got != 100 {
|
||||
t.Errorf("balance after duplicate = %d, want 100 (credited once)", got)
|
||||
}
|
||||
if ledgerRows(t, acc, "fund") != 1 {
|
||||
t.Error("duplicate callback wrote a second fund ledger row")
|
||||
}
|
||||
}
|
||||
|
||||
// TestPaymentsVKOrderFundCredits exercises the VK Votes rail over Postgres: a VK order prices the
|
||||
// pack in votes; the get_item lookup returns its title and vote price; a chargeable
|
||||
// order_status_change credits the vk segment exactly once (idempotent on VK's own order id).
|
||||
func TestPaymentsVKOrderFundCredits(t *testing.T) {
|
||||
ctx := context.Background()
|
||||
svc := newPaymentsService()
|
||||
acc := uuid.New()
|
||||
prod := seedPackProduct(t, 200, methodPrice{method: "vk", currency: "VOTE", amount: 30})
|
||||
|
||||
res, err := svc.CreateOrder(ctx, acc, payments.NewContext("vk", "web"), []payments.Source{payments.SourceVK}, prod, "vk")
|
||||
if err != nil {
|
||||
t.Fatalf("create vk order: %v", err)
|
||||
}
|
||||
if res.Amount.Currency() != payments.CurrencyVote || res.Amount.Minor() != 30 {
|
||||
t.Fatalf("vk order amount = %s, want 30 VOTE", res.Amount)
|
||||
}
|
||||
|
||||
// get_item phase: title + vote price.
|
||||
title, amount, err := svc.OrderItem(ctx, res.OrderID)
|
||||
if err != nil {
|
||||
t.Fatalf("order item: %v", err)
|
||||
}
|
||||
if title == "" || amount.Minor() != 30 || amount.Currency() != payments.CurrencyVote {
|
||||
t.Errorf("order item = %q / %s, want a title + 30 VOTE", title, amount)
|
||||
}
|
||||
|
||||
// order_status_change phase: VK's own order id is the idempotency key.
|
||||
paid, _ := payments.MoneyFromMinor(30, payments.CurrencyVote)
|
||||
out, err := svc.Fund(ctx, res.OrderID, "vk", "vk-order-777", paid)
|
||||
if err != nil {
|
||||
t.Fatalf("vk fund: %v", err)
|
||||
}
|
||||
if out.AlreadyCredited || out.Chips != 200 || out.Source != payments.SourceVK {
|
||||
t.Fatalf("vk fund outcome = %+v, want 200 chips credited to vk", out)
|
||||
}
|
||||
if got := readBalance(t, acc, "vk"); got != 200 {
|
||||
t.Errorf("vk balance after fund = %d, want 200", got)
|
||||
}
|
||||
|
||||
// A duplicate VK callback (same VK order id) credits nothing more.
|
||||
out2, err := svc.Fund(ctx, res.OrderID, "vk", "vk-order-777", paid)
|
||||
if err != nil {
|
||||
t.Fatalf("duplicate vk fund: %v", err)
|
||||
}
|
||||
if !out2.AlreadyCredited {
|
||||
t.Error("duplicate VK callback not flagged AlreadyCredited")
|
||||
}
|
||||
if got := readBalance(t, acc, "vk"); got != 200 {
|
||||
t.Errorf("vk balance after duplicate = %d, want 200 (credited once)", got)
|
||||
}
|
||||
}
|
||||
|
||||
// TestPaymentsTelegramStarsRail exercises the Telegram Stars rail over Postgres: an order prices the
|
||||
// pack in whole stars (XTR); a pre_checkout on the pending order is approved; the forwarded payment
|
||||
// credits the telegram segment exactly once (idempotent on the Telegram charge id); and a
|
||||
// pre_checkout after the order is paid is declined (a reusable invoice link paid twice).
|
||||
func TestPaymentsTelegramStarsRail(t *testing.T) {
|
||||
ctx := context.Background()
|
||||
svc := newPaymentsService()
|
||||
acc := uuid.New()
|
||||
prod := seedPackProduct(t, 50, methodPrice{method: "telegram", currency: "XTR", amount: 40}) // 40 stars fund 50 chips
|
||||
|
||||
res, err := svc.CreateOrder(ctx, acc, payments.NewContext("telegram", "android"), []payments.Source{payments.SourceTelegram}, prod, "telegram")
|
||||
if err != nil {
|
||||
t.Fatalf("create telegram order: %v", err)
|
||||
}
|
||||
if res.Amount.Currency() != payments.CurrencyStar || res.Amount.Minor() != 40 {
|
||||
t.Fatalf("telegram order amount = %s, want 40 XTR", res.Amount)
|
||||
}
|
||||
|
||||
// pre_checkout on the pending order: approved, and it reports the account for reason localisation.
|
||||
starAmt, _ := payments.MoneyFromMinor(40, payments.CurrencyStar)
|
||||
pc, err := svc.ValidatePreCheckout(ctx, res.OrderID, starAmt)
|
||||
if err != nil {
|
||||
t.Fatalf("pre_checkout: %v", err)
|
||||
}
|
||||
if !pc.OK || pc.AccountID != acc {
|
||||
t.Fatalf("pre_checkout = %+v, want approved for account %s", pc, acc)
|
||||
}
|
||||
|
||||
// The forwarded successful_payment credits once, idempotent on the Telegram charge id.
|
||||
out, err := svc.Fund(ctx, res.OrderID, "telegram", "tg-charge-1", starAmt)
|
||||
if err != nil {
|
||||
t.Fatalf("telegram fund: %v", err)
|
||||
}
|
||||
if out.AlreadyCredited || out.Chips != 50 || out.Source != payments.SourceTelegram {
|
||||
t.Fatalf("telegram fund outcome = %+v, want 50 chips credited to telegram", out)
|
||||
}
|
||||
if got := readBalance(t, acc, "telegram"); got != 50 {
|
||||
t.Errorf("telegram balance after fund = %d, want 50", got)
|
||||
}
|
||||
|
||||
// A retried forward (same charge id, e.g. a lost ack) credits nothing more.
|
||||
out2, err := svc.Fund(ctx, res.OrderID, "telegram", "tg-charge-1", starAmt)
|
||||
if err != nil {
|
||||
t.Fatalf("duplicate telegram fund: %v", err)
|
||||
}
|
||||
if !out2.AlreadyCredited {
|
||||
t.Error("retried forward not flagged AlreadyCredited")
|
||||
}
|
||||
if got := readBalance(t, acc, "telegram"); got != 50 {
|
||||
t.Errorf("telegram balance after retry = %d, want 50 (credited once)", got)
|
||||
}
|
||||
|
||||
// pre_checkout after the order is paid: declined (the reusable-link double-pay guard).
|
||||
pc2, err := svc.ValidatePreCheckout(ctx, res.OrderID, starAmt)
|
||||
if err != nil {
|
||||
t.Fatalf("pre_checkout after paid: %v", err)
|
||||
}
|
||||
if pc2.OK || pc2.Reason != payments.PreCheckoutAlreadyPaid {
|
||||
t.Errorf("pre_checkout after paid = %+v, want a decline with already_paid", pc2)
|
||||
}
|
||||
}
|
||||
|
||||
// TestPaymentsTelegramPreCheckoutDeclines covers the pre_checkout decline reasons: an unknown order
|
||||
// and an amount that no longer matches.
|
||||
func TestPaymentsTelegramPreCheckoutDeclines(t *testing.T) {
|
||||
ctx := context.Background()
|
||||
svc := newPaymentsService()
|
||||
starAmt, _ := payments.MoneyFromMinor(40, payments.CurrencyStar)
|
||||
|
||||
// An unknown order id declines as gone, with no account to localise against.
|
||||
pc, err := svc.ValidatePreCheckout(ctx, uuid.New(), starAmt)
|
||||
if err != nil {
|
||||
t.Fatalf("pre_checkout unknown: %v", err)
|
||||
}
|
||||
if pc.OK || pc.Reason != payments.PreCheckoutGone || pc.AccountID != (uuid.UUID{}) {
|
||||
t.Errorf("pre_checkout unknown = %+v, want a gone decline with no account", pc)
|
||||
}
|
||||
|
||||
// A pending order validated at the wrong amount declines as price-changed.
|
||||
acc := uuid.New()
|
||||
prod := seedPackProduct(t, 100, methodPrice{method: "telegram", currency: "XTR", amount: 80})
|
||||
res, err := svc.CreateOrder(ctx, acc, payments.NewContext("telegram", "android"), []payments.Source{payments.SourceTelegram}, prod, "telegram")
|
||||
if err != nil {
|
||||
t.Fatalf("create telegram order: %v", err)
|
||||
}
|
||||
wrong, _ := payments.MoneyFromMinor(40, payments.CurrencyStar)
|
||||
pc2, err := svc.ValidatePreCheckout(ctx, res.OrderID, wrong)
|
||||
if err != nil {
|
||||
t.Fatalf("pre_checkout mismatch: %v", err)
|
||||
}
|
||||
if pc2.OK || pc2.Reason != payments.PreCheckoutPriceChanged {
|
||||
t.Errorf("pre_checkout mismatch = %+v, want a price_changed decline", pc2)
|
||||
}
|
||||
}
|
||||
|
||||
// setRewardConfig sets the rewarded payout and caps on the shared config row, restoring the defaults
|
||||
// after the test (the row is a singleton shared across the sequential suite).
|
||||
func setRewardConfig(t *testing.T, payout, dailyCap, hourlyCap int) {
|
||||
t.Helper()
|
||||
if _, err := testDB.ExecContext(context.Background(),
|
||||
`UPDATE payments.config SET rewarded_payout_chips=$1, reward_daily_cap=$2, reward_hourly_cap=$3`,
|
||||
payout, dailyCap, hourlyCap); err != nil {
|
||||
t.Fatalf("set reward config: %v", err)
|
||||
}
|
||||
t.Cleanup(func() {
|
||||
_, _ = testDB.ExecContext(context.Background(),
|
||||
`UPDATE payments.config SET rewarded_payout_chips=0, reward_daily_cap=50, reward_hourly_cap=10`)
|
||||
})
|
||||
}
|
||||
|
||||
// TestPaymentsRewardCredit exercises the rewarded-video credit: a watched view credits the VK segment
|
||||
// the configured payout, a retried view (same nonce) credits once, and the hourly cap blocks the
|
||||
// third distinct view.
|
||||
func TestPaymentsRewardCredit(t *testing.T) {
|
||||
ctx := context.Background()
|
||||
svc := newPaymentsService()
|
||||
acc := uuid.New()
|
||||
setRewardConfig(t, 5, 5, 2) // 5 chips/view, daily 5, hourly 2
|
||||
vk := payments.NewContext("vk", "web")
|
||||
present := []payments.Source{payments.SourceVK}
|
||||
|
||||
out, err := svc.CreditReward(ctx, acc, vk, present, "n1")
|
||||
if err != nil {
|
||||
t.Fatalf("credit: %v", err)
|
||||
}
|
||||
if out.Chips != 5 || out.Capped {
|
||||
t.Fatalf("reward outcome = %+v, want 5 chips, not capped", out)
|
||||
}
|
||||
if got := readBalance(t, acc, "vk"); got != 5 {
|
||||
t.Errorf("vk balance = %d, want 5", got)
|
||||
}
|
||||
|
||||
// A retried view (same nonce) credits nothing more.
|
||||
out2, err := svc.CreditReward(ctx, acc, vk, present, "n1")
|
||||
if err != nil {
|
||||
t.Fatalf("retry: %v", err)
|
||||
}
|
||||
if !out2.AlreadyCredited {
|
||||
t.Error("retried view not flagged AlreadyCredited")
|
||||
}
|
||||
if got := readBalance(t, acc, "vk"); got != 5 {
|
||||
t.Errorf("vk balance after retry = %d, want 5 (credited once)", got)
|
||||
}
|
||||
|
||||
// A second distinct view credits again (2 total).
|
||||
if _, err := svc.CreditReward(ctx, acc, vk, present, "n2"); err != nil {
|
||||
t.Fatalf("credit 2: %v", err)
|
||||
}
|
||||
if got := readBalance(t, acc, "vk"); got != 10 {
|
||||
t.Errorf("vk balance = %d, want 10", got)
|
||||
}
|
||||
|
||||
// The third distinct view hits the hourly cap (2) — capped, no credit.
|
||||
out3, err := svc.CreditReward(ctx, acc, vk, present, "n3")
|
||||
if err != nil {
|
||||
t.Fatalf("credit 3: %v", err)
|
||||
}
|
||||
if !out3.Capped {
|
||||
t.Error("third view not capped (hourly cap 2)")
|
||||
}
|
||||
if got := readBalance(t, acc, "vk"); got != 10 {
|
||||
t.Errorf("vk balance after cap = %d, want 10 (capped view credited nothing)", got)
|
||||
}
|
||||
}
|
||||
|
||||
// TestPaymentsRewardDisabledAndContext verifies rewarded is inert when unconfigured (0 payout) and
|
||||
// refused outside a VK context (rewarded is VK-only, D28).
|
||||
func TestPaymentsRewardDisabledAndContext(t *testing.T) {
|
||||
ctx := context.Background()
|
||||
svc := newPaymentsService()
|
||||
acc := uuid.New()
|
||||
setRewardConfig(t, 0, 50, 10) // payout 0 = disabled
|
||||
|
||||
vk := payments.NewContext("vk", "web")
|
||||
out, err := svc.CreditReward(ctx, acc, vk, []payments.Source{payments.SourceVK}, "d1")
|
||||
if err != nil {
|
||||
t.Fatalf("credit (disabled): %v", err)
|
||||
}
|
||||
if out.Chips != 0 || out.Capped || out.AlreadyCredited {
|
||||
t.Fatalf("disabled reward outcome = %+v, want 0 chips, not capped", out)
|
||||
}
|
||||
|
||||
// A direct (non-VK) context is refused — rewarded is VK-only.
|
||||
direct := payments.NewContext("direct", "web")
|
||||
if _, err := svc.CreditReward(ctx, acc, direct, []payments.Source{payments.SourceDirect}, "d2"); !errors.Is(err, payments.ErrUntrusted) {
|
||||
t.Fatalf("direct-context reward = %v, want ErrUntrusted", err)
|
||||
}
|
||||
}
|
||||
|
||||
// TestPaymentsFundAmountMismatch verifies a callback whose paid amount does not match the order is
|
||||
// refused and credits nothing (§9: verify the amount after matching by order id).
|
||||
func TestPaymentsFundAmountMismatch(t *testing.T) {
|
||||
ctx := context.Background()
|
||||
svc := newPaymentsService()
|
||||
acc := uuid.New()
|
||||
prod := seedPackProduct(t, 100, methodPrice{method: "direct", currency: "RUB", amount: 14900})
|
||||
|
||||
res, err := svc.CreateOrder(ctx, acc, payments.NewContext("direct", "web"), []payments.Source{payments.SourceDirect}, prod, "robokassa")
|
||||
if err != nil {
|
||||
t.Fatalf("create order: %v", err)
|
||||
}
|
||||
underpaid, _ := payments.MoneyFromMinor(100, payments.CurrencyRUB)
|
||||
if _, err := svc.Fund(ctx, res.OrderID, "robokassa", res.OrderID.String(), underpaid); !errors.Is(err, payments.ErrAmountMismatch) {
|
||||
t.Fatalf("fund = %v, want ErrAmountMismatch", err)
|
||||
}
|
||||
if got := readBalance(t, acc, "direct"); got != 0 {
|
||||
t.Errorf("balance = %d, want 0 (nothing credited on mismatch)", got)
|
||||
}
|
||||
if ledgerRows(t, acc, "fund") != 0 {
|
||||
t.Error("fund ledger row written on an amount mismatch")
|
||||
}
|
||||
}
|
||||
|
||||
// TestPaymentsEventDispatchDrain verifies the payment_events dispatcher queue: a recorded event is
|
||||
// returned as undispatched until marked, then drops out (so the dispatcher delivers it once).
|
||||
func TestPaymentsEventDispatchDrain(t *testing.T) {
|
||||
ctx := context.Background()
|
||||
svc := newPaymentsService()
|
||||
acc := uuid.New()
|
||||
if err := svc.RecordPaymentEvent(ctx, acc, nil, "succeeded", []byte(`{"chips":10,"source":"direct"}`)); err != nil {
|
||||
t.Fatalf("record event: %v", err)
|
||||
}
|
||||
|
||||
// testDB is shared, so filter the queue to our account.
|
||||
find := func() *payments.PaymentEvent {
|
||||
evs, err := svc.UndispatchedEvents(ctx, 100)
|
||||
if err != nil {
|
||||
t.Fatalf("undispatched: %v", err)
|
||||
}
|
||||
for i := range evs {
|
||||
if evs[i].AccountID == acc {
|
||||
return &evs[i]
|
||||
}
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
mine := find()
|
||||
if mine == nil {
|
||||
t.Fatal("recorded event not in the undispatched queue")
|
||||
}
|
||||
if mine.Type != "succeeded" {
|
||||
t.Errorf("event type = %s, want succeeded", mine.Type)
|
||||
}
|
||||
if err := svc.MarkEventDispatched(ctx, mine.EventID); err != nil {
|
||||
t.Fatalf("mark dispatched: %v", err)
|
||||
}
|
||||
if find() != nil {
|
||||
t.Error("event still undispatched after MarkEventDispatched")
|
||||
}
|
||||
}
|
||||
|
||||
// TestPaymentsExpiredOrderStillCredits verifies an expired pending order is still honoured by a
|
||||
// later valid callback (§9/D23: expiry is cosmetic, the money is real).
|
||||
func TestPaymentsExpiredOrderStillCredits(t *testing.T) {
|
||||
ctx := context.Background()
|
||||
svc := newPaymentsService()
|
||||
acc := uuid.New()
|
||||
prod := seedPackProduct(t, 100, methodPrice{method: "direct", currency: "RUB", amount: 14900})
|
||||
|
||||
res, err := svc.CreateOrder(ctx, acc, payments.NewContext("direct", "web"), []payments.Source{payments.SourceDirect}, prod, "robokassa")
|
||||
if err != nil {
|
||||
t.Fatalf("create order: %v", err)
|
||||
}
|
||||
// Age the order well past the configured TTL, then sweep it to expired.
|
||||
if _, err := testDB.ExecContext(ctx,
|
||||
`UPDATE payments.orders SET created_at = now() - interval '1 day' WHERE order_id=$1`, res.OrderID); err != nil {
|
||||
t.Fatalf("age order: %v", err)
|
||||
}
|
||||
if n, err := svc.ExpireOrders(ctx); err != nil || n < 1 {
|
||||
t.Fatalf("expire orders = %d (err %v), want at least 1", n, err)
|
||||
}
|
||||
if orderStatus(t, res.OrderID) != "expired" {
|
||||
t.Fatalf("order status = %s, want expired before the late callback", orderStatus(t, res.OrderID))
|
||||
}
|
||||
|
||||
paid, _ := payments.MoneyFromMinor(14900, payments.CurrencyRUB)
|
||||
out, err := svc.Fund(ctx, res.OrderID, "robokassa", res.OrderID.String(), paid)
|
||||
if err != nil {
|
||||
t.Fatalf("fund after expiry: %v", err)
|
||||
}
|
||||
if out.AlreadyCredited || out.Chips != 100 {
|
||||
t.Fatalf("fund outcome = %+v, want a fresh 100-chip credit", out)
|
||||
}
|
||||
if got := readBalance(t, acc, "direct"); got != 100 {
|
||||
t.Errorf("balance = %d, want 100 (expired order honoured)", got)
|
||||
}
|
||||
if orderStatus(t, res.OrderID) != "paid" {
|
||||
t.Errorf("order status = %s, want paid after the honoured callback", orderStatus(t, res.OrderID))
|
||||
}
|
||||
}
|
||||
|
||||
// fundedOrder creates and funds an order for chips in the direct rail, returning its id and account.
|
||||
func fundedOrder(t *testing.T, svc *payments.Service, chips int, priceMinor int64) (uuid.UUID, uuid.UUID) {
|
||||
t.Helper()
|
||||
ctx := context.Background()
|
||||
acc := uuid.New()
|
||||
prod := seedPackProduct(t, chips, methodPrice{method: "direct", currency: "RUB", amount: priceMinor})
|
||||
res, err := svc.CreateOrder(ctx, acc, payments.NewContext("direct", "web"), []payments.Source{payments.SourceDirect}, prod, "robokassa")
|
||||
if err != nil {
|
||||
t.Fatalf("create order: %v", err)
|
||||
}
|
||||
paid, _ := payments.MoneyFromMinor(priceMinor, payments.CurrencyRUB)
|
||||
if _, err := svc.Fund(ctx, res.OrderID, "robokassa", res.OrderID.String(), paid); err != nil {
|
||||
t.Fatalf("fund: %v", err)
|
||||
}
|
||||
return res.OrderID, acc
|
||||
}
|
||||
|
||||
// TestPaymentsRefundFull reverses a fully-unspent order: all chips are clawed back, no loss/abuse.
|
||||
func TestPaymentsRefundFull(t *testing.T) {
|
||||
ctx := context.Background()
|
||||
svc := newPaymentsService()
|
||||
orderID, acc := fundedOrder(t, svc, 100, 14900)
|
||||
|
||||
refunded, _ := payments.MoneyFromMinor(14900, payments.CurrencyRUB)
|
||||
out, err := svc.Refund(ctx, orderID, "robokassa", "rk-refund-1", refunded)
|
||||
if err != nil {
|
||||
t.Fatalf("refund: %v", err)
|
||||
}
|
||||
if out.AlreadyRefunded || out.Revoked != 100 || out.Loss != 0 || out.Source != payments.SourceDirect {
|
||||
t.Fatalf("refund outcome = %+v, want 100 revoked, 0 loss", out)
|
||||
}
|
||||
if got := readBalance(t, acc, "direct"); got != 0 {
|
||||
t.Errorf("balance after refund = %d, want 0", got)
|
||||
}
|
||||
if ledgerRows(t, acc, "refund") != 1 {
|
||||
t.Errorf("refund ledger rows = %d, want 1", ledgerRows(t, acc, "refund"))
|
||||
}
|
||||
if abuse, loss := readRisk(t, acc); abuse || loss != 0 {
|
||||
t.Errorf("risk = (%v, %d), want (false, 0) — nothing was spent", abuse, loss)
|
||||
}
|
||||
// The order stays 'paid' — the refund lives in the ledger, not in the order status.
|
||||
if orderStatus(t, orderID) != "paid" {
|
||||
t.Errorf("order status = %s, want paid (refund is ledger-only)", orderStatus(t, orderID))
|
||||
}
|
||||
}
|
||||
|
||||
// TestPaymentsRefundAfterSpend reverses an order whose chips were partly spent: the reversal floors
|
||||
// at 0 (never negative), and the unrecoverable remainder is recorded as a loss + abuse flag.
|
||||
func TestPaymentsRefundAfterSpend(t *testing.T) {
|
||||
ctx := context.Background()
|
||||
svc := newPaymentsService()
|
||||
orderID, acc := fundedOrder(t, svc, 100, 14900)
|
||||
|
||||
// Simulate 70 chips already spent, leaving 30 in the funded segment.
|
||||
if _, err := testDB.ExecContext(ctx,
|
||||
`UPDATE payments.balances SET chips = 30 WHERE account_id = $1 AND source = 'direct'`, acc); err != nil {
|
||||
t.Fatalf("simulate spend: %v", err)
|
||||
}
|
||||
|
||||
refunded, _ := payments.MoneyFromMinor(14900, payments.CurrencyRUB)
|
||||
out, err := svc.Refund(ctx, orderID, "robokassa", "rk-refund-2", refunded)
|
||||
if err != nil {
|
||||
t.Fatalf("refund: %v", err)
|
||||
}
|
||||
if out.Revoked != 30 || out.Loss != 70 {
|
||||
t.Fatalf("refund outcome = %+v, want 30 revoked / 70 loss", out)
|
||||
}
|
||||
if got := readBalance(t, acc, "direct"); got != 0 {
|
||||
t.Errorf("balance after refund = %d, want 0 (floored, never negative)", got)
|
||||
}
|
||||
if abuse, loss := readRisk(t, acc); !abuse || loss != 70 {
|
||||
t.Errorf("risk = (%v, %d), want (true, 70)", abuse, loss)
|
||||
}
|
||||
}
|
||||
|
||||
// TestPaymentsRefundIdempotent verifies a replayed refund (same provider refund id) reverses nothing
|
||||
// more — the ledger idempotency index holds.
|
||||
func TestPaymentsRefundIdempotent(t *testing.T) {
|
||||
ctx := context.Background()
|
||||
svc := newPaymentsService()
|
||||
orderID, acc := fundedOrder(t, svc, 100, 14900)
|
||||
refunded, _ := payments.MoneyFromMinor(14900, payments.CurrencyRUB)
|
||||
|
||||
if _, err := svc.Refund(ctx, orderID, "robokassa", "rk-refund-dup", refunded); err != nil {
|
||||
t.Fatalf("first refund: %v", err)
|
||||
}
|
||||
// Re-credit the segment to prove the duplicate does not revoke again.
|
||||
if _, err := testDB.ExecContext(ctx,
|
||||
`UPDATE payments.balances SET chips = 100 WHERE account_id = $1 AND source = 'direct'`, acc); err != nil {
|
||||
t.Fatalf("re-credit: %v", err)
|
||||
}
|
||||
out2, err := svc.Refund(ctx, orderID, "robokassa", "rk-refund-dup", refunded)
|
||||
if err != nil {
|
||||
t.Fatalf("duplicate refund: %v", err)
|
||||
}
|
||||
if !out2.AlreadyRefunded {
|
||||
t.Error("duplicate refund not flagged AlreadyRefunded")
|
||||
}
|
||||
if got := readBalance(t, acc, "direct"); got != 100 {
|
||||
t.Errorf("balance after duplicate refund = %d, want 100 (not revoked twice)", got)
|
||||
}
|
||||
if ledgerRows(t, acc, "refund") != 1 {
|
||||
t.Errorf("refund ledger rows = %d, want 1 (duplicate wrote none)", ledgerRows(t, acc, "refund"))
|
||||
}
|
||||
}
|
||||
|
||||
// TestPaymentsRefundUnpaidOrder refuses to refund an order that was never funded.
|
||||
func TestPaymentsRefundUnpaidOrder(t *testing.T) {
|
||||
ctx := context.Background()
|
||||
svc := newPaymentsService()
|
||||
acc := uuid.New()
|
||||
prod := seedPackProduct(t, 100, methodPrice{method: "direct", currency: "RUB", amount: 14900})
|
||||
res, err := svc.CreateOrder(ctx, acc, payments.NewContext("direct", "web"), []payments.Source{payments.SourceDirect}, prod, "robokassa")
|
||||
if err != nil {
|
||||
t.Fatalf("create order: %v", err)
|
||||
}
|
||||
refunded, _ := payments.MoneyFromMinor(14900, payments.CurrencyRUB)
|
||||
if _, err := svc.Refund(ctx, res.OrderID, "robokassa", "rk-refund-x", refunded); !errors.Is(err, payments.ErrOrderNotPaid) {
|
||||
t.Fatalf("refund of a pending order = %v, want ErrOrderNotPaid", err)
|
||||
}
|
||||
if abuse, loss := readRisk(t, acc); abuse || loss != 0 {
|
||||
t.Errorf("risk = (%v, %d), want (false, 0) — no reversal on an unpaid order", abuse, loss)
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,101 @@
|
||||
//go:build integration
|
||||
|
||||
package inttest
|
||||
|
||||
import (
|
||||
"context"
|
||||
"net/http"
|
||||
"strings"
|
||||
"testing"
|
||||
|
||||
"github.com/google/uuid"
|
||||
|
||||
"scrabble/backend/internal/payments"
|
||||
)
|
||||
|
||||
// TestAccountStatement checks the admin financial statement: a funded pack shows as a chip segment
|
||||
// + a fund ledger row, an admin grant as a benefit + an admin_grant row, the ledger is newest-first,
|
||||
// and a clean account carries no refund risk.
|
||||
func TestAccountStatement(t *testing.T) {
|
||||
ctx := context.Background()
|
||||
svc := newPaymentsService()
|
||||
acc := uuid.New()
|
||||
|
||||
// A funded pack: 100 chips to the direct segment + a fund ledger row.
|
||||
prod := seedPackProduct(t, 100, methodPrice{method: "direct", currency: "RUB", amount: 14900})
|
||||
res, err := svc.CreateOrder(ctx, acc, payments.NewContext("direct", "web"), []payments.Source{payments.SourceDirect}, prod, "robokassa")
|
||||
if err != nil {
|
||||
t.Fatalf("create order: %v", err)
|
||||
}
|
||||
paid, _ := payments.MoneyFromMinor(14900, payments.CurrencyRUB)
|
||||
if _, err := svc.Fund(ctx, res.OrderID, "robokassa", res.OrderID.String(), paid); err != nil {
|
||||
t.Fatalf("fund: %v", err)
|
||||
}
|
||||
// An admin grant: 5 hints to the direct origin + an admin_grant ledger row.
|
||||
if err := svc.Grant(ctx, acc, payments.SourceDirect, 5, 0, false); err != nil {
|
||||
t.Fatalf("grant: %v", err)
|
||||
}
|
||||
|
||||
stmt, err := svc.AccountStatement(ctx, acc)
|
||||
if err != nil {
|
||||
t.Fatalf("statement: %v", err)
|
||||
}
|
||||
|
||||
if len(stmt.Segments) != 1 || stmt.Segments[0].Source != payments.SourceDirect || stmt.Segments[0].Chips != 100 {
|
||||
t.Fatalf("segments = %+v, want 100 chips on direct", stmt.Segments)
|
||||
}
|
||||
if len(stmt.Benefits) != 1 || stmt.Benefits[0].Origin != payments.SourceDirect || stmt.Benefits[0].Hints != 5 {
|
||||
t.Fatalf("benefits = %+v, want 5 hints on direct", stmt.Benefits)
|
||||
}
|
||||
if len(stmt.Ledger) != 2 {
|
||||
t.Fatalf("ledger rows = %d, want 2 (fund + admin_grant)", len(stmt.Ledger))
|
||||
}
|
||||
// Newest-first ordering (the grant is the later write).
|
||||
if stmt.Ledger[0].CreatedAt.Before(stmt.Ledger[1].CreatedAt) {
|
||||
t.Error("ledger is not newest-first")
|
||||
}
|
||||
kinds := map[string]int{}
|
||||
for _, e := range stmt.Ledger {
|
||||
kinds[e.Kind]++
|
||||
if e.Kind == "fund" && e.ChipsDelta != 100 {
|
||||
t.Errorf("fund chips delta = %d, want 100", e.ChipsDelta)
|
||||
}
|
||||
}
|
||||
if kinds["fund"] != 1 || kinds["admin_grant"] != 1 {
|
||||
t.Fatalf("ledger kinds = %v, want one fund + one admin_grant", kinds)
|
||||
}
|
||||
if stmt.Risk.Present {
|
||||
t.Errorf("risk = %+v, want none for a clean account", stmt.Risk)
|
||||
}
|
||||
}
|
||||
|
||||
// TestConsoleFinancePanel checks the user card renders the finance panel: a funded pack + an admin
|
||||
// grant surface as the segment balance, the benefit and the ledger rows.
|
||||
func TestConsoleFinancePanel(t *testing.T) {
|
||||
ctx := context.Background()
|
||||
srv, _, pay := bannerServer(t)
|
||||
id := provisionAccount(t)
|
||||
|
||||
prod := seedPackProduct(t, 100, methodPrice{method: "direct", currency: "RUB", amount: 14900})
|
||||
res, err := pay.CreateOrder(ctx, id, payments.NewContext("direct", "web"), []payments.Source{payments.SourceDirect}, prod, "robokassa")
|
||||
if err != nil {
|
||||
t.Fatalf("create order: %v", err)
|
||||
}
|
||||
paid, _ := payments.MoneyFromMinor(14900, payments.CurrencyRUB)
|
||||
if _, err := pay.Fund(ctx, res.OrderID, "robokassa", res.OrderID.String(), paid); err != nil {
|
||||
t.Fatalf("fund: %v", err)
|
||||
}
|
||||
if err := pay.Grant(ctx, id, payments.SourceDirect, 5, 0, false); err != nil {
|
||||
t.Fatalf("grant: %v", err)
|
||||
}
|
||||
|
||||
code, body := consoleDo(srv.Handler(), http.MethodGet, "http://admin.test/_gm/users/"+id.String(), "", "")
|
||||
if code != http.StatusOK {
|
||||
t.Fatalf("user card = %d, want 200", code)
|
||||
}
|
||||
for _, want := range []string{"Finance", "Chips (direct)", "Benefits (direct)", "5 hints", "admin_grant", "fund"} {
|
||||
if !strings.Contains(body, want) {
|
||||
t.Errorf("finance panel missing %q", want)
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
@@ -26,7 +26,8 @@ func TestSessionLifecycle(t *testing.T) {
|
||||
store := session.NewStore(testDB)
|
||||
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 {
|
||||
t.Fatalf("create session: %v", err)
|
||||
}
|
||||
@@ -36,6 +37,9 @@ func TestSessionLifecycle(t *testing.T) {
|
||||
if token == sess.TokenHash {
|
||||
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.
|
||||
got, err := svc.Resolve(ctx, token)
|
||||
@@ -63,8 +67,8 @@ func TestSessionLifecycle(t *testing.T) {
|
||||
if _, ok := cold.Get(session.HashToken(token)); !ok {
|
||||
t.Error("Warm must load the active session into the cache")
|
||||
}
|
||||
if got2, err := svc2.Resolve(ctx, token); err != nil || got2.ID != sess.ID {
|
||||
t.Errorf("resolve after warm = (%s, %v), want %s", got2.ID, err, 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, %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.
|
||||
|
||||
@@ -200,7 +200,11 @@ func (s *Service) merge(ctx context.Context, callerID, otherID uuid.UUID) (Merge
|
||||
}
|
||||
res := MergeResult{PrimaryID: primary}
|
||||
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 {
|
||||
return MergeResult{}, err
|
||||
}
|
||||
|
||||
@@ -15,6 +15,7 @@ import (
|
||||
"scrabble/backend/internal/account"
|
||||
"scrabble/backend/internal/engine"
|
||||
"scrabble/backend/internal/game"
|
||||
"scrabble/backend/internal/gamelimits"
|
||||
"scrabble/backend/internal/notify"
|
||||
"scrabble/backend/internal/postgres/jet/backend/model"
|
||||
"scrabble/backend/internal/postgres/jet/backend/table"
|
||||
@@ -218,6 +219,20 @@ func (svc *InvitationService) CreateInvitation(ctx context.Context, inviterID uu
|
||||
if !slices.Contains(game.AllowedTurnTimeouts, settings.TurnTimeout) {
|
||||
return Invitation{}, fmt.Errorf("%w: turn timeout %s not allowed", ErrInvalidInvitation, settings.TurnTimeout)
|
||||
}
|
||||
// A guest cannot invite: friend games are a durable-account feature. The UI hides the flow;
|
||||
// this is the server source of truth.
|
||||
if acc, err := svc.accounts.GetByID(ctx, inviterID); err != nil {
|
||||
return Invitation{}, err
|
||||
} else if acc.IsGuest {
|
||||
return Invitation{}, ErrGuestForbidden
|
||||
}
|
||||
// A durable inviter is still capped: refuse a new friend game once the per-tier friends limit is
|
||||
// reached. The guest branch above short-circuits before here, so this is the durable cap.
|
||||
if atLimit, err := svc.games.AtGameLimit(ctx, inviterID, gamelimits.KindFriends); err != nil {
|
||||
return Invitation{}, err
|
||||
} else if atLimit {
|
||||
return Invitation{}, game.ErrGameLimitReached
|
||||
}
|
||||
seen := map[uuid.UUID]bool{inviterID: true}
|
||||
// suppressed collects invitees who have blocked the inviter: the invitation is still
|
||||
// created and persisted for them, but they are never notified and never see it (their
|
||||
@@ -336,6 +351,7 @@ func (svc *InvitationService) startGame(ctx context.Context, invitationID uuid.U
|
||||
HintsPerPlayer: inv.Settings.HintsPerPlayer,
|
||||
DropoutTiles: inv.Settings.DropoutTiles,
|
||||
MultipleWordsPerTurn: inv.Settings.MultipleWordsPerTurn,
|
||||
Kind: gamelimits.KindFriends,
|
||||
})
|
||||
if err != nil {
|
||||
return err
|
||||
|
||||
@@ -15,17 +15,22 @@ import (
|
||||
|
||||
"scrabble/backend/internal/engine"
|
||||
"scrabble/backend/internal/game"
|
||||
"scrabble/backend/internal/gamelimits"
|
||||
"scrabble/backend/internal/notify"
|
||||
)
|
||||
|
||||
// GameCreator is the slice of the game domain the lobby needs: starting a seated
|
||||
// game and reading a player's initial view of it. game.Service satisfies it.
|
||||
// game, reading a player's initial view of it, and testing a caller's active-game
|
||||
// cap. game.Service satisfies it.
|
||||
type GameCreator interface {
|
||||
Create(ctx context.Context, params game.CreateParams) (game.Game, error)
|
||||
// InitialState returns a seated player's full initial view of a started game, used
|
||||
// to enrich the game_started event so the client renders the new game without a
|
||||
// follow-up fetch.
|
||||
InitialState(ctx context.Context, gameID, accountID uuid.UUID) (notify.PlayerState, error)
|
||||
// AtGameLimit reports whether accountID has reached its tier's active-game cap for kind. The
|
||||
// invitation path uses it to enforce the friends limit before opening one.
|
||||
AtGameLimit(ctx context.Context, accountID uuid.UUID, kind gamelimits.Kind) (bool, error)
|
||||
}
|
||||
|
||||
// RobotProvider supplies a robot account to substitute for a missing human in
|
||||
@@ -62,6 +67,9 @@ var (
|
||||
// ErrInvalidInvitation is returned for a malformed invitation (bad player
|
||||
// count, duplicate or self invitee, or unacceptable settings).
|
||||
ErrInvalidInvitation = errors.New("lobby: invalid invitation")
|
||||
// ErrGuestForbidden is returned when a guest attempts a durable-only action (invite a
|
||||
// friend); the friend flow is gated to durable accounts.
|
||||
ErrGuestForbidden = errors.New("lobby: guests cannot invite")
|
||||
// ErrInvitationBlocked is returned when a block stands between the inviter and
|
||||
// an invitee.
|
||||
ErrInvitationBlocked = errors.New("lobby: invitation blocked between accounts")
|
||||
|
||||
@@ -10,6 +10,7 @@ import (
|
||||
|
||||
"scrabble/backend/internal/engine"
|
||||
"scrabble/backend/internal/game"
|
||||
"scrabble/backend/internal/gamelimits"
|
||||
"scrabble/backend/internal/notify"
|
||||
)
|
||||
|
||||
@@ -141,6 +142,7 @@ func (m *Matchmaker) StartVsAI(ctx context.Context, accountID uuid.UUID, variant
|
||||
if rand.IntN(2) == 1 {
|
||||
params.Seats = []uuid.UUID{robotID, accountID}
|
||||
}
|
||||
params.Kind = gamelimits.KindVsAI
|
||||
g, err := m.games.Create(ctx, params)
|
||||
if err != nil {
|
||||
return EnqueueResult{}, err
|
||||
|
||||
@@ -37,6 +37,7 @@ func toWireGame(g GameSummary) wire.GameView {
|
||||
TurnTimeoutSecs: g.TurnTimeoutSecs,
|
||||
MultipleWordsPerTurn: g.MultipleWordsPerTurn,
|
||||
VsAI: g.VsAI,
|
||||
Kind: g.Kind,
|
||||
MoveCount: g.MoveCount,
|
||||
EndReason: g.EndReason,
|
||||
Seats: seats,
|
||||
@@ -83,7 +84,6 @@ func buildStateView(b *flatbuffers.Builder, s PlayerState) flatbuffers.UOffsetT
|
||||
Rack: s.Rack,
|
||||
BagLen: s.BagLen,
|
||||
HintsRemaining: s.HintsRemaining,
|
||||
WalletBalance: s.WalletBalance,
|
||||
Alphabet: alphabet,
|
||||
})
|
||||
}
|
||||
|
||||
@@ -73,6 +73,10 @@ const (
|
||||
// (e.g. an email was confirmed via the one-tap deeplink opened in another browser),
|
||||
// so it re-fetches profile.get. It carries no payload. In-app only.
|
||||
NotifyProfile = "profile"
|
||||
// NotifyPayment tells the client that the viewer's wallet changed from a payment-intake event
|
||||
// (a credit or a refund), so it re-fetches the wallet in place. It carries no payload. In-app
|
||||
// only; the payment_events dispatcher emits it after the crediting transaction commits.
|
||||
NotifyPayment = "payment"
|
||||
// NotifyUserBlocked confirms to the blocker that a per-user block took effect,
|
||||
// carrying the blocked account, so every one of the blocker's sessions updates the
|
||||
// in-game block/add-friend controls and the struck name in place. It is delivered
|
||||
|
||||
@@ -115,7 +115,7 @@ func TestGameOverPayloadRoundTrips(t *testing.T) {
|
||||
func TestOpponentMovedPayloadRoundTrips(t *testing.T) {
|
||||
uid, gid := uuid.New(), uuid.New()
|
||||
move := engine.MoveRecord{Player: 1, Action: engine.ActionPlay, Words: []string{"STOOL"}, Score: 24, Total: 130}
|
||||
summary := notify.GameSummary{ID: gid.String(), MoveCount: 9, ToMove: 0, Seats: []notify.SeatStanding{{Seat: 1, Score: 130}}}
|
||||
summary := notify.GameSummary{ID: gid.String(), MoveCount: 9, ToMove: 0, Kind: 2, Seats: []notify.SeatStanding{{Seat: 1, Score: 130}}}
|
||||
in := notify.OpponentMoved(uid, gid, move, summary, 42)
|
||||
if in.Kind != notify.KindOpponentMoved {
|
||||
t.Fatalf("kind = %q", in.Kind)
|
||||
@@ -132,7 +132,7 @@ func TestOpponentMovedPayloadRoundTrips(t *testing.T) {
|
||||
if m == nil || m.Player() != 1 || string(m.Action()) != "play" || m.Total() != 130 {
|
||||
t.Fatalf("move wrong: %+v", m)
|
||||
}
|
||||
if g := ev.Game(nil); g == nil || g.MoveCount() != 9 || g.ToMove() != 0 {
|
||||
if g := ev.Game(nil); g == nil || g.MoveCount() != 9 || g.ToMove() != 0 || g.Kind() != 2 {
|
||||
t.Fatalf("game summary wrong: %+v", ev.Game(nil))
|
||||
}
|
||||
}
|
||||
|
||||
@@ -35,6 +35,9 @@ type GameSummary struct {
|
||||
EndReason string
|
||||
Seats []SeatStanding
|
||||
LastActivityUnix int64
|
||||
// Kind is the game's origin for the active-game limits (0 unknown, 1 vs_ai, 2 random, 3 friends);
|
||||
// it rides live events so a lobby patch keeps the per-kind count correct.
|
||||
Kind int
|
||||
}
|
||||
|
||||
// AlphabetLetter is one variant alphabet entry (a display-only row) embedded in an
|
||||
@@ -56,7 +59,6 @@ type PlayerState struct {
|
||||
Rack []int
|
||||
BagLen int
|
||||
HintsRemaining int
|
||||
WalletBalance int
|
||||
Alphabet []AlphabetLetter
|
||||
}
|
||||
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
@@ -0,0 +1,87 @@
|
||||
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 {
|
||||
// Present products in the canonical listing order shared with the offer and the admin console
|
||||
// (packs first by rouble price, then values grouped hints → no-ads → combo and by chip price); the
|
||||
// client renders them as received. Ordered in place — the caller passes a fresh loadCatalog result
|
||||
// it does not reuse.
|
||||
sortCatalogEntries(entries)
|
||||
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
|
||||
}
|
||||
@@ -0,0 +1,259 @@
|
||||
package payments
|
||||
|
||||
import (
|
||||
"context"
|
||||
"errors"
|
||||
"fmt"
|
||||
"math"
|
||||
"slices"
|
||||
"strings"
|
||||
|
||||
"github.com/google/uuid"
|
||||
)
|
||||
|
||||
// AdminProduct is one catalog product for the admin editor: its full composition, every price, the
|
||||
// archived flag (Active), and whether it has ever been transacted — which forbids a hard delete
|
||||
// (orders / ledger rows reference it, and the ledger is append-only).
|
||||
type AdminProduct struct {
|
||||
ID uuid.UUID
|
||||
Title string
|
||||
Active bool
|
||||
Atoms []AtomLine
|
||||
Prices []PriceLine
|
||||
Transacted bool
|
||||
}
|
||||
|
||||
// AtomLine is one atom of a product: the atom type and its positive quantity.
|
||||
type AtomLine struct {
|
||||
Atom string
|
||||
Quantity int
|
||||
}
|
||||
|
||||
// PriceLine is one price of a product: the payment method ("" for a value's CHIP price, which is
|
||||
// stored with a NULL method), the currency, and the amount in that currency's minor units.
|
||||
type PriceLine struct {
|
||||
Method string
|
||||
Currency Currency
|
||||
Amount int64
|
||||
}
|
||||
|
||||
// ProductInput is the editable content of a product: its title, atom composition and prices.
|
||||
type ProductInput struct {
|
||||
Title string
|
||||
Atoms []AtomLine
|
||||
Prices []PriceLine
|
||||
}
|
||||
|
||||
// knownAtoms is the fixed atom set, mirroring the catalog_atom seed / CHECK.
|
||||
var knownAtoms = map[string]bool{atomChips: true, "hints": true, "noads_days": true, "tournament": true}
|
||||
|
||||
// validCurrency reports whether c is one of the four catalog currencies.
|
||||
func validCurrency(c Currency) bool {
|
||||
switch c {
|
||||
case CurrencyRUB, CurrencyVote, CurrencyStar, CurrencyChip:
|
||||
return true
|
||||
}
|
||||
return false
|
||||
}
|
||||
|
||||
// validateProduct checks a product's composition and prices. When sellable is true (the product is
|
||||
// or is becoming active) it also enforces the storefront shape: a pack (the chips atom ⇒ a money
|
||||
// price per method and no CHIP price, chips-only) or a value (no chips ⇒ a single CHIP price and no
|
||||
// money price). The tournament atom is never sellable (its economy is the tournament stage), so an
|
||||
// active product may not carry it; an archived draft may (a template for later) and skips the shape
|
||||
// check.
|
||||
func validateProduct(in ProductInput, sellable bool) error {
|
||||
if strings.TrimSpace(in.Title) == "" {
|
||||
return errors.New("product title is required")
|
||||
}
|
||||
if len(in.Atoms) == 0 {
|
||||
return errors.New("product needs at least one atom")
|
||||
}
|
||||
seen := map[string]bool{}
|
||||
hasChips, hasTournament, hasBenefit := false, false, false
|
||||
for _, a := range in.Atoms {
|
||||
if !knownAtoms[a.Atom] {
|
||||
return fmt.Errorf("unknown atom %q", a.Atom)
|
||||
}
|
||||
if seen[a.Atom] {
|
||||
return fmt.Errorf("duplicate atom %q", a.Atom)
|
||||
}
|
||||
seen[a.Atom] = true
|
||||
if a.Quantity <= 0 {
|
||||
return fmt.Errorf("atom %q quantity must be positive", a.Atom)
|
||||
}
|
||||
switch a.Atom {
|
||||
case atomChips:
|
||||
hasChips = true
|
||||
case "tournament":
|
||||
hasTournament = true
|
||||
default:
|
||||
hasBenefit = true
|
||||
}
|
||||
}
|
||||
|
||||
priceSeen := map[string]bool{}
|
||||
hasChipPrice, hasMoneyPrice := false, false
|
||||
for _, p := range in.Prices {
|
||||
if p.Method != "" && p.Method != string(SourceVK) && p.Method != string(SourceTelegram) && p.Method != string(SourceDirect) {
|
||||
return fmt.Errorf("invalid price method %q", p.Method)
|
||||
}
|
||||
if !validCurrency(p.Currency) {
|
||||
return fmt.Errorf("invalid currency %q", p.Currency)
|
||||
}
|
||||
if p.Amount < 0 {
|
||||
return errors.New("price amount must be non-negative")
|
||||
}
|
||||
if p.Currency == CurrencyChip && p.Method != "" {
|
||||
return errors.New("a CHIP price must have no method")
|
||||
}
|
||||
if p.Currency != CurrencyChip && p.Method == "" {
|
||||
return fmt.Errorf("a %s price needs a payment method", p.Currency)
|
||||
}
|
||||
key := p.Method + "|" + string(p.Currency)
|
||||
if priceSeen[key] {
|
||||
return fmt.Errorf("duplicate price (%s, %s)", p.Method, p.Currency)
|
||||
}
|
||||
priceSeen[key] = true
|
||||
if p.Currency == CurrencyChip {
|
||||
hasChipPrice = true
|
||||
} else {
|
||||
hasMoneyPrice = true
|
||||
}
|
||||
}
|
||||
|
||||
if !sellable {
|
||||
return nil // an archived draft may be incomplete
|
||||
}
|
||||
if hasTournament {
|
||||
return errors.New("a product carrying the tournament atom cannot be sold yet")
|
||||
}
|
||||
if hasChips {
|
||||
if hasBenefit {
|
||||
return errors.New("a chip pack must contain only the chips atom")
|
||||
}
|
||||
if !hasMoneyPrice || hasChipPrice {
|
||||
return errors.New("a chip pack needs a money price per method and no CHIP price")
|
||||
}
|
||||
} else {
|
||||
if !hasChipPrice || hasMoneyPrice {
|
||||
return errors.New("a value needs a single CHIP price and no money price")
|
||||
}
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
// AdminCatalog lists products with their composition, prices and whether each has been transacted,
|
||||
// for the admin editor. includeInactive adds the archived products to the active ones (the console's
|
||||
// active/all toggle); with it false only active products are returned. Read uncached, straight from
|
||||
// the catalog tables.
|
||||
func (s *Service) AdminCatalog(ctx context.Context, includeInactive bool) ([]AdminProduct, error) {
|
||||
return s.store.adminCatalog(ctx, includeInactive)
|
||||
}
|
||||
|
||||
// SortAdminCatalog orders an admin catalog list in place the same way the public offer lists products
|
||||
// ([projectOfferPricing]): chip packs first, ascending by rouble price; then chip-priced values,
|
||||
// grouped (hints only, no-ads only, no-ads + hints, tournament) and ascending by chip price within a
|
||||
// group. It is stable, so equal-keyed products keep their incoming order, and it keys archived
|
||||
// products the same as active ones (the admin list shows both).
|
||||
func SortAdminCatalog(products []AdminProduct) {
|
||||
slices.SortStableFunc(products, func(a, b AdminProduct) int {
|
||||
return compareCatalogRank(adminRank(a), adminRank(b))
|
||||
})
|
||||
}
|
||||
|
||||
// adminIsPack reports whether the product is a chip pack (it carries the chips atom).
|
||||
func adminIsPack(p AdminProduct) bool {
|
||||
for _, a := range p.Atoms {
|
||||
if a.Atom == atomChips {
|
||||
return true
|
||||
}
|
||||
}
|
||||
return false
|
||||
}
|
||||
|
||||
// adminValueGroup ranks a chip-priced value into the shared listing groups (see [valueGroup]).
|
||||
func adminValueGroup(p AdminProduct) int {
|
||||
hasHints, hasNoAds, hasTournament := false, false, false
|
||||
for _, a := range p.Atoms {
|
||||
switch a.Atom {
|
||||
case "hints":
|
||||
hasHints = true
|
||||
case "noads_days":
|
||||
hasNoAds = true
|
||||
case "tournament":
|
||||
hasTournament = true
|
||||
}
|
||||
}
|
||||
return valueGroup(hasHints, hasNoAds, hasTournament)
|
||||
}
|
||||
|
||||
// adminPriceAmount returns the minor-unit amount of the product's price for the method and currency,
|
||||
// or math.MaxInt64 when it carries no such price (so a misconfigured product sorts last, not first).
|
||||
func adminPriceAmount(p AdminProduct, method string, cur Currency) int64 {
|
||||
for _, pr := range p.Prices {
|
||||
if pr.Method == method && pr.Currency == cur {
|
||||
return pr.Amount
|
||||
}
|
||||
}
|
||||
return math.MaxInt64
|
||||
}
|
||||
|
||||
// CreateProduct validates and inserts a new product with its atoms and prices, returning its id. A
|
||||
// product created active must satisfy the sellable shape.
|
||||
func (s *Service) CreateProduct(ctx context.Context, in ProductInput, active bool) (uuid.UUID, error) {
|
||||
if err := validateProduct(in, active); err != nil {
|
||||
return uuid.Nil, err
|
||||
}
|
||||
id, err := s.store.createProduct(ctx, in, active, s.clock())
|
||||
if err == nil {
|
||||
s.markOfferStale()
|
||||
}
|
||||
return id, err
|
||||
}
|
||||
|
||||
// UpdateProduct validates and replaces a product's title, atoms and prices. An active product must
|
||||
// satisfy the sellable shape.
|
||||
func (s *Service) UpdateProduct(ctx context.Context, id uuid.UUID, in ProductInput) (err error) {
|
||||
active, err := s.store.productActive(ctx, id)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
if err := validateProduct(in, active); err != nil {
|
||||
return err
|
||||
}
|
||||
if err := s.store.updateProduct(ctx, id, in, s.clock()); err != nil {
|
||||
return err
|
||||
}
|
||||
s.markOfferStale()
|
||||
return nil
|
||||
}
|
||||
|
||||
// SetProductActive archives (active=false) or unarchives a product. Unarchiving revalidates the
|
||||
// sellable shape, so a draft or a tournament product cannot be put on sale.
|
||||
func (s *Service) SetProductActive(ctx context.Context, id uuid.UUID, active bool) error {
|
||||
if active {
|
||||
in, err := s.store.productInput(ctx, id)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
if err := validateProduct(in, true); err != nil {
|
||||
return err
|
||||
}
|
||||
}
|
||||
if err := s.store.setProductActive(ctx, id, active, s.clock()); err != nil {
|
||||
return err
|
||||
}
|
||||
s.markOfferStale()
|
||||
return nil
|
||||
}
|
||||
|
||||
// DeleteProduct hard-deletes a product only when it has never been transacted (no order or ledger
|
||||
// row references it); otherwise it returns ErrProductTransacted and the caller archives instead.
|
||||
func (s *Service) DeleteProduct(ctx context.Context, id uuid.UUID) error {
|
||||
if err := s.store.deleteProduct(ctx, id); err != nil {
|
||||
return err
|
||||
}
|
||||
s.markOfferStale()
|
||||
return nil
|
||||
}
|
||||
@@ -0,0 +1,47 @@
|
||||
package payments
|
||||
|
||||
import "testing"
|
||||
|
||||
func TestValidateProduct(t *testing.T) {
|
||||
pack := ProductInput{
|
||||
Title: "100 chips",
|
||||
Atoms: []AtomLine{{Atom: "chips", Quantity: 100}},
|
||||
Prices: []PriceLine{{Method: "direct", Currency: CurrencyRUB, Amount: 14900}},
|
||||
}
|
||||
value := ProductInput{
|
||||
Title: "5 hints",
|
||||
Atoms: []AtomLine{{Atom: "hints", Quantity: 5}},
|
||||
Prices: []PriceLine{{Method: "", Currency: CurrencyChip, Amount: 50}},
|
||||
}
|
||||
|
||||
tests := []struct {
|
||||
name string
|
||||
in ProductInput
|
||||
sellable bool
|
||||
wantErr bool
|
||||
}{
|
||||
{"valid pack", pack, true, false},
|
||||
{"valid value", value, true, false},
|
||||
{"pack with a benefit atom", ProductInput{Title: "x", Atoms: []AtomLine{{"chips", 100}, {"hints", 5}}, Prices: pack.Prices}, true, true},
|
||||
{"pack without money price", ProductInput{Title: "x", Atoms: pack.Atoms, Prices: value.Prices}, true, true},
|
||||
{"value without chip price", ProductInput{Title: "x", Atoms: value.Atoms, Prices: pack.Prices}, true, true},
|
||||
{"tournament sellable is refused", ProductInput{Title: "cup", Atoms: []AtomLine{{"tournament", 1}}, Prices: value.Prices}, true, true},
|
||||
{"tournament draft is allowed", ProductInput{Title: "cup", Atoms: []AtomLine{{"tournament", 1}}}, false, false},
|
||||
{"unknown atom", ProductInput{Title: "x", Atoms: []AtomLine{{"gold", 1}}}, false, true},
|
||||
{"duplicate atom", ProductInput{Title: "x", Atoms: []AtomLine{{"hints", 1}, {"hints", 2}}}, false, true},
|
||||
{"non-positive quantity", ProductInput{Title: "x", Atoms: []AtomLine{{"hints", 0}}}, false, true},
|
||||
{"chip price with a method", ProductInput{Title: "x", Atoms: value.Atoms, Prices: []PriceLine{{Method: "direct", Currency: CurrencyChip, Amount: 50}}}, true, true},
|
||||
{"money price without a method", ProductInput{Title: "x", Atoms: pack.Atoms, Prices: []PriceLine{{Method: "", Currency: CurrencyRUB, Amount: 149}}}, true, true},
|
||||
{"duplicate price", ProductInput{Title: "x", Atoms: pack.Atoms, Prices: []PriceLine{{Method: "direct", Currency: CurrencyRUB, Amount: 1}, {Method: "direct", Currency: CurrencyRUB, Amount: 2}}}, true, true},
|
||||
{"empty title", ProductInput{Title: " ", Atoms: value.Atoms, Prices: value.Prices}, true, true},
|
||||
{"no atoms", ProductInput{Title: "x", Prices: value.Prices}, true, true},
|
||||
}
|
||||
for _, tt := range tests {
|
||||
t.Run(tt.name, func(t *testing.T) {
|
||||
err := validateProduct(tt.in, tt.sellable)
|
||||
if (err != nil) != tt.wantErr {
|
||||
t.Fatalf("validateProduct = %v, wantErr %v", err, tt.wantErr)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,61 @@
|
||||
package payments
|
||||
|
||||
import (
|
||||
"cmp"
|
||||
"slices"
|
||||
)
|
||||
|
||||
// catalogRank is the canonical listing key for a catalog product: whether it is a chip pack, its
|
||||
// value subgroup (see [valueGroup]; unused for a pack) and the minor-unit amount its section sorts by
|
||||
// (a pack's direct rouble price, a value's chip price; math.MaxInt64 when absent, so a misconfigured
|
||||
// row sorts last rather than leading).
|
||||
type catalogRank struct {
|
||||
pack bool
|
||||
group int
|
||||
amount int64
|
||||
}
|
||||
|
||||
// compareCatalogRank is the single canonical product order, shared by the storefront ([projectCatalog]),
|
||||
// the public offer ([projectOfferPricing]) and the admin catalog ([SortAdminCatalog]): chip packs
|
||||
// first, ascending by rouble price; then chip-priced values grouped hints → no-ads → combo →
|
||||
// tournament and, within a group, ascending by chip price. A pack's group is unused (all packs share
|
||||
// one section). Callers use it through [entryRank] / [adminRank], which build the key from each
|
||||
// product shape, so the subgroup and ordering rules live in exactly one place.
|
||||
func compareCatalogRank(a, b catalogRank) int {
|
||||
if a.pack != b.pack {
|
||||
if a.pack {
|
||||
return -1 // packs (sales) before values (chip exchange)
|
||||
}
|
||||
return 1
|
||||
}
|
||||
if !a.pack {
|
||||
if d := cmp.Compare(a.group, b.group); d != 0 {
|
||||
return d
|
||||
}
|
||||
}
|
||||
return cmp.Compare(a.amount, b.amount)
|
||||
}
|
||||
|
||||
// entryRank builds the canonical rank of a storefront / offer catalog entry.
|
||||
func entryRank(e catalogEntry) catalogRank {
|
||||
if isPackEntry(e) {
|
||||
return catalogRank{pack: true, amount: offerSortAmount(e, string(SourceDirect), CurrencyRUB)}
|
||||
}
|
||||
return catalogRank{group: offerValueGroup(e), amount: offerSortAmount(e, "", CurrencyChip)}
|
||||
}
|
||||
|
||||
// adminRank builds the canonical rank of an admin catalog product.
|
||||
func adminRank(p AdminProduct) catalogRank {
|
||||
if adminIsPack(p) {
|
||||
return catalogRank{pack: true, amount: adminPriceAmount(p, string(SourceDirect), CurrencyRUB)}
|
||||
}
|
||||
return catalogRank{group: adminValueGroup(p), amount: adminPriceAmount(p, "", CurrencyChip)}
|
||||
}
|
||||
|
||||
// sortCatalogEntries orders storefront / offer entries in place into the canonical listing order
|
||||
// ([compareCatalogRank]). Stable, so equal-keyed products keep their incoming (creation) order.
|
||||
func sortCatalogEntries(entries []catalogEntry) {
|
||||
slices.SortStableFunc(entries, func(a, b catalogEntry) int {
|
||||
return compareCatalogRank(entryRank(a), entryRank(b))
|
||||
})
|
||||
}
|
||||
@@ -0,0 +1,230 @@
|
||||
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))
|
||||
}
|
||||
}
|
||||
|
||||
// TestProjectCatalog_CanonicalOrder checks the storefront lists products in the shared canonical order
|
||||
// (compareCatalogRank): chip packs first ascending by rouble price, then chip-priced values grouped
|
||||
// hints → no-ads → combo and ascending by chip price — the same order as the offer and the admin
|
||||
// console, regardless of catalog creation order.
|
||||
func TestProjectCatalog_CanonicalOrder(t *testing.T) {
|
||||
pack := func(title string, rub int64) catalogEntry {
|
||||
return catalogEntry{
|
||||
id: uuid.New(),
|
||||
title: title,
|
||||
atoms: []atomQty{{atomType: atomChips, quantity: 1}},
|
||||
prices: []priceRow{{method: "direct", currency: CurrencyRUB, amount: rub}},
|
||||
}
|
||||
}
|
||||
value := func(title string, chips int64, atoms ...string) catalogEntry {
|
||||
e := catalogEntry{id: uuid.New(), title: title, prices: []priceRow{{method: "", currency: CurrencyChip, amount: chips}}}
|
||||
for _, a := range atoms {
|
||||
e.atoms = append(e.atoms, atomQty{atomType: a, quantity: 1})
|
||||
}
|
||||
return e
|
||||
}
|
||||
// Deliberately shuffled on input; the projection must impose the canonical order.
|
||||
entries := []catalogEntry{
|
||||
pack("packDear", 30000),
|
||||
value("bundle", 500, "hints", "noads_days"),
|
||||
pack("packCheap", 10000),
|
||||
value("adsOnly", 150, "noads_days"),
|
||||
value("hintsBig", 200, "hints"),
|
||||
value("hintsSmall", 50, "hints"),
|
||||
}
|
||||
got := projectCatalog(entries, Context{Kind: SourceDirect})
|
||||
want := []string{"packCheap", "packDear", "hintsSmall", "hintsBig", "adsOnly", "bundle"}
|
||||
if len(got.Products) != len(want) {
|
||||
t.Fatalf("projected %d products, want %d", len(got.Products), len(want))
|
||||
}
|
||||
for i, p := range got.Products {
|
||||
if p.Title != want[i] {
|
||||
t.Fatalf("order[%d] = %q, want %q (full: %v)", i, p.Title, want[i], productTitles(got.Products))
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// productTitles extracts the projected product titles for a failure message.
|
||||
func productTitles(products []CatalogProduct) []string {
|
||||
out := make([]string, len(products))
|
||||
for i, p := range products {
|
||||
out[i] = p.Title
|
||||
}
|
||||
return out
|
||||
}
|
||||
|
||||
// TestSortAdminCatalog checks the admin list is ordered like the public offer: chip packs first,
|
||||
// ascending by rouble price; then values, grouped (hints only -> no-ads only -> no-ads + hints) and
|
||||
// ascending by chip price within a group. Stable and applied to active + archived alike.
|
||||
func TestSortAdminCatalog(t *testing.T) {
|
||||
pack := func(title string, rub int64) AdminProduct {
|
||||
return AdminProduct{
|
||||
Title: title,
|
||||
Atoms: []AtomLine{{Atom: atomChips, Quantity: 1}},
|
||||
Prices: []PriceLine{{Method: string(SourceDirect), Currency: CurrencyRUB, Amount: rub}},
|
||||
}
|
||||
}
|
||||
value := func(title string, chips int64, atoms ...string) AdminProduct {
|
||||
p := AdminProduct{Title: title, Prices: []PriceLine{{Currency: CurrencyChip, Amount: chips}}}
|
||||
for _, a := range atoms {
|
||||
p.Atoms = append(p.Atoms, AtomLine{Atom: a, Quantity: 1})
|
||||
}
|
||||
return p
|
||||
}
|
||||
products := []AdminProduct{
|
||||
pack("packDear", 30000),
|
||||
value("bundle", 500, "hints", "noads_days"),
|
||||
pack("packCheap", 10000),
|
||||
value("adsOnly", 150, "noads_days"),
|
||||
value("hintsBig", 200, "hints"),
|
||||
value("hintsSmall", 50, "hints"),
|
||||
}
|
||||
SortAdminCatalog(products)
|
||||
want := []string{"packCheap", "packDear", "hintsSmall", "hintsBig", "adsOnly", "bundle"}
|
||||
for i, p := range products {
|
||||
if p.Title != want[i] {
|
||||
t.Fatalf("order[%d] = %q, want %q (full: %v)", i, p.Title, want[i], titles(products))
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// titles extracts the product titles for a failure message.
|
||||
func titles(products []AdminProduct) []string {
|
||||
out := make([]string, len(products))
|
||||
for i, p := range products {
|
||||
out[i] = p.Title
|
||||
}
|
||||
return out
|
||||
}
|
||||
@@ -0,0 +1,205 @@
|
||||
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 purchase freeze: VK context on the trusted iOS
|
||||
// subtype. Apple's ToS forbids only BUYING in-app values there, so a purchase (money -> chips) is
|
||||
// refused; earning chips (rewarded ads) and SPENDING chips already in the VK wallet — earned or
|
||||
// bought on the same account elsewhere (e.g. VK Android) — are legal and stay allowed.
|
||||
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 only when the
|
||||
// platform is untrusted (fail-closed); VK-iOS is NOT excluded — the freeze is purchase-only, so
|
||||
// spending VK-wallet chips there is allowed. 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() {
|
||||
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 mirrors spendableSources (both gate only on a trusted
|
||||
// platform now that the VK-iOS freeze is purchase-only). 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 }
|
||||