Compare commits

..

17 Commits

Author SHA1 Message Date
developer 45957bdcd6 Merge pull request 'Release: promote development → master (old Android WebView support + unsupported-engine telemetry)' (#178) from development into master 2026-07-04 21:23:29 +00:00
developer 829e29a726 Merge pull request 'Release v1.8.0: prod build fix (re-promote)' (#175) from development into master 2026-07-03 21:48:13 +00:00
developer 399508f2f0 Merge pull request 'Release v1.8.0: promote development → master' (#173) from development into master 2026-07-03 21:31:25 +00:00
developer 3a18e683ca Merge pull request 'release: VK Mini App + landscape UI + dict v1.3.1 seed (development→master)' (#144) from development into master 2026-06-30 05:37:43 +00:00
developer 93d086a8a3 Merge pull request 'release: v1.7.0 — Telegram Mini App embedding enhancements' (#138) from development into master 2026-06-24 11:49:44 +00:00
developer 8fe1bdba6b Merge pull request 'release: v1.6.0 — promo deep-link seeds EN variant (+ UI nits)' (#135) from development into master 2026-06-23 21:02:19 +00:00
developer 7923b3cc09 Merge pull request 'release v1.5.1: support-relay card + topic-reopen fixes' (#133) from development into master 2026-06-23 16:54:01 +00:00
developer 4891216749 Merge pull request 'release v1.5.0: Telegram bot support relay' (#131) from development into master 2026-06-23 16:16:04 +00:00
developer f1b8769c89 Merge pull request 'release: v1.4.1 — Telegram nav (windowed, own back button, debug panel)' (#129) from development into master 2026-06-23 13:27:31 +00:00
developer b6f28a2423 Merge pull request 'release: v1.4.0 — Telegram launch diagnostic + dynamic SDK load' (#127) from development into master 2026-06-23 08:40:09 +00:00
developer e32ee9ce68 Merge pull request 'Release: development → master' (#125) from development into master 2026-06-22 22:36:42 +00:00
developer dc946a1faf Merge pull request 'release v1.2.2: edge HTTP/3 stall fix + db-size dashboard threshold' (#121) from development into master 2026-06-22 19:50:58 +00:00
developer 384bd143d0 Merge pull request 'Promote development → master: banner tip set + banner/push language fix' (#114) from development into master 2026-06-22 18:28:00 +00:00
developer c5d22fceca Merge pull request 'Promote development → master: Erudit blank star + dictionary v1.3.0 pin' (#111) from development into master 2026-06-22 13:12:01 +00:00
developer deaa7a29c5 Merge pull request 'Promote development → master (docs finalize + UI tweaks + Telegram name fallback)' (#108) from development into master 2026-06-22 07:27:40 +00:00
developer 24017bcb7f Merge pull request 'Promote development → master (deploy v2: versioning + visible jobs + rollback)' (#106) from development into master 2026-06-22 06:01:03 +00:00
developer 2c4f4b10dc Merge pull request 'Promote development → master (initial production release: pre-release line + Stage 18)' (#104) from development into master 2026-06-22 05:05:48 +00:00
658 changed files with 2446 additions and 62717 deletions
-242
View File
@@ -1,242 +0,0 @@
# 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:0007: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 (E0E9). 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).
-172
View File
@@ -1,172 +0,0 @@
# 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_RUSTORE_* 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_RUSTORE_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_RUSTORE_KEYSTORE_BASE64 secret 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_RUSTORE_KEYSTORE_PASSWORD }}
ANDROID_KEY_ALIAS: ${{ secrets.ANDROID_RUSTORE_KEY_ALIAS }}
ANDROID_KEY_PASSWORD: ${{ secrets.ANDROID_RUSTORE_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
+3 -113
View File
@@ -214,21 +214,9 @@ jobs:
run: pnpm exec playwright install chromium webkit run: pnpm exec playwright install chromium webkit
timeout-minutes: 5 timeout-minutes: 5
# The offline e2e plays a real local vs_ai game, so it needs the per-variant dawgs; fetch the
# release the same way the Go jobs do and point the mock preview's copy step (scripts/
# e2e-dict.mjs, via E2E_DICT_DIR below) at it.
- name: Fetch dictionary DAWGs
run: |
mkdir -p "${GITHUB_WORKSPACE}/dawg"
curl -fsSL -o /tmp/dawg.tar.gz "https://gitea.iliadenisov.ru/developer/scrabble-dictionary/releases/download/${DICT_VERSION}/scrabble-dawg-${DICT_VERSION}.tar.gz"
tar xzf /tmp/dawg.tar.gz -C "${GITHUB_WORKSPACE}/dawg"
ls -la "${GITHUB_WORKSPACE}/dawg"
- name: E2E smoke (mock) - name: E2E smoke (mock)
run: pnpm run test:e2e run: pnpm run test:e2e
timeout-minutes: 5 timeout-minutes: 5
env:
E2E_DICT_DIR: ${{ github.workspace }}/dawg
# conformance proves the client's local move preview (the ported dawg reader + # conformance proves the client's local move preview (the ported dawg reader +
# validator, ui/src/lib/dict) byte-for-byte against the authoritative Go engine: # validator, ui/src/lib/dict) byte-for-byte against the authoritative Go engine:
@@ -264,7 +252,6 @@ jobs:
run: | run: |
go run ./backend/cmd/dictgen -dawg-dir "${GITHUB_WORKSPACE}/dawg" -out /tmp/dictgold go run ./backend/cmd/dictgen -dawg-dir "${GITHUB_WORKSPACE}/dawg" -out /tmp/dictgold
go run ./backend/cmd/validategen -dawg-dir "${GITHUB_WORKSPACE}/dawg" -out /tmp/validgold go run ./backend/cmd/validategen -dawg-dir "${GITHUB_WORKSPACE}/dawg" -out /tmp/validgold
go run ./backend/cmd/movegen -dawg-dir "${GITHUB_WORKSPACE}/dawg" -out "${GITHUB_WORKSPACE}/movegold"
- name: Set up Node - name: Set up Node
uses: actions/setup-node@v4 uses: actions/setup-node@v4
@@ -284,7 +271,6 @@ jobs:
DICT_DAWG_DIR: ${{ github.workspace }}/dawg DICT_DAWG_DIR: ${{ github.workspace }}/dawg
DICT_GOLD_DIR: /tmp/dictgold DICT_GOLD_DIR: /tmp/dictgold
DICT_VALID_DIR: /tmp/validgold DICT_VALID_DIR: /tmp/validgold
DICT_MOVEGEN_DIR: ${{ github.workspace }}/movegold
run: pnpm exec vitest run src/lib/dict/ run: pnpm exec vitest run src/lib/dict/
# gate is the single branch-protection required check. It always runs and passes # gate is the single branch-protection required check. It always runs and passes
@@ -316,13 +302,9 @@ jobs:
# Auto test-deploy on a PR into development and on the push that merges it. # Auto test-deploy on a PR into development and on the push that merges it.
# A PR into master is test-only (this job is skipped); prod deploy is manual. # A PR into master is test-only (this job is skipped); prod deploy is manual.
# Gates on `gate` (so a real test failure blocks the deploy) but runs even when # Gates on `gate` (so a real test failure blocks the deploy) but runs even when
# some test jobs were path-skipped. Skipped entirely when neither the Go nor the # some test jobs were path-skipped.
# UI side changed (e.g. a docs-only change): the contour image is unchanged, so needs: [gate]
# there is nothing to redeploy. `changes` still defaults both to true when the if: ${{ (github.event_name == 'push' && github.ref == 'refs/heads/development') || (github.event_name == 'pull_request' && github.base_ref == 'development') }}
# diff is uncomputable, and a workflow/deploy edit forces both true, so an
# ambiguous or infra change still deploys as a safety net.
needs: [changes, gate]
if: ${{ (needs.changes.outputs.go == 'true' || needs.changes.outputs.ui == 'true') && ((github.event_name == 'push' && github.ref == 'refs/heads/development') || (github.event_name == 'pull_request' && github.base_ref == 'development')) }}
runs-on: ubuntu-latest runs-on: ubuntu-latest
defaults: defaults:
run: run:
@@ -353,11 +335,6 @@ jobs:
# for the server-side confidential code exchange — a SEPARATE VK app from the Mini # 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. # App above. One VK ID "Web" app serves every contour -> unprefixed secret.
GATEWAY_VK_ID_CLIENT_SECRET: ${{ secrets.GATEWAY_VK_ID_CLIENT_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 # 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. # 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 }} GATEWAY_HONEYTOKEN: ${{ secrets.TEST_GATEWAY_HONEYTOKEN }}
@@ -371,13 +348,6 @@ jobs:
SMTP_RELAY_HOST: ${{ vars.SMTP_RELAY_HOST }} SMTP_RELAY_HOST: ${{ vars.SMTP_RELAY_HOST }}
SMTP_RELAY_PORT: ${{ vars.SMTP_RELAY_PORT }} SMTP_RELAY_PORT: ${{ vars.SMTP_RELAY_PORT }}
SMTP_RELAY_TLS: ${{ vars.SMTP_RELAY_TLS }} 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 }} SMTP_RELAY_FROM: ${{ vars.TEST_SMTP_RELAY_FROM }}
# Operator alerts: backend admin emails (new feedback / complaints) + Grafana # Operator alerts: backend admin emails (new feedback / complaints) + Grafana
# infra alerts. Distinct senders + recipients; Grafana uses the relay's STARTTLS # infra alerts. Distinct senders + recipients; Grafana uses the relay's STARTTLS
@@ -412,9 +382,6 @@ jobs:
# the VK ID redirect URL is derived from PUBLIC_BASE_URL in the run step below. # 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_LINK: ${{ vars.VITE_VK_APP_LINK }}
VITE_VK_APP_ID: ${{ vars.VITE_VK_APP_ID }} 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 # 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. # compose ":-" empty default. Other unset vars likewise fall to their defaults.
POSTGRES_DB: ${{ vars.TEST_POSTGRES_DB }} POSTGRES_DB: ${{ vars.TEST_POSTGRES_DB }}
@@ -512,83 +479,6 @@ jobs:
docker logs --tail 50 scrabble-backend || true docker logs --tail 50 scrabble-backend || true
exit 1 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 - name: Probe the /dict edge route reaches the gateway
run: | run: |
set -u set -u
+1 -32
View File
@@ -77,7 +77,7 @@ jobs:
# The main-stack images via compose (reuses the build args, incl. VERSION); # The main-stack images via compose (reuses the build args, incl. VERSION);
# the bot separately, since it is profiled out of the prod compose. # the bot separately, since it is profiled out of the prod compose.
docker compose -f docker-compose.yml -f docker-compose.prod.yml build docker compose -f docker-compose.yml -f docker-compose.prod.yml build
docker compose -f docker-compose.yml -f docker-compose.prod.yml push postgres backend gateway landing validator renderer docker compose -f docker-compose.yml -f docker-compose.prod.yml push backend gateway landing validator renderer
docker build -f ../platform/telegram/Dockerfile --target bot --build-arg VERSION="$TAG" -t "$REGISTRY/scrabble-telegram-bot:$TAG" .. docker build -f ../platform/telegram/Dockerfile --target bot --build-arg VERSION="$TAG" -t "$REGISTRY/scrabble-telegram-bot:$TAG" ..
docker push "$REGISTRY/scrabble-telegram-bot:$TAG" docker push "$REGISTRY/scrabble-telegram-bot:$TAG"
@@ -99,33 +99,14 @@ jobs:
GRAFANA_ADMIN_PASSWORD: ${{ secrets.PROD_GRAFANA_ADMIN_PASSWORD }} GRAFANA_ADMIN_PASSWORD: ${{ secrets.PROD_GRAFANA_ADMIN_PASSWORD }}
TELEGRAM_BOT_TOKEN: ${{ secrets.PROD_TELEGRAM_BOT_TOKEN }} TELEGRAM_BOT_TOKEN: ${{ secrets.PROD_TELEGRAM_BOT_TOKEN }}
GATEWAY_VK_APP_SECRET: ${{ secrets.GATEWAY_VK_APP_SECRET }} GATEWAY_VK_APP_SECRET: ${{ secrets.GATEWAY_VK_APP_SECRET }}
# Robokassa direct-rail (backend BACKEND_ROBOKASSA_*): the prod shop login + the pass phrases
# that sign the launch request / verify the Result callback, and the test-mode flag — a var so
# go-live is a flag flip, not a secret redeploy ("1" runs test payments against the test
# passwords; empty/"0" is live). An empty login leaves the direct rail disabled.
ROBOKASSA_MERCHANT_LOGIN: ${{ secrets.PROD_BACKEND_ROBOKASSA_MERCHANT_LOGIN }}
ROBOKASSA_PASSWORD1: ${{ secrets.PROD_BACKEND_ROBOKASSA_PASSWORD1 }}
ROBOKASSA_PASSWORD2: ${{ secrets.PROD_BACKEND_ROBOKASSA_PASSWORD2 }}
ROBOKASSA_TEST: ${{ vars.PROD_BACKEND_ROBOKASSA_TEST }}
# VK ID web login: the "Web" app id (the gateway reuses it as GATEWAY_VK_ID_APP_ID at # VK ID web login: the "Web" app id (the gateway reuses it as GATEWAY_VK_ID_APP_ID at
# runtime) + the app's protected key. Both shared across contours. The redirect URL is # runtime) + the app's protected key. Both shared across contours. The redirect URL is
# derived from PUBLIC_BASE_URL in deploy/write-prod-env.sh. # derived from PUBLIC_BASE_URL in deploy/write-prod-env.sh.
VITE_VK_APP_ID: ${{ vars.VITE_VK_APP_ID }} VITE_VK_APP_ID: ${{ vars.VITE_VK_APP_ID }}
GATEWAY_VK_ID_CLIENT_SECRET: ${{ secrets.GATEWAY_VK_ID_CLIENT_SECRET }} GATEWAY_VK_ID_CLIENT_SECRET: ${{ secrets.GATEWAY_VK_ID_CLIENT_SECRET }}
# 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. # Planted honeytoken bearer: presenting it earns a 24h IP ban + a high-severity alarm.
# Per-contour secret; empty = trap off. Rendered by deploy/write-prod-env.sh. # Per-contour secret; empty = trap off. Rendered by deploy/write-prod-env.sh.
GATEWAY_HONEYTOKEN: ${{ secrets.PROD_GATEWAY_HONEYTOKEN }} GATEWAY_HONEYTOKEN: ${{ secrets.PROD_GATEWAY_HONEYTOKEN }}
# Community IP blocklist (Spamhaus DROP): opt-in. Set _ENABLED=true + _URL (the feed) once
# verified; _ALLOW is a comma-separated never-block set (own infra). Empty ⇒ off.
GATEWAY_BLOCKLIST_ENABLED: ${{ vars.PROD_GATEWAY_BLOCKLIST_ENABLED }}
GATEWAY_BLOCKLIST_URL: ${{ vars.PROD_GATEWAY_BLOCKLIST_URL }}
GATEWAY_BLOCKLIST_ALLOW: ${{ vars.PROD_GATEWAY_BLOCKLIST_ALLOW }}
# Signs the finished-game export download URLs (backend BACKEND_EXPORT_SIGN_KEY). # Signs the finished-game export download URLs (backend BACKEND_EXPORT_SIGN_KEY).
EXPORT_SIGN_KEY: ${{ secrets.PROD_EXPORT_SIGN_KEY }} EXPORT_SIGN_KEY: ${{ secrets.PROD_EXPORT_SIGN_KEY }}
# Transactional email via the shared Selectel relay (confirm-codes): one account for # Transactional email via the shared Selectel relay (confirm-codes): one account for
@@ -154,18 +135,6 @@ jobs:
DICT_VERSION: ${{ vars.DICT_VERSION }} DICT_VERSION: ${{ vars.DICT_VERSION }}
POSTGRES_DB: ${{ vars.PROD_POSTGRES_DB }} POSTGRES_DB: ${{ vars.PROD_POSTGRES_DB }}
POSTGRES_USER: ${{ vars.PROD_POSTGRES_USER }} POSTGRES_USER: ${{ vars.PROD_POSTGRES_USER }}
# Point-in-time recovery (pgBackRest -> S3), prod main host only. Endpoint/bucket/
# region + the archive-mode switch are variables; the S3 keys + the repository cipher
# passphrase are secrets. All empty/off until the operator arms archiving; flipping
# PROD_PGBACKREST_ARCHIVE_MODE=on activates it (deploy/README.md, arming).
PGBACKREST_ARCHIVE_MODE: ${{ vars.PROD_PGBACKREST_ARCHIVE_MODE }}
PGBACKREST_S3_ENDPOINT: ${{ vars.PROD_PGBACKREST_S3_ENDPOINT }}
PGBACKREST_S3_PORT: ${{ vars.PROD_PGBACKREST_S3_PORT }}
PGBACKREST_S3_BUCKET: ${{ vars.PROD_PGBACKREST_S3_BUCKET }}
PGBACKREST_S3_REGION: ${{ vars.PROD_PGBACKREST_S3_REGION }}
PGBACKREST_S3_KEY: ${{ secrets.PROD_PGBACKREST_S3_KEY }}
PGBACKREST_S3_KEY_SECRET: ${{ secrets.PROD_PGBACKREST_S3_KEY_SECRET }}
PGBACKREST_CIPHER_PASS: ${{ secrets.PROD_PGBACKREST_CIPHER_PASS }}
# TELEGRAM_MINIAPP_URL and GRAFANA_ROOT_URL are derived from PUBLIC_BASE_URL in # TELEGRAM_MINIAPP_URL and GRAFANA_ROOT_URL are derived from PUBLIC_BASE_URL in
# deploy/write-prod-env.sh, not stored variables. # deploy/write-prod-env.sh, not stored variables.
steps: steps:
-20
View File
@@ -61,19 +61,9 @@ jobs:
# the SAME env.sh (email / VK login / Grafana alerts survive a rollback). TELEGRAM_MINIAPP_URL # the SAME env.sh (email / VK login / Grafana alerts survive a rollback). TELEGRAM_MINIAPP_URL
# and GRAFANA_ROOT_URL are derived from PUBLIC_BASE_URL in deploy/write-prod-env.sh. # and GRAFANA_ROOT_URL are derived from PUBLIC_BASE_URL in deploy/write-prod-env.sh.
GATEWAY_VK_APP_SECRET: ${{ secrets.GATEWAY_VK_APP_SECRET }} GATEWAY_VK_APP_SECRET: ${{ secrets.GATEWAY_VK_APP_SECRET }}
# Robokassa direct-rail: the rollback re-renders the same runtime env (write-prod-env.sh), so
# it must carry the same credentials or the direct rail goes dark after a rollback.
ROBOKASSA_MERCHANT_LOGIN: ${{ secrets.PROD_BACKEND_ROBOKASSA_MERCHANT_LOGIN }}
ROBOKASSA_PASSWORD1: ${{ secrets.PROD_BACKEND_ROBOKASSA_PASSWORD1 }}
ROBOKASSA_PASSWORD2: ${{ secrets.PROD_BACKEND_ROBOKASSA_PASSWORD2 }}
ROBOKASSA_TEST: ${{ vars.PROD_BACKEND_ROBOKASSA_TEST }}
VITE_VK_APP_ID: ${{ vars.VITE_VK_APP_ID }} VITE_VK_APP_ID: ${{ vars.VITE_VK_APP_ID }}
GATEWAY_VK_ID_CLIENT_SECRET: ${{ secrets.GATEWAY_VK_ID_CLIENT_SECRET }} GATEWAY_VK_ID_CLIENT_SECRET: ${{ secrets.GATEWAY_VK_ID_CLIENT_SECRET }}
GATEWAY_HONEYTOKEN: ${{ secrets.PROD_GATEWAY_HONEYTOKEN }} GATEWAY_HONEYTOKEN: ${{ secrets.PROD_GATEWAY_HONEYTOKEN }}
# Community IP blocklist — rendered on rollback too so a rollback keeps the same edge policy.
GATEWAY_BLOCKLIST_ENABLED: ${{ vars.PROD_GATEWAY_BLOCKLIST_ENABLED }}
GATEWAY_BLOCKLIST_URL: ${{ vars.PROD_GATEWAY_BLOCKLIST_URL }}
GATEWAY_BLOCKLIST_ALLOW: ${{ vars.PROD_GATEWAY_BLOCKLIST_ALLOW }}
EXPORT_SIGN_KEY: ${{ secrets.PROD_EXPORT_SIGN_KEY }} EXPORT_SIGN_KEY: ${{ secrets.PROD_EXPORT_SIGN_KEY }}
SMTP_RELAY_USER: ${{ secrets.SMTP_RELAY_USER }} SMTP_RELAY_USER: ${{ secrets.SMTP_RELAY_USER }}
SMTP_RELAY_PASS: ${{ secrets.SMTP_RELAY_PASS }} SMTP_RELAY_PASS: ${{ secrets.SMTP_RELAY_PASS }}
@@ -87,16 +77,6 @@ jobs:
SERVICE_EMAIL: ${{ vars.PROD_SERVICE_EMAIL }} SERVICE_EMAIL: ${{ vars.PROD_SERVICE_EMAIL }}
GRAFANA_SMTP_PORT: ${{ vars.GRAFANA_SMTP_PORT }} GRAFANA_SMTP_PORT: ${{ vars.GRAFANA_SMTP_PORT }}
GF_SMTP_ENABLED: ${{ vars.PROD_GF_SMTP_ENABLED }} GF_SMTP_ENABLED: ${{ vars.PROD_GF_SMTP_ENABLED }}
# PITR archiving parity: a rollback must re-render the SAME env.sh, else it would
# silently disarm WAL archiving (PGBACKREST_ARCHIVE_MODE would fall back to off).
PGBACKREST_ARCHIVE_MODE: ${{ vars.PROD_PGBACKREST_ARCHIVE_MODE }}
PGBACKREST_S3_ENDPOINT: ${{ vars.PROD_PGBACKREST_S3_ENDPOINT }}
PGBACKREST_S3_PORT: ${{ vars.PROD_PGBACKREST_S3_PORT }}
PGBACKREST_S3_BUCKET: ${{ vars.PROD_PGBACKREST_S3_BUCKET }}
PGBACKREST_S3_REGION: ${{ vars.PROD_PGBACKREST_S3_REGION }}
PGBACKREST_S3_KEY: ${{ secrets.PROD_PGBACKREST_S3_KEY }}
PGBACKREST_S3_KEY_SECRET: ${{ secrets.PROD_PGBACKREST_S3_KEY_SECRET }}
PGBACKREST_CIPHER_PASS: ${{ secrets.PROD_PGBACKREST_CIPHER_PASS }}
INPUT_TARGET: ${{ inputs.target_version }} INPUT_TARGET: ${{ inputs.target_version }}
steps: steps:
- uses: actions/checkout@v4 - uses: actions/checkout@v4
-1204
View File
File diff suppressed because it is too large Load Diff
-8
View File
@@ -156,11 +156,3 @@ 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/` the single `.gitea/workflows/ci.yaml`. Committed edge codegen under `ui/src/gen/`
(regenerate with `pnpm codegen`); pnpm build-script approval lives in (regenerate with `pnpm codegen`); pnpm build-script approval lives in
`ui/pnpm-workspace.yaml` (`allowBuilds: esbuild: true`). `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
-1029
View File
File diff suppressed because it is too large Load Diff
-698
View File
@@ -1,698 +0,0 @@
# apple/ — native iOS/iPadOS app plan
Native **SwiftUI** application for iPhone and iPad, written **from scratch** (not a Capacitor
wrapper, unlike the Android app under `ui/android`). This document is the **source of truth and
continuity** for the effort: it lives in the repo so it travels to whatever machine does the work —
in particular the **Mac Claude session** that does the actual building. Read it first; append to it
as decisions land. Nothing here is final until built and verified; any decision can be reopened.
Items still open carry an **[OPEN / TRACKED]** or **[GATE]** marker.
Chat with the owner is in Russian per the user-level guide; this doc, like all repo docs, is in
**English**.
---
## 0. Working model — two contexts
- **Mac (primary).** All real development happens in a **Claude session on the Mac**: SwiftUI code,
`xcodebuild`, Simulator, tests, screenshots, signing, TestFlight/App Store. The Apple toolchain is
native there.
- **Linux dev-host (this repo's usual environment).** Used only to **lay groundwork for CI** (a macOS
runner, later) and to edit platform-agnostic Swift packages / docs. It **cannot** build or run an
iOS app — SwiftUI/UIKit and the iOS SDK exist only inside Xcode on macOS. (Swift the language runs
on Linux, but that is server-side Swift, irrelevant to iOS UI.)
Consequence: the heavy loop (build/sim/screenshot) is Mac-only; keep the SwiftUI shell thin and push
logic into plain Swift packages — good for testability regardless of host.
---
## 1. Tooling to install / verify on the Mac host
Do this first on the Mac before any code. The owner already has Xcode + Command Line Tools + a
simulator installed generally; the steps below are mostly **verify**, a few are **install**.
### Verify (already present, confirm versions)
- **Xcode 26** (latest) — we always build against the **newest SDK** (this is what grants the iOS 26
Liquid Glass appearance to standard controls; see §3). Confirm `xcodebuild -version`.
- **Command Line Tools** — `xcode-select -p` points at the Xcode 26 toolchain.
- **Swift 6** toolchain (bundled with Xcode 26) — `swift --version`.
- **Simulator runtimes** — need **both**:
- **iOS 18** runtime (our minimum deployment target — verify the app runs/looks correct there),
- **iOS 26** runtime (latest — verify Liquid Glass appearance).
Check under Xcode ▸ Settings ▸ Platforms; install any missing runtime there.
### Install (Homebrew, sudo-free where possible)
- **XcodeGen** — `brew install xcodegen`. Generates the `.xcodeproj` from a declarative `project.yml`
(see §4). This is the project-management tool of record.
- **swiftlint** and/or **swift-format**`brew install swiftlint swift-format`. Lint/format gate.
- *(optional, CI ergonomics)* **xcbeautify**`brew install xcbeautify`, prettifies `xcodebuild`
logs. Not required for local work.
### No install needed (SPM dependencies, pulled by the build)
- **swift-snapshot-testing** (Point-Free, open-source) — snapshot tests, added as an SPM package.
- **MetricKit** — first-party Apple framework for crash/hang/diagnostic capture; nothing to install.
### Deferred / not now
- **Apple Developer account** — in process; see the gate in §11. Not required to start (Simulator work
is unblocked without it).
- **fastlane / signing automation** — only when we reach device builds / CI / release. Revisit then.
- **macOS CI runner** (Gitea Actions on a Mac, or a cloud Mac) — the Linux-side groundwork; designed
later.
---
## 2. Fixed decisions
| Topic | Decision |
|---|---|
| Language / UI | Native **SwiftUI**, from scratch; **not** Capacitor. |
| Targets | **iPhone first**, then **iPad** (iPadOS) as a first-class target: reuse controls, adapt via layout. Possibly macOS/Catalyst far later — do not design for it now, but the `apple/` name leaves the door open. |
| Minimum OS | **iOS 18** (reaches ~A12 devices, iPhone XS/XR and newer; wide parity). Always **build with the latest SDK** (Xcode 26). |
| Directory | **`apple/`** in this monorepo (covers iPhone/iPad/future Mac; `ios/` too narrow, `swiftui/` names a framework not a platform). CI + docs stay co-located with the engine/wire. |
| Project tooling | **XcodeGen** (`project.yml`) + **SPM** modules. Not a hand-managed `.xcodeproj` (merge-hostile `project.pbxproj`, opaque, non-reproducible). Tuist only if modularization outgrows XcodeGen. |
| State management | **`@Observable`** (Observation framework), the idiomatic first-party default. **Not TCA** (heavy boilerplate, steep curve, large dependency; its benefits do not pay off at our size). Introduce a light unidirectional pattern by hand only where genuinely needed. |
| Concurrency | **Swift 6 strict concurrency** from the start (actors / `Sendable`) — cheaper to write new than to retrofit. |
| Localization / a11y | **First-class from the first screen**: RU/EN via String Catalogs, Dynamic Type, Dark Mode, Reduce Motion. Cheap now, expensive to retrofit. |
| Design relationship to web | **Not a mirror of the web app.** Board + game mechanics are preserved; the rest is designed fresh with iOS-native idioms. |
| UI philosophy | **System controls by default, custom only when necessary.** (Also "modern for free": Liquid Glass auto-applies to standard chrome on iOS 26.) |
| Auth | **Sign in with Apple** (required by Apple when any third-party social login is offered) **plus** email / VK / Telegram / … in the usual stacked-button login. Backend already has VK/TG/email; Apple is the only new provider. Details deferred to the descriptive phase. |
| Payments | Target **both** external rails (Robokassa / analogs, primary for RU) **and** Apple IAP, behind a **region-aware payment seam**. See §9 for the out-of-region reject risk (tracked separately). |
| Bundle id | Reuse **`ru.eruditgame.app`** (Apple/Google namespaces are independent; one bundle id for the universal iPhone+iPad app). |
| Design scope vs delivery | **Design/inventory scope = the full (online) app** — describe every moving part up front (online has more by design). **Implementation/delivery may stage** (stub online surfaces + stub engine first, wire real behind the same seams later). These are different axes; do not shrink the *design* to offline-first. |
| Crash reporting | **MetricKit only** (first-party, self-hosted upload; see §8). No PLCrashReporter, no Sentry for now. |
| Analytics | **None.** Nothing is tracked. No speculative "entry points" either (YAGNI; cheap to add later since actions centralize in `@Observable` models). Monitoring stays backend-only, as today. |
| Onboarding | **TipKit** (first-party, iOS 17+) for the coachmark tours, not a custom dimmed-overlay reimplementation of the web coachmarks. |
| Login (v1 surface) | **Sign in with Apple + email + guest.** VK / Telegram sign-in **deferred** to a later delivery (the login screen extends trivially with more stacked buttons). Note: with no third-party *social* provider in v1, SIWA is technically optional (email is not a social login), but we ship it deliberately; once VK/TG land it becomes mandatory. |
| Wallet / store / ad banner | **Designed in v1** (full inventory), **delivery staged.** Keeps the region-aware payment seam + the out-of-region reject risk (§10) in scope from the start. |
---
## 3. Why "min iOS 18" does not freeze the design (reference)
Appearance is governed by the **SDK you build against** + whether you use **standard components**,
**not** by the deployment target. Building with the iOS 26 SDK means standard controls
(NavigationStack, toolbars, TabView, Button, List, sheets, alerts, Form) **automatically** adopt
Liquid Glass on iOS 26, and render the iOS 18 style on iOS 18 — with no per-version code. iOS 26-only
APIs (e.g. `.glassEffect` on custom views) are used behind `if #available(iOS 26, *)` with a fallback.
**Custom-drawn UI** (board, tiles, score) looks identical on every version because we own it — so the
minimum has almost no visual cost for the game's core surface.
---
## 4. Proposed layout under `apple/` (to refine at setup)
```
apple/
project.yml # XcodeGen spec (committed; the .xcodeproj is generated, gitignored)
App/ # thin app target: @main entry, Info.plist, entitlements, asset catalog, app icon, capabilities, signing
Packages/ # SPM modules — the real code (merge-friendly, testable; non-UI ones buildable on Linux)
Domain/ # game state, rules glue, scoring, turn model (platform-agnostic)
Engine/ # Swift port of the move generator/validator/scorer + DAWG reader (parity-pinned) — later
Wire/ # Connect-Swift + FlatBuffers client to the gateway (same wire as web/Android)
DesignSystem/ # tokens, typography (SF), board/tile visual spec, reusable components
Features/ # per-screen feature modules (views + @Observable models)
DataMocks/ # mock implementations of the data protocols (the "seam"); powers Previews + e2e stubs
Tests/ # or per-package tests: Swift Testing, snapshot, XCUITest
README.md
```
Workflow discipline (XcodeGen): the generated `.xcodeproj` is a **disposable artifact** — never edit
project structure in the Xcode UI expecting it to persist; change `project.yml` and regenerate. Files
are picked up by globs, not hand-listed. XcodeGen passes through arbitrary raw build settings and
custom build phases, so custom needs are expressible; worst case escape hatches are committing the
`.xcodeproj` or migrating to Tuist — no dead end.
---
## 5. Determining optimization principles
These five shape the architecture from the start (not "add later"):
1. **Push (APNs) instead of a live socket while backgrounded.** For a turn-based game we do **not** hold
a socket in the background; the opponent's move arrives via **push**. This is an energy-architecture
decision, not just notifications — it puts APNs on the critical path.
2. **Board rendered with `Canvas`**, not a tree of ~225 `View` nodes (far cheaper in perf and memory).
3. **Engine off the main thread** — move generation, DAWG traversal, scoring, hints run on a background
actor/Task; the UI stays responsive. Swift 6 concurrency enforces this.
4. **`mmap` the dictionary (DAWG)** — memory-map, do not load the whole thing into RAM; low footprint.
5. **Fast silent boot + spinner-less resume via cache.** Show UI instantly; move heavy init (dictionary
prep) off the launch path (lazy until first needed). On foreground, render the persisted state
immediately and **silently** re-establish the stream, reconciling deltas — no blocking overlay. (Same
spirit as the Android native "silent+fast boot" and the web "offline return-online poll".)
### Lifecycle / background (the mechanism behind #1 and #5)
`ScenePhase`: on `.background`/`.inactive` tear down the live stream, stop timers, release resources; on
`.active` resume from cache + silent reconnect. Respect **Low Data Mode / cellular**
(`NWPathMonitor.isConstrained/isExpensive`). Batch network to avoid keeping the radio awake (radio tail).
---
## 6. Hygiene practices (apply as we go)
- **Memory warnings** — purge caches; watch for retain cycles (`[weak self]` around streams/observation).
- **Kill any animation while backgrounded** (do not tick the turn clock in the background).
- **Low Power Mode** (`ProcessInfo.isLowPowerModeEnabled`) — trim animations/background activity.
- **Reduce Motion** (`accessibilityReduceMotion`) — drop lavish animations (accessibility **and** battery).
- **Thermal state** — reduce load under throttling.
- Let the screen sleep; no needless wake locks. No sensors (geo/motion) — none are needed.
---
## 7. Storage — persist eagerly, do not trust `willTerminate`
There is **no reliable destructive callback** on iOS: `willTerminate` is often **not** called (swipe
force-kill, memory kill happen silently). Relying on catching a "destroy" event is a trap.
Pattern: **persist on the way to background** (`scenePhase → .inactive/.background`); snapshot the state
that matters (in-progress tile placement on the board, an uncommitted move). For the uncommitted
placement, also persist **incrementally** on meaningful changes (a tile placed / removed), **coalesced**
(not on every drag pixel). Data volume is tiny, writes are cheap. Store: SwiftData or plain file/SQLite —
decide at implementation. Net effect: even a silent kill loses nothing; the last valid state is on disk.
---
## 8. Testing — take all three layers
The mock **seam** (protocol-backed data access with `DataMocks`) is both the Preview data source **and**
the e2e stub mechanism.
1. **Unit — Swift Testing** (`import Testing`, `@Test`, `#expect`): domain/logic, engine **parity golden
tests**, wire **codec** tests, view-models. Pure (non-UI) SPM packages are **Linux/CI-buildable** — a
slice of tests can run in the ordinary CI without a Mac.
2. **Snapshot — swift-snapshot-testing**: visual regression of SwiftUI views against reference images
(fixed simulator device for determinism). Valuable for a from-scratch design.
3. **e2e — XCUITest against the mock seam**: the native analogue of the web Playwright mock e2e. The app
launches with a launch argument that switches to `DataMocks`; the whole UI is driven deterministically
with no server. Runs on the Mac runner in CI.
Lesson carried from web: the mock e2e **bypasses the real network**, so wire/codec bugs are **not** caught
there — they need the **codec unit tests**.
---
## 9. Crash reporting & analytics
- **Crashes: MetricKit.** `MXMetricManager` delivers crash/hang/CPU/disk diagnostics to the app; we
**upload the payload to our own backend** and view it in the existing **Grafana/logs** contour — fully
self-hosted, zero third-party, no paid service. Apple's Xcode Organizer / App Store Connect also provides
aggregated crashes for TestFlight/release builds for free.
- **Operational requirement:** crashes are unreadable without **symbolication** → CI **must archive the
`dSYM`** for every build/release (artifact), else reports are raw addresses.
- **Analytics: none** (see §2). No tracking, no ATT prompt, no IDFA — cleaner privacy labels as a bonus.
---
## 10. Payments — region-aware seam, out-of-region reject risk (tracked)
- Target **both** external rails (Robokassa/analogs, primary for RU) and Apple IAP, behind a **region-aware
payment seam** (distribution region is set per-country in App Store Connect).
- **The RU situation is a genuine grey/in-flux area:** Apple has effectively disabled IAP for RU accounts
while external merchants are not yet blocked — hence real apps running both paths. We follow that.
- **[OPEN / TRACKED] Out-of-region payments:** external payment for digital goods is a **rejection risk
outside the RU region** (App Store guideline 3.1.1). We need a mechanism to route/avoid so we do not get
rejected. Re-verify current policy at the payments implementation phase. Do not die on this hill; the
owner's real-world observation stands, but the risk is on record.
- **[OPEN / TRACKED] Cross-platform spend compliance:** must not let web-funded (same-email) chips be
spent inside the iOS app (see §14 → I). A dedicated payments session will settle the iOS spend-
visibility. iOS v1 shows the packs but blocks Buy with a soft redirect to the web/Android build.
---
## 11. Gates
- **[GATE] Apple Developer account** (in process). Development in the **Simulator is unblocked without it**.
It blocks, downstream: running on a **physical device**, enabling **Sign in with Apple** (needs an App ID
+ capability), **push (APNs)**, **TestFlight / App Store release**. Sequence work so these land after the
account is active.
---
## 12. Audio — future (only if sounds are added)
Two common anti-patterns we must **not** ship, both fixed via `AVAudioSession`:
1. **Never silence the user's other audio.** Category **`.ambient`** (+ `.mixWithOthers`): mixes with the
user's music/podcast, does **not** interrupt it, and respects the **silent switch**. The typical mistake
is `.playback`, which stops others — we do not use it.
2. **Never "capture" audio at launch.** Activate the audio session **lazily — only while actually playing a
sound** — and do not hold it active otherwise; never activate on boot. Avoids the "launched and muted my
music while playing nothing" annoyance.
---
## 13. Development sequence (the process, agreed)
**Design the full online app up front; build in a vertical slice, then breadth.** Order:
1. **Screen & moving-parts inventory** — enumerate every screen and, per screen, its states, data, and
reactions to actions (re-imagined for iOS, informed by the web functional domains but not mirroring
them). *(Next up — to be filled in below as we do step 1.)*
2. **Navigation / state skeleton** — an app-level router; transitions expressed as state (`NavigationStack`
path, state-bound sheets), not bolted on afterward.
3. **One vertical slice first — the game screen** (with stubs), fully interactive. This is where the
**design system** (tokens, SF typography, board/tile visual spec) and the **reusable component kit** are
born — on the highest-value screen, not on a login form.
4. **Then breadth** — the remaining screens along the established patterns; each screen built with its
controls **and** mock data together (not screens-then-transitions-then-controls in horizontal passes —
SwiftUI is data-driven, so a screen and its controls co-evolve and transitions fall out of the state
model).
Throughout: mock is an **architectural seam** (protocol + `DataMocks`), not throwaway — the real
networking later slots in behind the same protocols. The **Swift engine port** is its own workstream
(stub the game during the presentation phase; wire the real parity-pinned engine later).
---
## 14. Inventory — screens & moving parts (step 1)
Seeded from `docs/FUNCTIONAL.md`, re-imagined for iOS-native idioms. Tags: **[keep]** as-is in
spirit, **[re-imagine]** with a native idiom, **[deferred]** designed but delivered later. The
per-screen **moving parts** (states / data / actions) are filled in section by section next, starting
with the game screen (the vertical slice).
### A. Boot / launch
- **Loading splash** [keep] — crossword tiles (ЭРУДИТ / ЗАГРУЗКА / ОЖИДАНИЕ), minimal, aligned with the
fast-silent-boot principle (no empty flash).
- **Offline first-launch → straight into the lobby as guest** [keep] (as Android).
- **"Couldn't load" + Retry** [re-imagine] native, no web-sign-in fallback.
- **"Client too old" update screen** [keep] native: Update (Store) / Play offline.
### B. Onboarding
- **Coachmark tours** (lobby series + game series) [re-imagine] via **TipKit**.
### C. Auth / identity (v1: SIWA + email + guest)
- **Login screen** [re-imagine] stacked buttons: **Sign in with Apple** (native sheet) + **Email** + **guest**.
VK / Telegram rows **[deferred]**.
- **Email code entry** [keep].
### D. Lobby
- **"My games" list** [keep] — three sections (your turn / opponent / finished; empty hidden), ordering
rules, unread dots (red/amber), status-blink, swipe-to-remove finished, server games greyed-out offline.
Swipe/kebab → native **swipe actions**.
- **Bottom tab bar** [re-imagine] → `TabView`: New Game / Statistics / Settings hub.
### E. New Game
- **New Game screen** [keep] — Quick game (AI / random), With friends (invite / pass-and-play), variant
pick (gated by preferences), RU "multiple words per turn" toggle, per-kind caps 🔒 prompts.
- **Friend invite** [keep] — pick from friend list.
- **Pass-and-play setup** [keep] — host PIN keypad, player rows (24), optional per-seat PIN.
### F. Game — VERTICAL SLICE (step 3)
- **Game board screen** [keep] — board on **`Canvas`**, rack, action bar (combined pass/exchange, hint,
shuffle, confirm), on-board legality preview + score badge, bag count.
- **Scoreboard header / seats** [keep] — opponent cards with add-friend 🤝 / block ✖️.
- **Move history** [keep].
- **Comms screen** [keep] — 💬 chat / 🔎 dictionary tabs.
- **Dictionary word-check** [keep] — complaint, external reference link (network-gated).
- **Hint** [keep] — lays suggested tiles; vs_ai 🔒 idle-gate + countdown (steady in-app timer).
- **Pass / exchange** [re-imagine] native sheet.
- **Pass-and-play in game** [keep] — seat PIN unlock, leader controls (skip / remove / end early).
- **Connection-lost banner + frozen play area** [keep].
- **End-of-game + export** [keep] — GCG + PNG via the native **share sheet**.
### G. Social / friends
- **Friends screen** (Settings → Friends) [keep] — list, one-time code (issue / redeem), requests,
block / unblock / unfriend; kebab → native **context menu**.
- **Incoming invitations** [keep] — accept / decline.
### H. Profile & settings
- **Settings hub** [re-imagine] grouped `List`: settings, profile, friends, wallet, about.
- **Settings** [keep] — language, theme, board bonus-label style, reduce-motion, zoom-the-board,
notifications-in-app-only, block toggles.
- **Profile** [keep] — display name, timezone, away window, variant preferences, sign-in methods
(link / unlink / merge), delete account; inline editing.
- **Merge confirmation** (irreversible) [keep].
- **Delete account** [keep] — mailed code / typed phrase.
### I. Wallet & store (designed in v1, delivery staged)
- **Wallet** [keep, deferred delivery] — balance (per-platform chips), Active line, Buy / Spend toggle,
watch-ad option, Exchange confirm, store-compliance warnings. Region/store-aware; ties to the §10
payment seam.
### J. Feedback
- **Feedback screen** [keep] — message + single attachment, reply display, feedback-banned state.
### K. Info / legal
- **About** [keep] — version, links.
- **Legal docs** (EULA / privacy / offer) [re-imagine] — in-app viewer or Safari, not web pages.
### L. Cross-cutting (not screens)
- **Advertising banner** under the nav (free players) [keep] — component, campaign rotation / colours / urgent.
- **Push (APNs)** + real-time in-app updates — data flows, not a screen (APNs is also the background
energy model, §5).
### Excluded (web / platform-specific — not in the native iOS app)
- **Admin console `/_gm`** — server-rendered web, not part of any client.
- **Landing page** — web-only.
- **Telegram / VK Mini App** hosting + their diagnostic screens — the iOS app is not a Mini App.
- **PWA install CTA** — web-only.
- **Promo bot**, **Telegram support chat** — Telegram-side (in-app Feedback in §J is kept).
### Native idioms replacing web mechanics
`TabView` (tab bar) · native swipe actions / context menus (swipe-remove, kebab) · sheets (popups/modals)
· `ShareLink` / share sheet (export) · **TipKit** (coachmarks) · grouped `List` (settings/legal) · SIWA
native sheet (auth).
### Assets, tiles & icons- **Tiles are drawn as runtime vectors** (a rounded rect + a font glyph + the point value, on the board
`Canvas`) — **no image assets, no pre-generation, no local-store cache.** Resolution-independent (crisp
under zoom-on-drop), theme/variant/locale-adaptive for free. Cache rendered tile images
(`ImageRenderer`) **only if** profiling later shows a need. The crossword loading splash **reuses the
same tile component** (zero assets).
- **LaunchScreen** is static, OS-level, a simple brand mark (background + logo/wordmark; system font or a
single logo image — **no tile assets**). Its duration **equals the real launch time** — it **cannot** be
extended or held programmatically (and fake delays are discouraged); the "longer" splash is the **in-app
crossword splash** (its timing is ours, shown until the lobby is ready). Match the LaunchScreen
background to the in-app splash so the OS→app handoff is seamless (no flash).
- **Icons = SF Symbols** (the system icon set) everywhere — all the emoji in this plan are placeholders
for meaning; the real UI uses SF Symbols (gear, chart.bar, dice, bubble.left, shuffle, lightbulb,
person.badge.plus, xmark/hand.raised, square.and.arrow.up, lock, …). **Custom vectors only where no
system symbol fits:** the three variant emblems and the tiles.
### Per-screen moving parts
#### F. Game screen — detail
**Layout**
- **iPhone: full board always visible + zoom-on-drop** (hybrid, web-parity, setting-gated by the
existing "Zoom the board" toggle). Board scaled to width; on tile drop it magnifies toward the drop
point for precision.
- **Bottom-anchored stack**, top→bottom: *(top strip — the ad banner (L) + connection chrome, else
empty)* → **seats header****board****rack****bottom toolbar**. Everything is packed to the
bottom; the top strip hosts the L banner / Connecting…/Offline/connection-lost chrome, else empty.
- **Orientation: iPhone portrait-only; iPad portrait + landscape.**
**Four regions**
1. **Scoreboard / header** — seats (name, score, whose-turn), unread dot (red msg / amber nudge),
connectivity state ("Connecting…" / Offline chip / connection-lost banner), 💬 entry to history/comms.
2. **Board (`Canvas`)** — 15×15 grid, placed tiles, staged tiles with on-board legality preview
(light green when a word forms, darker on run-through board tiles, pink when none; orange badge =
score), zoom-on-drop.
3. **Rack** — player tiles, drag/tap to place, shuffle, in-rack reorder.
4. **Action bar** — combined pass/exchange, hint (vs_ai 🔒 idle-gate + countdown), shuffle, confirm
(only when the move is legal), bag count.
**State axes (repaint the screen)**
- **Whose turn:** mine (preview + submit) vs opponent's (draft placement only, position-only).
- **Phase:** waiting for opponent (auto-match "searching for opponent"; resign/chat/nudge off) →
active → finished.
- **Connectivity:** online / connecting / offline / connection-lost-in-online-game (frozen).
- **Kind:** vs_ai (honest 🤖: unlimited wallet-free hints idle-gated, no chat/nudge/add-friend, no
clock — 7-day only, no export) · auto-match 2p (possibly a disguised robot) · friend 24 ·
pass-and-play (seats, PINs, leader).
- **Variant:** Erudite / RU Scrabble / EN Scrabble; RU "multiple words per turn" toggle changes the
preview (main word only vs all perpendiculars).
**Interactions**
- Place tile (drag or tap; the game infers direction) · **blank ('?') letter picker** · recall a
staged tile to the rack · legality preview on-device (first-time warm-up; server fallback) · confirm
(only when legal) · resign · shuffle · **hint** (per-game allowance → wallet; vs_ai unlimited but
idle-gated 30 min, 🔒 + steady in-app countdown) · zoom-on-drop (setting) · **per-game composition
persistence** (rack arrangement + uncommitted move restored, cross-device).
**Sub-surfaces (from the header)**
- **Seat cards** (with history open): add-friend 🤝 / block ✖️, confirm on a fading ✅; disabled/
disappear rules.
- **Move history** (words, coordinates, scores; pass/exchange/resign/timeout notes; closing ± endgame
settlement).
- **Comms** — chat (1 msg/turn, ≤60 chars, no links) + dictionary word-check (complaint, external
reference link); **nudge**.
- **Pass-and-play** — seat PIN unlock (keypad replaces the locked seat's rack); leader button →
skip / remove / end-early (leader password); hints and chat off.
- **End-of-game** — result, final scores (yours first), export 📤 (GCG + PNG, never for honest-AI);
"could not be continued → draw" organizer note.
- **Connection-lost (online game)** — slim banner, play area freezes (rack + move controls disable;
resign/add-friend/block/chat hidden; a started move stays as a draft); if already in chat/dictionary
you stay (chat read-only, dictionary keeps checking on-device).
**UX decisions**
- **Comms sheet:** a **single detented sheet** with a segmented control **[History | Chat |
Dictionary]**; the board + seats header stay visible above; native swipe-to-dismiss + grabber.
**Opened by tapping the seats header** (where the unread dot / 💬 live). iPad landscape later adapts
it to a side column. (Simplifies the web's nested history → comms navigation.) The **export 📤**
lives inside this sheet (History segment, finished game) — **not** in the header.
- **Bottom toolbar (not a TabBar).** The in-game controls are actions, so they sit in a native bottom
toolbar (`ToolbarItem(.bottomBar)`, the Safari/Mail pattern), **not** the app `TabView` — which is
navigation and is **hidden inside the game**. Layout: **`[pass/exchange] — [hint ⇄ confirm] —
[shuffle]`** (3 slots). The centre control **morphs hint → confirm** only once a legal move is
staged (doubles as "you can commit" feedback). **Exchange** is inside the `pass/exchange` control
(select tiles = exchange, none = pass). **Recall** a staged tile by tapping it — no dedicated button.
- **Tile placement:** **both drag and tap-to-place**; recall by tap; the **blank ('?') letter picker**
is a popover over the variant's alphabet.
- **Seat cards:** composed from primitives (`HStack`/`VStack` + design tokens, optional `GroupBox`),
no prebuilt control. Base header shows name / score / whose-turn / unread dot only.
- **Opponent social actions:** **tap a seat card → a mini player panel** (popover on iPad, sheet on
iPhone) showing the relationship status (pending / friends / blocked) with **Add friend** / **Block**
(`Button(role: .destructive)` + native `confirmationDialog`). Replaces the web's in-place score-card
swap.
- **Rack:** shuffle button; reorder tiles by in-rack drag; tap selects a tile for tap-place; freed
slots render empty.
- **Waiting for opponent (auto-match):** the opponent card reads "searching for opponent" with a calm
activity indicator (not a blocking spinner); resign/chat/nudge hidden; you may arrange tiles if it is
your turn; a "you can close the app and come back" note.
- **vs_ai hint-lock:** the hint control carries a 🔒 badge while idle-gated; a tap shows a transient
"unlocks in N min" popover; the lock lifts live at the mark.
- **End-of-game:** a result plate on the board (win/lose/draw, final scores yours-first); export via the
sheet (above); never for honest-AI.
- **Connection-lost / offline chrome** (per FUNCTIONAL, native styling): offline = blue header + Offline
chip; brief blip = quiet "Connecting…" in the header; connection-lost in an online game = slim banner
+ frozen play area (rack/controls disabled, resign/social/chat hidden, started move kept as a draft).
*(F inventory + UX skeleton complete. Design-system specifics — colours, typography, tile visuals —
emerge while implementing the vertical slice, step 3.)*
#### D. Lobby — detail
**UX decisions**
- **List style:** native **inset-grouped `List`** (the Settings-app look — floating rounded section
cards over a grouped background). Sections: **your turn / opponent's turn / finished** (empty hidden).
- **Grouping (option A — unified lobby):** one lobby, all games in the three state sections. **Local
games** (offline vs_ai + pass-and-play, which never sync) are **badged inline** with an "on-device"
marker. When offline, **server rows grey out in place** (un-openable; a tap explains why) — not moved
or hidden.
- **Fold/unfold:** **opponent + finished** sections are collapsible (`DisclosureGroup`); **"your turn"
is always expanded** (it is the action section). Fold state is remembered per-device.
- **Bottom `TabView` (app navigation):** New Game / Statistics / Settings hub. (Confirmed: the TabBar
switches screens; in-game controls are a bottom toolbar, not this.)
- **Row anatomy:** opponent name (or "searching for opponent" for an unfilled auto-match) · a **variant
emblem** (three distinct icons — Erudit / RU Scrabble / EN Scrabble; **not** a national flag, which
can't separate the two Russian variants) · unread dot (red msg / amber nudge) · an **on-device badge**
for local games · a **result** for finished games. **No status icon** (the sections already group by
status). No avatars (name only). Compact, line-separated. *(Exact visual TBD on the slice.)*
**Behaviour (per FUNCTIONAL, kept)**
- **Ordering:** your-turn first (longest-waiting on top); opponent + finished most-recent first; a game
with any unread floats to the top within your-turn/opponent (finished keeps its order).
- **Live updates:** a listed game that becomes your-turn or finishes while the lobby is open
**flashes its row briefly** (the status-icon blink is retargeted to a row-level flash, since there is
no status icon); the game also moving into the *your turn* section is itself a signal. An opponent's-
turn change is silent, applied in place.
- **Unread dot:** next to the game — **red** when an unread message waits, **amber** when only nudges.
- **Remove finished:** native **swipe action** → ❌ (per-account, permanent, no undo). An AI game you
left (resign or 7-day lapse) auto-drops from *finished*; a normally-finished AI game stays until
removed; no other type auto-removes.
- **Cold open:** loading splash (crossword tiles ЭРУДИТ/ЗАГРУЗКА/ОЖИДАНИЕ) until the list is ready — no
empty flash.
- **Offline chrome:** blue header + Offline chip; Stats tab disabled; invitations hidden; server rows
greyed.
#### E. New Game — detail
**UX decisions**
- **Structure:** a top **segmented `[Quick game | With friends]`** + a contextual **inset-grouped form**
below (consistent with the lobby aesthetic).
- *Quick game:* opponent segmented **`[AI | Random]`** → variant (emblem chips) → RU "multiple words"
toggle → **Start**.
- *With friends:* rows **Invite a friend** / **Pass-and-play** → pushed sub-flows.
- **Variant picker:** **emblem chips / segmented** (≤3 variants); **hidden entirely when a single variant
is enabled** (the common default — Erudite only).
**Behaviour (per FUNCTIONAL, kept)**
- **Quick game:** AI default (instant 🤖, no wait) / Random (auto-match 2p — drops you into the game to
wait inside; "you can close the app and come back" note).
- **With friends offline → only pass-and-play** (invite needs the network).
- **RU "multiple words per turn":** default **off**; RU games only; English is always standard, no toggle.
- **Per-kind caps** (AI / random / friend): a start at its cap shows **🔒**; a tap opens a prompt — a
**guest** is invited to sign in / create an account, a **signed-in** player is told to finish a current
game. **Accepting an incoming invitation is never capped.**
- **Friend invite:** pick **24** from the friend list; the inviter sets the game settings; starts once
**all accept**; any decline cancels; an unanswered invite expires in **7 days**; shareable as a Telegram
deep link (on VK the link only opens the app, the code is entered by hand).
- **Pass-and-play setup:** host PIN keypad → "are you playing too?" (yes → first seat takes your name) →
name players 24 (add/remove; a removal asks the leader password) → optional per-seat PIN.
- **Per-game settings set** (inviter chooses for friend games; quick games use defaults): variant · RU
multiple-words · **move timeout** (5 min24 h, default 24 h) · **hints** (allowed? how many each) ·
**leaver's-tile disposition** (returned to bag / removed — 34-player games only).
**UX decisions (sub-details)**
- **Cap 🔒 prompt:** a 🔒 badge on the **Start** control (looks unavailable); a tap opens a native
alert — guest: a "Sign in / Create account" action; signed-in: an informational "finish a current
game first."
- **Pass-and-play setup** (pushed, stepped): (a) 4-digit host PIN on a custom lock-screen-style keypad
(no built-in iOS control — composed); (b) "are you playing too?"; (c) player rows as an editable
`List` (EditMode add/remove; a removal is gated by the leader password); (d) per-seat PIN via a small
lock toggle in the row.
- **Friend-invite** (pushed): multi-select from the friends `List` (checkmarks, 24) → a **settings
step** reusing the per-game settings set → **Send** (+ shareable link). Setting controls: move
timeout = native picker (5 min24 h); hints = toggle + stepper; leaver disposition = segmented
`[to bag | out of play]`.
#### C. Login / identity — detail (v1: SIWA + email + guest)
**UX decisions**
- **No hard login wall — guest by default** (offline-first, as Android native). First launch drops
straight into the lobby as a guest.
- **Reusable sign-in sheet:** a bottom **detented sheet** (same idiom as the comms sheet) with the
sign-in buttons in a **vertical stack****Sign in with Apple** (`SignInWithAppleButton`) + **Email**
(future VK/TG rows append here). **Shown once on first launch, dismissible** — dismiss = stays guest.
**Reused on demand** from the Profile "Sign in" action.
- **Email flow:** address → **6-digit code** entry → signed in / linked; rate-limit feedback (cooldown +
hourly cap). **Code-only on native** — the magic link is unused (it opens an external browser that
can't return to the app; mirrors the installed-PWA case in FUNCTIONAL).
- **SIWA:** native system sheet → gateway validates → create / link the account.
- **Merge on collision** (the identity already belongs to another account): guest initiator = seamless;
durable initiator = irreversible confirmation. Triggered by sign-in; the merge screen itself lives in
**H** (Accounts, linking & merge).
#### B. Onboarding — detail (TipKit, native)
**UX decisions**
- **TipKit, native flavour** (confirmed): contextual tips anchored to controls, ordered via **`TipGroup`**
**no full-screen dimming, no tap-anywhere-advance**; each tip dismisses itself (its ✕ or performing
the action). Shown once; per-device eligibility.
- **Two tours:** **lobby series** (⚙️ settings, ✏️ statistics, 🎲 new-game tabs) + **game series**
(scoreboard header, pass/exchange, hint, shuffle, rack). Localized (en/ru).
- **First-launch sequence:** lobby (guest) → sign-in sheet (C) → on dismiss → the TipKit lobby tour
(so the sheet and the tips don't overlap).
#### G. Friends — detail (Settings → Friends)
**UX decisions**
- **Structure:** a single **inset-grouped `List`** with sections **Requests** (incoming: accept /
decline; outgoing: cancel) · **Friends** · **Blocked** (unblock). A toolbar **"+"** opens the add
sheet.
- **Add sheet ("+"):** **Enter a code** (6-digit redeem, valid 12 h) / **Share my code** (generate +
`ShareLink`).
- **Friend-row actions:** **swipe actions****Remove** (destructive) + **Block** — each gated by a
naming **`confirmationDialog`** ("Remove from friends?" / "Block this player?").
- Chat / nudge are **not** here — they live in the in-game comms sheet (F). Global block toggles
(incoming chat / friend requests) live in **Profile (H)**.
**Behaviour (per FUNCTIONAL, kept)**
- Two ways to friend: redeem a one-time code, or a request to someone you've played with
(accept / ignore→30-day lapse / decline→blocks until they hand you a code). Cancel your outgoing;
unfriend removes it.
- A per-user block is one-directional and silent; **unblock and unfriend live only here**.
#### H. Profile & settings — detail
**UX decisions**
- **Settings hub** = an icon **`List` of drill-down `NavigationLink` rows** (the Podcasts → Library
pattern), SF-symbol icons, with room left at the bottom for optional general info later. Rows:
- **Interface** (⚙️) → display-settings screen.
- **Friends** (person.2) → G.
- **Wallet** (creditcard) → I. **Hidden for guests.**
- **Info** (info.circle) → About + version + legal (K).
- **Feedback** → J. **Hidden for guests.** Unread-reply **badge** on the Settings tab **and** on this
row.
- **Profile = a top-right nav-bar icon** (`ToolbarItem(.topBarTrailing)`, `person.circle`; filled /
initial when signed in) on the settings hub: **guest → the sign-in sheet (C); signed-in → the profile
screen.** (Duplicated onto the lobby only if asked later.)
- **Interface screen** (display prefs): language · theme · board bonus-label style · reduce-motion ·
zoom-the-board.
**Guest has no editable settings** — the random nick is not changeable, the variant is always
`erudite_ru`, the timezone is auto-detected (never set by hand). So there is **no guest profile
screen**: the profile icon for a guest opens the **sign-in sheet** (confirms C). It also confirms E's
"variant picker hidden when a single variant is enabled" for guests.
**Profile screen (signed-in / durable)** — an inset-grouped `Form`:
- **Name** — inline `TextField` + validation (letters + `·`/`.`/`_` separator, optional trailing `.`
or ≤5 digits, ≤32 chars, ≤5 specials).
- **Play** — timezone (UTC-offset picker, pre-filled from the device) · away window (10-min grid, ≤12 h,
wraps midnight; a two-time range) · variant preferences (multi-select emblems, ≥1 kept) ·
notifications-in-app-only (`Switch`, default on).
- **Privacy** — global block toggles (incoming chat / friend requests).
- **Sign-in methods** — rows link / unlink; **email is "changed", not unlinked**; the **last** method
can't be unlinked; a provider that belongs to another account offers the irreversible **merge** (guest
initiator seamless, durable initiator confirmed).
- **Delete account** — destructive footer; confirm via a mailed code (email account) or a typed phrase.
Legal-retention removal (not erasure), per FUNCTIONAL.
#### I. Wallet & store — detail (durable only; delivery staged)
**UX decisions**
- **Structure:** inset-grouped — a **balance card** (running-context «Фишка» first, then linked
other-platform chips behind their logos: VK / Telegram / web / **Apple** as a new context) → **Active**
line (hints remaining + ad-free end date / "forever"; hidden when empty) → segmented **`[Buy | Spend]`**
→ rows. **No purchase history.**
- **Buy (v1):** the chip packs are **shown** (the user sees purchases exist in the game), but tapping
**Buy** shows a light message — *"Purchases are unavailable on the Apple platform. You might like
another version of [our app](landing)."* — a soft redirect to the Android/PWA build while payments are
not wired. Chosen over hiding so the offering stays visible; unlocking real Buy later (IAP / external
rails) is then just enabling the path.
- **Spend:** values (extra hints, days without ads) at a fixed chip price; **Exchange** action + a
confirm `confirmationDialog`.
**[OPEN / TRACKED — a dedicated payments session, not resolved here]**
- **Cross-platform spend compliance (Apple landmine):** we must **not** let a user buy chips on the web
(email login) and then **spend** them inside the iOS app where the same email is signed in — spending
externally-funded digital currency in-app violates App Store rules. This reshapes the iOS
**store-compliance spend-visibility** (what of the balance is spendable on iOS, given there is no
Apple-funded chip yet). The owner will settle it in a dedicated payments session. Ties to §10.
#### J. Feedback — detail (durable only; guests hidden)
**UX decisions**
- inset-grouped `Form`: **`TextEditor`** (≤1024, live counter) → attachment row → **Send**.
- **Attachment:** one file ≤1 MB (images / PDF / text-log / office / RTF / archives). **"Attach"** →
menu **`PhotosPicker` (Photos)** / **`fileImporter` (Files)**; **no camera**. An unsupported file is
refused **without naming** the allowed types; the chosen file shows as a removable chip.
- **After send:** the form clears, "Ваше сообщение отправлено"; while the operator has not dealt with it,
a locked state "Ожидаем рассмотрения вашего последнего обращения" (Send disabled).
- **Reply** below: "Ответ на ваше последнее сообщение"; links open in `SFSafariViewController`; marked
read on view; gone after a week.
- **Unread-reply badge:** Settings tab + Feedback row (H).
- **Feedback-barred** player: Send disabled.
#### K. Info / legal — detail
**UX decisions**
- **About screen** (Info hub row): app name · **version** (native build + the client-version) · links to
the three legal docs · copyright; optional "Rate on the App Store".
- **Legal docs** (EULA `/eula/`, privacy `/privacy/`, offer `/offer/` + live price list): open via
**`SFSafariViewController` to the hosted URLs** — always current, the offer's price list stays live,
bilingual handled by the page; no render pipeline duplicated in the app. **Offline:** unavailable
("недоступно офлайн").
#### A. Boot / launch & update — detail
**UX decisions**
- **LaunchScreen:** static, OS-level, instant (**iOS cannot animate the launch screen**) — brand/logo.
- **In-app loading:** the crossword-tiles splash (ЭРУДИТ/ЗАГРУЗКА/ОЖИДАНИЕ) shows **only while the lobby
list isn't ready**; fast silent boot → usually straight to the lobby. Offline cold launch → straight to
the offline lobby.
- **"Couldn't load" + Retry:** a native screen when an online launch can't reach the backend (a quiet
retry first).
- **Client too old** (only on an online action, never while playing offline): full-screen, non-dismissable
**Update** (App Store listing) + **Play offline** (keep on-device games).
- **"Update available" (soft, not required):** a **transient top banner styled like a native
notification** — "Пора обновить приложение", **tap → App Store**, **auto-dismiss after 5 s**, shown on
**every launch when online**. (iOS has no first-party toast → a custom notification-style banner;
deviates by choice from the web's persistent dismissible bar.)
#### L. Advertising banner — detail (cross-cutting component)
**UX decisions**
- **Placement:** a one-line strip **on the main tabs (Lobby / New Game / Statistics) AND in-game (F)**
players spend most time in-game. Hidden in settings drill-downs and modal flows. **In-game it occupies
the top strip** (the empty space above the seats header), coexisting with the connection chrome
(Connecting… / Offline / connection-lost banner sit with it).
- **Audience:** free players only (not lifetime-paid, no purchased hints, no `no_banner` role); guests
see it; appears/disappears **in place** on a property change.
- **Language (native, no bot channel):** the **interface language** (RU default) — there is no "bot you
play through" here.
- **Not hidden under TipKit** (no dimming overlay, unlike the web coachmarks).
**Behaviour (per FUNCTIONAL, kept)**
- Operator campaigns (weight %, optional window); compete in proportion to weight; a **house/default**
campaign fills the unsold share; an **urgent** campaign shows to everyone and is the only thing shown.
- Fair weighted rotation; a multi-message campaign cycles in turn; transitions (fade-out → gap →
fade-in); long-message scroll; timings from the server (`/_gm/banners`).
- Per-non-default-campaign colours (theme-aware set + optional dark override; derived border); urgent flag.
+37 -14
View File
@@ -1,18 +1,41 @@
# Icons # Erudit — site icons + Open Graph card
The whole shipped icon set is sliced from **one master** — see The favicon set and the `og:image` link-preview card for the public landing
[`brand/`](brand/) for the reference generator, the pinned font and the committed (`ui/landing.html`) and the SPA shell (`ui/index.html`). Same design language as the
outputs, and [`../../docs/ICON_BRANDBOOK.md`](../../docs/ICON_BRANDBOOK.md) for how the [VK loading-screen logo](../vk/README.md): the wooden Erudit «Э» tile (score `8`),
mark is constructed. The per-platform target map (which file goes where) is in with the wordmark on the app's dark board green (`ui/src/app.css` tokens).
[`../../docs/ICONS.md`](../../docs/ICONS.md).
| 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**.
```sh ```sh
cd brand cd assets/icons
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) # 1. (optional) re-extract the glyphs — only if the font or the wordmark changes:
> was replaced by `brand/` when the icon was rebranded onto the Spectral «Э» master. # default font: /usr/share/fonts/truetype/liberation/LiberationSans-Regular.ttf
> The separate VK loading-screen animation lives in [`../vk/`](../vk/) and is unrelated. node build/extract.js [/path/to/font.ttf] # -> build/glyphs.json
# 2. regenerate everything in ui/public/:
node build/generate.js
```
-2
View File
@@ -1,2 +0,0 @@
node_modules/
package-lock.json
-32
View File
@@ -1,32 +0,0 @@
# 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).
Binary file not shown.
-93
View File
@@ -1,93 +0,0 @@
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.
Binary file not shown.

Before

Width:  |  Height:  |  Size: 19 KiB

-53
View File
@@ -1,53 +0,0 @@
'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'));
// The full-bleed master (square tile + master mark) — the legacy SQUARE launcher (API < 26)
// uses it so old Android shows a large mark rather than the safe-zone foreground shrunk into
// the middle. The round legacy icon keeps the safe-zone composite (a round mask would clip
// the master's corner ✻).
const MASTER = uri(join(ROOT, 'ui', 'assets', 'icon.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));
// Square launcher: the full-bleed master (large mark). Round launcher: the safe-zone
// composite, circle-clipped (the master's corner ✻ would be clipped by the round mask).
writeFileSync(join(dir, 'ic_launcher.png'), await shot(im(MASTER, lg), 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();
-163
View File
@@ -1,163 +0,0 @@
'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 ✻).
// Every value is the earlier placement scaled 0.75 about the icon centre (0.5, 0.5): the
// whole mark is 25% smaller, its «Э»↔✻ arrangement unchanged, so the ✻ that used to graze
// the round mask now sits well inside the safe zone. Round-mask launchers (adaptive + the
// legacy round icon) get more breathing room; the full-bleed master is untouched.
const FOREGROUND = { lh: 0.4043, lc: [0.5176, 0.4963], sd: 0.0989, sc: [0.6970, 0.6700] };
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>`;
}
-105
View File
@@ -1,105 +0,0 @@
'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');
Binary file not shown.

Before

Width:  |  Height:  |  Size: 477 KiB

File diff suppressed because one or more lines are too long

Before

Width:  |  Height:  |  Size: 6.5 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 288 KiB

@@ -1 +0,0 @@
<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>

Before

Width:  |  Height:  |  Size: 1.5 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 24 KiB

@@ -1 +0,0 @@
<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>

Before

Width:  |  Height:  |  Size: 1.4 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 405 KiB

-1
View File
@@ -1 +0,0 @@
<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>

Before

Width:  |  Height:  |  Size: 2.8 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 290 KiB

@@ -1 +0,0 @@
<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>

Before

Width:  |  Height:  |  Size: 1.5 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 24 KiB

@@ -1 +0,0 @@
<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>

Before

Width:  |  Height:  |  Size: 1.4 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 411 KiB

-1
View File
@@ -1 +0,0 @@
<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>

Before

Width:  |  Height:  |  Size: 2.8 KiB

-12
View File
@@ -1,12 +0,0 @@
{
"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"
}
}
-21
View File
@@ -1,21 +0,0 @@
'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');
Binary file not shown.

Before

Width:  |  Height:  |  Size: 103 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 103 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 32 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 15 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 38 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 1.6 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 122 KiB

+78
View File
@@ -0,0 +1,78 @@
'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(''));
+128
View File
@@ -0,0 +1,128 @@
'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');
}
})();
File diff suppressed because one or more lines are too long
+2 -7
View File
@@ -49,14 +49,9 @@ COPY --from=build /out/backend /usr/local/bin/backend
# Own the seed dictionary as the nonroot runtime user (UID 65532): a named volume # Own the seed dictionary as the nonroot runtime user (UID 65532): a named volume
# mounted at /opt/dawg inherits this ownership on first use, so the admin console # mounted at /opt/dawg inherits this ownership on first use, so the admin console
# can write new version subdirectories at runtime. The volume preserves uploaded # can write new version subdirectories at runtime. The volume preserves uploaded
# versions across deploys and, once seeded, is not re-seeded (ARCHITECTURE.md §5). # versions across deploys and, once seeded, is not re-seeded — so after bootstrap
# every dictionary change goes through the console, not a rebuild (ARCHITECTURE.md §5).
COPY --from=dawg --chown=65532:65532 /dawg /opt/dawg COPY --from=dawg --chown=65532:65532 /dawg /opt/dawg
# A second, UNSHADOWED copy of the build's DAWGs: the /opt/dawg volume mount hides the
# image's copy there, so this is where engine.DeliverVersion reads the build version from
# to add it (add-only) to the volume on boot — the deploy-delivers path that lets a bumped
# BACKEND_DICT_VERSION go live for new games without an admin upload (ARCHITECTURE.md §5).
COPY --from=dawg --chown=65532:65532 /dawg /opt/dawg-seed
ENV BACKEND_DICT_DIR=/opt/dawg ENV BACKEND_DICT_DIR=/opt/dawg
ENV BACKEND_DICT_SEED_DIR=/opt/dawg-seed
ENV BACKEND_DICT_VERSION=${DICT_VERSION} ENV BACKEND_DICT_VERSION=${DICT_VERSION}
ENTRYPOINT ["/usr/local/bin/backend"] ENTRYPOINT ["/usr/local/bin/backend"]
+12 -37
View File
@@ -33,16 +33,11 @@ real game seating the caller with an **empty opponent seat** (status `open`) or,
another player already waits for the same variant and per-turn word rule, seats the another player already waits for the same variant and per-turn word rule, seats the
caller into that open game and starts it — and caller into that open game and starts it — and
friend-game invitations (invite → accept, starting a 24 player game once every friend-game invitations (invite → accept, starting a 24 player game once every
invitee accepts). **Per-tier, per-kind active-game caps** limit a player's simultaneous invitee accepts). A **simultaneous-game cap** (`game.MaxActiveQuickGames` = 10) limits a
unfinished games by kind — `vs_ai`, `random` (quick auto-match), `friends` — with separate player's active quick games — status `active`/`open`, excluding invitation-linked friend
guest and durable-account tiers held in the single-row `backend.config` table (a `-1` means games (`game.Service.CountActiveQuickGames`); the server refuses `lobby/enqueue` and
unlimited), read through an in-memory cache (`internal/gamelimits`) and tuned live in the admin `invitations` creation with **409 `game_limit_reached`** at the cap (accepting an invitation
(`/_gm/limits`, no redeploy). Each game is tagged with its `games.game_kind` on creation; is exempt), and the `games.list` response carries an `at_game_limit` flag for the lobby.
`game.Service.AtGameLimit` counts the account's `active`/`open` games of a kind against the
tier's cap. The server refuses `lobby/enqueue` (random/vs_ai) with **409 `game_limit_reached`**
at the cap, and the `invitations` (friends) path enforces the durable friends cap and refuses a
**guest** outright (guests cannot use friends); accepting an invitation is exempt. The
`games.list` response carries an `at_game_limit` flag (the random-kind cap) for the lobby.
`internal/social` owns the friend graph (request/accept), `internal/social` owns the friend graph (request/accept),
per-user blocks, and per-game chat with nudges folded in as a message kind; chat per-user blocks, and per-game chat with nudges folded in as a message kind; chat
messages are length-capped, content-filtered (no links/emails/phone numbers, messages are length-capped, content-filtered (no links/emails/phone numbers,
@@ -105,10 +100,7 @@ state, lobby enqueue, chat). The social/account/history operations under
`/api/v1/user`: `friends/*` (request/respond/cancel/unfriend, `/api/v1/user`: `friends/*` (request/respond/cancel/unfriend,
list/incoming, the one-time `code` issue/redeem), `blocks/*`, `invitations/*` list/incoming, the one-time `code` issue/redeem), `blocks/*`, `invitations/*`
(create/accept/decline/cancel/list), `PUT profile`, `email/{request,confirm}`, (create/accept/decline/cancel/list), `PUT profile`, `email/{request,confirm}`,
`stats`, `games/:id/gcg` (finished-only), and the payments `wallet` / `stats`, and `games/:id/gcg` (finished-only). The `internal/notify` hub feeds a
`wallet/catalog` (`GET` — the context-visible chip segments + benefits, and the
storefront catalog) / `wallet/buy` (`POST` — a chip spend on a value), served by
`internal/payments` behind the store-compliance gate. The `internal/notify` hub feeds a
second listener — `internal/pushgrpc`, a gRPC server (`BACKEND_GRPC_ADDR`) streaming second listener — `internal/pushgrpc`, a gRPC server (`BACKEND_GRPC_ADDR`) streaming
live events (your-turn, opponent-moved, chat, nudge, match-found, notify) to the live events (your-turn, opponent-moved, chat, nudge, match-found, notify) to the
gateway. The gateway-only `POST /api/v1/internal/push-target` (a user's gateway. The gateway-only `POST /api/v1/internal/push-target` (a user's
@@ -140,38 +132,21 @@ 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` + 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 `/_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 window and bilingual messages, plus the global display timings. `GET /api/v1/user/profile` attaches
the resolved, weighted campaign feed for an **eligible** viewer (no active **no-ads** benefit the resolved, weighted campaign feed for an **eligible** viewer (`!paid_account && hint_balance == 0
applicable in the current context and no **`no_banner`** role; the message language picked by && !no_banner` role, the message language picked by `preferred_language`); changing those inputs
`preferred_language`); changing those inputs publishes a `notify` `banner` re-poll signal so the client shows/hides it in place. The shared wire
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. contracts live in the sibling [`../pkg`](../pkg) module.
**Account linking & merge** (`/api/v1/user/link/*`). `internal/link` **Account linking & merge** (`/api/v1/user/link/*`). `internal/link`
orchestrates it: an email confirm-code or a gateway-validated Telegram identity is 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 attached to the current account, and when the identity already has its own account
the two are merged in one transaction (`internal/accountmerge`) — stats summed, the two are merged in one transaction (`internal/accountmerge`) — stats and the hint
identities/games/chat/complaints transferred, wallet summed, `paid_account` ORed, identities/games/chat/complaints transferred,
friends/blocks de-duplicated, the secondary kept as a `merged_into` tombstone (so a 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. 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 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. durable owner — then the durable account wins and a fresh session is minted for it.
The `accounts.merged_into`/`merged_at` columns back this. This supersedes the The `accounts.paid_account`/`merged_into`/`merged_at` columns back this. This supersedes the
former `email.bind.*` edge surface (the `RequestCode`/`ConfirmCode` primitives stay). former `email.bind.*` edge surface (the `RequestCode`/`ConfirmCode` primitives stay).
Rate-limit observability: the gateway posts its periodic rejection Rate-limit observability: the gateway posts its periodic rejection
+11 -107
View File
@@ -12,6 +12,7 @@ import (
"fmt" "fmt"
"log" "log"
"os/signal" "os/signal"
"strings"
"syscall" "syscall"
"time" "time"
@@ -28,11 +29,9 @@ import (
"scrabble/backend/internal/engine" "scrabble/backend/internal/engine"
"scrabble/backend/internal/feedback" "scrabble/backend/internal/feedback"
"scrabble/backend/internal/game" "scrabble/backend/internal/game"
"scrabble/backend/internal/gamelimits"
"scrabble/backend/internal/link" "scrabble/backend/internal/link"
"scrabble/backend/internal/lobby" "scrabble/backend/internal/lobby"
"scrabble/backend/internal/notify" "scrabble/backend/internal/notify"
"scrabble/backend/internal/payments"
"scrabble/backend/internal/postgres" "scrabble/backend/internal/postgres"
"scrabble/backend/internal/pushgrpc" "scrabble/backend/internal/pushgrpc"
"scrabble/backend/internal/ratewatch" "scrabble/backend/internal/ratewatch"
@@ -111,12 +110,6 @@ func run(ctx context.Context, cfg config.Config, logger *zap.Logger) error {
} }
logger.Info("database migrations applied") logger.Info("database migrations applied")
// Deliver the build's dictionary version onto the persistent volume if a redeploy
// bumped it (add-only; old versions in-flight games pin are untouched), so the new
// dictionary is resident and can be activated below without an admin upload.
if err := engine.DeliverVersion(cfg.Game.DictDir, cfg.Game.DictSeedDir, cfg.Game.DictVersion); err != nil {
return fmt.Errorf("deliver dictionary version: %w", err)
}
registry, err := engine.OpenWithVersions(cfg.Game.DictDir, cfg.Game.DictVersion) registry, err := engine.OpenWithVersions(cfg.Game.DictDir, cfg.Game.DictVersion)
if err != nil { if err != nil {
return fmt.Errorf("load dictionaries: %w", err) return fmt.Errorf("load dictionaries: %w", err)
@@ -124,7 +117,6 @@ func run(ctx context.Context, cfg config.Config, logger *zap.Logger) error {
defer func() { _ = registry.Close() }() defer func() { _ = registry.Close() }()
logger.Info("dictionaries loaded", logger.Info("dictionaries loaded",
zap.String("dir", cfg.Game.DictDir), zap.String("dir", cfg.Game.DictDir),
zap.String("seed_dir", cfg.Game.DictSeedDir),
zap.String("version", cfg.Game.DictVersion)) zap.String("version", cfg.Game.DictVersion))
// Admin console: an optional backend client to the Telegram connector // Admin console: an optional backend client to the Telegram connector
@@ -154,27 +146,16 @@ func run(ctx context.Context, cfg config.Config, logger *zap.Logger) error {
accounts := account.NewStore(db) accounts := account.NewStore(db)
accounts.SetMetrics(tel.MeterProvider().Meter("scrabble/backend/account")) accounts.SetMetrics(tel.MeterProvider().Meter("scrabble/backend/account"))
games := game.NewService(game.NewStore(db), accounts, registry, cfg.Game, logger) games := game.NewService(game.NewStore(db), accounts, registry, cfg.Game, logger)
// Reconcile the active dictionary version with the registry: the build version // Reconcile the persisted active dictionary version with the registry: a
// (delivered above) becomes active so a redeploy that bumped it goes live for new // version activated through the admin console (and written to the dictionary
// games, except a newer version installed out-of-band through the admin console, // volume) is adopted again after a restart; otherwise the configured seed
// which is not downgraded by a restart (docs/ARCHITECTURE.md §5). // version is kept and persisted (docs/ARCHITECTURE.md §5).
if err := games.InitActiveVersion(ctx); err != nil { if err := games.InitActiveVersion(ctx); err != nil {
return fmt.Errorf("init active dictionary version: %w", err) return fmt.Errorf("init active dictionary version: %w", err)
} }
logger.Info("active dictionary version", zap.String("version", games.ActiveVersion())) logger.Info("active dictionary version", zap.String("version", games.ActiveVersion()))
games.SetNotifier(hub) games.SetNotifier(hub)
games.SetMetrics(tel.MeterProvider().Meter("scrabble/backend/game")) games.SetMetrics(tel.MeterProvider().Meter("scrabble/backend/game"))
// Active-game limit config: the per-tier, per-kind caps in backend.config, read once into an
// in-memory cache at boot and refreshed when the admin edits them. A boot-time load fails fast if
// the single config row is missing; the game domain reads the cache on every new-game gate.
gameLimits := gamelimits.NewService(gamelimits.NewStore(db))
if err := gameLimits.Load(ctx); err != nil {
return fmt.Errorf("load game-limit config: %w", err)
}
games.SetGameLimits(gameLimits)
logger.Info("game-limit config loaded")
go games.RunSweeper(ctx, cfg.Game.TimeoutSweepInterval) go games.RunSweeper(ctx, cfg.Game.TimeoutSweepInterval)
logger.Info("game turn-timeout sweeper started", logger.Info("game turn-timeout sweeper started",
zap.Duration("interval", cfg.Game.TimeoutSweepInterval)) zap.Duration("interval", cfg.Game.TimeoutSweepInterval))
@@ -214,8 +195,7 @@ func run(ctx context.Context, cfg config.Config, logger *zap.Logger) error {
emails.SetSendLimiter(account.NewSendLimiter(time.Minute, 5)) emails.SetSendLimiter(account.NewSendLimiter(time.Minute, 5))
// Account linking & merge: the orchestrator over the account, merge and // Account linking & merge: the orchestrator over the account, merge and
// session layers. Wired to the /api/v1/user/link REST surface below. // session layers. Wired to the /api/v1/user/link REST surface below.
merger := accountmerge.NewMerger(db) links := link.NewService(emails, accounts, accountmerge.NewMerger(db), sessions)
links := link.NewService(emails, accounts, merger, sessions)
socialSvc := social.NewService(social.NewStore(db), accounts, games) socialSvc := social.NewService(social.NewStore(db), accounts, games)
socialSvc.SetNotifier(hub) socialSvc.SetNotifier(hub)
socialSvc.SetMetrics(tel.MeterProvider().Meter("scrabble/backend/social")) socialSvc.SetMetrics(tel.MeterProvider().Meter("scrabble/backend/social"))
@@ -236,9 +216,11 @@ func run(ctx context.Context, cfg config.Config, logger *zap.Logger) error {
// Operator alert emails on new feedback / word complaints, coalesced into one digest // Operator alert emails on new feedback / word complaints, coalesced into one digest
// per interval. Inert unless a distinct admin sender and recipient are configured. // per interval. Inert unless a distinct admin sender and recipient are configured.
if cfg.SMTP.AdminFrom != "" && cfg.SMTP.AdminTo != "" { if cfg.SMTP.AdminFrom != "" && cfg.SMTP.AdminTo != "" {
// The alert digest carries no admin-console link on purpose — an admin URL must not consoleURL := ""
// travel in an email (a mail provider could cache or index it); see adminalert.New. if cfg.PublicBaseURL != "" {
alerts := adminalert.New(mailer, feedbackSvc, games, cfg.SMTP.AdminFrom, cfg.SMTP.AdminTo, logger) consoleURL = strings.TrimRight(cfg.PublicBaseURL, "/") + "/_gm"
}
alerts := adminalert.New(mailer, feedbackSvc, games, cfg.SMTP.AdminFrom, cfg.SMTP.AdminTo, consoleURL, logger)
go alerts.Run(ctx, adminAlertInterval) go alerts.Run(ctx, adminAlertInterval)
logger.Info("admin alert worker started", zap.Duration("interval", adminAlertInterval)) logger.Info("admin alert worker started", zap.Duration("interval", adminAlertInterval))
} }
@@ -281,28 +263,6 @@ func run(ctx context.Context, cfg config.Config, logger *zap.Logger) error {
// block and the banner admin console section. // block and the banner admin console section.
adsSvc := ads.NewService(ads.NewStore(db)) adsSvc := ads.NewService(ads.NewStore(db))
// In-game currency domain (data foundation): the payments schema behind a
// narrow interface. A boot-time reachability check fails fast if the schema
// did not migrate; the wallet routes are registered when that surface lands.
paymentsSvc := payments.NewService(payments.NewStore(db))
if err := paymentsSvc.Ping(ctx); err != nil {
return fmt.Errorf("payments schema unreachable: %w", err)
}
logger.Info("payments domain ready")
// 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 // The image-render sidecar client for the PNG export artifact; nil (PNG
// download answers 404) when BACKEND_RENDERER_URL is unset. // download answers 404) when BACKEND_RENDERER_URL is unset.
var renderer *render.Client var renderer *render.Client
@@ -330,12 +290,9 @@ func run(ctx context.Context, cfg config.Config, logger *zap.Logger) error {
RateWatch: rateWatch, RateWatch: rateWatch,
BanView: banView, BanView: banView,
Ads: adsSvc, Ads: adsSvc,
Payments: paymentsSvc,
GameLimits: gameLimits,
Notifier: hub, Notifier: hub,
ExportSignKey: cfg.ExportSignKey, ExportSignKey: cfg.ExportSignKey,
Renderer: renderer, Renderer: renderer,
Robokassa: cfg.Robokassa,
}) })
pushSrv := pushgrpc.NewServer(cfg.GRPCAddr, hub, logger) pushSrv := pushgrpc.NewServer(cfg.GRPCAddr, hub, logger)
@@ -344,13 +301,6 @@ func run(ctx context.Context, cfg config.Config, logger *zap.Logger) error {
logger.Info("servers starting", logger.Info("servers starting",
zap.String("http_addr", cfg.HTTPAddr), zap.String("http_addr", cfg.HTTPAddr),
zap.String("grpc_addr", cfg.GRPCAddr)) 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) errc := make(chan error, 2)
go func() { errc <- pushSrv.Run(ctx) }() go func() { errc <- pushSrv.Run(ctx) }()
go func() { errc <- srv.Run(ctx) }() go func() { errc <- srv.Run(ctx) }()
@@ -360,52 +310,6 @@ func run(ctx context.Context, cfg config.Config, logger *zap.Logger) error {
return err 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 // 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). // configured, otherwise the development log mailer (the code is logged, not sent).
func newMailer(cfg account.SMTPConfig, logger *zap.Logger) account.Mailer { func newMailer(cfg account.SMTPConfig, logger *zap.Logger) account.Mailer {
+5 -12
View File
@@ -9,8 +9,7 @@
// 1. start a postgres:17-alpine container via testcontainers-go // 1. start a postgres:17-alpine container via testcontainers-go
// 2. open it with search_path=backend and apply the embedded goose migrations // 2. open it with search_path=backend and apply the embedded goose migrations
// 3. drop goose's bookkeeping table so jet does not generate a model for it // 3. drop goose's bookkeeping table so jet does not generate a model for it
// 4. run jet's PostgreSQL generator for the backend and payments schemas into // 4. run jet's PostgreSQL generator for schema=backend into internal/postgres/jet
// internal/postgres/jet (one subdirectory per schema)
package main package main
import ( import (
@@ -37,7 +36,6 @@ const (
superuserPassword = "scrabble" superuserPassword = "scrabble"
superuserDatabase = "scrabble_backend" superuserDatabase = "scrabble_backend"
backendSchema = "backend" backendSchema = "backend"
paymentsSchema = "payments"
containerStartup = 90 * time.Second containerStartup = 90 * time.Second
jetOutputDirSuffix = "internal/postgres/jet" jetOutputDirSuffix = "internal/postgres/jet"
) )
@@ -107,16 +105,11 @@ func run(ctx context.Context) error {
return fmt.Errorf("drop goose_db_version: %w", err) return fmt.Errorf("drop goose_db_version: %w", err)
} }
// Each schema generates into its own jet/<schema>/ subdirectory (the if err := jetpostgres.GenerateDB(db, backendSchema, outputDir); err != nil {
// generator wipes only that subtree), so the two calls do not clobber each return fmt.Errorf("jet generate: %w", err)
// other. GenerateDB takes the schema explicitly, so the connection's
// search_path=backend does not affect the payments introspection.
for _, schema := range []string{backendSchema, paymentsSchema} {
if err := jetpostgres.GenerateDB(db, schema, outputDir); err != nil {
return fmt.Errorf("jet generate schema=%s: %w", schema, err)
}
log.Printf("jetgen: generated jet code into %s (schema=%s)", outputDir, schema)
} }
log.Printf("jetgen: generated jet code into %s (schema=%s)", outputDir, backendSchema)
return nil return nil
} }
-417
View File
@@ -1,417 +0,0 @@
// Command movegen emits golden conformance fixtures for the client-side move
// generator port (ui/src/lib/dict). It is a dev tool, run by hand; its output is
// committed so the TypeScript parity tests run without a Go toolchain.
//
// For each small sample dictionary (English and Russian — the latter reaches
// alphabet index 32, exercising the 33-letter cross-set boundary) it writes:
//
// - sample_<tag>.dawg the serialized dictionary (the reader/cursor fixture)
// - sample_<tag>.words.json the stored words + their alphabet indexes
// - sample_<tag>.gen.json ranked move-generation results from the real solver,
// for a handful of positions, plus the ruleset the TS
// side rebuilds to score identically
//
// Positions are built with only the solver's public API: an empty board, and
// two-ply positions reached by applying the solver's own top move (so no internal
// encoding is needed). Regenerate with:
//
// go run ./backend/cmd/movegen -out ui/src/lib/dict/testdata
package main
import (
"bytes"
"encoding/json"
"flag"
"log"
"os"
"path/filepath"
"strings"
"gitea.iliadenisov.ru/developer/scrabble-solver/board"
"gitea.iliadenisov.ru/developer/scrabble-solver/rack"
"gitea.iliadenisov.ru/developer/scrabble-solver/rules"
"gitea.iliadenisov.ru/developer/scrabble-solver/scrabble"
dawg "github.com/iliadenisov/dafsa"
)
// sampleWordsEN is the English sample dictionary, in strictly increasing
// alphabet-index order (the builder requires it). Shared prefixes (car/care/cars),
// shared suffixes (cats/dogs), internal-final nodes (do, an) and a one-letter word.
var sampleWordsEN = []string{
"a", "an", "and", "ant",
"car", "care", "cared", "cares", "cars", "cat", "cats",
"do", "doe", "does", "dog", "dogs", "done", "dot",
}
// sampleWordsRU is the Russian sample dictionary, in strictly increasing index
// order. It deliberately includes words starting with я (index 32) so the ported
// cross-set handles alphabet indexes past JS's 31-bit shift boundary.
var sampleWordsRU = []string{"ад", "ар", "оса", "я", "яд", "яр"}
// sampleFixture is the JSON committed with the .dawg so the TypeScript cursor test
// knows the exact word set (as alphabet indexes) to expect from enumeration.
type sampleFixture struct {
Alphabet string `json:"alphabet"`
NumAdded int `json:"numAdded"`
Words []string `json:"words"`
Indexes [][]int `json:"indexes"`
}
// genFixture is the move-generation golden set for one sample dictionary.
type genFixture struct {
Ruleset genRuleset `json:"ruleset"`
Cases []genCase `json:"cases"`
}
// genRuleset is the scoring data the TS side rebuilds so evaluate() matches the Go
// solver: letter values, premium multipliers per square, the centre, rack size and bonus.
type genRuleset struct {
Size int `json:"size"`
Cols int `json:"cols"`
Center int `json:"center"`
RackSize int `json:"rackSize"`
Bingo int `json:"bingo"`
Values []int `json:"values"`
LetterMult [][]int `json:"letterMult"`
WordMult [][]int `json:"wordMult"`
}
// genTile is one placed tile (a board tile or a move placement).
type genTile struct {
Row int `json:"row"`
Col int `json:"col"`
Letter int `json:"letter"`
Blank bool `json:"blank"`
}
// genRack is a rack as a multiset of letter indexes plus a blank count.
type genRack struct {
Letters []int `json:"letters"`
Blanks int `json:"blanks"`
}
// genMove is one ranked generated play: its orientation, placed tiles and total score.
type genMove struct {
Dir int `json:"dir"`
Tiles []genTile `json:"tiles"`
Score int `json:"score"`
}
// genCase is one generation position: the tiles already on the board (empty when
// none), the rack, the mode/rule and the ranked moves the solver returns.
type genCase struct {
Name string `json:"name"`
Placed []genTile `json:"placed"`
Rack genRack `json:"rack"`
Mode int `json:"mode"`
IgnoreCrossWords bool `json:"ignoreCrossWords"`
Moves []genMove `json:"moves"`
}
func main() {
out := flag.String("out", "ui/src/lib/dict/testdata", "output directory for fixtures")
dawgDir := flag.String("dawg-dir", "", "when set, emit real-dictionary move-gen golden from the .dawg files in this dir (conformance mode) instead of the committed samples")
flag.Parse()
if err := os.MkdirAll(*out, 0o755); err != nil {
log.Fatalf("movegen: mkdir %s: %v", *out, err)
}
if *dawgDir != "" {
buildReal(*dawgDir, *out)
return
}
emitRulesets()
buildSample(*out, "en", rules.English(), sampleWordsEN, []genCase{
emptyCase("empty-cared", englishRack("caredts", 0), scrabble.Both, false),
emptyCase("empty-dogs", englishRack("dogsent", 0), scrabble.Both, false),
emptyCase("empty-blank", englishRack("caret", 1), scrabble.Both, false),
emptyCase("empty-single-word", englishRack("caredts", 0), scrabble.Both, true),
})
buildSample(*out, "ru", rules.RussianScrabble(), sampleWordsRU, []genCase{
emptyCase("empty-yad", russianRack("ядрасо", 0), scrabble.Both, false),
})
}
// buildSample writes the dawg, the word fixture and the generation golden set for
// one sample dictionary. Two-ply cases are appended: the solver's own top move from
// the first non-empty result is applied, then generation runs again on the new rack.
func buildSample(out, tag string, rs *rules.Ruleset, words []string, cases []genCase) {
idx := rs.Alphabet
b := dawg.New(idx)
indexes := make([][]int, 0, len(words))
for _, w := range words {
if err := b.Add(w); err != nil {
log.Fatalf("movegen[%s]: add %q: %v", tag, w, err)
}
enc, err := idx.Encode(w)
if err != nil {
log.Fatalf("movegen[%s]: encode %q: %v", tag, w, err)
}
ints := make([]int, len(enc))
for i, x := range enc {
ints[i] = int(x)
}
indexes = append(indexes, ints)
}
finder := b.Finish()
writeJSON(filepath.Join(out, "sample_"+tag+".words.json"), sampleFixture{
Alphabet: tag, NumAdded: finder.NumAdded(), Words: words, Indexes: indexes,
})
dawgPath := filepath.Join(out, "sample_"+tag+".dawg")
if _, err := finder.Save(dawgPath); err != nil {
log.Fatalf("movegen[%s]: save %s: %v", tag, dawgPath, err)
}
s := scrabble.NewSolver(rs, finder)
for i := range cases {
runCase(s, rs, &cases[i], nil)
}
// A two-ply position from the first standard case that produced a move.
if two := twoPly(s, rs, cases); two != nil {
cases = append(cases, *two)
}
writeJSON(filepath.Join(out, "sample_"+tag+".gen.json"), genFixture{
Ruleset: rulesetOf(rs), Cases: cases,
})
log.Printf("movegen[%s]: %d words, %d cases", tag, finder.NumAdded(), len(cases))
}
// realVariant maps a shipped dictionary file to the ruleset that scores it and the racks
// the conformance positions use. smallRack/blankRack keep the first-move (empty board)
// lists bounded on a dense dictionary; fullRack drives a deep 7-tile mid-game position,
// kept small by the anchors around the already-placed word.
type realVariant struct {
file, variant, smallRack, blankRack, fullRack string
rs *rules.Ruleset
}
// buildReal emits move-generation golden from the real shipped dictionaries in dawgDir —
// the full alphabets and deep graphs the tiny samples cannot reach — one
// <variant>.movegen.json per variant. Like the dictgen/validategen vectors it is
// regenerated in CI and never committed, so it pins no dictionary version into the repo.
func buildReal(dawgDir, out string) {
reals := []realVariant{
{"en_sowpods", "scrabble_en", "aine", "ain", "aeinrst", rules.English()},
{"ru_scrabble", "scrabble_ru", "аеин", "аен", "аеиноср", rules.RussianScrabble()},
{"ru_erudit", "erudit_ru", "аеин", "аен", "аеиноср", rules.Erudit()},
}
for _, v := range reals {
data, err := os.ReadFile(filepath.Join(dawgDir, v.file+".dawg"))
if err != nil {
log.Fatalf("movegen[%s]: read dawg: %v", v.variant, err)
}
finder, err := dawg.Read(bytes.NewReader(data), 0)
if err != nil {
log.Fatalf("movegen[%s]: parse dawg: %v", v.variant, err)
}
s := scrabble.NewSolver(v.rs, finder)
cases := []genCase{
emptyCase("first-move", encRack(v.rs, v.smallRack, 0), scrabble.Both, false),
emptyCase("first-move-blank", encRack(v.rs, v.blankRack, 1), scrabble.Both, false),
}
for i := range cases {
runCase(s, v.rs, &cases[i], nil)
}
// A deep 7-tile mid-game: place the top first move, then generate again. The
// anchors around the placed word bound the list while still exercising a full rack,
// deep left/right extension and wide cross-sets over the real graph.
full := encRack(v.rs, v.fullRack, 0)
b := board.New(v.rs.Rows, v.rs.Cols)
if m1 := s.GenerateMovesOpts(b, toRack(v.rs.Size(), full), scrabble.Both, scrabble.PlayOptions{}); len(m1) > 0 {
mid := genCase{Name: "mid-game", Rack: full, Mode: int(scrabble.Both)}
runCase(s, v.rs, &mid, tilesOf(m1[0].Tiles))
cases = append(cases, mid)
}
writeJSON(filepath.Join(out, v.variant+".movegen.json"), genFixture{Ruleset: rulesetOf(v.rs), Cases: cases})
total := 0
for _, c := range cases {
total += len(c.Moves)
}
_ = finder.Close()
log.Printf("movegen[%s]: %d cases, %d golden moves", v.variant, len(cases), total)
}
}
// encRack encodes a rack given as the variant's letters (plus a blank count) into the
// index-based genRack the fixtures carry.
func encRack(rs *rules.Ruleset, letters string, blanks int) genRack {
enc, err := rs.Alphabet.Encode(letters)
if err != nil {
log.Fatalf("movegen: encode rack %q: %v", letters, err)
}
idx := make([]int, len(enc))
for i, b := range enc {
idx[i] = int(b)
}
return genRack{Letters: idx, Blanks: blanks}
}
// runCase fills a case's Moves by generating on a board holding the given placed
// tiles (nil = empty board).
func runCase(s *scrabble.Solver, rs *rules.Ruleset, c *genCase, placed []genTile) {
bd := board.New(rs.Rows, rs.Cols)
for _, t := range placed {
bd.Set(t.Row, t.Col, cellByte(t.Letter, t.Blank))
}
c.Placed = placed
rk := toRack(rs.Size(), c.Rack)
moves := s.GenerateMovesOpts(bd, rk, scrabble.Mode(c.Mode), scrabble.PlayOptions{IgnoreCrossWords: c.IgnoreCrossWords})
c.Moves = movesOf(moves)
}
// twoPly reaches a mid-game position by applying the top move of the first standard
// case that generated one, then generates again with a fresh rack of the same tiles.
func twoPly(s *scrabble.Solver, rs *rules.Ruleset, cases []genCase) *genCase {
for _, c := range cases {
if c.IgnoreCrossWords || len(c.Moves) == 0 {
continue
}
placed := c.Moves[0].Tiles
next := genCase{Name: "two-ply", Rack: c.Rack, Mode: int(scrabble.Both)}
runCase(s, rs, &next, placed)
return &next
}
return nil
}
// cellByte encodes a board cell the way internal/encoding.Cell does (bits 0-5 hold
// letter+1, bit 7 marks a blank). Duplicated here because that package is internal
// to the solver module and cannot be imported.
func cellByte(letter int, blank bool) byte {
v := byte(letter+1) & 0x3f
if blank {
v |= 0x80
}
return v
}
func toRack(size int, r genRack) rack.Rack {
rk := rack.New(size)
for _, l := range r.Letters {
rk.Add(byte(l))
}
for i := 0; i < r.Blanks; i++ {
rk.AddBlank()
}
return rk
}
func rulesetOf(rs *rules.Ruleset) genRuleset {
lm := make([][]int, rs.Rows)
wm := make([][]int, rs.Rows)
for r := 0; r < rs.Rows; r++ {
lm[r] = make([]int, rs.Cols)
wm[r] = make([]int, rs.Cols)
for c := 0; c < rs.Cols; c++ {
p := rs.Premium(r, c)
lm[r][c] = p.LetterMult()
wm[r][c] = p.WordMult()
}
}
return genRuleset{
Size: rs.Size(), Cols: rs.Cols, Center: rs.Center, RackSize: rs.RackSize,
Bingo: rs.Bingo, Values: rs.Values, LetterMult: lm, WordMult: wm,
}
}
func movesOf(ms []scrabble.Move) []genMove {
out := make([]genMove, len(ms))
for i, m := range ms {
out[i] = genMove{Dir: int(m.Dir), Tiles: tilesOf(m.Tiles), Score: m.Score}
}
return out
}
func tilesOf(ps []scrabble.Placement) []genTile {
out := make([]genTile, len(ps))
for i, p := range ps {
out[i] = genTile{Row: p.Row, Col: p.Col, Letter: int(p.Letter), Blank: p.Blank}
}
return out
}
// emptyCase builds an empty-board case (Moves filled later by runCase).
func emptyCase(name string, r genRack, mode scrabble.Mode, ignoreCross bool) genCase {
return genCase{Name: name, Rack: r, Mode: int(mode), IgnoreCrossWords: ignoreCross}
}
// englishRack builds a rack from lowercase a-z letters (index = letter-'a').
func englishRack(letters string, blanks int) genRack {
idx := make([]int, 0, len(letters))
for _, ch := range letters {
idx = append(idx, int(ch-'a'))
}
return genRack{Letters: idx, Blanks: blanks}
}
// russianRack builds a rack from the Russian sample letters used above.
func russianRack(letters string, blanks int) genRack {
m := map[rune]int{'а': 0, 'д': 4, 'о': 15, 'р': 17, 'с': 18, 'я': 32}
idx := make([]int, 0, len([]rune(letters)))
for _, ch := range letters {
i, ok := m[ch]
if !ok {
log.Fatalf("movegen: russianRack: no index for %q", string(ch))
}
idx = append(idx, i)
}
return genRack{Letters: idx, Blanks: blanks}
}
// emitRulesets writes the per-variant static ruleset data (tile values, bag counts, blanks,
// bingo, rack size) the offline engine mirrors in ui/src/lib/localgame/ruleset.ts, so a TS
// parity test can pin that hand-copied table to the Go rulesets (scrabble-solver/rules).
func emitRulesets() {
type rsFix struct {
Size int `json:"size"`
RackSize int `json:"rackSize"`
Bingo int `json:"bingo"`
Blanks int `json:"blanks"`
Values []int `json:"values"`
Counts []int `json:"counts"`
Letters []string `json:"letters"`
}
out := map[string]rsFix{}
for _, v := range []struct {
name string
rs *rules.Ruleset
}{
{"scrabble_en", rules.English()},
{"scrabble_ru", rules.RussianScrabble()},
{"erudit_ru", rules.Erudit()},
} {
letters := make([]string, v.rs.Size())
for i := range letters {
ch, err := v.rs.Alphabet.Character(byte(i))
if err != nil {
log.Fatalf("movegen: %s letter %d: %v", v.name, i, err)
}
letters[i] = strings.ToUpper(ch)
}
out[v.name] = rsFix{Size: v.rs.Size(), RackSize: v.rs.RackSize, Bingo: v.rs.Bingo, Blanks: v.rs.Blanks, Values: v.rs.Values, Counts: v.rs.Counts, Letters: letters}
}
dir := filepath.Join("ui", "src", "lib", "localgame", "testdata")
if err := os.MkdirAll(dir, 0o755); err != nil {
log.Fatalf("movegen: mkdir %s: %v", dir, err)
}
writeJSON(filepath.Join(dir, "rulesets.json"), out)
log.Printf("movegen: wrote %s (3 variants)", filepath.Join(dir, "rulesets.json"))
}
func writeJSON(path string, v any) {
data, err := json.MarshalIndent(v, "", " ")
if err != nil {
log.Fatalf("movegen: marshal %s: %v", path, err)
}
if err := os.WriteFile(path, append(data, '\n'), 0o644); err != nil {
log.Fatalf("movegen: write %s: %v", path, err)
}
}
+61 -35
View File
@@ -10,7 +10,6 @@ import (
"database/sql" "database/sql"
"errors" "errors"
"fmt" "fmt"
"math/rand/v2"
"strings" "strings"
"time" "time"
@@ -44,7 +43,9 @@ var ErrNotFound = errors.New("account: not found")
// local-time window (in TimeZone) during which the player is asleep, so the // 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 // 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, // 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.) // 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.
type Account struct { type Account struct {
ID uuid.UUID ID uuid.UUID
DisplayName string DisplayName string
@@ -52,6 +53,7 @@ type Account struct {
TimeZone string TimeZone string
AwayStart time.Time AwayStart time.Time
AwayEnd time.Time AwayEnd time.Time
HintBalance int
BlockChat bool BlockChat bool
BlockFriendRequests bool BlockFriendRequests bool
// VariantPreferences is the set of game variants (engine.Variant stable labels: // VariantPreferences is the set of game variants (engine.Variant stable labels:
@@ -67,6 +69,10 @@ type Account struct {
// true (the default): the platform side-service skips out-of-app push for the // true (the default): the platform side-service skips out-of-app push for the
// account. // account.
NotificationsInAppOnly bool 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 // 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 // uuid.Nil for a live account. A tombstone keeps the row so the no-cascade
// foreign keys of a shared finished game stay valid. // foreign keys of a shared finished game stay valid.
@@ -387,21 +393,6 @@ func (s *Store) Identities(ctx context.Context, accountID uuid.UUID) ([]Identity
return out, nil 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 // ListAccounts returns accounts for the admin user list, newest first, paginated
// by limit and offset. // by limit and offset.
func (s *Store) ListAccounts(ctx context.Context, limit, offset int) ([]Account, error) { func (s *Store) ListAccounts(ctx context.Context, limit, offset int) ([]Account, error) {
@@ -526,13 +517,8 @@ func (s *Store) create(ctx context.Context, kind, externalID string, seed provis
return created, nil return created, nil
} }
// guestDisplayName mints the display name stamped on a freshly provisioned guest: "Guest" plus a // guestDisplayName is the display name stamped on a freshly provisioned guest.
// random six-digit suffix (e.g. "Guest042317"), so a guest is distinguishable to opponents — in a const guestDisplayName = "Guest"
// random match the other player sees a distinct name rather than every guest reading a bare
// "Guest". Not an identifier and not unique (collisions are harmless — it is a label only).
func guestDisplayName() string {
return fmt.Sprintf("Guest%06d", rand.IntN(1_000_000))
}
// ProvisionGuest creates a fresh ephemeral guest account: a durable row carrying // ProvisionGuest creates a fresh ephemeral guest account: a durable row carrying
// no identity, flagged is_guest, so it can hold a session and a game seat (both // no identity, flagged is_guest, so it can hold a session and a game seat (both
@@ -540,7 +526,7 @@ func guestDisplayName() string {
// and history. Guests are not reused — each bootstrap mints a new account. browserTZ // and history. Guests are not reused — each bootstrap mints a new account. browserTZ
// (the client's detected "±HH:MM" UTC offset) seeds the guest's time zone, falling // (the client's detected "±HH:MM" UTC offset) seeds the guest's time zone, falling
// back to the 'UTC' default when empty or malformed. // back to the 'UTC' default when empty or malformed.
func (s *Store) ProvisionGuest(ctx context.Context, browserTZ, language string) (Account, error) { func (s *Store) ProvisionGuest(ctx context.Context, browserTZ string) (Account, error) {
accountID, err := uuid.NewV7() accountID, err := uuid.NewV7()
if err != nil { if err != nil {
return Account{}, fmt.Errorf("account: new guest id: %w", err) return Account{}, fmt.Errorf("account: new guest id: %w", err)
@@ -549,17 +535,9 @@ func (s *Store) ProvisionGuest(ctx context.Context, browserTZ, language string)
if tz == "" { if tz == "" {
tz = "UTC" tz = "UTC"
} }
// Seed the interface language from the client's detected locale (validated to a supported
// one), so a fresh guest's language-dependent server content — the ad banner, bot messages —
// is right from first contact rather than the 'en' column default until the client's later
// language reconcile catches up. An unsupported or absent code keeps the 'en' default.
lang := supportedLanguage(language)
if lang == "" {
lang = "en"
}
stmt := table.Accounts. stmt := table.Accounts.
INSERT(table.Accounts.AccountID, table.Accounts.DisplayName, table.Accounts.IsGuest, table.Accounts.TimeZone, table.Accounts.PreferredLanguage). INSERT(table.Accounts.AccountID, table.Accounts.DisplayName, table.Accounts.IsGuest, table.Accounts.TimeZone).
VALUES(accountID, guestDisplayName(), true, tz, lang). VALUES(accountID, guestDisplayName, true, tz).
RETURNING(table.Accounts.AllColumns) RETURNING(table.Accounts.AllColumns)
var row model.Accounts var row model.Accounts
@@ -570,6 +548,52 @@ func (s *Store) ProvisionGuest(ctx context.Context, browserTZ, language string)
return modelToAccount(row), nil 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 // 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 // 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 // re-flag after an operator clear starts a fresh timestamp. An infra marker, not
@@ -625,10 +649,12 @@ func modelToAccount(row model.Accounts) Account {
TimeZone: row.TimeZone, TimeZone: row.TimeZone,
AwayStart: row.AwayStart, AwayStart: row.AwayStart,
AwayEnd: row.AwayEnd, AwayEnd: row.AwayEnd,
HintBalance: int(row.HintBalance),
BlockChat: row.BlockChat, BlockChat: row.BlockChat,
BlockFriendRequests: row.BlockFriendRequests, BlockFriendRequests: row.BlockFriendRequests,
IsGuest: row.IsGuest, IsGuest: row.IsGuest,
NotificationsInAppOnly: row.NotificationsInAppOnly, NotificationsInAppOnly: row.NotificationsInAppOnly,
PaidAccount: row.PaidAccount,
MergedInto: mergedInto, MergedInto: mergedInto,
FlaggedHighRateAt: flaggedHighRateAt, FlaggedHighRateAt: flaggedHighRateAt,
CreatedAt: row.CreatedAt, CreatedAt: row.CreatedAt,
+11 -16
View File
@@ -105,10 +105,9 @@ func (s *EmailService) allowSend(email string) bool {
// issueCode generates a fresh confirm-code and one-tap deeplink token for (accountID, // issueCode generates a fresh confirm-code and one-tap deeplink token for (accountID,
// email), replaces any prior pending confirmation, and mails the branded code in // email), replaces any prior pending confirmation, and mails the branded code in
// locale; purpose selects the email wording and what verifying does. omitLink drops the // locale; purpose selects the email wording and what verifying does. Only the SHA-256
// one-tap deeplink from the email (account deletion, or a login requested from an installed // hashes of the code and token are stored.
// PWA). Only the SHA-256 hashes of the code and token are stored. func (s *EmailService) issueCode(ctx context.Context, accountID uuid.UUID, email, purpose, locale string) error {
func (s *EmailService) issueCode(ctx context.Context, accountID uuid.UUID, email, purpose, locale string, omitLink bool) error {
code, codeHash, err := generateCode() code, codeHash, err := generateCode()
if err != nil { if err != nil {
return err return err
@@ -120,12 +119,11 @@ func (s *EmailService) issueCode(ctx context.Context, accountID uuid.UUID, email
if err := s.store.replacePendingConfirmation(ctx, accountID, email, codeHash, tokenHash, purpose, s.now().Add(emailCodeTTL)); err != nil { if err := s.store.replacePendingConfirmation(ctx, accountID, email, codeHash, tokenHash, purpose, s.now().Add(emailCodeTTL)); err != nil {
return err return err
} }
// Omit the one-tap deeplink when asked: for a login from an installed PWA (the link would // Account deletion is never a one-tap link (a prefetch or a stray click must not delete
// open in a separate browser whose minted session cannot reach the PWA), or for account // an account): the delete code is entered in the app only, so the email omits the
// deletion (a prefetch or stray click must not delete an account — the delete code is entered // deeplink and ConfirmByToken refuses a delete token.
// in the app only, and ConfirmByToken refuses a delete token).
deeplink := s.confirmURL(token, locale) deeplink := s.confirmURL(token, locale)
if purpose == purposeDelete || omitLink { if purpose == purposeDelete {
deeplink = "" deeplink = ""
} }
msg, err := renderConfirmationEmail(purpose, code, deeplink, s.baseURL, locale) msg, err := renderConfirmationEmail(purpose, code, deeplink, s.baseURL, locale)
@@ -177,7 +175,7 @@ func (s *EmailService) RequestCode(ctx context.Context, accountID uuid.UUID, ema
} }
return ErrEmailTaken return ErrEmailTaken
} }
return s.issueCode(ctx, accountID, addr, purposeLink, s.accountLocale(ctx, accountID), false) return s.issueCode(ctx, accountID, addr, purposeLink, s.accountLocale(ctx, accountID))
} }
// ConfirmCode verifies code for accountID and email. On success it attaches a // ConfirmCode verifies code for accountID and email. On success it attaches a
@@ -223,11 +221,8 @@ func (s *EmailService) ConfirmCode(ctx context.Context, accountID uuid.UUID, ema
// the ordinary returning-user login. The code is mailed to the address, so only its // the ordinary returning-user login. The code is mailed to the address, so only its
// real owner can complete the login. On first contact browserTZ (the client's // real owner can complete the login. On first contact browserTZ (the client's
// detected "±HH:MM" UTC offset) seeds the new account's time zone and language its UI // detected "±HH:MM" UTC offset) seeds the new account's time zone and language its UI
// language. When pwa is set (the request came from an installed PWA) the login email omits the // language. It returns the target account id for the subsequent LoginWithCode.
// one-tap confirm link — it would open in a separate browser, out of the PWA's reach — so the func (s *EmailService) RequestLoginCode(ctx context.Context, email, browserTZ, language string) (uuid.UUID, error) {
// code is entered in the same window. It returns the target account id for the subsequent
// LoginWithCode.
func (s *EmailService) RequestLoginCode(ctx context.Context, email, browserTZ, language string, pwa bool) (uuid.UUID, error) {
addr, err := normalizeEmail(email) addr, err := normalizeEmail(email)
if err != nil { if err != nil {
return uuid.UUID{}, err return uuid.UUID{}, err
@@ -239,7 +234,7 @@ func (s *EmailService) RequestLoginCode(ctx context.Context, email, browserTZ, l
if err != nil { if err != nil {
return uuid.UUID{}, err return uuid.UUID{}, err
} }
if err := s.issueCode(ctx, acc.ID, addr, purposeLogin, language, pwa); err != nil { if err := s.issueCode(ctx, acc.ID, addr, purposeLogin, language); err != nil {
return uuid.UUID{}, err return uuid.UUID{}, err
} }
return acc.ID, nil return acc.ID, nil
+3 -3
View File
@@ -92,7 +92,7 @@ func (s *EmailService) RequestLinkCode(ctx context.Context, accountID uuid.UUID,
if !s.allowSend(addr) { if !s.allowSend(addr) {
return ErrTooManyRequests return ErrTooManyRequests
} }
return s.issueCode(ctx, accountID, addr, purposeLink, s.accountLocale(ctx, accountID), false) return s.issueCode(ctx, accountID, addr, purposeLink, s.accountLocale(ctx, accountID))
} }
// ConfirmLink verifies code for (accountID, email) and reports the address's // ConfirmLink verifies code for (accountID, email) and reports the address's
@@ -140,7 +140,7 @@ func (s *EmailService) RequestChangeCode(ctx context.Context, accountID uuid.UUI
if !s.allowSend(addr) { if !s.allowSend(addr) {
return ErrTooManyRequests return ErrTooManyRequests
} }
return s.issueCode(ctx, accountID, addr, purposeChange, s.accountLocale(ctx, accountID), false) return s.issueCode(ctx, accountID, addr, purposeChange, s.accountLocale(ctx, accountID))
} }
// ConfirmChange verifies code for (accountID, newEmail) and atomically replaces the // ConfirmChange verifies code for (accountID, newEmail) and atomically replaces the
@@ -199,7 +199,7 @@ func (s *EmailService) RequestDeleteCode(ctx context.Context, accountID uuid.UUI
if !s.allowSend(addr) { if !s.allowSend(addr) {
return ErrTooManyRequests return ErrTooManyRequests
} }
return s.issueCode(ctx, accountID, addr, purposeDelete, s.accountLocale(ctx, accountID), false) return s.issueCode(ctx, accountID, addr, purposeDelete, s.accountLocale(ctx, accountID))
} }
// VerifyDeleteCode verifies the account-deletion code against the account's own email and // VerifyDeleteCode verifies the account-deletion code against the account's own email and
+20 -40
View File
@@ -51,24 +51,8 @@ var ErrSameAccount = errors.New("accountmerge: primary and secondary are the sam
type Merger struct { type Merger struct {
db *sql.DB db *sql.DB
now func() time.Time now func() time.Time
// payments, when set, merges the two accounts' chip segments and benefits by origin inside
// the merge transaction (SetPayments). Nil leaves payments untouched (tests that do not
// exercise the wallet).
payments PaymentsMerger
} }
// PaymentsMerger is the payments surface the account merge enlists: fold the secondary's
// segments and benefits into the primary within the merge transaction, then invalidate the
// affected read caches after the commit. *payments.Service satisfies it.
type PaymentsMerger interface {
MergeTx(ctx context.Context, tx *sql.Tx, primary, secondary uuid.UUID) error
Invalidate(ids ...uuid.UUID)
}
// SetPayments installs the payments merge hook. It must be called during startup wiring; the
// default (nil) merges no wallet state.
func (m *Merger) SetPayments(p PaymentsMerger) { m.payments = p }
// NewMerger constructs a Merger over db. // NewMerger constructs a Merger over db.
func NewMerger(db *sql.DB) *Merger { func NewMerger(db *sql.DB) *Merger {
return &Merger{db: db, now: func() time.Time { return time.Now().UTC() }} return &Merger{db: db, now: func() time.Time { return time.Now().UTC() }}
@@ -83,7 +67,7 @@ func (m *Merger) Merge(ctx context.Context, primary, secondary uuid.UUID) error
return ErrSameAccount return ErrSameAccount
} }
now := m.now() now := m.now()
if err := withTx(ctx, m.db, func(tx *sql.Tx) error { return withTx(ctx, m.db, func(tx *sql.Tx) error {
if err := guardActiveSharedGame(ctx, tx, primary, secondary); err != nil { if err := guardActiveSharedGame(ctx, tx, primary, secondary); err != nil {
return err return err
} }
@@ -123,21 +107,8 @@ func (m *Merger) Merge(ctx context.Context, primary, secondary uuid.UUID) error
if err := deleteEphemerals(ctx, tx, secondary); err != nil { if err := deleteEphemerals(ctx, tx, secondary); err != nil {
return err return err
} }
if m.payments != nil {
if err := m.payments.MergeTx(ctx, tx, primary, secondary); err != nil {
return fmt.Errorf("accountmerge: payments: %w", err)
}
}
return tombstone(ctx, tx, primary, secondary, now) return tombstone(ctx, tx, primary, secondary, now)
}); err != nil { })
return err
}
// The payments read cache is invalidated only after the merge commits, so a read racing the
// transaction cannot re-cache pre-merge state (both accounts' rows are moved or dropped).
if m.payments != nil {
m.payments.Invalidate(primary, secondary)
}
return nil
} }
// guardActiveSharedGame returns ErrActiveGameConflict when primary and secondary // guardActiveSharedGame returns ErrActiveGameConflict when primary and secondary
@@ -277,16 +248,25 @@ func mergeBestMoves(ctx context.Context, tx *sql.Tx, primary, secondary uuid.UUI
return nil return nil
} }
// mergeAccountFields bumps the primary account's updated_at to reflect the merge. The former // mergeAccountFields adds secondary's hint wallet to primary and ORs the paid flag;
// hint-wallet and paid-flag merge moved to the payments domain, where segments and benefits // all other profile fields stay the primary's.
// merge by origin (see the payments MergeTx step and docs/PAYMENTS.md §6); the legacy func mergeAccountFields(ctx context.Context, tx *sql.Tx, primary, secondary uuid.UUID, now time.Time) error {
// accounts.hint_balance / paid_account columns are deprecated and no longer read or written. var sec model.Accounts
func mergeAccountFields(ctx context.Context, tx *sql.Tx, primary, _ uuid.UUID, now time.Time) error { if err := postgres.SELECT(table.Accounts.AllColumns).
upd := table.Accounts.UPDATE(table.Accounts.UpdatedAt). FROM(table.Accounts).
SET(postgres.TimestampzT(now)). WHERE(table.Accounts.AccountID.EQ(postgres.UUID(secondary))).
WHERE(table.Accounts.AccountID.EQ(postgres.UUID(primary))) 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)))
if _, err := upd.ExecContext(ctx, tx); err != nil { if _, err := upd.ExecContext(ctx, tx); err != nil {
return fmt.Errorf("accountmerge: touch primary account: %w", err) return fmt.Errorf("accountmerge: update primary account: %w", err)
} }
return nil return nil
} }
+9 -8
View File
@@ -33,21 +33,21 @@ type Notifier struct {
complaints ComplaintCounter complaints ComplaintCounter
from string from string
to string to string
consoleURL string
clock func() time.Time clock func() time.Time
log *zap.Logger log *zap.Logger
last time.Time last time.Time
} }
// New constructs a Notifier. from and to are the alert sender and recipient(s); log may be // New constructs a Notifier. from and to are the alert sender and recipient(s); consoleURL,
// nil. The watermark starts at "now", so only items arriving after start-up are reported. The // when non-empty, is the admin-console link included in the email. log may be nil. The
// digest deliberately carries no admin-console link — an admin URL must never travel in an // watermark starts at "now", so only items arriving after start-up are reported.
// email, where a mail provider could cache or index it. func New(mailer account.Mailer, fb FeedbackCounter, cp ComplaintCounter, from, to, consoleURL string, log *zap.Logger) *Notifier {
func New(mailer account.Mailer, fb FeedbackCounter, cp ComplaintCounter, from, to string, log *zap.Logger) *Notifier {
if log == nil { if log == nil {
log = zap.NewNop() log = zap.NewNop()
} }
return &Notifier{ return &Notifier{
mailer: mailer, feedback: fb, complaints: cp, from: from, to: to, mailer: mailer, feedback: fb, complaints: cp, from: from, to: to, consoleURL: consoleURL,
clock: func() time.Time { return time.Now().UTC() }, log: log, last: time.Now().UTC(), clock: func() time.Time { return time.Now().UTC() }, log: log, last: time.Now().UTC(),
} }
} }
@@ -103,9 +103,10 @@ func (n *Notifier) digest(fb, cp int) account.Message {
parts = append(parts, fmt.Sprintf("%d new word complaint(s)", cp)) parts = append(parts, fmt.Sprintf("%d new word complaint(s)", cp))
} }
summary := strings.Join(parts, ", ") summary := strings.Join(parts, ", ")
// No admin-console link in the body: an admin URL must never travel in an email (a mail
// provider could cache or index it). The operator opens the console directly.
text := summary + "." text := summary + "."
if n.consoleURL != "" {
text += "\n\nOpen the admin console: " + n.consoleURL
}
return account.Message{ return account.Message{
From: n.from, From: n.from,
To: n.to, To: n.to,
+4 -6
View File
@@ -28,7 +28,7 @@ func (m *recordingMailer) Send(_ context.Context, msg account.Message) error {
func TestNotifierSkipsWhenNothingNew(t *testing.T) { func TestNotifierSkipsWhenNothingNew(t *testing.T) {
mailer := &recordingMailer{} mailer := &recordingMailer{}
n := New(mailer, fbCounter{0}, cpCounter{0}, "alerts@erudit-game.ru", "op@x.ru", nil) n := New(mailer, fbCounter{0}, cpCounter{0}, "alerts@erudit-game.ru", "op@x.ru", "", nil)
n.tick(context.Background()) n.tick(context.Background())
if len(mailer.sent) != 0 { if len(mailer.sent) != 0 {
t.Fatalf("sent %d emails, want 0 when nothing is new", len(mailer.sent)) t.Fatalf("sent %d emails, want 0 when nothing is new", len(mailer.sent))
@@ -37,7 +37,7 @@ func TestNotifierSkipsWhenNothingNew(t *testing.T) {
func TestNotifierDigestsNewItems(t *testing.T) { func TestNotifierDigestsNewItems(t *testing.T) {
mailer := &recordingMailer{} mailer := &recordingMailer{}
n := New(mailer, fbCounter{2}, cpCounter{1}, "alerts@erudit-game.ru", "op@x.ru, two@x.ru", nil) n := New(mailer, fbCounter{2}, cpCounter{1}, "alerts@erudit-game.ru", "op@x.ru, two@x.ru", "https://erudit-game.ru/_gm", nil)
n.tick(context.Background()) n.tick(context.Background())
if len(mailer.sent) != 1 { if len(mailer.sent) != 1 {
t.Fatalf("sent %d emails, want 1 digest", len(mailer.sent)) t.Fatalf("sent %d emails, want 1 digest", len(mailer.sent))
@@ -49,9 +49,7 @@ func TestNotifierDigestsNewItems(t *testing.T) {
if !strings.Contains(msg.Subject, "2 new feedback") || !strings.Contains(msg.Subject, "1 new word complaint") { if !strings.Contains(msg.Subject, "2 new feedback") || !strings.Contains(msg.Subject, "1 new word complaint") {
t.Errorf("digest subject = %q, want the feedback + complaint counts", msg.Subject) t.Errorf("digest subject = %q, want the feedback + complaint counts", msg.Subject)
} }
// The digest must never carry an admin-console link — an admin URL in an email is a leak if !strings.Contains(msg.Text, "/_gm") {
// (mail providers cache/index it). t.Errorf("digest body = %q, want the console link", msg.Text)
if strings.Contains(msg.Text, "/_gm") || strings.Contains(strings.ToLower(msg.Text), "admin console") {
t.Errorf("digest body = %q, must not carry an admin-console link", msg.Text)
} }
} }
@@ -193,24 +193,3 @@ code { background: var(--bg); padding: 0.05rem 0.3rem; border-radius: 4px; }
.replay-log { margin: 0.4rem 0 0; padding-left: 1.4rem; max-height: 14rem; overflow: auto; font-size: 0.85rem; } .replay-log { margin: 0.4rem 0 0; padding-left: 1.4rem; max-height: 14rem; overflow: auto; font-size: 0.85rem; }
.replay-log li { color: var(--ink-dim); padding: 0.1rem 0; } .replay-log li { color: var(--ink-dim); padding: 0.1rem 0; }
.replay-log li.cur { color: var(--ink); font-weight: 600; } .replay-log li.cur { color: var(--ink); font-weight: 600; }
/* Banner colour override editor + live preview (banner_detail). The override
fieldsets group the enable toggle with the native colour swatches; the preview
renders a sample strip on both themes from those inputs (see the inline script). */
.ovr { border: 1px solid var(--line); border-radius: 6px; padding: 0.3rem 0.8rem 0.7rem; margin: 0.2rem 0; }
.ovr legend { padding: 0 0.3rem; font-size: 0.85rem; color: var(--ink); }
.ovr legend label { flex-direction: row; align-items: center; gap: 0.4rem; color: var(--ink); }
.ovr .note { margin: 0.2rem 0 0.4rem; }
.ovr .swatches { display: flex; flex-wrap: wrap; gap: 1rem; }
.ovr .swatches label { flex-direction: column; gap: 0.25rem; align-items: flex-start; }
.ovr input[type=color] { width: 3rem; height: 1.8rem; padding: 0; border: 1px solid var(--line); border-radius: 4px; background: var(--bg); cursor: pointer; }
.ovr input[type=color]:disabled { opacity: 0.4; cursor: default; }
.ovr .hex { font-size: 0.72rem; color: var(--ink-dim); font-variant-numeric: tabular-nums; }
.banner-preview { display: flex; flex-wrap: wrap; gap: 0.8rem; margin: 0.7rem 0 0.2rem; }
.banner-preview .bp { flex: 1 1 18rem; }
.banner-preview .bp-label { display: block; font-size: 0.75rem; color: var(--ink-dim); margin-bottom: 0.25rem; }
.ad-frame { padding: 0.7rem; border-radius: 6px; border: 1px solid var(--line); }
.ad-frame.light { background: #f4f6f9; }
.ad-frame.dark { background: #0f1420; }
.ad-sample { padding: 0.35rem 0.7rem; font-size: 0.85rem; white-space: nowrap; overflow: hidden; text-overflow: ellipsis; }
.ad-sample .ad-link { text-decoration: underline; }
@@ -15,14 +15,12 @@
<a href="/_gm/"{{if eq .ActiveNav "dashboard"}} class="active"{{end}}>Dashboard</a> <a href="/_gm/"{{if eq .ActiveNav "dashboard"}} class="active"{{end}}>Dashboard</a>
<a href="/_gm/users"{{if eq .ActiveNav "users"}} class="active"{{end}}>Users</a> <a href="/_gm/users"{{if eq .ActiveNav "users"}} class="active"{{end}}>Users</a>
<a href="/_gm/games"{{if eq .ActiveNav "games"}} class="active"{{end}}>Games</a> <a href="/_gm/games"{{if eq .ActiveNav "games"}} class="active"{{end}}>Games</a>
<a href="/_gm/limits"{{if eq .ActiveNav "limits"}} class="active"{{end}}>Limits</a>
<a href="/_gm/complaints"{{if eq .ActiveNav "complaints"}} class="active"{{end}}>Complaints</a> <a href="/_gm/complaints"{{if eq .ActiveNav "complaints"}} class="active"{{end}}>Complaints</a>
<a href="/_gm/feedback"{{if eq .ActiveNav "feedback"}} class="active"{{end}}>Feedback</a> <a href="/_gm/feedback"{{if eq .ActiveNav "feedback"}} class="active"{{end}}>Feedback</a>
<a href="/_gm/messages"{{if eq .ActiveNav "messages"}} class="active"{{end}}>Messages</a> <a href="/_gm/messages"{{if eq .ActiveNav "messages"}} class="active"{{end}}>Messages</a>
<a href="/_gm/throttled"{{if eq .ActiveNav "throttled"}} class="active"{{end}}>Throttled</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/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/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/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/broadcast"{{if eq .ActiveNav "broadcast"}} class="active"{{end}}>Broadcast</a>
<a href="/_gm/grafana/">Grafana ↗</a> <a href="/_gm/grafana/">Grafana ↗</a>
@@ -11,72 +11,10 @@
<label>Starts (UTC) <input type="datetime-local" name="starts_at" value="{{.StartsAt}}"></label> <label>Starts (UTC) <input type="datetime-local" name="starts_at" value="{{.StartsAt}}"></label>
<label>Ends (UTC) <input type="datetime-local" name="ends_at" value="{{.EndsAt}}"></label> <label>Ends (UTC) <input type="datetime-local" name="ends_at" value="{{.EndsAt}}"></label>
<label><input type="checkbox" name="enabled"{{if .Enabled}} checked{{end}}> Enabled</label> <label><input type="checkbox" name="enabled"{{if .Enabled}} checked{{end}}> Enabled</label>
<fieldset class="ovr">
<legend><label><input type="checkbox" name="urgent"{{if .Urgent}} checked{{end}}> Urgent</label></legend>
<p class="note">Shows to <em>everyone</em>, always — bypassing paid accounts, hint wallets and the no-banner role. While any urgent campaign is live it is the only thing the strip shows (other campaigns and the default are suppressed). For system alerts.</p>
</fieldset>
<fieldset class="ovr" data-ovr-group>
<legend><label><input type="checkbox" name="override_all_on" data-ovr="all"{{if .OverrideAllOn}} checked{{end}}> Colour override — all themes</label></legend>
<div class="swatches">
<label>Background <input type="color" name="override_bg" value="{{.AllBg}}" data-ovr-color{{if not .OverrideAllOn}} disabled{{end}}><span class="hex"></span></label>
<label>Text <input type="color" name="override_fg" value="{{.AllFg}}" data-ovr-color{{if not .OverrideAllOn}} disabled{{end}}><span class="hex"></span></label>
<label>Link <input type="color" name="override_link" value="{{.AllLink}}" data-ovr-color{{if not .OverrideAllOn}} disabled{{end}}><span class="hex"></span></label>
</div>
</fieldset>
<fieldset class="ovr" data-ovr-group>
<legend><label><input type="checkbox" name="override_dark_on" data-ovr="dark"{{if .OverrideDarkOn}} checked{{end}}> Colour override — dark theme only</label></legend>
<div class="swatches">
<label>Background <input type="color" name="override_bg_dark" value="{{.DarkBg}}" data-ovr-color{{if not .OverrideDarkOn}} disabled{{end}}><span class="hex"></span></label>
<label>Text <input type="color" name="override_fg_dark" value="{{.DarkFg}}" data-ovr-color{{if not .OverrideDarkOn}} disabled{{end}}><span class="hex"></span></label>
<label>Link <input type="color" name="override_link_dark" value="{{.DarkLink}}" data-ovr-color{{if not .OverrideDarkOn}} disabled{{end}}><span class="hex"></span></label>
</div>
</fieldset>
{{end}} {{end}}
<div><button type="submit">Save</button></div> <div><button type="submit">Save</button></div>
</form> </form>
{{if not .IsDefault}} {{if not .IsDefault}}
<div class="banner-preview">
<div class="bp"><span class="bp-label">Light theme</span><div class="ad-frame light"><div class="ad-sample" id="prev-light"><span>Sample banner text — <span class="ad-link">a link</span></span></div></div></div>
<div class="bp"><span class="bp-label">Dark theme</span><div class="ad-frame dark"><div class="ad-sample" id="prev-dark"><span>Sample banner text — <span class="ad-link">a link</span></span></div></div></div>
</div>
<p class="note">Live preview of the strip on both themes. An empty override falls back to the neutral theme colours; the top/bottom border is derived from the background.</p>
<script>
(function(){
// Neutral fallbacks — mirror ui/src/app.css (--ad-bg / --text-muted / --accent).
var TOK={light:{bg:'#e3e7ee',fg:'#6b7280',link:'#2f6df6'},dark:{bg:'#272f3c',fg:'#9aa3b2',link:'#5b8cff'}};
function byName(n){return document.querySelector('[name="'+n+'"]');}
var allOn=byName('override_all_on'), darkOn=byName('override_dark_on');
if(!allOn||!darkOn){return;}
var f={ab:byName('override_bg'),af:byName('override_fg'),al:byName('override_link'),db:byName('override_bg_dark'),df:byName('override_fg_dark'),dl:byName('override_link_dark')};
var lightEl=document.getElementById('prev-light'), darkEl=document.getElementById('prev-dark');
function hexToRgb(h){h=h.replace('#','');return [parseInt(h.slice(0,2),16),parseInt(h.slice(2,4),16),parseInt(h.slice(4,6),16)];}
function pad(x){x=Math.max(0,Math.min(255,Math.round(x))).toString(16);return x.length<2?'0'+x:x;}
function rgbToHex(r){return '#'+pad(r[0])+pad(r[1])+pad(r[2]);}
function mix(a,b,t){return [a[0]+(b[0]-a[0])*t,a[1]+(b[1]-a[1])*t,a[2]+(b[2]-a[2])*t];}
function lum(r){return (0.2126*r[0]+0.7152*r[1]+0.0722*r[2])/255;}
// Derived border: nudge the background 14% toward black on a light bg, toward white on a dark bg.
function border(bg){var r=hexToRgb(bg);return rgbToHex(mix(r, lum(r)>0.5?[0,0,0]:[255,255,255], 0.14));}
function paint(el,c){
el.style.background=c.bg; el.style.color=c.fg;
el.style.borderTop='1px solid '+border(c.bg); el.style.borderBottom='1px solid '+border(c.bg);
var a=el.querySelector('.ad-link'); if(a){a.style.color=c.link;}
}
function resolve(){
var light=allOn.checked?{bg:f.ab.value,fg:f.af.value,link:f.al.value}:TOK.light;
var dark=darkOn.checked?{bg:f.db.value,fg:f.df.value,link:f.dl.value}
:(allOn.checked?{bg:f.ab.value,fg:f.af.value,link:f.al.value}:TOK.dark);
paint(lightEl,light); paint(darkEl,dark);
document.querySelectorAll('.ovr .swatches label').forEach(function(lab){
var inp=lab.querySelector('input[type=color]'), hx=lab.querySelector('.hex');
if(inp&&hx){hx.textContent=inp.value;}
});
}
function toggleGroup(chk){chk.closest('fieldset').querySelectorAll('[data-ovr-color]').forEach(function(inp){inp.disabled=!chk.checked;});}
[allOn,darkOn].forEach(function(chk){chk.addEventListener('change',function(){toggleGroup(chk);resolve();});});
Object.keys(f).forEach(function(k){f[k].addEventListener('input',resolve);});
resolve();
})();
</script>
<form class="form" method="post" action="/_gm/banners/{{.ID}}/delete" onsubmit="return confirm('Delete this campaign and its messages?')"> <form class="form" method="post" action="/_gm/banners/{{.ID}}/delete" onsubmit="return confirm('Delete this campaign and its messages?')">
<button type="submit" class="danger">Delete campaign</button> <button type="submit" class="danger">Delete campaign</button>
</form> </form>
@@ -1,66 +0,0 @@
{{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>Payment availability (kill switch)</h2>
<p class="note">Turn purchases off on a rail/channel and show the user a localized reason on their next attempt. Unchecked = off; an empty message shows a built-in &ldquo;temporarily unavailable&rdquo;. Fail-open: a rail you never touch stays on. A per-user &ldquo;allow&rdquo; override (on a user card) bypasses this switch.</p>
{{range .Rails}}
<form class="form col" method="post" action="/_gm/catalog/rail-status">
<input type="hidden" name="rail" value="{{.Rail}}">
<label><input type="checkbox" name="enabled" {{if .Enabled}}checked{{end}}> <code>{{.Rail}}</code> &mdash; purchases enabled</label>
<label>Message RU <input type="text" name="message_ru" maxlength="200" value="{{.MessageRU}}"></label>
<label>Message EN <input type="text" name="message_en" maxlength="200" value="{{.MessageEN}}"></label>
<div><button type="submit">Save</button></div>
</form>
{{end}}
</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,7 +8,6 @@
<li><b>Dictionary</b> {{.DictVersion}}</li> <li><b>Dictionary</b> {{.DictVersion}}</li>
<li><b>Status</b> {{.Status}}{{if .EndReason}} ({{.EndReason}}){{end}}</li> <li><b>Status</b> {{.Status}}{{if .EndReason}} ({{.EndReason}}){{end}}</li>
<li><b>AI game</b> {{if .VsAI}}🤖 yes{{else}}no{{end}}</li> <li><b>AI game</b> {{if .VsAI}}🤖 yes{{else}}no{{end}}</li>
<li><b>Word rule</b> {{if .MultipleWordsPerTurn}}multiple words per turn{{else}}single word per turn{{end}}</li>
<li><b>Players</b> {{.Players}}</li> <li><b>Players</b> {{.Players}}</li>
<li><b>To move</b> seat {{.ToMove}}</li> <li><b>To move</b> seat {{.ToMove}}</li>
<li><b>Moves</b> {{.MoveCount}}</li> <li><b>Moves</b> {{.MoveCount}}</li>
@@ -8,11 +8,11 @@
<a href="/_gm/games?status=finished"{{if eq .Status "finished"}} class="active"{{end}}>finished</a> <a href="/_gm/games?status=finished"{{if eq .Status "finished"}} class="active"{{end}}>finished</a>
</nav> </nav>
<table class="list"> <table class="list">
<thead><tr><th>Game</th><th>Variant</th><th>Kind</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>Status</th><th>🤖</th><th class="num">Players</th><th>Updated</th></tr></thead>
<tbody> <tbody>
{{range .Items}} {{range .Items}}
<tr><td><a href="/_gm/games/{{.ID}}">{{.ID}}</a></td><td>{{.Variant}}</td><td>{{.Kind}}</td><td>{{.Status}}</td><td>{{if .VsAI}}🤖{{end}}</td><td class="num">{{.Players}}</td><td>{{.UpdatedAt}}</td></tr> <tr><td><a href="/_gm/games/{{.ID}}">{{.ID}}</a></td><td>{{.Variant}}</td><td>{{.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}} {{else}}<tr><td colspan="6"><span class="note">no games</span></td></tr>{{end}}
</tbody> </tbody>
</table> </table>
<nav class="pager"> <nav class="pager">
@@ -1,19 +0,0 @@
{{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}}
@@ -1,25 +0,0 @@
{{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,6 +10,8 @@
<li><b>Timezone</b> {{.TimeZone}}</li> <li><b>Timezone</b> {{.TimeZone}}</li>
<li><b>Guest</b> {{if .Guest}}yes{{else}}no{{end}}</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>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 .MergedInto}}<li><b>Merged into</b> {{.MergedInto}}</li>{{end}}
{{if .FlaggedHighRateAt}}<li><b>High-rate flag</b> <span class="warn">{{.FlaggedHighRateAt}}</span></li>{{end}} {{if .FlaggedHighRateAt}}<li><b>High-rate flag</b> <span class="warn">{{.FlaggedHighRateAt}}</span></li>{{end}}
<li><b>Created</b> {{.CreatedAt}}</li> <li><b>Created</b> {{.CreatedAt}}</li>
@@ -19,6 +21,10 @@
<button type="submit">Clear high-rate flag</button> <button type="submit">Clear high-rate flag</button>
</form> </form>
{{end}} {{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>
<section class="panel"><h2>Statistics</h2> <section class="panel"><h2>Statistics</h2>
{{if .HasStats}} {{if .HasStats}}
@@ -63,61 +69,6 @@
<div><button type="submit">{{if .Suspension.Blocked}}Re-block{{else}}Block{{end}}</button></div> <div><button type="submit">{{if .Suspension.Blocked}}Re-block{{else}}Block{{end}}</button></div>
</form> </form>
</section> </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>Payment override</h3>
<form class="form" method="post" action="/_gm/users/{{.ID}}/purchase-override">
<label>Purchases for this account
<select name="override">
<option value="default" {{if eq .PurchaseOverride "default"}}selected{{end}}>Default (follow the rail switch)</option>
<option value="allow" {{if eq .PurchaseOverride "allow"}}selected{{end}}>Always allow (bypasses the rail switch only, not security)</option>
<option value="deny" {{if eq .PurchaseOverride "deny"}}selected{{end}}>Always deny</option>
</select></label>
<div><button type="submit">Save</button></div>
</form>
<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>Shop</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>{{.Shop}}</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> <section class="panel"><h2>Roles</h2>
{{$id := .ID}} {{$id := .ID}}
{{if .Roles}} {{if .Roles}}
@@ -221,11 +172,11 @@
{{end}} {{end}}
<section class="panel"><h2>Games</h2> <section class="panel"><h2>Games</h2>
<table class="list"> <table class="list">
<thead><tr><th>Game</th><th>Variant</th><th>Kind</th><th>Status</th><th class="num">Players</th><th>Updated</th></tr></thead> <thead><tr><th>Game</th><th>Variant</th><th>Status</th><th class="num">Players</th><th>Updated</th></tr></thead>
<tbody> <tbody>
{{range .Games}} {{range .Games}}
<tr><td><a href="/_gm/games/{{.ID}}">{{.ID}}</a></td><td>{{.Variant}}</td><td>{{.Kind}}</td><td>{{.Status}}</td><td class="num">{{.Players}}</td><td>{{.UpdatedAt}}</td></tr> <tr><td><a href="/_gm/games/{{.ID}}">{{.ID}}</a></td><td>{{.Variant}}</td><td>{{.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}} {{else}}<tr><td colspan="5"><span class="note">no games</span></td></tr>{{end}}
</tbody> </tbody>
</table> </table>
</section> </section>
+5 -172
View File
@@ -149,6 +149,7 @@ type UserDetailView struct {
TimeZone string TimeZone string
Guest bool Guest bool
NotificationsInAppOnly bool NotificationsInAppOnly bool
PaidAccount bool
// MergedInto is the primary account id when this account has been retired by a // MergedInto is the primary account id when this account has been retired by a
// merge, or empty for a live account. // merge, or empty for a live account.
MergedInto string MergedInto string
@@ -164,6 +165,10 @@ type UserDetailView struct {
// FlaggedHighRateAt is the pre-formatted soft high-rate marker timestamp, // FlaggedHighRateAt is the pre-formatted soft high-rate marker timestamp,
// empty for an unflagged account; the card shows it with the Clear action. // empty for an unflagged account; the card shows it with the Clear action.
FlaggedHighRateAt string 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 CreatedAt string
HasStats bool HasStats bool
Stats StatsRow Stats StatsRow
@@ -195,59 +200,6 @@ type UserDetailView struct {
Blocks []RelationRow Blocks []RelationRow
BlockedBy []RelationRow BlockedBy []RelationRow
Friends []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
// PurchaseOverride is the account's per-account purchase override ("default"/"allow"/"deny"),
// shown in and edited from the user card's payment-override control.
PurchaseOverride string
// 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 / direct-rail shop 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
Shop string
Snapshot string
At string
} }
// RelationRow is one cross-linked account in the user card's blocks / blocked-by / friends // RelationRow is one cross-linked account in the user card's blocks / blocked-by / friends
@@ -316,8 +268,6 @@ type GameRow struct {
UpdatedAt string UpdatedAt string
// VsAI marks an honest-AI game (rendered as 🤖 in the list's AI column). // VsAI marks an honest-AI game (rendered as 🤖 in the list's AI column).
VsAI bool VsAI bool
// Kind is the game's origin tag label: vs_ai / random / friends / unknown.
Kind string
} }
// GamesView is the paginated games list, optionally filtered by status. // GamesView is the paginated games list, optionally filtered by status.
@@ -327,17 +277,6 @@ type GamesView struct {
Pager Pager Pager Pager
} }
// GameLimitsView is the per-tier, per-kind active-game limit form: each field is a cap where -1
// is unlimited, 0 blocks the kind, and a positive value caps concurrent games of that kind.
type GameLimitsView struct {
GuestVsAI int
GuestRandom int
GuestFriends int
DurableVsAI int
DurableRandom int
DurableFriends int
}
// GameDetailView is one game with its seats. // GameDetailView is one game with its seats.
type GameDetailView struct { type GameDetailView struct {
ID string ID string
@@ -353,10 +292,6 @@ type GameDetailView struct {
FinishedAt string FinishedAt string
// VsAI marks an honest-AI game (shown as a 🤖 flag in the summary). // VsAI marks an honest-AI game (shown as a 🤖 flag in the summary).
VsAI bool VsAI bool
// 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 Seats []SeatRow
// HasRobot is true when any seat is a robot, gating the robot-target caption; // HasRobot is true when any seat is a robot, gating the robot-target caption;
// RobotTargetPct is the configured global play-to-win rate, in percent. // RobotTargetPct is the configured global play-to-win rate, in percent.
@@ -563,12 +498,6 @@ type BannerCampaignRow struct {
// BannerDetailView is the campaign detail/edit page. StartsAt/EndsAt are the // BannerDetailView is the campaign detail/edit page. StartsAt/EndsAt are the
// "YYYY-MM-DDTHH:MM" (UTC) values for the datetime-local inputs, empty when open. // "YYYY-MM-DDTHH:MM" (UTC) values for the datetime-local inputs, empty when open.
//
// The colour-override and urgent fields drive the non-default campaign's editor:
// OverrideAllOn/OverrideDarkOn report whether each colour set is active, and the
// six *Bg/*Fg/*Link values seed the native colour inputs — the stored override
// when a set is on, otherwise the neutral theme token so the picker starts from a
// sensible colour and the live preview shows the real fallback.
type BannerDetailView struct { type BannerDetailView struct {
ID string ID string
Name string Name string
@@ -577,15 +506,6 @@ type BannerDetailView struct {
Enabled bool Enabled bool
StartsAt string StartsAt string
EndsAt string EndsAt string
Urgent bool
OverrideAllOn bool
AllBg string
AllFg string
AllLink string
OverrideDarkOn bool
DarkBg string
DarkFg string
DarkLink string
Messages []BannerMessageRow Messages []BannerMessageRow
} }
@@ -677,90 +597,3 @@ type FeedbackDetailView struct {
UserTZ string UserTZ string
Banned bool 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
// Rails is the per-rail operational kill-switch state (enabled + per-language off-message), shown
// in and edited from the page's payment-availability form.
Rails []RailStatusRow
}
// RailStatusRow is one payment rail's operational availability in the kill-switch editor: the rail
// key, whether purchases are enabled, and the operator's per-language off-message shown to the user.
type RailStatusRow struct {
Rail string
Enabled bool
MessageRU string
MessageEN string
}
// 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
}
+19 -75
View File
@@ -30,15 +30,6 @@ var ErrDefaultImmutable = errors.New("ads: the default campaign cannot be modifi
// window or message body). // window or message body).
var ErrValidation = errors.New("ads: validation") var ErrValidation = errors.New("ads: validation")
// ColorSet is an optional per-campaign colour override for the banner strip:
// background, foreground (text) and link, each a "#rrggbb" hex string. The three
// are set together or the whole set is absent (a nil *ColorSet).
type ColorSet struct {
Bg string
Fg string
Link string
}
// Campaign is one advertising placement order with its messages. // Campaign is one advertising placement order with its messages.
type Campaign struct { type Campaign struct {
ID uuid.UUID // uuid.Nil on create ID uuid.UUID // uuid.Nil on create
@@ -49,16 +40,6 @@ type Campaign struct {
StartsAt *time.Time // nil = open-ended start; always nil for the default StartsAt *time.Time // nil = open-ended start; always nil for the default
EndsAt *time.Time // nil = open-ended end; always nil for the default EndsAt *time.Time // nil = open-ended end; always nil for the default
Messages []Message Messages []Message
// OverrideAll paints the strip on every theme; OverrideDark, when set, further
// overrides the dark theme (the client resolves dark ← dark ?? all ?? token,
// light ← all ?? token). Both are nil for the default campaign and for a
// campaign that keeps the neutral theme tokens. Non-default only.
OverrideAll *ColorSet
OverrideDark *ColorSet
// Urgent forces the banner on every viewer (bypassing eligibility) and, while
// any urgent campaign is active, suppresses every non-urgent campaign and the
// default remainder. Non-default only; always false for the default campaign.
Urgent bool
CreatedAt time.Time CreatedAt time.Time
UpdatedAt time.Time UpdatedAt time.Time
} }
@@ -89,32 +70,29 @@ type Timings struct {
// ActiveCampaign is one campaign in the resolved rotation feed sent to a client: // ActiveCampaign is one campaign in the resolved rotation feed sent to a client:
// its GCD-reduced show weight and its messages, already resolved to the viewer's // its GCD-reduced show weight and its messages, already resolved to the viewer's
// language and in display (round-robin) order, plus the optional colour overrides // language and in display (round-robin) order.
// the client applies to the strip (nil = the neutral theme tokens). Urgency is not
// carried here: it is resolved server-side into the set's membership and the
// eligibility bypass, so the client only ever renders what it is sent.
type ActiveCampaign struct { type ActiveCampaign struct {
Weight int Weight int
Messages []string Messages []string
OverrideAll *ColorSet }
OverrideDark *ColorSet
// Eligible reports whether an account should be shown the advertising banner: a
// free account (not paid) with an empty hint wallet and without the no_banner
// role. The no_banner role suppresses the banner unconditionally; buying a paid
// account or any hints also removes it.
func Eligible(paidAccount bool, hintBalance int, hasNoBanner bool) bool {
return !paidAccount && hintBalance <= 0 && !hasNoBanner
} }
// computeActiveSet builds the resolved rotation feed from the enabled campaigns // computeActiveSet builds the resolved rotation feed from the enabled campaigns
// at time now, in language lang, and reports whether the feed is an urgent one. // at time now, in language lang. Campaigns outside their validity window, and
// Campaigns outside their validity window, and campaigns with no messages, are // campaigns with no messages, are dropped. The default campaign's effective
// dropped. // weight is the remainder up to 100% — max(0, 100 - sum of active timed
// // weights) — so it fills unsold inventory and is dropped entirely when timed
// While any active campaign is urgent, the feed is the urgent campaigns alone — // campaigns already reach 100%. Weights are then reduced by their GCD so the
// every non-urgent timed campaign and the default remainder are suppressed for // fair rotation cycle stays short. The input is expected to be the enabled
// the duration (and the caller shows the feed to every viewer, bypassing // campaigns (ActiveCampaigns); disabled ones must already be excluded.
// eligibility). Otherwise the default campaign's effective weight is the func computeActiveSet(campaigns []Campaign, now time.Time, lang string) []ActiveCampaign {
// remainder up to 100% — max(0, 100 - sum of active timed weights) — so it fills
// unsold inventory and is dropped entirely when timed campaigns already reach
// 100%. Weights are then reduced by their GCD so the fair rotation cycle stays
// short. The input is expected to be the enabled campaigns (ActiveCampaigns);
// disabled ones must already be excluded.
func computeActiveSet(campaigns []Campaign, now time.Time, lang string) ([]ActiveCampaign, bool) {
var timed []Campaign var timed []Campaign
var def *Campaign var def *Campaign
for i := range campaigns { for i := range campaigns {
@@ -130,54 +108,20 @@ func computeActiveSet(campaigns []Campaign, now time.Time, lang string) ([]Activ
timed = append(timed, c) timed = append(timed, c)
} }
} }
if urgent := filterUrgent(timed); len(urgent) > 0 {
out := make([]ActiveCampaign, 0, len(urgent))
for _, c := range urgent {
out = append(out, activeFrom(c, lang))
}
reduceByGCD(out)
return out, true
}
sumTimed := 0 sumTimed := 0
for _, c := range timed { for _, c := range timed {
sumTimed += c.Weight sumTimed += c.Weight
} }
out := make([]ActiveCampaign, 0, len(timed)+1) out := make([]ActiveCampaign, 0, len(timed)+1)
for _, c := range timed { for _, c := range timed {
out = append(out, activeFrom(c, lang)) out = append(out, ActiveCampaign{Weight: c.Weight, Messages: resolveBodies(c.Messages, lang)})
} }
if def != nil { if def != nil {
if dw := 100 - sumTimed; dw > 0 { if dw := 100 - sumTimed; dw > 0 {
a := activeFrom(*def, lang) out = append(out, ActiveCampaign{Weight: dw, Messages: resolveBodies(def.Messages, lang)})
a.Weight = dw // the default's stored weight is nominal; it fills the remainder
out = append(out, a)
} }
} }
reduceByGCD(out) reduceByGCD(out)
return out, false
}
// activeFrom projects a campaign to its rotation-feed entry: its show weight, its
// language-resolved messages and its colour overrides (carried through by
// reference, they are read-only).
func activeFrom(c Campaign, lang string) ActiveCampaign {
return ActiveCampaign{
Weight: c.Weight,
Messages: resolveBodies(c.Messages, lang),
OverrideAll: c.OverrideAll,
OverrideDark: c.OverrideDark,
}
}
// filterUrgent returns the urgent campaigns among cs, preserving order. It is the
// preempt selector: a non-empty result makes the whole feed urgent.
func filterUrgent(cs []Campaign) []Campaign {
var out []Campaign
for _, c := range cs {
if c.Urgent {
out = append(out, c)
}
}
return out return out
} }
+27 -59
View File
@@ -6,6 +6,30 @@ import (
"time" "time"
) )
func TestEligible(t *testing.T) {
tests := []struct {
name string
paidAccount bool
hintBalance int
hasNoBanner bool
want bool
}{
{name: "free, empty wallet, no role", want: true},
{name: "paid", paidAccount: true, want: false},
{name: "has hints", hintBalance: 3, want: false},
{name: "no_banner role", hasNoBanner: true, want: false},
{name: "paid and has hints", paidAccount: true, hintBalance: 5, want: false},
{name: "no_banner overrides everything", hasNoBanner: true, want: false},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
if got := Eligible(tt.paidAccount, tt.hintBalance, tt.hasNoBanner); got != tt.want {
t.Errorf("Eligible(%v,%d,%v) = %v, want %v", tt.paidAccount, tt.hintBalance, tt.hasNoBanner, got, tt.want)
}
})
}
}
func TestComputeActiveSet(t *testing.T) { func TestComputeActiveSet(t *testing.T) {
now := time.Date(2026, 6, 15, 12, 0, 0, 0, time.UTC) now := time.Date(2026, 6, 15, 12, 0, 0, 0, time.UTC)
past := now.Add(-24 * time.Hour) past := now.Add(-24 * time.Hour)
@@ -22,19 +46,12 @@ func TestComputeActiveSet(t *testing.T) {
timed := func(name string, weight int, starts, ends *time.Time, msgs []Message) Campaign { timed := func(name string, weight int, starts, ends *time.Time, msgs []Message) Campaign {
return Campaign{Name: name, Weight: weight, Enabled: true, StartsAt: starts, EndsAt: ends, Messages: msgs} return Campaign{Name: name, Weight: weight, Enabled: true, StartsAt: starts, EndsAt: ends, Messages: msgs}
} }
urgent := func(name string, weight int, msgs []Message) Campaign {
c := timed(name, weight, nil, nil, msgs)
c.Urgent = true
return c
}
red := &ColorSet{Bg: "#aa0000", Fg: "#ffffff", Link: "#ffdd00"}
tests := []struct { tests := []struct {
name string name string
campaigns []Campaign campaigns []Campaign
lang string lang string
want []ActiveCampaign want []ActiveCampaign
wantUrgent bool
}{ }{
{ {
name: "default only reduces to weight 1", name: "default only reduces to weight 1",
@@ -135,71 +152,22 @@ func TestComputeActiveSet(t *testing.T) {
lang: "en", lang: "en",
want: []ActiveCampaign{{Weight: 1, Messages: []string{"one-en", "two-en"}}}, want: []ActiveCampaign{{Weight: 1, Messages: []string{"one-en", "two-en"}}},
}, },
{
name: "urgent preempts default and normal timed",
campaigns: []Campaign{
def(msg("house")),
timed("promo", 40, nil, nil, msg("promo")),
urgent("alert", 50, msg("alert")),
},
lang: "en",
// only the urgent campaign survives; a lone weight reduces to 1.
want: []ActiveCampaign{{Weight: 1, Messages: []string{"alert-en"}}},
wantUrgent: true,
},
{
name: "multiple urgent share the feed by gcd",
campaigns: []Campaign{
def(msg("house")),
urgent("a", 60, msg("a")),
urgent("b", 40, msg("b")),
},
lang: "en",
// default and any non-urgent dropped; gcd(60,40)=20 -> 3 and 2.
want: []ActiveCampaign{
{Weight: 3, Messages: []string{"a-en"}},
{Weight: 2, Messages: []string{"b-en"}},
},
wantUrgent: true,
},
{
name: "out-of-window urgent does not preempt",
campaigns: []Campaign{
def(msg("house")),
func() Campaign { c := urgent("future", 50, msg("future")); c.StartsAt = &future; return c }(),
},
lang: "en",
// the urgent campaign is not yet live, so the normal default feed stands.
want: []ActiveCampaign{{Weight: 1, Messages: []string{"house-en"}}},
},
{
name: "colour overrides ride the active campaign",
campaigns: []Campaign{
def(msg("house")),
func() Campaign { c := timed("promo", 100, nil, nil, msg("promo")); c.OverrideAll = red; return c }(),
},
lang: "en",
want: []ActiveCampaign{{Weight: 1, Messages: []string{"promo-en"}, OverrideAll: red}},
},
} }
for _, tt := range tests { for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) { t.Run(tt.name, func(t *testing.T) {
got, gotUrgent := computeActiveSet(tt.campaigns, now, tt.lang) got := computeActiveSet(tt.campaigns, now, tt.lang)
if !reflect.DeepEqual(got, tt.want) { if !reflect.DeepEqual(got, tt.want) {
t.Errorf("computeActiveSet() =\n %#v\nwant\n %#v", got, tt.want) t.Errorf("computeActiveSet() =\n %#v\nwant\n %#v", got, tt.want)
} }
if gotUrgent != tt.wantUrgent {
t.Errorf("computeActiveSet() urgent = %v, want %v", gotUrgent, tt.wantUrgent)
}
}) })
} }
} }
func TestComputeActiveSetEmpty(t *testing.T) { func TestComputeActiveSetEmpty(t *testing.T) {
// No campaigns at all yields an empty (non-nil-or-nil) feed without panicking. // No campaigns at all yields an empty (non-nil-or-nil) feed without panicking.
if got, urgent := computeActiveSet(nil, time.Now(), "en"); len(got) != 0 || urgent { if got := computeActiveSet(nil, time.Now(), "en"); len(got) != 0 {
t.Errorf("computeActiveSet(nil) = %#v urgent=%v, want empty non-urgent", got, urgent) t.Errorf("computeActiveSet(nil) = %#v, want empty", got)
} }
} }
+5 -62
View File
@@ -3,7 +3,6 @@ package ads
import ( import (
"context" "context"
"fmt" "fmt"
"regexp"
"strings" "strings"
"time" "time"
@@ -41,20 +40,17 @@ func NewService(store *Store) *Service { return &Service{store: store} }
// ActiveSet returns the resolved rotation feed for a viewer in language lang // ActiveSet returns the resolved rotation feed for a viewer in language lang
// (en/ru) together with the global display timings: the currently-active // (en/ru) together with the global display timings: the currently-active
// campaigns, each with its GCD-reduced show weight and its messages resolved to // campaigns, each with its GCD-reduced show weight and its messages resolved to
// lang, ready for the client's weighted round-robin. The bool result reports // lang, ready for the client's weighted round-robin.
// whether the feed is urgent — an urgent feed is shown to every viewer func (s *Service) ActiveSet(ctx context.Context, lang string) ([]ActiveCampaign, Timings, error) {
// regardless of eligibility (the caller skips the eligibility gate for it).
func (s *Service) ActiveSet(ctx context.Context, lang string) ([]ActiveCampaign, Timings, bool, error) {
campaigns, err := s.store.ActiveCampaigns(ctx) campaigns, err := s.store.ActiveCampaigns(ctx)
if err != nil { if err != nil {
return nil, Timings{}, false, err return nil, Timings{}, err
} }
timings, err := s.store.Settings(ctx) timings, err := s.store.Settings(ctx)
if err != nil { if err != nil {
return nil, Timings{}, false, err return nil, Timings{}, err
} }
set, urgent := computeActiveSet(campaigns, time.Now().UTC(), lang) return computeActiveSet(campaigns, time.Now().UTC(), lang), timings, nil
return set, timings, urgent, nil
} }
// ListCampaigns returns every campaign with its messages, for the admin console. // ListCampaigns returns every campaign with its messages, for the admin console.
@@ -80,13 +76,8 @@ func (s *Service) CreateCampaign(ctx context.Context, c Campaign) (uuid.UUID, er
if err := validWindow(c.StartsAt, c.EndsAt); err != nil { if err := validWindow(c.StartsAt, c.EndsAt); err != nil {
return uuid.Nil, err return uuid.Nil, err
} }
all, dark, err := validOverrides(c.OverrideAll, c.OverrideDark)
if err != nil {
return uuid.Nil, err
}
return s.store.CreateCampaign(ctx, Campaign{ return s.store.CreateCampaign(ctx, Campaign{
Name: name, Weight: c.Weight, Enabled: c.Enabled, StartsAt: c.StartsAt, EndsAt: c.EndsAt, Name: name, Weight: c.Weight, Enabled: c.Enabled, StartsAt: c.StartsAt, EndsAt: c.EndsAt,
OverrideAll: all, OverrideDark: dark, Urgent: c.Urgent,
}) })
} }
@@ -108,7 +99,6 @@ func (s *Service) UpdateCampaign(ctx context.Context, c Campaign) error {
upd.Enabled = true upd.Enabled = true
upd.StartsAt = nil upd.StartsAt = nil
upd.EndsAt = nil upd.EndsAt = nil
// The default (house) campaign stays plain: no colour overrides, never urgent.
} else { } else {
if err := validWeight(c.Weight); err != nil { if err := validWeight(c.Weight); err != nil {
return err return err
@@ -116,17 +106,10 @@ func (s *Service) UpdateCampaign(ctx context.Context, c Campaign) error {
if err := validWindow(c.StartsAt, c.EndsAt); err != nil { if err := validWindow(c.StartsAt, c.EndsAt); err != nil {
return err return err
} }
all, dark, err := validOverrides(c.OverrideAll, c.OverrideDark)
if err != nil {
return err
}
upd.Weight = c.Weight upd.Weight = c.Weight
upd.Enabled = c.Enabled upd.Enabled = c.Enabled
upd.StartsAt = c.StartsAt upd.StartsAt = c.StartsAt
upd.EndsAt = c.EndsAt upd.EndsAt = c.EndsAt
upd.OverrideAll = all
upd.OverrideDark = dark
upd.Urgent = c.Urgent
} }
return s.store.UpdateCampaign(ctx, upd) return s.store.UpdateCampaign(ctx, upd)
} }
@@ -271,46 +254,6 @@ func validWindow(starts, ends *time.Time) error {
return nil return nil
} }
// hexColor matches a "#rrggbb" colour — the format the console's native colour
// input emits and the wire carries.
var hexColor = regexp.MustCompile(`^#[0-9a-fA-F]{6}$`)
// validOverrides validates the two optional colour sets together and returns
// their normalised copies (nil when a set is absent), so a caller can store them
// directly.
func validOverrides(all, dark *ColorSet) (*ColorSet, *ColorSet, error) {
va, err := validColorSet(all)
if err != nil {
return nil, nil, err
}
vd, err := validColorSet(dark)
if err != nil {
return nil, nil, err
}
return va, vd, nil
}
// validColorSet validates one optional colour override: a nil set passes (no
// override); otherwise all three colours must be present and "#rrggbb". It
// returns a trimmed, lower-cased copy.
func validColorSet(cs *ColorSet) (*ColorSet, error) {
if cs == nil {
return nil, nil
}
bg := strings.TrimSpace(cs.Bg)
fg := strings.TrimSpace(cs.Fg)
link := strings.TrimSpace(cs.Link)
if bg == "" || fg == "" || link == "" {
return nil, fmt.Errorf("%w: a colour override needs all of background, text and link", ErrValidation)
}
for _, h := range []string{bg, fg, link} {
if !hexColor.MatchString(h) {
return nil, fmt.Errorf("%w: colours must be #rrggbb hex", ErrValidation)
}
}
return &ColorSet{Bg: strings.ToLower(bg), Fg: strings.ToLower(fg), Link: strings.ToLower(link)}, nil
}
// validBodies trims and bounds both mandatory language bodies of a message. // validBodies trims and bounds both mandatory language bodies of a message.
func validBodies(bodyEn, bodyRu string) (string, string, error) { func validBodies(bodyEn, bodyRu string) (string, string, error) {
en := strings.TrimSpace(bodyEn) en := strings.TrimSpace(bodyEn)
+2 -40
View File
@@ -90,23 +90,15 @@ func (s *Store) Campaign(ctx context.Context, id uuid.UUID) (Campaign, error) {
func (s *Store) CreateCampaign(ctx context.Context, c Campaign) (uuid.UUID, error) { func (s *Store) CreateCampaign(ctx context.Context, c Campaign) (uuid.UUID, error) {
id := uuid.New() id := uuid.New()
now := time.Now().UTC() now := time.Now().UTC()
abg, afg, alink := colorCols(c.OverrideAll)
dbg, dfg, dlink := colorCols(c.OverrideDark)
stmt := table.AdCampaigns.INSERT( stmt := table.AdCampaigns.INSERT(
table.AdCampaigns.CampaignID, table.AdCampaigns.Name, table.AdCampaigns.Weight, table.AdCampaigns.CampaignID, table.AdCampaigns.Name, table.AdCampaigns.Weight,
table.AdCampaigns.IsDefault, table.AdCampaigns.Enabled, table.AdCampaigns.IsDefault, table.AdCampaigns.Enabled,
table.AdCampaigns.StartsAt, table.AdCampaigns.EndsAt, table.AdCampaigns.StartsAt, table.AdCampaigns.EndsAt,
table.AdCampaigns.OverrideBg, table.AdCampaigns.OverrideFg, table.AdCampaigns.OverrideLink,
table.AdCampaigns.OverrideBgDark, table.AdCampaigns.OverrideFgDark, table.AdCampaigns.OverrideLinkDark,
table.AdCampaigns.Urgent,
table.AdCampaigns.CreatedAt, table.AdCampaigns.UpdatedAt, table.AdCampaigns.CreatedAt, table.AdCampaigns.UpdatedAt,
).VALUES( ).VALUES(
postgres.UUID(id), postgres.String(c.Name), postgres.Int(int64(c.Weight)), postgres.UUID(id), postgres.String(c.Name), postgres.Int(int64(c.Weight)),
postgres.Bool(false), postgres.Bool(c.Enabled), postgres.Bool(false), postgres.Bool(c.Enabled),
tsOrNull(c.StartsAt), tsOrNull(c.EndsAt), tsOrNull(c.StartsAt), tsOrNull(c.EndsAt),
abg, afg, alink,
dbg, dfg, dlink,
postgres.Bool(c.Urgent),
postgres.TimestampzT(now), postgres.TimestampzT(now), postgres.TimestampzT(now), postgres.TimestampzT(now),
) )
if _, err := stmt.ExecContext(ctx, s.db); err != nil { if _, err := stmt.ExecContext(ctx, s.db); err != nil {
@@ -119,20 +111,12 @@ func (s *Store) CreateCampaign(ctx context.Context, c Campaign) (uuid.UUID, erro
// window. The default flag is never touched here. Returns ErrNotFound when no // window. The default flag is never touched here. Returns ErrNotFound when no
// campaign matches. // campaign matches.
func (s *Store) UpdateCampaign(ctx context.Context, c Campaign) error { func (s *Store) UpdateCampaign(ctx context.Context, c Campaign) error {
abg, afg, alink := colorCols(c.OverrideAll)
dbg, dfg, dlink := colorCols(c.OverrideDark)
stmt := table.AdCampaigns.UPDATE( stmt := table.AdCampaigns.UPDATE(
table.AdCampaigns.Name, table.AdCampaigns.Weight, table.AdCampaigns.Enabled, table.AdCampaigns.Name, table.AdCampaigns.Weight, table.AdCampaigns.Enabled,
table.AdCampaigns.StartsAt, table.AdCampaigns.EndsAt, table.AdCampaigns.StartsAt, table.AdCampaigns.EndsAt, table.AdCampaigns.UpdatedAt,
table.AdCampaigns.OverrideBg, table.AdCampaigns.OverrideFg, table.AdCampaigns.OverrideLink,
table.AdCampaigns.OverrideBgDark, table.AdCampaigns.OverrideFgDark, table.AdCampaigns.OverrideLinkDark,
table.AdCampaigns.Urgent, table.AdCampaigns.UpdatedAt,
).SET( ).SET(
postgres.String(c.Name), postgres.Int(int64(c.Weight)), postgres.Bool(c.Enabled), postgres.String(c.Name), postgres.Int(int64(c.Weight)), postgres.Bool(c.Enabled),
tsOrNull(c.StartsAt), tsOrNull(c.EndsAt), tsOrNull(c.StartsAt), tsOrNull(c.EndsAt), postgres.TimestampzT(time.Now().UTC()),
abg, afg, alink,
dbg, dfg, dlink,
postgres.Bool(c.Urgent), postgres.TimestampzT(time.Now().UTC()),
).WHERE(table.AdCampaigns.CampaignID.EQ(postgres.UUID(c.ID))) ).WHERE(table.AdCampaigns.CampaignID.EQ(postgres.UUID(c.ID)))
return execOne(ctx, s.db, stmt, "update campaign") return execOne(ctx, s.db, stmt, "update campaign")
} }
@@ -280,33 +264,11 @@ func modelToCampaign(r model.AdCampaigns) Campaign {
Enabled: r.Enabled, Enabled: r.Enabled,
StartsAt: r.StartsAt, StartsAt: r.StartsAt,
EndsAt: r.EndsAt, EndsAt: r.EndsAt,
OverrideAll: colorSetFrom(r.OverrideBg, r.OverrideFg, r.OverrideLink),
OverrideDark: colorSetFrom(r.OverrideBgDark, r.OverrideFgDark, r.OverrideLinkDark),
Urgent: r.Urgent,
CreatedAt: r.CreatedAt, CreatedAt: r.CreatedAt,
UpdatedAt: r.UpdatedAt, UpdatedAt: r.UpdatedAt,
} }
} }
// colorSetFrom rebuilds an optional ColorSet from three nullable colour columns.
// The all-or-nothing CHECK keeps the trio consistent, so a nil in any one means
// the set is absent.
func colorSetFrom(bg, fg, link *string) *ColorSet {
if bg == nil || fg == nil || link == nil {
return nil
}
return &ColorSet{Bg: *bg, Fg: *fg, Link: *link}
}
// colorCols renders a *ColorSet as its three column value-expressions in
// bg, fg, link order — NULL for each when the set is absent.
func colorCols(cs *ColorSet) (bg, fg, link postgres.Expression) {
if cs == nil {
return postgres.NULL, postgres.NULL, postgres.NULL
}
return postgres.String(cs.Bg), postgres.String(cs.Fg), postgres.String(cs.Link)
}
func modelToMessage(r model.AdMessages) Message { func modelToMessage(r model.AdMessages) Message {
return Message{ return Message{
ID: r.MessageID, ID: r.MessageID,
-38
View File
@@ -14,7 +14,6 @@ import (
"scrabble/backend/internal/lobby" "scrabble/backend/internal/lobby"
"scrabble/backend/internal/postgres" "scrabble/backend/internal/postgres"
"scrabble/backend/internal/ratewatch" "scrabble/backend/internal/ratewatch"
"scrabble/backend/internal/robokassa"
"scrabble/backend/internal/robot" "scrabble/backend/internal/robot"
"scrabble/backend/internal/telemetry" "scrabble/backend/internal/telemetry"
) )
@@ -65,9 +64,6 @@ type Config struct {
// RendererURL is the base URL of the internal image-render sidecar (e.g. // RendererURL is the base URL of the internal image-render sidecar (e.g.
// http://renderer:8090). Empty disables the PNG export artifact. // http://renderer:8090). Empty disables the PNG export artifact.
RendererURL string RendererURL string
// Robokassa configures the direct-rail (RUB) payment provider — one merchant shop per channel
// (D42). An empty set leaves the direct order and Result-callback endpoints unregistered.
Robokassa robokassa.Shops
} }
// Defaults applied when the corresponding environment variable is unset. // Defaults applied when the corresponding environment variable is unset.
@@ -106,7 +102,6 @@ func Load() (Config, error) {
gm := game.DefaultConfig() gm := game.DefaultConfig()
gm.DictDir = envOr("BACKEND_DICT_DIR", gm.DictDir) gm.DictDir = envOr("BACKEND_DICT_DIR", gm.DictDir)
gm.DictVersion = envOr("BACKEND_DICT_VERSION", gm.DictVersion) gm.DictVersion = envOr("BACKEND_DICT_VERSION", gm.DictVersion)
gm.DictSeedDir = envOr("BACKEND_DICT_SEED_DIR", gm.DictSeedDir)
if gm.TimeoutSweepInterval, err = envDuration("BACKEND_GAME_TIMEOUT_SWEEP_INTERVAL", gm.TimeoutSweepInterval); err != nil { if gm.TimeoutSweepInterval, err = envDuration("BACKEND_GAME_TIMEOUT_SWEEP_INTERVAL", gm.TimeoutSweepInterval); err != nil {
return Config{}, err return Config{}, err
} }
@@ -158,21 +153,6 @@ func Load() (Config, error) {
AdminTo: os.Getenv("BACKEND_ADMIN_EMAIL"), AdminTo: os.Getenv("BACKEND_ADMIN_EMAIL"),
} }
// Robokassa direct rail: one merchant shop per channel (D42). The legacy single-shop vars seed
// the web channel so existing deploys keep working; the per-channel vars add the rest. A shop
// with no MerchantLogin is dropped (the rail stays dormant when none is configured).
shops := robokassa.Shops{}
web := robokassaShop("BACKEND_ROBOKASSA_WEB")
if web.MerchantLogin == "" {
web = robokassaShop("BACKEND_ROBOKASSA") // legacy single-shop credentials seed the web channel
}
if web.MerchantLogin != "" {
shops[robokassa.ChannelWeb] = web
}
if android := robokassaShop("BACKEND_ROBOKASSA_ANDROID"); android.MerchantLogin != "" {
shops[robokassa.ChannelAndroid] = android
}
c := Config{ c := Config{
HTTPAddr: envOr("BACKEND_HTTP_ADDR", defaultHTTPAddr), HTTPAddr: envOr("BACKEND_HTTP_ADDR", defaultHTTPAddr),
GRPCAddr: envOr("BACKEND_GRPC_ADDR", defaultGRPCAddr), GRPCAddr: envOr("BACKEND_GRPC_ADDR", defaultGRPCAddr),
@@ -190,7 +170,6 @@ func Load() (Config, error) {
GuestRetention: guestRetention, GuestRetention: guestRetention,
ExportSignKey: os.Getenv("BACKEND_EXPORT_SIGN_KEY"), ExportSignKey: os.Getenv("BACKEND_EXPORT_SIGN_KEY"),
RendererURL: os.Getenv("BACKEND_RENDERER_URL"), RendererURL: os.Getenv("BACKEND_RENDERER_URL"),
Robokassa: shops,
} }
if err := c.validate(); err != nil { if err := c.validate(); err != nil {
return Config{}, err return Config{}, err
@@ -243,11 +222,6 @@ func (c Config) validate() error {
return fmt.Errorf("config: BACKEND_PUBLIC_BASE_URL %q must be an absolute URL (scheme://host)", c.PublicBaseURL) return fmt.Errorf("config: BACKEND_PUBLIC_BASE_URL %q must be an absolute URL (scheme://host)", c.PublicBaseURL)
} }
} }
for channel, shop := range c.Robokassa {
if shop.Password1 == "" || shop.Password2 == "" {
return fmt.Errorf("config: robokassa shop %q: password1 and password2 must be set when its merchant login is", channel)
}
}
return nil return nil
} }
@@ -287,15 +261,3 @@ func envDuration(key string, fallback time.Duration) (time.Duration, error) {
} }
return d, nil return d, nil
} }
// robokassaShop reads a Robokassa shop's four credentials from the environment under prefix (e.g.
// "BACKEND_ROBOKASSA_WEB" → _MERCHANT_LOGIN / _PASSWORD1 / _PASSWORD2 / _TEST). A missing
// MerchantLogin yields a zero Config the caller drops.
func robokassaShop(prefix string) robokassa.Config {
return robokassa.Config{
MerchantLogin: os.Getenv(prefix + "_MERCHANT_LOGIN"),
Password1: os.Getenv(prefix + "_PASSWORD1"),
Password2: os.Getenv(prefix + "_PASSWORD2"),
IsTest: os.Getenv(prefix+"_TEST") == "1",
}
}
-92
View File
@@ -1,92 +0,0 @@
package engine
import (
"errors"
"fmt"
"io"
"os"
"path/filepath"
)
// DeliverVersion makes the build's dictionary version resident on the persistent
// dictionary volume without disturbing the versions in-flight games already pin.
//
// The volume is seeded from the image once and never re-seeded, and the image's
// /opt/dawg is shadowed by that volume mount, so a redeploy that bumped
// BACKEND_DICT_VERSION cannot otherwise make the new DAWGs reachable at runtime.
// The image therefore keeps an unshadowed read-only copy at seedDir, and this
// copies it into dictDir/<version>/ when the version is not already present —
// neither the flat seed's own recorded label nor an existing version
// subdirectory. It is add-only and idempotent: the flat seed and any prior admin
// uploads are never touched, so old games keep the version they pin while the new
// one becomes resident and activatable (see game.Service.InitActiveVersion). A
// blank seedDir disables delivery (the pre-existing seed-only behaviour).
func DeliverVersion(dictDir, seedDir, version string) error {
if seedDir == "" || version == "" {
return nil
}
target := filepath.Join(dictDir, version)
if _, err := os.Stat(target); err == nil {
return nil // already delivered on a prior boot
} else if !errors.Is(err, os.ErrNotExist) {
return fmt.Errorf("engine: stat delivered dictionary %s: %w", target, err)
}
// A fresh volume is populated from the image and recorded as this version by the
// marker: it is the flat dir, so there is nothing to deliver. resolveSeedVersion
// records the label on first use, matching OpenWithVersions.
seed, err := resolveSeedVersion(dictDir, version)
if err != nil {
return err
}
if seed == version {
return nil
}
// Stage into a dot-prefixed directory (skipped by OpenWithVersions' version scan)
// then rename, so a crash mid-copy never leaves a half-populated version subdir.
staging := filepath.Join(dictDir, ".staging-deliver-"+version)
if err := os.RemoveAll(staging); err != nil {
return fmt.Errorf("engine: clear deliver staging %s: %w", staging, err)
}
if err := os.MkdirAll(staging, 0o755); err != nil {
return fmt.Errorf("engine: create deliver staging %s: %w", staging, err)
}
for _, file := range dictFiles {
src := filepath.Join(seedDir, file)
if _, err := os.Stat(src); errors.Is(err, os.ErrNotExist) {
continue // a variant absent from the seed is simply absent under this version
} else if err != nil {
_ = os.RemoveAll(staging)
return fmt.Errorf("engine: stat seed dawg %s: %w", src, err)
}
if err := copyFile(src, filepath.Join(staging, file)); err != nil {
_ = os.RemoveAll(staging)
return err
}
}
if err := os.Rename(staging, target); err != nil {
_ = os.RemoveAll(staging)
return fmt.Errorf("engine: publish delivered dictionary %s: %w", target, err)
}
return nil
}
// copyFile copies a single file, truncating the destination.
func copyFile(src, dst string) error {
in, err := os.Open(src)
if err != nil {
return fmt.Errorf("engine: open %s: %w", src, err)
}
defer func() { _ = in.Close() }()
out, err := os.OpenFile(dst, os.O_WRONLY|os.O_CREATE|os.O_TRUNC, 0o644)
if err != nil {
return fmt.Errorf("engine: create %s: %w", dst, err)
}
if _, err := io.Copy(out, in); err != nil {
_ = out.Close()
return fmt.Errorf("engine: copy %s -> %s: %w", src, dst, err)
}
if err := out.Close(); err != nil {
return fmt.Errorf("engine: finalise %s: %w", dst, err)
}
return nil
}
-68
View File
@@ -1,68 +0,0 @@
package engine
import (
"os"
"path/filepath"
"testing"
)
func TestDeliverVersion(t *testing.T) {
// A blank seed directory disables delivery (the pre-existing seed-only behaviour).
if err := DeliverVersion(t.TempDir(), "", "v1.3.1"); err != nil {
t.Fatalf("blank seedDir: %v", err)
}
// An existing volume flat-seeded as v1.3.0 gets v1.3.1 delivered as a subdirectory,
// add-only: the flat seed's marker is left intact and the new version's DAWGs land.
dict := t.TempDir()
seed := t.TempDir()
if err := os.WriteFile(filepath.Join(dict, seedMarkerFile), []byte("v1.3.0\n"), 0o644); err != nil {
t.Fatal(err)
}
for _, f := range dictFiles {
if err := os.WriteFile(filepath.Join(seed, f), []byte("dawg:"+f), 0o644); err != nil {
t.Fatal(err)
}
}
if err := DeliverVersion(dict, seed, "v1.3.1"); err != nil {
t.Fatalf("deliver v1.3.1: %v", err)
}
for _, f := range dictFiles {
got, err := os.ReadFile(filepath.Join(dict, "v1.3.1", f))
if err != nil {
t.Fatalf("delivered %s: %v", f, err)
}
if string(got) != "dawg:"+f {
t.Errorf("delivered %s = %q, want the seed bytes", f, got)
}
}
if m, _ := os.ReadFile(filepath.Join(dict, seedMarkerFile)); string(m) != "v1.3.0\n" {
t.Errorf("flat seed marker relabelled to %q (delivery must be add-only)", m)
}
// Idempotent: a second call is a no-op and leaves no staging directory behind.
if err := DeliverVersion(dict, seed, "v1.3.1"); err != nil {
t.Fatalf("re-deliver v1.3.1: %v", err)
}
if entries, _ := os.ReadDir(dict); hasStaging(entries) {
t.Errorf("a staging directory survived delivery")
}
// On a fresh directory the build version becomes the flat seed, so nothing is delivered
// as a redundant subdirectory.
fresh := t.TempDir()
if err := DeliverVersion(fresh, seed, "v1.3.1"); err != nil {
t.Fatalf("fresh deliver: %v", err)
}
if _, err := os.Stat(filepath.Join(fresh, "v1.3.1")); !os.IsNotExist(err) {
t.Errorf("fresh volume got a redundant v1.3.1 subdir; it should be the flat seed")
}
}
func hasStaging(entries []os.DirEntry) bool {
for _, e := range entries {
if len(e.Name()) >= len(".staging") && e.Name()[:len(".staging")] == ".staging" {
return true
}
}
return false
}
-123
View File
@@ -1,123 +0,0 @@
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))
}
+5 -5
View File
@@ -25,11 +25,11 @@ const seedMarkerFile = ".seed_version"
// BACKEND_DICT_VERSION) and return it — the seed of a fresh volume; // BACKEND_DICT_VERSION) and return it — the seed of a fresh volume;
// - already-seeded directory: return the recorded marker and ignore bootVersion. // - already-seeded directory: return the recorded marker and ignore bootVersion.
// //
// So a later build seed never relabels the already-seeded flat bytes — which would void // So bumping the build seed on a live volume is a harmless no-op (it only takes
// games pinned to the prior label and mis-serve new ones. The new build version is instead // effect on a future fresh volume) instead of relabelling the already-seeded bytes —
// delivered as a NEW subdirectory (DeliverVersion) and made active (Service.InitActiveVersion), // which would void games pinned to the prior label and mis-serve new ones. New games
// so a redeploy still moves new games to it while old games keep the flat label. New games // still pin the active version (DB-persisted, set by the admin console), which is the
// pin the active version (DB-persisted); a console upload can also set it out-of-band. // real way a running contour moves to a new release.
// //
// A directory that cannot be written makes the first record fail; that also breaks // A directory that cannot be written makes the first record fail; that also breaks
// the admin console (which writes version subdirectories here), so the error is // the admin console (which writes version subdirectories here), so the error is
-7
View File
@@ -18,13 +18,6 @@ type Config struct {
// DictVersion labels the dictionary version new games pin. Sourced from // DictVersion labels the dictionary version new games pin. Sourced from
// BACKEND_DICT_VERSION. // BACKEND_DICT_VERSION.
DictVersion string DictVersion string
// DictSeedDir holds the image's read-only copy of the build's DictVersion DAWGs,
// on a path the persistent DictDir volume does NOT shadow. On boot the backend
// delivers this version into DictDir/<DictVersion>/ if absent (add-only), so a
// redeploy that bumped DICT_VERSION makes the new dictionary resident and activatable
// without disturbing the versions in-flight games already pin. Sourced from
// BACKEND_DICT_SEED_DIR; empty disables delivery (the pre-existing seed-only behaviour).
DictSeedDir string
// TimeoutSweepInterval is how often the sweeper scans for overdue turns. // TimeoutSweepInterval is how often the sweeper scans for overdue turns.
// Sourced from BACKEND_GAME_TIMEOUT_SWEEP_INTERVAL. // Sourced from BACKEND_GAME_TIMEOUT_SWEEP_INTERVAL.
TimeoutSweepInterval time.Duration TimeoutSweepInterval time.Duration
+1 -1
View File
@@ -52,7 +52,6 @@ func gameSummary(g Game, names []string) notify.GameSummary {
TurnTimeoutSecs: int(g.TurnTimeout.Seconds()), TurnTimeoutSecs: int(g.TurnTimeout.Seconds()),
MultipleWordsPerTurn: g.MultipleWordsPerTurn, MultipleWordsPerTurn: g.MultipleWordsPerTurn,
VsAI: g.VsAI, VsAI: g.VsAI,
Kind: int(g.Kind),
MoveCount: g.MoveCount, MoveCount: g.MoveCount,
EndReason: g.EndReason, EndReason: g.EndReason,
Seats: seats, Seats: seats,
@@ -75,6 +74,7 @@ func playerState(v StateView, names []string, includeAlphabet bool) (notify.Play
Rack: rack, Rack: rack,
BagLen: v.BagLen, BagLen: v.BagLen,
HintsRemaining: v.HintsRemaining, HintsRemaining: v.HintsRemaining,
WalletBalance: v.WalletBalance,
} }
if includeAlphabet { if includeAlphabet {
tab, err := engine.AlphabetTable(v.Game.Variant) tab, err := engine.AlphabetTable(v.Game.Variant)
+8 -31
View File
@@ -55,39 +55,16 @@ func TestPayloadExchangeRoundTrip(t *testing.T) {
} }
func TestHintsRemaining(t *testing.T) { func TestHintsRemaining(t *testing.T) {
cases := []struct{ allowance, used, want int }{ cases := []struct{ allowance, used, wallet, want int }{
{1, 0, 1}, {1, 0, 3, 4},
{1, 1, 0}, {1, 1, 3, 3},
{1, 2, 0}, // used past allowance clamps to 0 {1, 2, 3, 3}, // used past allowance clamps to 0
{3, 1, 2}, {0, 0, 5, 5},
{0, 0, 0}, {2, 1, 0, 1},
} }
for _, c := range cases { for _, c := range cases {
if got := hintsRemaining(c.allowance, c.used); got != c.want { if got := hintsRemaining(c.allowance, c.used, c.wallet); got != c.want {
t.Errorf("hintsRemaining(%d,%d) = %d, want %d", c.allowance, c.used, got, c.want) t.Errorf("hintsRemaining(%d,%d,%d) = %d, want %d", c.allowance, c.used, c.wallet, got, c.want)
}
}
}
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)
} }
} }
} }
+35 -161
View File
@@ -17,10 +17,7 @@ import (
"scrabble/backend/internal/account" "scrabble/backend/internal/account"
"scrabble/backend/internal/engine" "scrabble/backend/internal/engine"
"scrabble/backend/internal/gamelimits"
"scrabble/backend/internal/notify" "scrabble/backend/internal/notify"
"scrabble/backend/internal/payments"
"scrabble/backend/internal/session"
) )
// Service is the game domain: it drives the engine over a single match, persists // Service is the game domain: it drives the engine over a single match, persists
@@ -53,14 +50,6 @@ type Service struct {
// its asynchronous TriggerMove); nil disables the fast path (the scan still covers // its asynchronous TriggerMove); nil disables the fast path (the scan still covers
// these games). Kept as a func so the game package never imports the robot package. // these games). Kept as a func so the game package never imports the robot package.
aiTrigger func(gameID uuid.UUID) aiTrigger func(gameID uuid.UUID)
// hintWallet is the payments surface the online-game hint path spends against (the
// segmented, context-aware hint balance). It is set by SetHintWallet during wiring; when
// nil, only the free per-game allowance is served (no purchased hints). vs_ai hints are
// wallet-free and never touch it.
hintWallet HintWallet
// limits is the per-tier active-game cap config (cached). Set by SetGameLimits during wiring;
// when nil, no active-game limit is enforced (a game is always creatable).
limits *gamelimits.Service
// clearNudges, when set, marks the actor's pending nudges in a game read once they // clearNudges, when set, marks the actor's pending nudges in a game read once they
// have committed a move (a nudge answered by moving stops counting as unread). It is // have committed a move (a nudge answered by moving stops counting as unread). It is
// best-effort and kept as a func so the game package never imports the social package. // best-effort and kept as a func so the game package never imports the social package.
@@ -116,70 +105,6 @@ func (svc *Service) SetAITrigger(trigger func(gameID uuid.UUID)) {
svc.aiTrigger = trigger svc.aiTrigger = trigger
} }
// HintWallet is the payments surface the online-game hint path uses: the context-aware hint
// balance (HintsAvailable) and a one-hint spend (SpendHint), both keyed by the trusted execution
// context and the account's present identity sources. *payments.Service satisfies it.
type HintWallet interface {
HintsAvailable(ctx context.Context, accountID uuid.UUID, cxt payments.Context, present []payments.Source) (int, error)
SpendHint(ctx context.Context, accountID uuid.UUID, cxt payments.Context, present []payments.Source) (bool, error)
}
// SetHintWallet installs the payments hint wallet the online-game hint path spends against. It
// must be called during startup wiring; the default (nil) serves only the free per-game allowance.
func (svc *Service) SetHintWallet(w HintWallet) {
svc.hintWallet = w
}
// 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 // SetNudgeClearer installs the hook that marks a mover's pending nudges read after
// their move commits. It must be called during startup wiring; the default (nil) // their move commits. It must be called during startup wiring; the default (nil)
// leaves nudges to be cleared only when the recipient opens the move history or chat. // leaves nudges to be cleared only when the recipient opens the move history or chat.
@@ -232,31 +157,21 @@ func (svc *Service) ActiveVersion() string {
} }
// InitActiveVersion reconciles the persisted active dictionary version with the // InitActiveVersion reconciles the persisted active dictionary version with the
// registry at startup and makes the build's version (BACKEND_DICT_VERSION, delivered // registry at startup. If a version was persisted and is resident, it becomes the
// resident by engine.DeliverVersion) authoritative: a redeploy that bumped the build // active version; otherwise the configured seed version is kept and persisted as
// version thus activates it for new games without any admin step, while old games keep // the initial active version. Call once during wiring, before serving traffic.
// the version they pin. The one exception is a version installed out-of-band through
// the admin console that is NEWER than the build version and still resident — it is
// kept, so a restart on an older image never downgrades a hotfix. A build version that
// is not resident (delivery disabled or a partial state) falls back to a resident
// persisted version. The chosen version is persisted so the admin console reflects it.
// Call once during wiring, before serving traffic.
func (svc *Service) InitActiveVersion(ctx context.Context) error { func (svc *Service) InitActiveVersion(ctx context.Context) error {
persisted, ok, err := svc.store.GetActiveDictVersion(ctx) persisted, ok, err := svc.store.GetActiveDictVersion(ctx)
if err != nil { if err != nil {
return err return err
} }
target := svc.activeVersion() // the build seed version (config.DictVersion) if ok && svc.versionResident(persisted) {
if ok && svc.versionResident(persisted) && compareDictVersions(persisted, target) > 0 {
target = persisted // a newer out-of-band install wins over the build version
}
if !svc.versionResident(target) && ok && svc.versionResident(persisted) {
target = persisted // the build version is unloadable — keep a resident one
}
svc.verMu.Lock() svc.verMu.Lock()
svc.version = target svc.version = persisted
svc.verMu.Unlock() svc.verMu.Unlock()
return svc.store.SetActiveDictVersion(ctx, target) return nil
}
return svc.store.SetActiveDictVersion(ctx, svc.activeVersion())
} }
// SetActiveVersion records version as the active dictionary version, persisting it // SetActiveVersion records version as the active dictionary version, persisting it
@@ -383,7 +298,6 @@ func (svc *Service) Create(ctx context.Context, params CreateParams) (Game, erro
dropoutTiles: params.DropoutTiles.String(), dropoutTiles: params.DropoutTiles.String(),
multipleWordsPerTurn: params.MultipleWordsPerTurn, multipleWordsPerTurn: params.MultipleWordsPerTurn,
vsAI: params.VsAI, vsAI: params.VsAI,
kind: params.Kind,
} }
if err := svc.store.CreateGame(ctx, ins, seats, seeding.draws); err != nil { if err := svc.store.CreateGame(ctx, ins, seats, seeding.draws); err != nil {
return Game{}, err return Game{}, err
@@ -447,7 +361,6 @@ func (svc *Service) OpenOrJoin(ctx context.Context, accountID uuid.UUID, params
multipleWordsPerTurn: params.MultipleWordsPerTurn, multipleWordsPerTurn: params.MultipleWordsPerTurn,
status: StatusOpen, status: StatusOpen,
openDeadline: &deadline, openDeadline: &deadline,
kind: gamelimits.KindRandom,
} }
// Decide the first move now by the official draw, with the not-yet-arrived opponent as a // Decide the first move now by the official draw, with the not-yet-arrived opponent as a
// synthetic placeholder (uuid.Nil): the draw fixes who sits at seat 0 — and so moves // synthetic placeholder (uuid.Nil): the draw fixes who sits at seat 0 — and so moves
@@ -1145,31 +1058,10 @@ func (svc *Service) MarkChangesApplied(ctx context.Context, variant engine.Varia
return svc.store.MarkChangesApplied(ctx, variant.String(), version) return svc.store.MarkChangesApplied(ctx, variant.String(), version)
} }
// hintIdleWindow is how long a vs_ai player must be stuck on a turn (since the robot's last move) // Hint reveals the top-scoring legal play for the requesting player on their
// before the idle hint unlocks. Mirrors the offline client (lib/hints HINT_GATE_MS = 30 min). // turn, spending one hint from their per-game allowance and then their profile
const hintIdleWindow = 30 * time.Minute // wallet. It returns ErrHintsDisabled, ErrNoHintsLeft or ErrNoHintAvailable as
// appropriate.
// hintUnlockLeftSeconds is the seconds until g's vs_ai idle hint unlocks for seat, measured from now:
// the robot's last move (the current turn's start, on the human's turn) plus the window, floored at
// 0 and ceiled to whole seconds. It is 0 for a non-vs_ai game, when it is not seat's turn, or on the
// human's first move (MoveCount 0, no robot move yet) — the gate is an anti-frustration aid, not a
// first-move tax. The client anchors a monotonic countdown to it, so a client clock cannot skew it.
func hintUnlockLeftSeconds(g Game, seat int, now time.Time) int {
if !g.VsAI || g.ToMove != seat || g.MoveCount < 1 {
return 0
}
left := g.TurnStartedAt.Add(hintIdleWindow).Sub(now)
if left <= 0 {
return 0
}
return int((left + time.Second - 1) / time.Second) // ceil to whole seconds (no math import)
}
// Hint reveals the top-scoring legal play for the requesting player on their turn. For a human game
// it spends one hint from the per-game allowance then the profile wallet (ErrHintsDisabled /
// ErrNoHintsLeft / ErrNoHintAvailable). For a vs_ai game the hint is unlimited and wallet-free but
// idle-gated from the server clock (ErrHintLocked until the window elapses) and counts toward no
// hint statistic.
func (svc *Service) Hint(ctx context.Context, gameID, accountID uuid.UUID) (HintResult, error) { func (svc *Service) Hint(ctx context.Context, gameID, accountID uuid.UUID) (HintResult, error) {
pre, err := svc.store.GetGame(ctx, gameID) pre, err := svc.store.GetGame(ctx, gameID)
if err != nil { if err != nil {
@@ -1188,40 +1080,13 @@ func (svc *Service) Hint(ctx context.Context, gameID, accountID uuid.UUID) (Hint
if !pre.HintsAllowed { if !pre.HintsAllowed {
return HintResult{}, ErrHintsDisabled return HintResult{}, ErrHintsDisabled
} }
if pre.VsAI { acc, err := svc.accounts.GetByID(ctx, accountID)
// 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 { if err != nil {
return HintResult{}, err 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 used := pre.Seats[seat].HintsUsed
fromAllowance := used < pre.HintsPerPlayer fromAllowance := used < pre.HintsPerPlayer
if !fromAllowance && wallet <= 0 { if !fromAllowance && acc.HintBalance <= 0 {
return HintResult{}, ErrNoHintsLeft return HintResult{}, ErrNoHintsLeft
} }
@@ -1236,9 +1101,9 @@ func (svc *Service) Hint(ctx context.Context, gameID, accountID uuid.UUID) (Hint
return HintResult{}, ErrNoHintAvailable return HintResult{}, ErrNoHintAvailable
} }
walletAfter := wallet walletAfter := acc.HintBalance
if !fromAllowance { if !fromAllowance {
spent, err := svc.hintWallet.SpendHint(ctx, accountID, cxt, present) spent, err := svc.accounts.SpendHint(ctx, accountID)
if err != nil { if err != nil {
return HintResult{}, err return HintResult{}, err
} }
@@ -1254,7 +1119,7 @@ func (svc *Service) Hint(ctx context.Context, gameID, accountID uuid.UUID) (Hint
return HintResult{}, err return HintResult{}, err
} }
used++ used++
return HintResult{Move: move, HintsRemaining: hintsRemaining(pre.HintsPerPlayer, used), WalletBalance: walletAfter}, nil return HintResult{Move: move, HintsRemaining: hintsRemaining(pre.HintsPerPlayer, used, walletAfter), WalletBalance: walletAfter}, nil
} }
// Candidates returns the to-move player's legal plays for a seated player on // Candidates returns the to-move player's legal plays for a seated player on
@@ -1319,6 +1184,10 @@ func (svc *Service) GameState(ctx context.Context, gameID, accountID uuid.UUID)
if !ok { if !ok {
return StateView{}, ErrNotAPlayer return StateView{}, ErrNotAPlayer
} }
acc, err := svc.accounts.GetByID(ctx, accountID)
if err != nil {
return StateView{}, err
}
unlock := svc.locks.lock(gameID) unlock := svc.locks.lock(gameID)
defer unlock() defer unlock()
@@ -1337,11 +1206,8 @@ func (svc *Service) GameState(ctx context.Context, gameID, accountID uuid.UUID)
Seat: seat, Seat: seat,
Rack: g.Hand(seat), Rack: g.Hand(seat),
BagLen: g.BagLen(), BagLen: g.BagLen(),
// HintsRemaining is the per-seat allowance only; the purchasable wallet lives on the profile HintsRemaining: hintsRemaining(pre.HintsPerPlayer, pre.Seats[seat].HintsUsed, acc.HintBalance),
// (payments) and the client adds it (lib/hints.hintsLeft). WalletBalance: acc.HintBalance,
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 }, nil
} }
@@ -1433,6 +1299,14 @@ func (svc *Service) ListForLobby(ctx context.Context, accountID uuid.UUID) ([]Ga
return kept, nil return kept, nil
} }
// CountActiveQuickGames reports how many in-progress quick games the account holds —
// the count the simultaneous-game limit (MaxActiveQuickGames) is checked against. It
// counts active and still-open quick games (including honest-AI ones) and excludes
// friend games created by invitation and finished games. See Store.CountActiveQuickGames.
func (svc *Service) CountActiveQuickGames(ctx context.Context, accountID uuid.UUID) (int, error) {
return svc.store.CountActiveQuickGames(ctx, accountID)
}
// HideGame hides a finished game from accountID's own lobby (it stays visible to the other // HideGame hides a finished game from accountID's own lobby (it stays visible to the other
// players); it is irreversible by design. Only a player of a finished game may hide it // players); it is irreversible by design. Only a player of a finished game may hide it
// (ErrNotAPlayer / ErrGameActive otherwise); hiding an already-hidden game is a no-op. // (ErrNotAPlayer / ErrGameActive otherwise); hiding an already-hidden game is a no-op.
@@ -1812,10 +1686,10 @@ func (svc *Service) DictBytes(variant engine.Variant, version string) ([]byte, e
return svc.registry.DictBytes(variant, version) return svc.registry.DictBytes(variant, version)
} }
// hintsRemaining is the unspent per-game hint allowance. The purchasable wallet is separate, // hintsRemaining is a player's remaining hint budget: the unspent per-game
// carried on the profile (payments), and the client adds it (lib/hints.hintsLeft). // allowance plus the profile wallet.
func hintsRemaining(allowance, used int) int { func hintsRemaining(allowance, used, wallet int) int {
return max(0, allowance-used) return max(0, allowance-used) + wallet
} }
// allowedTimeout reports whether d is one of the offered move clocks. // allowedTimeout reports whether d is one of the offered move clocks.
+24 -20
View File
@@ -15,7 +15,6 @@ import (
"scrabble/backend/internal/account" "scrabble/backend/internal/account"
"scrabble/backend/internal/engine" "scrabble/backend/internal/engine"
"scrabble/backend/internal/gamelimits"
"scrabble/backend/internal/postgres/jet/backend/model" "scrabble/backend/internal/postgres/jet/backend/model"
"scrabble/backend/internal/postgres/jet/backend/table" "scrabble/backend/internal/postgres/jet/backend/table"
) )
@@ -46,8 +45,6 @@ type gameInsert struct {
multipleWordsPerTurn bool multipleWordsPerTurn bool
// vsAI marks an honest-AI game (games.vs_ai). // vsAI marks an honest-AI game (games.vs_ai).
vsAI bool vsAI bool
// kind tags the game's origin (games.game_kind) for the active-game limits.
kind gamelimits.Kind
// status is the lifecycle state to create the game in: StatusActive for a normal // status is the lifecycle state to create the game in: StatusActive for a normal
// seated game, StatusOpen for an auto-match game still awaiting an opponent. An // seated game, StatusOpen for an auto-match game still awaiting an opponent. An
// empty string defaults to StatusActive. // empty string defaults to StatusActive.
@@ -141,20 +138,6 @@ func (s *Store) CreateGame(ctx context.Context, ins gameInsert, seats []seatInse
}) })
} }
// CountActiveByKind counts the account's active (open or in-progress) games of the given kind — the
// per-tier active-game limit is checked against it before a new game of that kind is created.
func (s *Store) CountActiveByKind(ctx context.Context, accountID uuid.UUID, kind gamelimits.Kind) (int, error) {
var n int
if err := s.db.QueryRowContext(ctx,
`SELECT count(*) FROM backend.games g
JOIN backend.game_players p ON p.game_id = g.game_id
WHERE p.account_id = $1 AND g.game_kind = $2 AND g.status IN ('open', 'active')`,
accountID, int16(kind)).Scan(&n); err != nil {
return 0, fmt.Errorf("game: count active by kind %s: %w", accountID, err)
}
return n, nil
}
// insertGameTx inserts the games row and one game_players row per seat (seat 0 // insertGameTx inserts the games row and one game_players row per seat (seat 0
// first) on tx, stamping each seat's display-name snapshot. A seat whose account id is // first) on tx, stamping each seat's display-name snapshot. A seat whose account id is
// uuid.Nil is written with a NULL account_id (and an empty snapshot) — the still-empty // uuid.Nil is written with a NULL account_id (and an empty snapshot) — the still-empty
@@ -172,9 +155,9 @@ func insertGameTx(ctx context.Context, tx *sql.Tx, ins gameInsert, seats []seatI
table.Games.GameID, table.Games.Variant, table.Games.DictVersion, table.Games.Seed, table.Games.GameID, table.Games.Variant, table.Games.DictVersion, table.Games.Seed,
table.Games.Status, table.Games.Players, table.Games.TurnTimeoutSecs, table.Games.Status, table.Games.Players, table.Games.TurnTimeoutSecs,
table.Games.HintsAllowed, table.Games.HintsPerPlayer, table.Games.OpenDeadlineAt, table.Games.HintsAllowed, table.Games.HintsPerPlayer, table.Games.OpenDeadlineAt,
table.Games.DropoutTiles, table.Games.MultipleWordsPerTurn, table.Games.VsAi, table.Games.GameKind, table.Games.DropoutTiles, table.Games.MultipleWordsPerTurn, table.Games.VsAi,
).VALUES(ins.id, ins.variant, ins.dictVersion, ins.seed, status, ins.players, ).VALUES(ins.id, ins.variant, ins.dictVersion, ins.seed, status, ins.players,
ins.turnTimeoutSecs, ins.hintsAllowed, ins.hintsPerPlayer, deadline, ins.dropoutTiles, ins.multipleWordsPerTurn, ins.vsAI, int16(ins.kind)) ins.turnTimeoutSecs, ins.hintsAllowed, ins.hintsPerPlayer, deadline, ins.dropoutTiles, ins.multipleWordsPerTurn, ins.vsAI)
if _, err := gi.ExecContext(ctx, tx); err != nil { if _, err := gi.ExecContext(ctx, tx); err != nil {
return fmt.Errorf("insert game: %w", err) return fmt.Errorf("insert game: %w", err)
} }
@@ -518,6 +501,28 @@ func (s *Store) ListGamesForAccount(ctx context.Context, accountID uuid.UUID) ([
return out, nil return out, nil
} }
// CountActiveQuickGames counts the account's in-progress quick games — the ones the
// simultaneous-game limit (MaxActiveQuickGames) is checked against. It includes both
// active and still-open (awaiting-opponent) games, the honest-AI ones among them, and
// excludes friend games (those linked to a game_invitations row) and finished games.
// A hidden game still occupies a slot, so this is a dedicated count rather than a
// filter over ListGamesForAccount (which drops hidden games). Joining on the account's
// own seat counts each game once (an open game's empty opponent seat has no account).
func (s *Store) CountActiveQuickGames(ctx context.Context, accountID uuid.UUID) (int, error) {
// The status literals are game.StatusActive / game.StatusOpen, matching the
// games.status CHECK in the baseline migration.
const q = `
SELECT COUNT(*) FROM backend.games g
JOIN backend.game_players gp ON gp.game_id = g.game_id
LEFT JOIN backend.game_invitations gi ON gi.game_id = g.game_id
WHERE gp.account_id = $1 AND g.status IN ('active', 'open') AND gi.game_id IS NULL`
var n int
if err := s.db.QueryRowContext(ctx, q, accountID).Scan(&n); err != nil {
return 0, fmt.Errorf("game: count active quick games: %w", err)
}
return n, nil
}
// HideGame hides a game from the account's own lobby list (idempotent). The caller validates the // HideGame hides a game from the account's own lobby list (idempotent). The caller validates the
// game is finished and the account is a player. // game is finished and the account is a player.
func (s *Store) HideGame(ctx context.Context, accountID, gameID uuid.UUID) error { func (s *Store) HideGame(ctx context.Context, accountID, gameID uuid.UUID) error {
@@ -1356,7 +1361,6 @@ func projectGame(g model.Games, seats []model.GamePlayers) (Game, error) {
} }
out.MultipleWordsPerTurn = g.MultipleWordsPerTurn out.MultipleWordsPerTurn = g.MultipleWordsPerTurn
out.VsAI = g.VsAi out.VsAI = g.VsAi
out.Kind = gamelimits.Kind(g.GameKind)
if g.EndReason != nil { if g.EndReason != nil {
out.EndReason = *g.EndReason out.EndReason = *g.EndReason
} }
+16 -20
View File
@@ -7,7 +7,6 @@ import (
"github.com/google/uuid" "github.com/google/uuid"
"scrabble/backend/internal/engine" "scrabble/backend/internal/engine"
"scrabble/backend/internal/gamelimits"
) )
// Status values persisted in the games.status column. // Status values persisted in the games.status column.
@@ -66,10 +65,6 @@ var (
ErrNoHintsLeft = errors.New("game: no hints remaining") ErrNoHintsLeft = errors.New("game: no hints remaining")
// ErrNoHintAvailable is returned when the player has no legal play to reveal. // ErrNoHintAvailable is returned when the player has no legal play to reveal.
ErrNoHintAvailable = errors.New("game: no legal move to suggest") ErrNoHintAvailable = errors.New("game: no legal move to suggest")
// ErrHintLocked is returned when a vs_ai game's idle hint is still gated (the player has not yet
// been stuck the idle window since the robot's last move). The client normally pre-gates from
// StateView.HintUnlockLeftSeconds, so this is the authoritative server-clock backstop.
ErrHintLocked = errors.New("game: hint is not available yet")
// ErrGameLimitReached is returned when an account already holds MaxActiveQuickGames // ErrGameLimitReached is returned when an account already holds MaxActiveQuickGames
// simultaneous quick games and tries to create another game (quick or by invitation). // simultaneous quick games and tries to create another game (quick or by invitation).
ErrGameLimitReached = errors.New("game: simultaneous game limit reached") ErrGameLimitReached = errors.New("game: simultaneous game limit reached")
@@ -95,6 +90,15 @@ const DefaultTurnTimeout = 24 * time.Hour
// one of AllowedTurnTimeouts (never offered in the creation UI). // one of AllowedTurnTimeouts (never offered in the creation UI).
const AIInactivityTimeout = 7 * 24 * time.Hour const AIInactivityTimeout = 7 * 24 * time.Hour
// MaxActiveQuickGames is the cap on a player's simultaneous quick games (human
// auto-match and honest-AI), counting both in-progress (StatusActive) and
// still-open, awaiting-opponent (StatusOpen) games. Friend games created by
// invitation are not counted. At the cap the backend refuses to create a new game
// of any kind — quick or by invitation — and the lobby disables "New Game";
// accepting an incoming invitation is always allowed. See
// Store.CountActiveQuickGames.
const MaxActiveQuickGames = 10
// aiPlayerName labels the robot seat in an honest-AI game's GCG export, so a downloaded // aiPlayerName labels the robot seat in an honest-AI game's GCG export, so a downloaded
// game file shows a clean "AI" rather than the robot's human-like pool name (the in-app // game file shows a clean "AI" rather than the robot's human-like pool name (the in-app
// UI shows 🤖 from the game's vs_ai flag). // UI shows 🤖 from the game's vs_ai flag).
@@ -117,10 +121,6 @@ type CreateParams struct {
// robot is seated at once, the move clock is AIInactivityTimeout, and chat/nudge // robot is seated at once, the move clock is AIInactivityTimeout, and chat/nudge
// and finish-time statistics are suppressed. Set by the lobby's AI-match path. // and finish-time statistics are suppressed. Set by the lobby's AI-match path.
VsAI bool VsAI bool
// Kind tags the game's origin (games.game_kind) for the active-game limits: vs_ai / random /
// friends. The lobby sets it; a zero (unknown) kind is never gated. The active-game limit itself
// is enforced at the new-game handler (Server.ensureUnderGameLimit), not here.
Kind gamelimits.Kind
} }
// Game is the persisted state of a match: the games row joined with its seats. // Game is the persisted state of a match: the games row joined with its seats.
@@ -147,9 +147,6 @@ type Game struct {
// VsAI is true for an honest-AI game (games.vs_ai): the opponent is a robot the // VsAI is true for an honest-AI game (games.vs_ai): the opponent is a robot the
// player knowingly chose, shown as 🤖, with chat/nudge disabled and no statistics. // player knowingly chose, shown as 🤖, with chat/nudge disabled and no statistics.
VsAI bool VsAI bool
// Kind is the game's origin tag (games.game_kind) for the active-game limits: vs_ai / random /
// friends, or unknown for an untagged game. Read-only projection; set once on creation.
Kind gamelimits.Kind
} }
// Seat is one player's standing in a game. // Seat is one player's standing in a game.
@@ -210,9 +207,10 @@ type MoveResult struct {
BagLen int BagLen int
} }
// HintResult is a revealed hint with the per-seat allowance remaining (HintsRemaining) and the // HintResult is a revealed hint and the requesting player's remaining hint
// purchasable hint wallet after spending one (WalletBalance, from payments). The client adopts // budget (per-seat allowance plus profile wallet) after spending one. WalletBalance is
// WalletBalance into the profile so the badge stays live across games (lib/hints). // the global wallet alone, so the client can keep its live wallet authoritative and
// re-derive the per-game allowance (HintsRemaining - WalletBalance).
type HintResult struct { type HintResult struct {
Move engine.MoveRecord Move engine.MoveRecord
HintsRemaining int HintsRemaining int
@@ -239,11 +237,9 @@ type StateView struct {
Rack []string Rack []string
BagLen int BagLen int
HintsRemaining int HintsRemaining int
// HintUnlockLeftSeconds is, for a vs_ai game on the requesting player's turn, the seconds left // WalletBalance is the player's global hint-wallet balance alone (HintsRemaining folds
// until the idle hint unlocks (the robot's last move plus the idle window, from the server clock); // it in with the per-game allowance), so the client keeps the wallet live across games.
// 0 for the human's first move, when it is not their turn, or a non-vs_ai game. The vs_ai hint is WalletBalance int
// unlimited and wallet-free; this idle gate replaces the allowance/wallet for it.
HintUnlockLeftSeconds int
} }
// HistoryMove is one decoded journal row, independent of any dictionary. // HistoryMove is one decoded journal row, independent of any dictionary.
-45
View File
@@ -1,45 +0,0 @@
package game
import (
"strconv"
"strings"
)
// compareDictVersions orders two dictionary version labels of the shape "vMAJOR.MINOR.PATCH"
// numerically, returning -1, 0 or +1 as a is less than, equal to or greater than b. Labels
// that do not parse fall back to a byte comparison, so the ordering is always total (a mixed
// or malformed pair never crashes the boot-time active-version reconcile).
func compareDictVersions(a, b string) int {
pa, oka := parseDictVersion(a)
pb, okb := parseDictVersion(b)
if !oka || !okb {
return strings.Compare(a, b)
}
for i := range pa {
if pa[i] != pb[i] {
if pa[i] < pb[i] {
return -1
}
return 1
}
}
return 0
}
// parseDictVersion parses a "vMAJOR.MINOR.PATCH" label into its three numeric parts, reporting
// whether it matched that shape.
func parseDictVersion(v string) ([3]int, bool) {
parts := strings.Split(strings.TrimPrefix(v, "v"), ".")
if len(parts) != 3 {
return [3]int{}, false
}
var out [3]int
for i, p := range parts {
n, err := strconv.Atoi(p)
if err != nil {
return [3]int{}, false
}
out[i] = n
}
return out, true
}
-37
View File
@@ -1,37 +0,0 @@
package game
import "testing"
func TestCompareDictVersions(t *testing.T) {
cases := []struct {
a, b string
want int
}{
{"v1.3.0", "v1.3.1", -1},
{"v1.3.1", "v1.3.0", 1},
{"v1.3.1", "v1.3.1", 0},
{"v1.3.9", "v1.3.10", -1}, // numeric, not lexical
{"v1.10.0", "v1.9.0", 1},
{"v2.0.0", "v1.9.9", 1},
{"1.3.1", "v1.3.1", 0}, // the leading v is optional
// Non-semver falls back to a byte comparison, keeping the ordering total.
{"weird", "weird", 0},
{"a", "b", -1},
}
for _, c := range cases {
if got := compareDictVersions(c.a, c.b); sign(got) != c.want {
t.Errorf("compareDictVersions(%q, %q) = %d, want sign %d", c.a, c.b, got, c.want)
}
}
}
func sign(n int) int {
switch {
case n < 0:
return -1
case n > 0:
return 1
default:
return 0
}
}
-149
View File
@@ -1,149 +0,0 @@
// 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
}
@@ -1,44 +0,0 @@
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)
}
}
+3 -23
View File
@@ -5,7 +5,6 @@ package inttest
import ( import (
"context" "context"
"errors" "errors"
"regexp"
"testing" "testing"
"time" "time"
@@ -230,40 +229,21 @@ func TestProvisionSeedsTimeZone(t *testing.T) {
t.Errorf("TimeZone = %q, want UTC fallback for a malformed offset", bad.TimeZone) t.Errorf("TimeZone = %q, want UTC fallback for a malformed offset", bad.TimeZone)
} }
// A guest is seeded its detected offset and interface language; an empty offset keeps the // A guest is seeded its detected offset; an empty one keeps the UTC default.
// UTC default and an empty/unsupported language keeps the 'en' default. guest, err := store.ProvisionGuest(ctx, "-05:30")
guest, err := store.ProvisionGuest(ctx, "-05:30", "ru")
if err != nil { if err != nil {
t.Fatalf("provision guest: %v", err) t.Fatalf("provision guest: %v", err)
} }
if guest.TimeZone != "-05:30" { if guest.TimeZone != "-05:30" {
t.Errorf("guest TimeZone = %q, want the seeded -05:30", guest.TimeZone) t.Errorf("guest TimeZone = %q, want the seeded -05:30", guest.TimeZone)
} }
if guest.PreferredLanguage != "ru" { plainGuest, err := store.ProvisionGuest(ctx, "")
t.Errorf("guest PreferredLanguage = %q, want the seeded ru", guest.PreferredLanguage)
}
// A guest's display name is "Guest" + a random six-digit suffix (distinct to opponents).
if m, _ := regexp.MatchString(`^Guest\d{6}$`, guest.DisplayName); !m {
t.Errorf("guest DisplayName = %q, want Guest + 6 digits", guest.DisplayName)
}
plainGuest, err := store.ProvisionGuest(ctx, "", "")
if err != nil { if err != nil {
t.Fatalf("provision plain guest: %v", err) t.Fatalf("provision plain guest: %v", err)
} }
if plainGuest.TimeZone != "UTC" { if plainGuest.TimeZone != "UTC" {
t.Errorf("plain guest TimeZone = %q, want UTC default", plainGuest.TimeZone) t.Errorf("plain guest TimeZone = %q, want UTC default", plainGuest.TimeZone)
} }
if plainGuest.PreferredLanguage != "en" {
t.Errorf("plain guest PreferredLanguage = %q, want en default", plainGuest.PreferredLanguage)
}
// An unsupported locale falls back to the 'en' default rather than persisting a junk value.
frGuest, err := store.ProvisionGuest(ctx, "", "fr-FR")
if err != nil {
t.Fatalf("provision fr guest: %v", err)
}
if frGuest.PreferredLanguage != "en" {
t.Errorf("unsupported-locale guest PreferredLanguage = %q, want en default", frGuest.PreferredLanguage)
}
} }
// TestProvisionTelegramUnknownLanguageDefaults checks an unsupported Telegram // TestProvisionTelegramUnknownLanguageDefaults checks an unsupported Telegram
@@ -1,79 +0,0 @@
//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"))
}
}
@@ -1,77 +0,0 @@
//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)
}
}
}
+71
View File
@@ -4,6 +4,7 @@ package inttest
import ( import (
"context" "context"
"errors"
"net/http" "net/http"
"net/http/httptest" "net/http/httptest"
"strings" "strings"
@@ -279,6 +280,76 @@ 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 // 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. // 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) { func consoleDo(h http.Handler, method, target, body, origin string) (int, string) {
+14 -262
View File
@@ -15,15 +15,13 @@ import (
"scrabble/backend/internal/account" "scrabble/backend/internal/account"
"scrabble/backend/internal/ads" "scrabble/backend/internal/ads"
"scrabble/backend/internal/payments"
"scrabble/backend/internal/server" "scrabble/backend/internal/server"
) )
// bannerServer assembles a console-capable server with the ads domain wired. // bannerServer assembles a console-capable server with the ads domain wired.
func bannerServer(t *testing.T) (*server.Server, *ads.Service, *payments.Service) { func bannerServer(t *testing.T) (*server.Server, *ads.Service) {
t.Helper() t.Helper()
adsSvc := ads.NewService(ads.NewStore(testDB)) adsSvc := ads.NewService(ads.NewStore(testDB))
paySvc := newPaymentsService()
srv := server.New(":0", server.Deps{ srv := server.New(":0", server.Deps{
Logger: zap.NewNop(), Logger: zap.NewNop(),
Accounts: account.NewStore(testDB), Accounts: account.NewStore(testDB),
@@ -31,9 +29,8 @@ func bannerServer(t *testing.T) (*server.Server, *ads.Service, *payments.Service
Registry: testRegistry, Registry: testRegistry,
DictDir: dictDir(), DictDir: dictDir(),
Ads: adsSvc, Ads: adsSvc,
Payments: paySvc,
}) })
return srv, adsSvc, paySvc return srv, adsSvc
} }
// findCampaign returns the id of the campaign with the given name, or fails. // findCampaign returns the id of the campaign with the given name, or fails.
@@ -74,7 +71,7 @@ func defaultCampaign(t *testing.T, svc *ads.Service) ads.Campaign {
// reorder/delete. // reorder/delete.
func TestBannerConsoleCRUD(t *testing.T) { func TestBannerConsoleCRUD(t *testing.T) {
ctx := context.Background() ctx := context.Background()
srv, adsSvc, _ := bannerServer(t) srv, adsSvc := bannerServer(t)
h := srv.Handler() h := srv.Handler()
base := "http://admin.test/_gm" base := "http://admin.test/_gm"
const origin = "http://admin.test" const origin = "http://admin.test"
@@ -154,7 +151,7 @@ func TestBannerConsoleCRUD(t *testing.T) {
// unrelated campaign's URL must be refused. // unrelated campaign's URL must be refused.
func TestBannerMessageOwnership(t *testing.T) { func TestBannerMessageOwnership(t *testing.T) {
ctx := context.Background() ctx := context.Background()
srv, adsSvc, _ := bannerServer(t) srv, adsSvc := bannerServer(t)
h := srv.Handler() h := srv.Handler()
base := "http://admin.test/_gm" base := "http://admin.test/_gm"
const origin = "http://admin.test" const origin = "http://admin.test"
@@ -191,24 +188,17 @@ func TestBannerMessageOwnership(t *testing.T) {
} }
} }
// profileBanner is the banner (and interstitial-ad) block of the profile.get JSON response. // profileBanner is the banner block of the profile.get JSON response.
type profileBanner struct { type profileBanner struct {
Banner *struct { Banner *struct {
Campaigns []struct { Campaigns []struct {
Weight int `json:"weight"` Weight int `json:"weight"`
Messages []string `json:"messages"` Messages []string `json:"messages"`
OverrideBg string `json:"override_bg"`
} `json:"campaigns"` } `json:"campaigns"`
Timings struct { Timings struct {
HoldMs int `json:"hold_ms"` HoldMs int `json:"hold_ms"`
} `json:"timings"` } `json:"timings"`
} `json:"banner"` } `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 // TestBannerProfileEligibility checks the profile.get banner block follows
@@ -216,11 +206,10 @@ type profileBanner struct {
// wallet each suppress it. // wallet each suppress it.
func TestBannerProfileEligibility(t *testing.T) { func TestBannerProfileEligibility(t *testing.T) {
ctx := context.Background() ctx := context.Background()
srv, _, pay := bannerServer(t) srv, _ := bannerServer(t)
accounts := account.NewStore(testDB) accounts := account.NewStore(testDB)
id := provisionAccount(t) id := provisionAccount(t)
// getBanner fetches the profile banner with no platform header (an untrusted context).
getBanner := func() profileBanner { getBanner := func() profileBanner {
t.Helper() t.Helper()
rec := userGet(t, srv, "/api/v1/user/profile", id) rec := userGet(t, srv, "/api/v1/user/profile", id)
@@ -233,27 +222,10 @@ func TestBannerProfileEligibility(t *testing.T) {
} }
return p return p
} }
// getBannerTG fetches the profile banner in a trusted Telegram context — the account's
// telegram identity makes telegram the applicable origin for its benefits.
getBannerTG := func() profileBanner {
t.Helper()
rec := httptest.NewRecorder()
req := httptest.NewRequest(http.MethodGet, "/api/v1/user/profile", nil)
req.Header.Set("X-User-ID", id.String())
req.Header.Set("X-Platform", "telegram/android")
srv.Handler().ServeHTTP(rec, req)
if rec.Code != http.StatusOK {
t.Fatalf("profile = %d, want 200", rec.Code)
}
var p profileBanner
if err := json.Unmarshal(rec.Body.Bytes(), &p); err != nil {
t.Fatalf("decode profile: %v", err)
}
return p
}
// A free account with no role and no benefit is eligible. // A free account with an empty hint wallet and no role is eligible.
if p := getBanner(); p.Banner == nil || len(p.Banner.Campaigns) == 0 || p.Banner.Timings.HoldMs <= 0 { p := getBanner()
if p.Banner == nil || len(p.Banner.Campaigns) == 0 || p.Banner.Timings.HoldMs <= 0 {
t.Fatalf("eligible account: banner=%v", p.Banner) t.Fatalf("eligible account: banner=%v", p.Banner)
} }
@@ -268,106 +240,19 @@ func TestBannerProfileEligibility(t *testing.T) {
t.Fatalf("revoke role: %v", err) t.Fatalf("revoke role: %v", err)
} }
// A hint wallet no longer suppresses the banner — hints and no-ads are distinct benefits now. // A non-empty hint wallet suppresses it.
if err := pay.Grant(ctx, id, payments.SourceTelegram, 5, 0, false); err != nil { if _, err := accounts.GrantHints(ctx, id, 5); err != nil {
t.Fatalf("grant hints: %v", err) t.Fatalf("grant hints: %v", err)
} }
if p := getBannerTG(); p.Banner == nil { if p := getBanner(); p.Banner != nil {
t.Fatal("a hint wallet must NOT suppress the banner") t.Fatal("a non-empty hint wallet still shows 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 // TestBannerSurvivesProfileUpdate guards that a profile update (e.g. a language switch) returns the
// banner block too, so the client's profile keeps the banner instead of losing it until reload. // banner block too, so the client's profile keeps the banner instead of losing it until reload.
func TestBannerSurvivesProfileUpdate(t *testing.T) { func TestBannerSurvivesProfileUpdate(t *testing.T) {
srv, _, _ := bannerServer(t) srv, _ := bannerServer(t)
id := provisionAccount(t) id := provisionAccount(t)
body := `{"display_name":"Tester","preferred_language":"ru","time_zone":"UTC","away_start":"00:00",` + body := `{"display_name":"Tester","preferred_language":"ru","time_zone":"UTC","away_start":"00:00",` +
@@ -389,136 +274,3 @@ func TestBannerSurvivesProfileUpdate(t *testing.T) {
t.Fatalf("profile update dropped the banner block: %v", p.Banner) t.Fatalf("profile update dropped the banner block: %v", p.Banner)
} }
} }
// TestBannerConsoleColorsAndUrgent drives the colour-override + urgent editor over
// HTTP against real Postgres: an incomplete or malformed colour set is refused, a
// full two-set override + urgent round-trips through the store (exercising the new
// columns and CHECK constraints), clearing the sets removes the override, and the
// default campaign stays plain (colours + urgent are ignored for it).
func TestBannerConsoleColorsAndUrgent(t *testing.T) {
ctx := context.Background()
srv, adsSvc, _ := bannerServer(t)
h := srv.Handler()
base := "http://admin.test/_gm"
const origin = "http://admin.test"
name := "Brand-" + uuid.NewString()[:8]
if code, body := consoleDo(h, http.MethodPost, base+"/banners", "name="+name+"&weight=20&enabled=on", origin); code != http.StatusOK || !strings.Contains(body, "Created") {
t.Fatalf("create = %d, has Created=%v", code, strings.Contains(body, "Created"))
}
id := findCampaign(t, adsSvc, name)
t.Cleanup(func() { _ = adsSvc.DeleteCampaign(ctx, id) })
detail := base + "/banners/" + id.String()
// A partial colour set (a missing colour) is refused by validation.
partial := "name=" + name + "&weight=20&enabled=on&override_all_on=on&override_bg=%23aa0000&override_fg=&override_link=%23ffdd00"
if code, b := consoleDo(h, http.MethodPost, detail, partial, origin); code != http.StatusOK || !strings.Contains(b, "Not saved") {
t.Fatalf("partial colour = %d, has 'Not saved'=%v", code, strings.Contains(b, "Not saved"))
}
// A malformed hex is refused.
badHex := "name=" + name + "&weight=20&enabled=on&override_all_on=on&override_bg=red&override_fg=%23ffffff&override_link=%23ffdd00"
if code, b := consoleDo(h, http.MethodPost, detail, badHex, origin); code != http.StatusOK || !strings.Contains(b, "Not saved") {
t.Fatalf("bad hex = %d, has 'Not saved'=%v", code, strings.Contains(b, "Not saved"))
}
// Both colour sets + urgent persist and round-trip through the store.
full := "name=" + name + "&weight=20&enabled=on&urgent=on" +
"&override_all_on=on&override_bg=%23aa0000&override_fg=%23ffffff&override_link=%23ffdd00" +
"&override_dark_on=on&override_bg_dark=%23330000&override_fg_dark=%23eeeeee&override_link_dark=%23ffcc00"
if code, b := consoleDo(h, http.MethodPost, detail, full, origin); code != http.StatusOK || !strings.Contains(b, "Saved") {
t.Fatalf("full save = %d, has Saved=%v", code, strings.Contains(b, "Saved"))
}
cm, err := adsSvc.Campaign(ctx, id)
if err != nil {
t.Fatalf("read campaign: %v", err)
}
if cm.OverrideAll == nil || cm.OverrideAll.Bg != "#aa0000" || cm.OverrideAll.Fg != "#ffffff" || cm.OverrideAll.Link != "#ffdd00" {
t.Fatalf("override all = %+v", cm.OverrideAll)
}
if cm.OverrideDark == nil || cm.OverrideDark.Bg != "#330000" || cm.OverrideDark.Fg != "#eeeeee" || cm.OverrideDark.Link != "#ffcc00" {
t.Fatalf("override dark = %+v", cm.OverrideDark)
}
if !cm.Urgent {
t.Fatal("urgent not persisted")
}
// Clearing the sets (both checkboxes off, urgent off) removes the override.
if code, _ := consoleDo(h, http.MethodPost, detail, "name="+name+"&weight=20&enabled=on", origin); code != http.StatusOK {
t.Fatalf("clear = %d", code)
}
if cm, _ := adsSvc.Campaign(ctx, id); cm.OverrideAll != nil || cm.OverrideDark != nil || cm.Urgent {
t.Fatalf("override not cleared: all=%v dark=%v urgent=%v", cm.OverrideAll, cm.OverrideDark, cm.Urgent)
}
// The default campaign stays plain: submitted colours + urgent are ignored for it.
def := defaultCampaign(t, adsSvc)
defForm := "name=Default+%28house%29&urgent=on&override_all_on=on&override_bg=%23aa0000&override_fg=%23ffffff&override_link=%23ffdd00"
if code, _ := consoleDo(h, http.MethodPost, base+"/banners/"+def.ID.String(), defForm, origin); code != http.StatusOK {
t.Fatalf("update default = %d", code)
}
if again := defaultCampaign(t, adsSvc); again.OverrideAll != nil || again.OverrideDark != nil || again.Urgent {
t.Fatalf("default not plain: all=%v dark=%v urgent=%v", again.OverrideAll, again.OverrideDark, again.Urgent)
}
}
// TestBannerUrgentBypassesEligibility checks the urgent flag at the profile level:
// an urgent campaign preempts the feed (only it shows, with its colours) and reaches
// every viewer — including the no_banner role and a non-empty hint wallet that
// otherwise suppress the banner.
func TestBannerUrgentBypassesEligibility(t *testing.T) {
ctx := context.Background()
srv, adsSvc, _ := bannerServer(t)
accounts := account.NewStore(testDB)
id := provisionAccount(t)
urgentID, err := adsSvc.CreateCampaign(ctx, ads.Campaign{
Name: "Alert-" + uuid.NewString()[:8], Weight: 10, Enabled: true, Urgent: true,
OverrideAll: &ads.ColorSet{Bg: "#aa0000", Fg: "#ffffff", Link: "#ffdd00"},
})
if err != nil {
t.Fatalf("create urgent: %v", err)
}
t.Cleanup(func() { _ = adsSvc.DeleteCampaign(ctx, urgentID) })
if _, err := adsSvc.AddMessage(ctx, urgentID, "System maintenance tonight", "Ночью тех.работы"); err != nil {
t.Fatalf("add urgent message: %v", err)
}
get := func() profileBanner {
t.Helper()
rec := userGet(t, srv, "/api/v1/user/profile", id)
if rec.Code != http.StatusOK {
t.Fatalf("profile = %d", rec.Code)
}
var p profileBanner
if err := json.Unmarshal(rec.Body.Bytes(), &p); err != nil {
t.Fatalf("decode: %v", err)
}
return p
}
assertUrgentOnly := func(what string, p profileBanner) {
t.Helper()
if p.Banner == nil {
t.Fatalf("%s: no banner", what)
}
if len(p.Banner.Campaigns) != 1 {
t.Fatalf("%s: %d campaigns, want only the urgent one (default preempted)", what, len(p.Banner.Campaigns))
}
c := p.Banner.Campaigns[0]
if len(c.Messages) != 1 || c.Messages[0] != "System maintenance tonight" {
t.Fatalf("%s: messages = %v, want only the urgent one", what, c.Messages)
}
if c.OverrideBg != "#aa0000" {
t.Fatalf("%s: override_bg = %q, want #aa0000", what, c.OverrideBg)
}
}
// A normal viewer sees only the urgent campaign (the default is preempted).
assertUrgentOnly("eligible", get())
// The no_banner role no longer suppresses it — urgent reaches everyone.
if err := accounts.GrantRole(ctx, id, account.RoleNoBanner); err != nil {
t.Fatalf("grant role: %v", err)
}
assertUrgentOnly("no_banner", get())
if err := accounts.RevokeRole(ctx, id, account.RoleNoBanner); err != nil {
t.Fatalf("revoke role: %v", err)
}
}
@@ -1,153 +0,0 @@
//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"))
}
}
+1 -1
View File
@@ -216,7 +216,7 @@ func TestConfirmCodeClearsGuest(t *testing.T) {
mailer := &capturingMailer{} mailer := &capturingMailer{}
svc := account.NewEmailService(store, mailer, "https://erudit-game.ru") svc := account.NewEmailService(store, mailer, "https://erudit-game.ru")
guest, err := store.ProvisionGuest(ctx, "", "") guest, err := store.ProvisionGuest(ctx, "")
if err != nil { if err != nil {
t.Fatalf("provision guest: %v", err) t.Fatalf("provision guest: %v", err)
} }
@@ -103,31 +103,14 @@ func TestDictionaryUpdateFlow(t *testing.T) {
t.Errorf("installed dawg missing on disk: %v", err) t.Errorf("installed dawg missing on disk: %v", err)
} }
// The choice survives a restart on the SAME build: an out-of-band admin install newer // The choice survives a restart: a fresh service over the same DB and registry
// than the build version is not downgraded (a restart on an older image keeps the hotfix). // re-adopts the persisted active version.
restarted := newGameServiceOn(dir, "v1.0.0", reg) restarted := newGameServiceOn(dir, "v1.0.0", reg)
if err := restarted.InitActiveVersion(ctx); err != nil { if err := restarted.InitActiveVersion(ctx); err != nil {
t.Fatalf("restart init: %v", err) t.Fatalf("restart init: %v", err)
} }
if got := restarted.ActiveVersion(); got != "v9.9.9" { if got := restarted.ActiveVersion(); got != "v9.9.9" {
t.Errorf("restarted active version = %q, want v9.9.9 (a newer out-of-band install is kept)", got) t.Errorf("restarted active version = %q, want v9.9.9 (persisted)", got)
}
// A redeploy that delivered a build version NEWER than the out-of-band one activates it
// for new games without any admin step (the deploy-delivers path — docs/ARCHITECTURE.md §5).
if _, err := reg.LoadAvailable(dir, "v10.0.0"); err != nil {
t.Fatalf("make v10.0.0 resident: %v", err)
}
deployed := newGameServiceOn(dir, "v10.0.0", reg)
if err := deployed.InitActiveVersion(ctx); err != nil {
t.Fatalf("deploy init: %v", err)
}
if got := deployed.ActiveVersion(); got != "v10.0.0" {
t.Errorf("deployed active version = %q, want v10.0.0 (a newer build version wins)", got)
}
// Restore the out-of-band active version so the assertions below still read v9.9.9.
if err := restarted.SetActiveVersion(ctx, "v9.9.9"); err != nil {
t.Fatalf("restore active: %v", err)
} }
// New games pin the new version; the in-progress game keeps its own, still resident. // New games pin the new version; the in-progress game keeps its own, still resident.
+6 -34
View File
@@ -206,7 +206,7 @@ func TestEmailLoginFlow(t *testing.T) {
svc := account.NewEmailService(account.NewStore(testDB), mailer, "https://erudit-game.ru") svc := account.NewEmailService(account.NewStore(testDB), mailer, "https://erudit-game.ru")
email := "login-" + uuid.NewString() + "@example.com" email := "login-" + uuid.NewString() + "@example.com"
accountID, err := svc.RequestLoginCode(ctx, email, "+02:00", "en", false) accountID, err := svc.RequestLoginCode(ctx, email, "+02:00", "en")
if err != nil { if err != nil {
t.Fatalf("request login code: %v", err) t.Fatalf("request login code: %v", err)
} }
@@ -233,7 +233,7 @@ func TestEmailLoginFlow(t *testing.T) {
} }
// A second login for the same email is the returning user: same account. // A second login for the same email is the returning user: same account.
if _, err := svc.RequestLoginCode(ctx, email, "", "ru", false); err != nil { if _, err := svc.RequestLoginCode(ctx, email, "", "ru"); err != nil {
t.Fatalf("second request: %v", err) t.Fatalf("second request: %v", err)
} }
acc2, err := svc.LoginWithCode(ctx, email, sixDigit.FindString(mailer.lastBody)) acc2, err := svc.LoginWithCode(ctx, email, sixDigit.FindString(mailer.lastBody))
@@ -255,7 +255,7 @@ func TestEmailLoginProvisionsGuestUntilConfirmed(t *testing.T) {
svc := account.NewEmailService(store, mailer, "https://erudit-game.ru") svc := account.NewEmailService(store, mailer, "https://erudit-game.ru")
email := "squat-" + uuid.NewString() + "@example.com" email := "squat-" + uuid.NewString() + "@example.com"
id, err := svc.RequestLoginCode(ctx, email, "", "en", false) id, err := svc.RequestLoginCode(ctx, email, "", "en")
if err != nil { if err != nil {
t.Fatalf("request login code: %v", err) t.Fatalf("request login code: %v", err)
} }
@@ -279,34 +279,6 @@ func TestEmailLoginProvisionsGuestUntilConfirmed(t *testing.T) {
} }
} }
// TestEmailLoginPWAOmitsLink: a login requested from an installed PWA (pwa=true) mails only the
// code — no one-tap confirm link, which would otherwise open in a separate browser out of the
// PWA's reach. A non-PWA request keeps the link.
func TestEmailLoginPWAOmitsLink(t *testing.T) {
ctx := context.Background()
mailer := &capturingMailer{}
svc := account.NewEmailService(account.NewStore(testDB), mailer, "https://erudit-game.ru")
pwaEmail := "pwa-login-" + uuid.NewString() + "@example.com"
if _, err := svc.RequestLoginCode(ctx, pwaEmail, "", "en", true); err != nil {
t.Fatalf("pwa request login: %v", err)
}
if sixDigit.FindString(mailer.lastBody) == "" {
t.Errorf("pwa login mail must still carry the code, body %q", mailer.lastBody)
}
if confirmToken.MatchString(mailer.lastBody) {
t.Errorf("pwa login mail must omit the one-tap confirm link, body %q", mailer.lastBody)
}
webEmail := "web-login-" + uuid.NewString() + "@example.com"
if _, err := svc.RequestLoginCode(ctx, webEmail, "", "en", false); err != nil {
t.Fatalf("web request login: %v", err)
}
if !confirmToken.MatchString(mailer.lastBody) {
t.Errorf("non-pwa login mail must keep the one-tap confirm link, body %q", mailer.lastBody)
}
}
// confirmToken extracts the one-tap deeplink token from the /app/#/confirm/<token> link // confirmToken extracts the one-tap deeplink token from the /app/#/confirm/<token> link
// the branded email carries. // the branded email carries.
var confirmToken = regexp.MustCompile(`/confirm/([A-Za-z0-9_-]+)`) var confirmToken = regexp.MustCompile(`/confirm/([A-Za-z0-9_-]+)`)
@@ -329,7 +301,7 @@ func TestConfirmByTokenLogin(t *testing.T) {
svc := account.NewEmailService(store, mailer, "https://erudit-game.ru") svc := account.NewEmailService(store, mailer, "https://erudit-game.ru")
email := "tok-login-" + uuid.NewString() + "@example.com" email := "tok-login-" + uuid.NewString() + "@example.com"
id, err := svc.RequestLoginCode(ctx, email, "", "en", false) id, err := svc.RequestLoginCode(ctx, email, "", "en")
if err != nil { if err != nil {
t.Fatalf("request login: %v", err) t.Fatalf("request login: %v", err)
} }
@@ -410,7 +382,7 @@ func TestEmailAccountSeedsDisplayName(t *testing.T) {
svc := account.NewEmailService(store, &capturingMailer{}, "https://erudit-game.ru") svc := account.NewEmailService(store, &capturingMailer{}, "https://erudit-game.ru")
local := "kaya-" + uuid.NewString()[:8] local := "kaya-" + uuid.NewString()[:8]
id, err := svc.RequestLoginCode(ctx, local+"@example.com", "", "en", false) id, err := svc.RequestLoginCode(ctx, local+"@example.com", "", "en")
if err != nil { if err != nil {
t.Fatalf("request login: %v", err) t.Fatalf("request login: %v", err)
} }
@@ -431,7 +403,7 @@ func TestConfirmByTokenLinkClearsGuest(t *testing.T) {
store := account.NewStore(testDB) store := account.NewStore(testDB)
mailer := &capturingMailer{} mailer := &capturingMailer{}
svc := account.NewEmailService(store, mailer, "https://erudit-game.ru") svc := account.NewEmailService(store, mailer, "https://erudit-game.ru")
guest, err := store.ProvisionGuest(ctx, "", "") guest, err := store.ProvisionGuest(ctx, "")
if err != nil { if err != nil {
t.Fatalf("provision guest: %v", err) t.Fatalf("provision guest: %v", err)
} }
+108 -191
View File
@@ -5,7 +5,6 @@ package inttest
import ( import (
"context" "context"
"encoding/json" "encoding/json"
"errors"
"fmt" "fmt"
"net/http" "net/http"
"net/http/httptest" "net/http/httptest"
@@ -19,119 +18,59 @@ import (
"scrabble/backend/internal/account" "scrabble/backend/internal/account"
"scrabble/backend/internal/engine" "scrabble/backend/internal/engine"
"scrabble/backend/internal/game" "scrabble/backend/internal/game"
"scrabble/backend/internal/gamelimits"
"scrabble/backend/internal/lobby"
"scrabble/backend/internal/server" "scrabble/backend/internal/server"
"scrabble/backend/internal/social"
) )
// The game-limit suite covers the per-tier, per-kind active-game caps (backend.config): the // The game-limit suite covers the simultaneous quick-game cap (game.MaxActiveQuickGames):
// game_kind tag persisted per creation path, the tier resolution + counting rule // the counting rule (Store/Service.CountActiveQuickGames) and the HTTP gate that refuses a
// (game.Service.AtGameLimit), the HTTP gate + lobby at_game_limit flag, the guest gate on friend // new game once the cap is reached, while accepting an incoming invitation stays allowed.
// actions, and the config hot-cache reflecting an admin edit. Accepting an invitation stays exempt.
// newGameLimits builds a gamelimits service over the shared pool and loads the seeded config // TestCountActiveQuickGames checks the count includes active and open quick games (the
// (guest 1/1/0, durable 10/10/10). // honest-AI ones among them) and excludes finished games, friend games (created by
func newGameLimits(t *testing.T) *gamelimits.Service { // invitation) and games the account is not seated in.
t.Helper() func TestCountActiveQuickGames(t *testing.T) {
gl := gamelimits.NewService(gamelimits.NewStore(testDB))
if err := gl.Load(context.Background()); err != nil {
t.Fatalf("load game limits: %v", err)
}
return gl
}
// restoreDefaultLimits resets the single config row to the migration seed on cleanup, so a test that
// edited it never leaks its values into another (the row is a shared singleton).
func restoreDefaultLimits(t *testing.T, gl *gamelimits.Service) {
t.Helper()
t.Cleanup(func() {
_ = gl.Update(context.Background(), gamelimits.Config{
Guest: gamelimits.Limits{VsAI: 1, Random: 1, Friends: 0},
Durable: gamelimits.Limits{VsAI: 10, Random: 10, Friends: 10},
})
})
}
// gameKind reads a game's persisted game_kind tag.
func gameKind(t *testing.T, gameID uuid.UUID) int16 {
t.Helper()
var k int16
if err := testDB.QueryRowContext(context.Background(),
`SELECT game_kind FROM backend.games WHERE game_id=$1`, gameID).Scan(&k); err != nil {
t.Fatalf("read game_kind: %v", err)
}
return k
}
// mustCreateKind creates an active game seating the accounts, tagged with kind, and returns its id.
func mustCreateKind(t *testing.T, games *game.Service, seats []uuid.UUID, kind gamelimits.Kind) uuid.UUID {
t.Helper()
g, err := games.Create(context.Background(), game.CreateParams{
Variant: engine.VariantEnglish, Seats: seats, TurnTimeout: 24 * time.Hour, Kind: kind,
})
if err != nil {
t.Fatalf("create game (kind=%d): %v", kind, err)
}
return g.ID
}
// startFriendGameID has inviter invite invitee to a friend game and the invitee accept, returning
// the started game's id.
func startFriendGameID(t *testing.T, inv *lobby.InvitationService, inviter, invitee uuid.UUID) uuid.UUID {
t.Helper()
ctx := context.Background()
invitation, err := inv.CreateInvitation(ctx, inviter, []uuid.UUID{invitee}, englishInvite())
if err != nil {
t.Fatalf("create invitation: %v", err)
}
got, err := inv.RespondInvitation(ctx, invitation.ID, invitee, true)
if err != nil {
t.Fatalf("accept invitation: %v", err)
}
if got.GameID == nil {
t.Fatal("accepted invitation has no game id")
}
return *got.GameID
}
// TestGameKindPersisted checks each creation path stamps the right game_kind: random (auto-match)
// =2, vs_ai =1, friend (by invitation) =3.
func TestGameKindPersisted(t *testing.T) {
ctx := context.Background() ctx := context.Background()
clearOpenGames(t) clearOpenGames(t)
games := newGameService() games := newGameService()
inv := newInvitationService() human := provisionAccount(t)
human, opp := provisionAccount(t), provisionAccount(t) opp := provisionAccount(t)
rnd, _, err := games.OpenOrJoin(ctx, human, game.CreateParams{Variant: engine.VariantEnglish, TurnTimeout: 24 * time.Hour}, time.Now().Add(time.Minute), nil) if n := mustCount(t, games, human); n != 0 {
if err != nil { t.Fatalf("fresh account count = %d, want 0", n)
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)
} }
ai := mustCreateKind(t, games, []uuid.UUID{human, opp}, gamelimits.KindVsAI) // An open (awaiting-opponent) quick game counts.
if k := gameKind(t, ai); k != int16(gamelimits.KindVsAI) { if _, _, err := games.OpenOrJoin(ctx, human, game.CreateParams{
t.Errorf("vs_ai game_kind = %d, want %d", k, gamelimits.KindVsAI) Variant: engine.VariantEnglish, TurnTimeout: 24 * time.Hour,
}, time.Now().Add(time.Minute), nil); err != nil {
t.Fatalf("open quick game: %v", err)
} }
// An active quick game and an honest-AI quick game both count (neither has an invitation row).
mustCreateQuick(t, games, []uuid.UUID{human, opp}, false, 1)
mustCreateQuick(t, games, []uuid.UUID{human, opp}, true, 2)
friend := startFriendGameID(t, inv, human, opp) // A finished quick game does NOT count.
if k := gameKind(t, friend); k != int16(gamelimits.KindFriends) { fin := mustCreateQuick(t, games, []uuid.UUID{human, opp}, false, 3)
t.Errorf("friend game_kind = %d, want %d", k, gamelimits.KindFriends) 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)
} }
} }
// TestGuestActiveGameLimitHTTP drives the guest random cap through the assembled server: the first // TestGameLimitGate drives the cap through the assembled HTTP server: under the cap the lobby
// auto-match opens a game, the lobby then flags at_game_limit, and a second enqueue is refused 409 // reports at_game_limit false; at the cap it flips to true and both new-game endpoints (quick
// game_limit_reached. It also checks the guest vs_ai and friends caps resolve at the domain level. // enqueue and invitation creation) are refused with 409 game_limit_reached, while accepting an
func TestGuestActiveGameLimitHTTP(t *testing.T) { // incoming invitation is still allowed.
func TestGameLimitGate(t *testing.T) {
ctx := context.Background() ctx := context.Background()
clearOpenGames(t)
gl := newGameLimits(t)
games := newGameService() games := newGameService()
games.SetGameLimits(gl)
srv := server.New(":0", server.Deps{ srv := server.New(":0", server.Deps{
Logger: zaptest.NewLogger(t), Logger: zaptest.NewLogger(t),
DB: testDB, DB: testDB,
@@ -139,110 +78,88 @@ func TestGuestActiveGameLimitHTTP(t *testing.T) {
Games: games, Games: games,
Matchmaker: newMatchmaker(t, newRobotService(t, newGameService()), time.Minute, 0), Matchmaker: newMatchmaker(t, newRobotService(t, newGameService()), time.Minute, 0),
Invitations: newInvitationService(), Invitations: newInvitationService(),
GameLimits: gl,
}) })
guest := provisionGuest(t) human := provisionAccount(t)
if gamesListAtLimit(t, srv, guest) {
t.Fatal("a fresh guest must be under the random game limit")
}
// The first auto-match opens a random game (guest random cap = 1).
if rec := userPost(t, srv, "/api/v1/user/lobby/enqueue", guest, `{"variant":"erudit_ru"}`); rec.Code != http.StatusOK {
t.Fatalf("first enqueue = %d (%s), want 200", rec.Code, rec.Body.String())
}
// At the cap: the lobby flags it and a second enqueue is refused with the stable code.
if !gamesListAtLimit(t, srv, guest) {
t.Fatal("after one random game the guest must be at the limit")
}
if rec := userPost(t, srv, "/api/v1/user/lobby/enqueue", guest, `{"variant":"erudit_ru"}`); rec.Code != http.StatusConflict || errorCode(t, rec) != "game_limit_reached" {
t.Fatalf("second enqueue = (%d, %q), want (409, game_limit_reached)", rec.Code, errorCode(t, rec))
}
// A guest is refused the friends flow outright at the HTTP edge (403 guest_forbidden).
opp := provisionAccount(t) opp := provisionAccount(t)
// Under the cap: the lobby reports the player is not limited.
if gamesListAtLimit(t, srv, human) {
t.Fatal("a fresh account must be under the game limit")
}
// Reach the cap with active quick games seating the human (no invitation row → quick).
for i := 0; i < game.MaxActiveQuickGames; i++ {
mustCreateQuick(t, games, []uuid.UUID{human, opp}, false, int64(i+1))
}
// At the cap: the lobby flags it and both create paths are refused with the stable code.
if !gamesListAtLimit(t, srv, human) {
t.Fatalf("at %d games at_game_limit must be true", game.MaxActiveQuickGames)
}
// erudit_ru is in the default variant preferences, so the variant gate passes and the
// game-limit gate is what fires here.
if rec := userPost(t, srv, "/api/v1/user/lobby/enqueue", human, `{"variant":"erudit_ru"}`); rec.Code != http.StatusConflict || errorCode(t, rec) != "game_limit_reached" {
t.Fatalf("enqueue at limit = (%d, %q), want (409, game_limit_reached)", rec.Code, errorCode(t, rec))
}
invBody := fmt.Sprintf(`{"variant":"erudit_ru","invitee_ids":[%q]}`, opp.String()) invBody := fmt.Sprintf(`{"variant":"erudit_ru","invitee_ids":[%q]}`, opp.String())
if rec := userPost(t, srv, "/api/v1/user/invitations", guest, invBody); rec.Code != http.StatusForbidden || errorCode(t, rec) != "guest_forbidden" { if rec := userPost(t, srv, "/api/v1/user/invitations", human, invBody); rec.Code != http.StatusConflict || errorCode(t, rec) != "game_limit_reached" {
t.Fatalf("guest invitation = (%d, %q), want (403, guest_forbidden)", rec.Code, errorCode(t, rec)) t.Fatalf("invitation at limit = (%d, %q), want (409, game_limit_reached)", rec.Code, errorCode(t, rec))
} }
// The guest vs_ai cap is 1: one vs_ai game puts the guest at the vs_ai limit. // Accepting an incoming invitation is never blocked, even at the cap: another player invites
mustCreateKind(t, games, []uuid.UUID{guest, opp}, gamelimits.KindVsAI) // the capped human, who accepts over HTTP and the game starts (friend games do not count).
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)
}
// 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)
}
}
// 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) inviter := provisionAccount(t)
d2, d3 := provisionAccount(t), provisionAccount(t) inv := newInvitationService()
invitation, err := inv.CreateInvitation(ctx, inviter, []uuid.UUID{human}, englishInvite())
if err != nil {
t.Fatalf("create invitation: %v", err)
}
if rec := userPost(t, srv, "/api/v1/user/invitations/"+invitation.ID.String()+"/accept", human, ""); rec.Code != http.StatusOK {
t.Fatalf("accept at limit = %d (%s), want 200 — accept must bypass the cap", rec.Code, rec.Body.String())
}
}
// The inviter's first friend game reaches the (lowered) cap of 1. // mustCount returns the account's active-quick-game count, failing on error.
startFriendGameID(t, inv, inviter, d2) func mustCount(t *testing.T, games *game.Service, id uuid.UUID) int {
if at, err := games.AtGameLimit(ctx, inviter, gamelimits.KindFriends); err != nil || !at { t.Helper()
t.Fatalf("inviter friends at-limit = (%v, %v), want (true, nil)", at, err) n, err := games.CountActiveQuickGames(context.Background(), id)
if err != nil {
t.Fatalf("count active quick games: %v", err)
} }
// A second invitation is refused: the cache reflects the edit. return n
if _, err := inv.CreateInvitation(ctx, inviter, []uuid.UUID{d3}, englishInvite()); !errors.Is(err, game.ErrGameLimitReached) { }
t.Fatalf("second invitation err = %v, want ErrGameLimitReached", err)
// mustCreateQuick creates an active quick game (no invitation) seating the given accounts and
// returns its id; vsAI flags an honest-AI game (the service then applies the 7-day clock).
func mustCreateQuick(t *testing.T, games *game.Service, seats []uuid.UUID, vsAI bool, seed int64) uuid.UUID {
t.Helper()
p := game.CreateParams{Variant: engine.VariantEnglish, Seats: seats, TurnTimeout: 24 * time.Hour, Seed: seed}
if vsAI {
p.VsAI = true
p.TurnTimeout = 0 // the service applies the 7-day AI inactivity clock for vs_ai games
}
g, err := games.Create(context.Background(), p)
if err != nil {
t.Fatalf("create quick game (vsAI=%v): %v", vsAI, err)
}
return g.ID
}
// startFriendGame has a fresh inviter invite the given account to a friend game and the invitee
// accept it, so the (active) friend game exists with its game_invitations row.
func startFriendGame(t *testing.T, invitee uuid.UUID) {
t.Helper()
ctx := context.Background()
inv := newInvitationService()
inviter := provisionAccount(t)
invitation, err := inv.CreateInvitation(ctx, inviter, []uuid.UUID{invitee}, englishInvite())
if err != nil {
t.Fatalf("create invitation: %v", err)
}
if _, err := inv.RespondInvitation(ctx, invitation.ID, invitee, true); err != nil {
t.Fatalf("accept invitation: %v", err)
} }
// Accept stays exempt: someone else invites the capped inviter, who accepts and the game starts.
startFriendGameID(t, inv, d3, inviter)
} }
// gamesListAtLimit fetches /api/v1/user/games and returns its at_game_limit flag. // gamesListAtLimit fetches /api/v1/user/games and returns its at_game_limit flag.
+16 -20
View File
@@ -14,8 +14,6 @@ import (
"scrabble/backend/internal/account" "scrabble/backend/internal/account"
"scrabble/backend/internal/engine" "scrabble/backend/internal/engine"
"scrabble/backend/internal/game" "scrabble/backend/internal/game"
"scrabble/backend/internal/payments"
"scrabble/backend/internal/session"
) )
// TestListForAccount checks the lobby "my games" query: it returns exactly the // TestListForAccount checks the lobby "my games" query: it returns exactly the
@@ -403,13 +401,8 @@ func gameStatus(t *testing.T, svc *game.Service, id uuid.UUID) (status, endReaso
// TestHintPolicy exercises the per-game allowance, the profile wallet and the // TestHintPolicy exercises the per-game allowance, the profile wallet and the
// disabled switch. // disabled switch.
func TestHintPolicy(t *testing.T) { func TestHintPolicy(t *testing.T) {
// A trusted platform context so the online hint wallet (payments) is reachable; a provisioned ctx := context.Background()
// account carries a telegram identity, so the telegram origin is the applicable one here.
ctx := session.WithPlatform(context.Background(),
session.Platform{Kind: session.PlatformKindTelegram, Subtype: session.SubtypeAndroid})
svc := newGameService() svc := newGameService()
pay := newPaymentsService()
svc.SetHintWallet(pay) // share the handle so a grant invalidates the wallet the game reads
seats := []uuid.UUID{provisionAccount(t), provisionAccount(t)} seats := []uuid.UUID{provisionAccount(t), provisionAccount(t)}
seed := openingSeed(t) seed := openingSeed(t)
g, err := svc.Create(ctx, game.CreateParams{ g, err := svc.Create(ctx, game.CreateParams{
@@ -424,30 +417,25 @@ func TestHintPolicy(t *testing.T) {
t.Fatalf("first hint: %v", err) t.Fatalf("first hint: %v", err)
} }
// The allowance is spent before the wallet: with an empty wallet, the state now reports no // The allowance is spent before the wallet: with an empty wallet, the state now reports no
// per-game allowance left (HintsRemaining is the allowance alone; the wallet lives on the profile). // hints left and a zero wallet, so the per-game allowance (HintsRemaining-WalletBalance) is 0.
st, err := svc.GameState(ctx, g.ID, seats[0]) st, err := svc.GameState(ctx, g.ID, seats[0])
if err != nil { if err != nil {
t.Fatalf("state: %v", err) t.Fatalf("state: %v", err)
} }
if st.HintsRemaining != 0 { if st.HintsRemaining != 0 || st.WalletBalance != 0 {
t.Errorf("after allowance hint: hints=%d, want 0", st.HintsRemaining) t.Errorf("after allowance hint: hints=%d wallet=%d, want 0/0", st.HintsRemaining, st.WalletBalance)
} }
if _, err := svc.Hint(ctx, g.ID, seats[0]); !errors.Is(err, game.ErrNoHintsLeft) { if _, err := svc.Hint(ctx, g.ID, seats[0]); !errors.Is(err, game.ErrNoHintsLeft) {
t.Fatalf("second hint = %v, want ErrNoHintsLeft", err) t.Fatalf("second hint = %v, want ErrNoHintsLeft", err)
} }
// Grant 2 hints on the telegram origin through the service so its read cache is invalidated setHintBalance(t, seats[0], 2)
// (a raw insert would leave the cache warmed at zero by the allowance hint above).
if err := pay.Grant(ctx, seats[0], payments.SourceTelegram, 2, 0, false); err != nil {
t.Fatalf("grant hints: %v", err)
}
res, err := svc.Hint(ctx, g.ID, seats[0]) // spends the wallet res, err := svc.Hint(ctx, g.ID, seats[0]) // spends the wallet
if err != nil { if err != nil {
t.Fatalf("wallet hint: %v", err) t.Fatalf("wallet hint: %v", err)
} }
// The allowance stays exhausted (HintsRemaining is the allowance alone, so 0); the wallet dropped // The allowance stays exhausted; the wallet dropped 2->1, and WalletBalance carries it alone.
// 2->1 and WalletBalance carries it (the client adopts it into the profile). if res.HintsRemaining != 1 || res.WalletBalance != 1 {
if res.HintsRemaining != 0 || res.WalletBalance != 1 { t.Errorf("wallet hint: hints=%d wallet=%d, want 1/1", res.HintsRemaining, res.WalletBalance)
t.Errorf("wallet hint: hints=%d wallet=%d, want 0/1", res.HintsRemaining, res.WalletBalance)
} }
// game_players.hints_used counts BOTH hints (1 allowance + 1 wallet) — the per-game total // game_players.hints_used counts BOTH hints (1 allowance + 1 wallet) — the per-game total
// that feeds the player's lifetime hint statistics, not just the allowance. // that feeds the player's lifetime hint statistics, not just the allowance.
@@ -615,6 +603,14 @@ func setAway(t *testing.T, id uuid.UUID, tz, start, end string) {
} }
} }
func setHintBalance(t *testing.T, id uuid.UUID, n int) {
t.Helper()
if _, err := testDB.ExecContext(context.Background(),
`UPDATE backend.accounts SET hint_balance = $1 WHERE account_id = $2`, n, id); err != nil {
t.Fatalf("set hint balance: %v", err)
}
}
func equalStrings(a, b []string) bool { func equalStrings(a, b []string) bool {
if len(a) != len(b) { if len(a) != len(b) {
return false return false
+1 -35
View File
@@ -17,7 +17,6 @@ import (
"scrabble/backend/internal/engine" "scrabble/backend/internal/engine"
"scrabble/backend/internal/game" "scrabble/backend/internal/game"
"scrabble/backend/internal/lobby" "scrabble/backend/internal/lobby"
"scrabble/backend/internal/payments"
"scrabble/backend/internal/robot" "scrabble/backend/internal/robot"
"scrabble/backend/internal/social" "scrabble/backend/internal/social"
) )
@@ -43,42 +42,9 @@ func newGameService() *game.Service {
zap.NewNop(), zap.NewNop(),
) )
svc.SetFirstMoveEntropy(seatZeroFirstMove) svc.SetFirstMoveEntropy(seatZeroFirstMove)
svc.SetHintWallet(newPaymentsService())
return svc return svc
} }
// newPaymentsService builds a payments service over the shared pool (its own in-process read
// cache), for wiring the game hint wallet and the account-merge fold in the integration suite.
func newPaymentsService() *payments.Service {
return payments.NewService(payments.NewStore(testDB))
}
// seedBenefit writes a payments benefit row for an account+origin directly (bypassing the
// service), used to stand up wallet state a test then spends or merges. A non-zero hints count
// and/or the forever flag are set; a caller wanting a no-ads term sets it via seedBenefitUntil.
func seedBenefit(t *testing.T, id uuid.UUID, origin string, hints int, forever bool) {
t.Helper()
if _, err := testDB.ExecContext(context.Background(),
`INSERT INTO payments.benefits (account_id, origin, hints, ads_forever) VALUES ($1,$2,$3,$4)
ON CONFLICT (account_id, origin) DO UPDATE SET hints = EXCLUDED.hints, ads_forever = EXCLUDED.ads_forever`,
id, origin, hints, forever); err != nil {
t.Fatalf("seed benefit: %v", err)
}
}
// readBenefit reads an account's benefit row for an origin: the hint count and the forever flag
// (both zero/false when the row is absent).
func readBenefit(t *testing.T, id uuid.UUID, origin string) (hints int, forever bool) {
t.Helper()
err := testDB.QueryRowContext(context.Background(),
`SELECT hints, ads_forever FROM payments.benefits WHERE account_id=$1 AND origin=$2`, id, origin).
Scan(&hints, &forever)
if err != nil && !errors.Is(err, sql.ErrNoRows) {
t.Fatalf("read benefit: %v", err)
}
return hints, forever
}
// seatZeroFirstMove is a first-move-draw entropy factory that always elects the first // seatZeroFirstMove is a first-move-draw entropy factory that always elects the first
// listed account as the leader, keeping the integration suite's turn order stable // listed account as the leader, keeping the integration suite's turn order stable
// despite the real draw's randomness: the first contender draws a blank — the best // despite the real draw's randomness: the first contender draws a blank — the best
@@ -154,7 +120,7 @@ func provisionAccount(t *testing.T) uuid.UUID {
// provisionGuest creates a fresh ephemeral guest account and returns its id. // provisionGuest creates a fresh ephemeral guest account and returns its id.
func provisionGuest(t *testing.T) uuid.UUID { func provisionGuest(t *testing.T) uuid.UUID {
t.Helper() t.Helper()
acc, err := account.NewStore(testDB).ProvisionGuest(context.Background(), "", "") acc, err := account.NewStore(testDB).ProvisionGuest(context.Background(), "")
if err != nil { if err != nil {
t.Fatalf("provision guest: %v", err) t.Fatalf("provision guest: %v", err)
} }
+27 -48
View File
@@ -58,6 +58,14 @@ func bestMoveCount(t *testing.T, id uuid.UUID) int {
return n return n
} }
func setWallet(t *testing.T, id uuid.UUID, hints int, paid bool) {
t.Helper()
if _, err := testDB.ExecContext(context.Background(),
`UPDATE backend.accounts SET hint_balance=$2, paid_account=$3 WHERE account_id=$1`, id, hints, paid); err != nil {
t.Fatalf("set wallet: %v", err)
}
}
func bindEmailIdentity(t *testing.T, acc uuid.UUID, email string) { func bindEmailIdentity(t *testing.T, acc uuid.UUID, email string) {
t.Helper() t.Helper()
if _, err := testDB.ExecContext(context.Background(), if _, err := testDB.ExecContext(context.Background(),
@@ -123,7 +131,6 @@ func TestAccountMergeCore(t *testing.T) {
ctx := context.Background() ctx := context.Background()
store := account.NewStore(testDB) store := account.NewStore(testDB)
merger := accountmerge.NewMerger(testDB) merger := accountmerge.NewMerger(testDB)
merger.SetPayments(newPaymentsService())
primary := provisionAccount(t) primary := provisionAccount(t)
secondary := provisionAccount(t) secondary := provisionAccount(t)
@@ -133,8 +140,8 @@ func TestAccountMergeCore(t *testing.T) {
setStats(t, secondary, 3, 1, 2, 400, 80) setStats(t, secondary, 3, 1, 2, 400, 80)
setStatsCounts(t, primary, 100, 5) setStatsCounts(t, primary, 100, 5)
setStatsCounts(t, secondary, 50, 8) setStatsCounts(t, secondary, 50, 8)
seedBenefit(t, primary, "telegram", 2, false) setWallet(t, primary, 2, false)
seedBenefit(t, secondary, "telegram", 5, true) // hints sum, forever OR-s on merge setWallet(t, secondary, 5, true)
// Best moves: secondary's scrabble_en (80) beats primary's (50) and is kept; secondary's // Best moves: secondary's scrabble_en (80) beats primary's (50) and is kept; secondary's
// scrabble_ru (30) is new to primary and carried over. // scrabble_ru (30) is new to primary and carried over.
setBestMove(t, primary, "scrabble_en", 50) setBestMove(t, primary, "scrabble_en", 50)
@@ -158,13 +165,15 @@ func TestAccountMergeCore(t *testing.T) {
t.Error("secondary stats row should be deleted after merge") t.Error("secondary stats row should be deleted after merge")
} }
// The payments wallet merged by origin: hints sum (2+5) and the forever flag OR-s; the acc, err := store.GetByID(ctx, primary)
// deprecated accounts.hint_balance / paid_account columns are no longer touched by a merge. if err != nil {
if hints, forever := readBenefit(t, primary, "telegram"); hints != 7 || !forever { t.Fatalf("get primary: %v", err)
t.Errorf("merged telegram benefit = hints %d forever %v, want 7/true", hints, forever)
} }
if hints, _ := readBenefit(t, secondary, "telegram"); hints != 0 { if acc.HintBalance != 7 {
t.Errorf("secondary benefit should be cleared after merge, got hints %d", hints) t.Errorf("hint balance = %d, want 7", acc.HintBalance)
}
if !acc.PaidAccount {
t.Error("paid_account should be true (ORed from secondary)")
} }
if owner, ok, _ := store.AccountIDByIdentity(ctx, account.KindEmail, email); !ok || owner != primary { if owner, ok, _ := store.AccountIDByIdentity(ctx, account.KindEmail, email); !ok || owner != primary {
@@ -347,10 +356,8 @@ func TestAccountLinkEmailMergeIntoCaller(t *testing.T) {
} }
} }
// TestAccountLinkGuestInversion auto-merges a guest initiator into the durable account that // TestAccountLinkGuestInversion merges a guest initiator into the durable account
// owns the email AT THE CONFIRM STEP (no merge confirmation): the guest is retired, the durable // that owns the email: the durable account wins and a fresh session is minted.
// account wins and a fresh session is minted for it (the client adopts the switch and lands on
// the durable account as if it simply logged in).
func TestAccountLinkGuestInversion(t *testing.T) { func TestAccountLinkGuestInversion(t *testing.T) {
ctx := context.Background() ctx := context.Background()
store := account.NewStore(testDB) store := account.NewStore(testDB)
@@ -366,18 +373,17 @@ func TestAccountLinkGuestInversion(t *testing.T) {
t.Fatalf("request: %v", err) t.Fatalf("request: %v", err)
} }
code := sixDigit.FindString(mailer.lastBody) code := sixDigit.FindString(mailer.lastBody)
confirm, err := links.ConfirmEmail(ctx, guest, email, code) if _, err := links.ConfirmEmail(ctx, guest, email, code); err != nil {
if err != nil {
t.Fatalf("confirm: %v", err) t.Fatalf("confirm: %v", err)
} }
// A guest initiator does not get a MergeRequired confirmation — the merge runs inline. merge, err := links.MergeEmail(ctx, guest, email, code)
if !confirm.Merged || confirm.MergeRequired { if err != nil {
t.Fatalf("confirm = %+v, want an inline (auto) merge", confirm) t.Fatalf("merge: %v", err)
} }
if confirm.Merge.PrimaryID != durable { if merge.PrimaryID != durable {
t.Fatalf("primary = %s, want durable %s", confirm.Merge.PrimaryID, durable) t.Fatalf("primary = %s, want durable %s", merge.PrimaryID, durable)
} }
if confirm.Merge.SwitchedToken == "" { if merge.SwitchedToken == "" {
t.Error("a guest initiator whose durable counterpart wins must get a switched session token") t.Error("a guest initiator whose durable counterpart wins must get a switched session token")
} }
if mergedInto(t, guest) != durable { if mergedInto(t, guest) != durable {
@@ -388,33 +394,6 @@ func TestAccountLinkGuestInversion(t *testing.T) {
} }
} }
// TestAccountLinkGuestAutoMergeActiveGameConflict guards that a guest's auto-merge is REFUSED
// (not silently swallowed) at the confirm step when the guest and the durable account share an
// active game: the caller surfaces ErrActiveGameConflict (mapped to a clear "finish that game
// first" message) rather than seating one player against themselves.
func TestAccountLinkGuestAutoMergeActiveGameConflict(t *testing.T) {
ctx := context.Background()
mailer := &capturingMailer{}
links := newLinkService(mailer)
durable := provisionAccount(t)
email := "conflict-" + uuid.NewString() + "@example.com"
bindEmailIdentity(t, durable, email)
guest := provisionGuest(t)
seatGame(t, []uuid.UUID{durable, guest}, 24*time.Hour) // an active game the two share
if err := links.RequestEmail(ctx, guest, email); err != nil {
t.Fatalf("request: %v", err)
}
code := sixDigit.FindString(mailer.lastBody)
if _, err := links.ConfirmEmail(ctx, guest, email, code); err != accountmerge.ErrActiveGameConflict {
t.Fatalf("confirm = %v, want ErrActiveGameConflict surfaced by the auto-merge", err)
}
if mergedInto(t, guest) != uuid.Nil {
t.Error("a refused auto-merge must not tombstone the guest")
}
}
// TestAccountLinkFreeVK binds a free VK identity (gateway-validated, no code) and // TestAccountLinkFreeVK binds a free VK identity (gateway-validated, no code) and
// promotes a guest to durable — the ConfirmVK counterpart of the free-email case. // promotes a guest to durable — the ConfirmVK counterpart of the free-email case.
func TestAccountLinkFreeVK(t *testing.T) { func TestAccountLinkFreeVK(t *testing.T) {
@@ -1,83 +0,0 @@
//go:build integration
package inttest
import (
"context"
"testing"
"github.com/google/uuid"
"scrabble/backend/internal/payments"
)
// TestPaymentAvailabilityKillSwitch exercises the per-rail kill switch + the per-account override
// end-to-end against Postgres: fail-open, a disabled rail with a localized message, per-rail
// granularity, and the allow/deny/default overrides.
func TestPaymentAvailabilityKillSwitch(t *testing.T) {
ctx := context.Background()
svc := newPaymentsService()
acc := uuid.New()
// Fail-open: an untouched rail is enabled.
if ok, _, err := svc.CanPurchase(ctx, acc, payments.RailDirectWeb, "en"); err != nil || !ok {
t.Fatalf("fail-open: CanPurchase = %v/%v, want true", ok, err)
}
// Disable the web rail with a per-language message → blocked with that message, localized.
if err := svc.SetRailStatus(ctx, payments.RailDirectWeb, payments.RailAvailability{MessageRU: "Чиним", MessageEN: "Fixing"}); err != nil {
t.Fatalf("set rail status: %v", err)
}
if ok, reason, err := svc.CanPurchase(ctx, acc, payments.RailDirectWeb, "ru"); err != nil || ok || reason != "Чиним" {
t.Fatalf("disabled rail (ru): CanPurchase = %v/%q/%v, want false/Чиним", ok, reason, err)
}
if ok, reason, _ := svc.CanPurchase(ctx, acc, payments.RailDirectWeb, "en"); ok || reason != "Fixing" {
t.Fatalf("disabled rail (en): = %v/%q, want false/Fixing", ok, reason)
}
// Per-rail granularity: another rail stays enabled.
if ok, _, _ := svc.CanPurchase(ctx, acc, payments.RailDirectAndroid, "en"); !ok {
t.Fatalf("android rail should stay enabled")
}
// A per-account "allow" override bypasses the disabled rail.
if err := svc.SetPurchaseOverride(ctx, acc, payments.OverrideAllow); err != nil {
t.Fatalf("set override allow: %v", err)
}
if ok, _, _ := svc.CanPurchase(ctx, acc, payments.RailDirectWeb, "en"); !ok {
t.Fatalf("allow override should bypass the disabled rail")
}
if ov, err := svc.PurchaseOverrideFor(ctx, acc); err != nil || ov != payments.OverrideAllow {
t.Fatalf("PurchaseOverrideFor = %v/%v, want allow", ov, err)
}
// A "deny" override blocks even an enabled rail.
if err := svc.SetPurchaseOverride(ctx, acc, payments.OverrideDeny); err != nil {
t.Fatalf("set override deny: %v", err)
}
if ok, reason, _ := svc.CanPurchase(ctx, acc, payments.RailDirectAndroid, "en"); ok || reason == "" {
t.Fatalf("deny override should block the enabled android rail with a reason, got %v/%q", ok, reason)
}
// Clearing the override (default) restores rail-driven behaviour.
if err := svc.SetPurchaseOverride(ctx, acc, payments.OverrideDefault); err != nil {
t.Fatalf("clear override: %v", err)
}
if ov, _ := svc.PurchaseOverrideFor(ctx, acc); ov != payments.OverrideDefault {
t.Fatalf("after clear, override = %v, want default", ov)
}
if ok, _, _ := svc.CanPurchase(ctx, acc, payments.RailDirectAndroid, "en"); !ok {
t.Fatalf("after clearing override, android rail should be enabled again")
}
// RailStatuses reflects the stored web-rail change and fills the rest fail-open.
all, err := svc.RailStatuses(ctx)
if err != nil {
t.Fatalf("rail statuses: %v", err)
}
if all[payments.RailDirectWeb].Enabled {
t.Fatalf("web rail should read disabled")
}
if !all[payments.RailVK].Enabled {
t.Fatalf("untouched vk rail should read enabled")
}
}
@@ -1,155 +0,0 @@
//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)
}
}
@@ -1,563 +0,0 @@
//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)
}
}

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