Compare commits
115 Commits
c3eecf16b3
...
v1.20.0
| Author | SHA1 | Date | |
|---|---|---|---|
| aaf162de40 | |||
| 7cc2b50d23 | |||
| eb7fa98426 | |||
| 81d5383c42 | |||
| b6af682381 | |||
| ed79c30d2a | |||
| 471fadc6a4 | |||
| 564abdcf88 | |||
| c522e77599 | |||
| 28afcff551 | |||
| c34e2a8dbf | |||
| 25381f70a3 | |||
| 5ce9f7e9b4 | |||
| 522beec82e | |||
| 3b485883ee | |||
| efeed17abc | |||
| cc34622630 | |||
| f6d256215a | |||
| fe5a3d6d3b | |||
| 7e142d471c | |||
| dbe2c4cb94 | |||
| 8c5b659ddf | |||
| a067a2fd01 | |||
| 8becdb45e4 | |||
| 25b5ed5516 | |||
| de5ab9186c | |||
| 7df078f5ed | |||
| 1d77ee83b3 | |||
| 5a4c460268 | |||
| 807edff327 | |||
| 0f3b4dbcff | |||
| 3456a0b3ff | |||
| 93ccc8c449 | |||
| e0360c98e2 | |||
| a41281c495 | |||
| 11f89f6477 | |||
| 2d1fadb50c | |||
| 09e05eef18 | |||
| 73baf58002 | |||
| eec225c4ee | |||
| ca2c6487cf | |||
| e7cb60c996 | |||
| 49c53794f4 | |||
| 4a0689a4ac | |||
| 0eb72ba955 | |||
| e077258567 | |||
| a035edfb54 | |||
| bcd5a1d02d | |||
| a57fd355ba | |||
| 2ea91a8354 | |||
| de003e862a | |||
| aaf2825260 | |||
| 0f5db0ee91 | |||
| 53b33073ac | |||
| e3c5cff7b7 | |||
| 0c5e40c509 | |||
| edaf2dfd9e | |||
| 516ffbe5f0 | |||
| 3ce460f72a | |||
| 4ba9da6721 | |||
| ad1cc361e9 | |||
| 2c2316fb5e | |||
| bb71e7b1c7 | |||
| 5c1f64c7d1 | |||
| b3cf024e9e | |||
| 6e120bdaa7 | |||
| 40acbcccdd | |||
| b54371845f | |||
| b6c2598710 | |||
| 6badc20078 | |||
| a241e43d79 | |||
| 8ce986922a | |||
| 3469278260 | |||
| c66bf1eceb | |||
| 7b4d2421e2 | |||
| bf46b9492d | |||
| 1e1117c28e | |||
| 77a690fcf6 | |||
| 0ca01133b5 | |||
| 2683103fc1 | |||
| 2d2dd2bc47 | |||
| 45f0b34881 | |||
| df9eace09f | |||
| 0a0a9e5a8d | |||
| 0c9678c42b | |||
| e40adfb0c7 | |||
| 3306a016a0 | |||
| e45167041f | |||
| ed53e25e57 | |||
| 2e5136b22a | |||
| 1bf612a087 | |||
| ec5c6afa23 | |||
| 82648a4398 | |||
| d2d6955cbf | |||
| 18785efc8c | |||
| 780ff68ec2 | |||
| 57ff2d03f8 | |||
| a9d0986e74 | |||
| 45957bdcd6 | |||
| 829e29a726 | |||
| 399508f2f0 | |||
| 3a18e683ca | |||
| 93d086a8a3 | |||
| 8fe1bdba6b | |||
| 7923b3cc09 | |||
| 4891216749 | |||
| f1b8769c89 | |||
| b6f28a2423 | |||
| e32ee9ce68 | |||
| dc946a1faf | |||
| 384bd143d0 | |||
| c5d22fceca | |||
| deaa7a29c5 | |||
| 24017bcb7f | |||
| 2c4f4b10dc |
@@ -0,0 +1,242 @@
|
|||||||
|
# Agent field notes — scrabble-game
|
||||||
|
|
||||||
|
Non-obvious, hard-won project knowledge that is **not** in the main docs (`CLAUDE.md`, `docs/*`,
|
||||||
|
`deploy/README.md`). Kept in the repo so it travels with a clone to any host. These are working
|
||||||
|
memory, not a spec — **verify any named file / flag / function against the current code before acting
|
||||||
|
on it**, and prefer the authoritative docs where they overlap. Add to this file as new gotchas turn up.
|
||||||
|
|
||||||
|
## Codegen & build
|
||||||
|
|
||||||
|
- **jetgen churns everything.** `backend/cmd/jetgen` regenerates go-jet code for *all* tables and may
|
||||||
|
reorder output. After running it, revert the churn on tables you did not touch and commit only your
|
||||||
|
table's change.
|
||||||
|
- **flatc is version-pinned** (`pkg/Makefile`, `REQUIRED_FLATC = 23.5.26`, hard-checked). A different
|
||||||
|
flatc silently churns the generated wire code and can flip wire defaults. Never regenerate FBS with
|
||||||
|
another flatc version.
|
||||||
|
- **gopls lags codegen.** Right after an FBS/jet regen, gopls shows phantom "undefined" errors. Trust
|
||||||
|
`go build` / `go test`, not the editor squiggles.
|
||||||
|
- **go-jet type mapping:** SQL `numeric` → `float64`, `interval` → `string`. Never store money as
|
||||||
|
`numeric` (float precision loss) — use **bigint minor units + a `Money` type**; store durations as
|
||||||
|
**int seconds**, not `interval`.
|
||||||
|
- **`go mod tidy` chokes on dot-free local module paths** (`scrabble/...`). Hand-edit `go.mod` when a
|
||||||
|
bump is needed. The solver (`../scrabble-solver`) is consumed via `go.work` replace locally, but a
|
||||||
|
prod bump goes through a solver **PR → master + a published tag**, not a local replace.
|
||||||
|
- **Run the whole CI suite locally before pushing** — unit + integration (`//go:build integration`,
|
||||||
|
Postgres) + the UI job + codegen check. Do not lean on CI to catch what a local run would.
|
||||||
|
- **CI runner shares this host's `/tmp` as a different user.** In workflow steps, write artifacts to
|
||||||
|
`${GITHUB_WORKSPACE}`, never a fixed `/tmp/...` path (cross-user permission failures otherwise).
|
||||||
|
- **pnpm corepack pre-flight flakes.** `pnpm exec` / `pnpm check` occasionally abort on a corepack
|
||||||
|
pre-flight. Dodge it by invoking the tool directly: `node_modules/.bin/<tool>`.
|
||||||
|
|
||||||
|
## Native Android build (Capacitor)
|
||||||
|
|
||||||
|
The native Android app is a Capacitor 8 wrapper of the `ui` SPA, scaffolded under
|
||||||
|
`ui/` (a Node project outside `go.work`). Hard-won bring-up facts — verify against current code:
|
||||||
|
|
||||||
|
- **Capacitor 8 needs JDK 21, not 17.** `@capacitor/android` compiles at `VERSION_21`; a JDK 17 Gradle
|
||||||
|
run dies with `error: invalid source release: 21`. Install sudo-free via the Homebrew **formula**
|
||||||
|
`brew install openjdk@21` (the `temurin@21` **cask** wants sudo for a system `.pkg`, unusable
|
||||||
|
non-interactively) + a user symlink into `~/Library/Java/JavaVirtualMachines/` so
|
||||||
|
`/usr/libexec/java_home -v 21` finds it. JDK 17 stays fine for `sdkmanager`/`avdmanager`.
|
||||||
|
- **Cap 8 SDK pins:** compileSdk/targetSdk **36**, minSdk **24**, Gradle **8.14.3**, AGP **8.13.0**
|
||||||
|
(`ui/android/variables.gradle`). Install `platforms;android-36` — Android Studio's newer
|
||||||
|
`android-36.1` does NOT satisfy compileSdk 36.
|
||||||
|
- **SDK is Android Studio's** at `~/Library/Android/sdk` (no `cmdline-tools` by default → `brew install
|
||||||
|
--cask android-commandlinetools`, then `sdkmanager`/`avdmanager --sdk_root=$HOME/Library/Android/sdk`).
|
||||||
|
Gradle finds it via **`ANDROID_HOME`** (`local.properties` is gitignored, machine-specific).
|
||||||
|
- **Build recipe:** `cd ui && pnpm build && node_modules/.bin/cap sync android`; then `cd ui/android &&
|
||||||
|
ANDROID_HOME=~/Library/Android/sdk JAVA_HOME=$(/usr/libexec/java_home -v 21) ./gradlew assembleDebug`
|
||||||
|
→ `ui/android/app/build/outputs/apk/debug/app-debug.apk`. Prefer the local `ui/node_modules/.bin/cap`
|
||||||
|
(dodges the corepack flake).
|
||||||
|
- **Emulator smoke:** existing AVDs `Pixel_10` / `Pixel_4_Android_10_API_29` / `Pixel_Android_9`;
|
||||||
|
`emulator -avd <name>`, `adb install -r <apk>`, `adb shell monkey -p ru.eruditgame.app -c
|
||||||
|
android.intent.category.LAUNCHER 1`, `adb exec-out screencap -p > x.png`. The boot wait needs `sleep`
|
||||||
|
→ run it as a **background** Bash task (foreground `sleep` is blocked). Browsers/WebView are cached, so a
|
||||||
|
full offline vs_ai turn is drivable via `adb shell input tap` (tap through the first-run coachmark tour —
|
||||||
|
each tap advances one step — then Hint places a suggested word; commit → the robot replies).
|
||||||
|
- **Android 15+ edge-to-edge safe-area (WebView-version-dependent, bit me on API 37).** targetSdk 36 forces
|
||||||
|
edge-to-edge — the WebView draws behind the status bar (top) and the gesture-nav home indicator (bottom).
|
||||||
|
On Android **WebView < 140**, `env(safe-area-inset-*)` wrongly reports **0**, so chrome relying on it draws
|
||||||
|
under the bars and is untappable: the top nav under the clock, and the game's bottom action bar's **centre**
|
||||||
|
button (Hint) under the home-indicator pill (side buttons still work; `navigation_mode`=2 is gesture nav).
|
||||||
|
Fix is CSS-only, in TWO parts: (1) Capacitor 8's **SystemBars** plugin (built into `@capacitor/core`,
|
||||||
|
`insetsHandling:'css'` default — no dep, no config) injects correct `--safe-area-inset-*`; consume them as
|
||||||
|
`--tg-safe-*: var(--safe-area-inset-*, env(safe-area-inset-*, 0px))` (`ui/src/app.css`) — fixes any consumer
|
||||||
|
that ALREADY applies the token (the bottom bars: `Game.svelte`/`Screen.svelte` `--tg-safe-bottom`).
|
||||||
|
(2) But a consumer that never applied the top inset on the native path is NOT fixed by the token alone — the
|
||||||
|
**header's** top inset was Telegram-fullscreen-scoped only, so the native header sat under the status bar on
|
||||||
|
EVERY WebView; it needed its own `.bar { padding-top: calc(var(--safe-area-inset-top, 0px) + 5px) }`
|
||||||
|
(`Header.svelte`, native-only via the plugin var; tg-fullscreen still overrides via specificity). **Measure
|
||||||
|
per element, don't eyeball** — a centred title at y=11 under a 54px bar reads as "fine" in a screenshot but
|
||||||
|
is overlapping; an emulator WebView auto-updated to ≥140 (Chrome 149) also hides the `env()`=0 half (so the
|
||||||
|
BOTTOM looks fine there while the user's < 140 device overlaps). **Inspect a live debug WebView** over CDP:
|
||||||
|
`adb forward tcp:9222 localabstract:$(adb shell cat /proc/net/unix | grep -o 'webview_devtools_remote_[0-9]*'
|
||||||
|
| head -1)`, then Playwright `chromium.connectOverCDP('http://localhost:9222')` → `page.evaluate` (read each
|
||||||
|
element's `getBoundingClientRect().top`, and `--safe-area-inset-*` vs `env(...)`).
|
||||||
|
- **`sharp` is whitelisted** in `ui/pnpm-workspace.yaml` (`allowBuilds: sharp: true`) — `@capacitor/assets`
|
||||||
|
uses it for `pnpm android:assets` (launcher icon/splash); else pnpm 11 raises `ERR_PNPM_IGNORED_BUILDS`.
|
||||||
|
- **Bundled offline dicts come from the `scrabble-dictionary` release** (`scrabble-dawg-<DICT_VERSION>.tar.gz`,
|
||||||
|
keyed on the `DICT_VERSION` Gitea var — the same source the backend image + CI `curl`), NOT
|
||||||
|
`scrabble-solver/dawg` (those are the solver's pinned test fixtures).
|
||||||
|
|
||||||
|
## Wire / schema evolution (client ↔ gateway ↔ backend)
|
||||||
|
|
||||||
|
- **FBS is additive-only.** Add **trailing** fields ("added trailing — backward-compatible"). Never
|
||||||
|
delete or reorder a mid-table field — **deprecate** it (`(deprecated)`); deleting shifts field IDs
|
||||||
|
and breaks older readers.
|
||||||
|
- **Retiring a domain field must also retire the WIRE field it fed** — by deprecation, not deletion,
|
||||||
|
and do not just zero it: a dead `0` the client dutifully syncs will clobber the real value.
|
||||||
|
- **The gateway transcodes FBS ↔ backend JSON DTOs in lockstep.** A wire change usually means editing
|
||||||
|
both the FBS schema and the backend DTO.
|
||||||
|
- **Seat display name is built in two places** — `game.Service` live events **and** the server REST
|
||||||
|
DTOs. Change both or they drift.
|
||||||
|
- **A per-viewer flag is computed only in the per-viewer REST DTO**; the client seeds it from REST,
|
||||||
|
then bumps it from the live event. Don't expect it on the shared broadcast.
|
||||||
|
|
||||||
|
## UI / Svelte 5
|
||||||
|
|
||||||
|
- **Never name a `$state` variable `state`.** `svelte-check` then misreads `$state` as a store
|
||||||
|
subscription. Rename (e.g. `view`).
|
||||||
|
- **Svelte trims literal edge whitespace** in markup. To keep a separator space, emit an expression:
|
||||||
|
`{' : '}`.
|
||||||
|
- **No global `.btn` / `.ghost` button classes.** Style buttons per-component with scoped CSS + design
|
||||||
|
tokens (mirror `NewGame`'s `.invite`).
|
||||||
|
- **A `$state` proxy fails structured-clone** when persisted to IndexedDB. Snapshot at the call site
|
||||||
|
with `$state.snapshot(...)` before storing.
|
||||||
|
|
||||||
|
## Platform / WebView quirks (Telegram, VK, iOS, native, old Android)
|
||||||
|
|
||||||
|
- **Telegram `showPopup` eats the user-activation.** Share / clipboard called from inside a
|
||||||
|
`showPopup` callback fail (no gesture). Use your own `Modal` for gesture-gated Web APIs.
|
||||||
|
- **iOS Telegram `<a download blob:>` navigates away** and strands the SPA. Deliver files by **Web
|
||||||
|
Share on mobile**, and only use a `<a download>` Blob path on **desktop**.
|
||||||
|
- **Android TG/VK WebViews** lack `navigator.share` and ignore `<a download>`. Deliver a
|
||||||
|
client-generated file by **copying it to the clipboard**.
|
||||||
|
- **VK Android WebView ignores `target=_blank`.** Open external links through `lib/links.ts`
|
||||||
|
(routes to `vk.com/away.php`). Verify on-device.
|
||||||
|
- **Per-platform file-delivery last hop differs** (TG / VK / plain browser) — there is a delivery
|
||||||
|
matrix; do not re-litigate it without new on-device facts.
|
||||||
|
- **Old Android System WebView floor ≈ Chrome 67.** The bundle targets **es2019** (esbuild lowers
|
||||||
|
syntax), a conditional `core-js` polyfill loads only on old engines, and `index.html` has a boot
|
||||||
|
gate (BigInt / Proxy are the hard block → the unsupported-engine screen). There's also a `vmin`
|
||||||
|
glyph fallback for old rendering.
|
||||||
|
- **iOS WKWebView overscroll and Telegram swipe-to-close are not reproducible in Playwright.** Verify
|
||||||
|
those live on the deployed contour, not in the e2e.
|
||||||
|
- **Telegram Desktop Mini App shows a persistent bottom-right loader** — that's the long-lived
|
||||||
|
Subscribe stream, cosmetic, deliberately left as-is.
|
||||||
|
|
||||||
|
## Testing
|
||||||
|
|
||||||
|
- **UI test layers:** vitest (node env, pure logic, **no jsdom**) + Playwright **mock** e2e. The mock
|
||||||
|
e2e **bypasses the codec**, so wire/codec bugs need **codec unit tests**, not e2e coverage.
|
||||||
|
- **Mock overlay blocks e2e.** The cold-load overlay must be instant under the mock build or it
|
||||||
|
intercepts Playwright taps.
|
||||||
|
- **Mock tile pools lack a blank `'?'`.** The seeded game `G1` hard-codes one; flip `G1`'s variant to
|
||||||
|
eyeball per-variant tiles.
|
||||||
|
- **Durable Playwright MCP servers** (chromium + webkit) exist for UI inspection (there's a
|
||||||
|
plugin-config gotcha in wiring them).
|
||||||
|
- **The `playwright test` runner can't *fetch* browsers in this sandbox** — `playwright install` dies
|
||||||
|
with `EBADF` against the Playwright CDN (blocked network), even with the sandbox off. Run the e2e in
|
||||||
|
**CI** (the `ui` job installs chromium+webkit), or drive a state live through the **Playwright MCP**
|
||||||
|
browser against a local `vite --mode mock` server for visual verification. **But if chromium/webkit are
|
||||||
|
already cached** in `~/Library/Caches/ms-playwright/`, `playwright test` runs locally fine (only the CDN
|
||||||
|
fetch is blocked) — the native offline-first e2e was run green locally this way (chromium + webkit),
|
||||||
|
its dawgs from the sibling `../../scrabble-solver/dawg` via the webServer's `bundle-dicts.mjs` fallback.
|
||||||
|
- **A native (Capacitor) e2e must inject `window.androidBridge`, NOT `window.Capacitor.getPlatform`.**
|
||||||
|
`@capacitor/core` (pulled in during boot by `initNativeShell`'s dynamic `@capacitor/app` import)
|
||||||
|
**replaces** any pre-set `window.Capacitor` with its own shim and derives the platform from
|
||||||
|
`window.androidBridge` (android) / `window.webkit.messageHandlers` (ios) — so a bare injected
|
||||||
|
`Capacitor.getPlatform: () => 'android'` is clobbered to `web` and the boot falls to `/login`. Inject
|
||||||
|
`window.androidBridge = { postMessage(){} }` in an `addInitScript` (see `e2e/native.spec.ts` `simulateNative`);
|
||||||
|
`initNativeShell` is written to tolerate the stub bridge (`try/catch` round the `@capacitor/app` addListener).
|
||||||
|
- **`docker run -p ...` boot tests fail from the shell** (published ports unreachable in this env).
|
||||||
|
Use **testcontainers** for container-backed tests.
|
||||||
|
- **Distroless images run as UID 65532 (nonroot).** Bind-mounted TLS keys must be **0644** (not 0600)
|
||||||
|
or the service crash-loops on start.
|
||||||
|
|
||||||
|
## Deploy / test contour (operational)
|
||||||
|
|
||||||
|
- **The TEST contour runs on THIS dev host.** Inspect it via `docker` / Prometheus. A host-side
|
||||||
|
`curl` to caddy **hangs** (NAT hairpin) — don't debug the edge that way.
|
||||||
|
- **The contour's client IP is the home-router SNAT address**, not the real external IP. Correct in
|
||||||
|
prod, not a bug — which is why the IP ban / blocklist are **prod-only**.
|
||||||
|
- **The contour is one shared env, last-deploy-wins.** Keep a multi-PR batch a **linear stack** (one
|
||||||
|
PR, one deploy) so deploys don't clobber each other.
|
||||||
|
- **A schema/wire PR breaks the contour** until a `DROP SCHEMA` + backend restart. Note such a
|
||||||
|
prerelease step in `PRERELEASE.md`.
|
||||||
|
- **A contour DB wipe resets the account but not the client's stored locale**; the reconciler then
|
||||||
|
syncs the stale locale, masking a fresh TG `language_code` seed. Clear client prefs too when testing
|
||||||
|
locale.
|
||||||
|
- **`DNS=` in `TEST_AWG_CONF` pins the VPN netns to 1.1.1.1** → internal names go NXDOMAIN → the
|
||||||
|
bot-link silently dies. Diagnose from inside the netns.
|
||||||
|
- **Swap one contour service to a local image** without deploy secrets via a busybox-in-the-netns
|
||||||
|
socket-inspect trick (single-service recreate).
|
||||||
|
- **A rolling deploy did NOT recreate caddy on a config-only change.** Force `--force-recreate` for
|
||||||
|
caddy; don't trust "deploy green" for an edge-config change.
|
||||||
|
- **A new gateway edge route MUST be added to the Caddyfile `@gateway` matcher** or it falls through
|
||||||
|
to the landing catch-all. Add a CI probe for the route.
|
||||||
|
- **Prod caddy logs warnings only, no access log.** Trace a request via the backend "http request"
|
||||||
|
telemetry log or Tempo, not caddy.
|
||||||
|
- **Confirm a release is live without SSH** by grepping the served SPA: `__APP_VERSION__` inside
|
||||||
|
`/assets/main-*.js`.
|
||||||
|
- **"App hangs on load" was a dead HTTP/3 advert:** caddy sent `Alt-Svc: h3` with no UDP/443 open;
|
||||||
|
clients cache it ~30 days. Fix with `Alt-Svc: clear`.
|
||||||
|
- **Config-poison deploy loop:** a crash-looping config-mounted service + a missing bind source
|
||||||
|
produces a root-owned directory that then fails deploys. Break it by removing the root-owned dir.
|
||||||
|
- **Maintenance-window contract** (planned-deploy 503): a marker header, `/_gm` exempt, the flag spans
|
||||||
|
the whole roll, the SPA overlay reloads on recovery. Don't break these invariants.
|
||||||
|
- **A new dictionary goes live via a `/_gm/dictionary` upload, NOT a redeploy.** In-flight games keep
|
||||||
|
their pinned version. The **owner** does the upload.
|
||||||
|
- **Renderer deploy job flakes** on the skia-canvas GitHub binary download when its lockfile changes —
|
||||||
|
re-run, it's not your code.
|
||||||
|
|
||||||
|
## Repo workflow
|
||||||
|
|
||||||
|
- **PR-based, zero issues.** Work is tracked via PRs + `PRERELEASE.md`. "заведи задачу" means *do it*
|
||||||
|
/ add a plan line — not file a tracker issue.
|
||||||
|
- **`tea` CLI for all Gitea ops** (PRs, secrets, variables, dispatch). `gh` does **not** work here. The
|
||||||
|
agent **cannot self-approve** a PR but **can merge** it after the owner approves. Watch the
|
||||||
|
stale-mergeable trap (re-check mergeability right before merging).
|
||||||
|
- **Watch every push/merge/deploy to green** with `python3 ~/.claude/bin/gitea-ci-watch.py`, launched
|
||||||
|
**bare** under a background task. It polls run-level conclusions; its `ALL GREEN` already covers the
|
||||||
|
gated deploy job. Pass `--no-runs 600` when the runner is busy. A **merge** is the most-forgotten
|
||||||
|
case — watch the post-merge runs too.
|
||||||
|
- **After a merge, switch to the merged-into branch** (`development`/`master`), pull, and prune the
|
||||||
|
local feature branch.
|
||||||
|
- **The contour deploy probe checks the backend `/readyz`**; a PR deploy builds the PR's own code; a
|
||||||
|
wedged contour can be recovered by recreating the host container set.
|
||||||
|
|
||||||
|
## Domain semantics (not obvious from the code)
|
||||||
|
|
||||||
|
- **Account deletion must NOT delete any user messages** — including feedback / support. Interview the
|
||||||
|
owner on every deletion point before wiring it.
|
||||||
|
- **`account.time_zone` is `NOT NULL DEFAULT 'UTC'`, seeded from a detected ±HH:MM offset.** An email
|
||||||
|
account row is created at the **code-request** step, not at confirmation.
|
||||||
|
- **The robot has two distinct time windows:** a **sleep window** (~00:00–07:00, gates its moves and
|
||||||
|
nudges) vs the **player away window** (turn-timeout only). "окно отсутствия" in dialogue = the
|
||||||
|
**sleep** window.
|
||||||
|
|
||||||
|
## Production topology
|
||||||
|
|
||||||
|
- **Two prod hosts.** `main` — full stack + ACME/edge (Selectel); `tg` — Telegram bot only (vdsina).
|
||||||
|
SSH aliases `scrabble-main-ops` / `scrabble-tg-ops`. Deploy is **manual dispatch only**, rolling +
|
||||||
|
health-gated + auto-rollback. Hosts are provisioned by `deploy/ansible/` (inventory + vars there —
|
||||||
|
the source of truth for IPs/roles).
|
||||||
|
- **The agent has targeted root SSH** to the hosts (and may run the prod Ansible). Surface every
|
||||||
|
owner-side step **before** a deploy, not after.
|
||||||
|
|
||||||
|
## Feature-area pointers (state lives in the linked docs/plans, not here)
|
||||||
|
|
||||||
|
- **Monetization** — «Фишка» currency, per-platform wallets, ads. Agreements in
|
||||||
|
`docs/PAYMENTS_DECISIONS_ru.md`; a phased plan (E0–E9). go-jet money rule above applies.
|
||||||
|
- **PITR** — pgBackRest → Selectel S3 (encrypted, 30-day), currently **gated off**; runbook in
|
||||||
|
`deploy/README.md`. Gotcha: run pgBackRest via docker-exec **as the `postgres` user** with
|
||||||
|
`--pg1-user=scrabble`.
|
||||||
|
- **Offline mode** — the robot brain, move generator/validator/scorer and DAWG reader are **JS ports**
|
||||||
|
of the Go engine, bundled client-side and **parity-pinned** by golden tests. Multi-phase; native
|
||||||
|
offline-first bundles the dictionaries.
|
||||||
|
- **VK ID web login** — raw OAuth 2.1 against `id.vk.com`, a **separate VK "Web" app** from the Mini
|
||||||
|
App, server-side confidential code exchange.
|
||||||
|
- **Email relay** — Selectel SMTP + confirm / link / unlink / deletion codes + alerts.
|
||||||
|
- **Native Android** — Capacitor bundle model, client-version gate, offline-first, RuStore; the
|
||||||
|
design lives in `docs/ARCHITECTURE.md` (§2 gate, §3 identity, §13 native build).
|
||||||
@@ -0,0 +1,172 @@
|
|||||||
|
# Manual signed-APK build for the standalone Android app (RuStore). Runs ONLY from master, ONLY on
|
||||||
|
# workflow_dispatch with confirm=build — the same deliberate-manual shape as prod-deploy.yaml, never on
|
||||||
|
# a PR. It builds the native-flavoured SPA, bundles the offline dictionaries into the APK assets, and
|
||||||
|
# assembles a release APK, uploaded as a run artifact (RuStore upload stays manual for the MVP).
|
||||||
|
#
|
||||||
|
# Signing degrades gracefully: with the ANDROID_KEYSTORE_* secrets present the APK is signed; without
|
||||||
|
# them build.gradle produces an UNSIGNED release APK (so a dry run still proves the whole pipeline).
|
||||||
|
# The keystore is a publication prerequisite — see deploy/README.md (Android build/release runbook).
|
||||||
|
# The toolchain is self-provisioned here (JDK 21 + a cached Android SDK), so the runner host needs
|
||||||
|
# nothing pre-installed.
|
||||||
|
name: android-build
|
||||||
|
run-name: "android build ${{ github.sha }}"
|
||||||
|
|
||||||
|
on:
|
||||||
|
workflow_dispatch:
|
||||||
|
inputs:
|
||||||
|
confirm:
|
||||||
|
description: 'Type "build" to confirm an APK build from master.'
|
||||||
|
required: true
|
||||||
|
default: ""
|
||||||
|
|
||||||
|
permissions:
|
||||||
|
contents: read
|
||||||
|
|
||||||
|
env:
|
||||||
|
NO_COLOR: "1"
|
||||||
|
# The dictionary release, one source of truth (same Gitea variable the backend image + CI use). It
|
||||||
|
# both fetches the DAWGs and labels the bundled files (VITE_DICT_VERSION must equal __DICT_VERSION__).
|
||||||
|
DICT_VERSION: ${{ vars.DICT_VERSION }}
|
||||||
|
VITE_DICT_VERSION: ${{ vars.DICT_VERSION }}
|
||||||
|
# Hide in-app purchases in the RuStore MVP (RuStore, not Google Play — VITE_GP_BUILD stays unset).
|
||||||
|
VITE_PAYMENTS_DISABLED: "1"
|
||||||
|
# The update overlay's store target; empty until publication (the button no-ops, and the version gate
|
||||||
|
# is dormant in the MVP so it never fires). Set the ANDROID_RUSTORE_URL variable when the app is live.
|
||||||
|
VITE_RUSTORE_URL: ${{ vars.ANDROID_RUSTORE_URL }}
|
||||||
|
# Host-executor runner: the Android SDK is pre-installed on the host. Override ANDROID_SDK_DIR if it
|
||||||
|
# lives elsewhere (the default matches deploy/README.md's install path).
|
||||||
|
ANDROID_HOME: ${{ vars.ANDROID_SDK_DIR || '/opt/android-sdk' }}
|
||||||
|
ANDROID_SDK_ROOT: ${{ vars.ANDROID_SDK_DIR || '/opt/android-sdk' }}
|
||||||
|
|
||||||
|
jobs:
|
||||||
|
build:
|
||||||
|
if: ${{ github.ref == 'refs/heads/master' && inputs.confirm == 'build' }}
|
||||||
|
runs-on: ubuntu-latest
|
||||||
|
defaults:
|
||||||
|
run:
|
||||||
|
shell: bash
|
||||||
|
steps:
|
||||||
|
- uses: actions/checkout@v4
|
||||||
|
with:
|
||||||
|
fetch-depth: 0
|
||||||
|
|
||||||
|
# Host-executor runner: the Android SDK is pre-installed on the host (JDK 21 comes from setup-java
|
||||||
|
# below). Fail fast + legibly if the runner user cannot read/execute it or a needed package is
|
||||||
|
# missing — this doubles as the runner-access check (deploy/README.md).
|
||||||
|
- name: Verify the host Android SDK
|
||||||
|
run: |
|
||||||
|
sm="$ANDROID_HOME/cmdline-tools/latest/bin/sdkmanager"
|
||||||
|
if [ ! -x "$sm" ]; then
|
||||||
|
echo "::error::sdkmanager not found/executable at $sm — set the ANDROID_SDK_DIR variable if the SDK lives elsewhere, or grant the runner user read+exec: sudo chmod -R a+rX \"$ANDROID_HOME\""; exit 1
|
||||||
|
fi
|
||||||
|
for pkg in "platforms/android-36" "build-tools"; do
|
||||||
|
if [ ! -d "$ANDROID_HOME/$pkg" ]; then
|
||||||
|
echo "::error::missing $ANDROID_HOME/$pkg — run: \"$sm\" 'platforms;android-36' 'build-tools;36.0.0'"; exit 1
|
||||||
|
fi
|
||||||
|
done
|
||||||
|
echo "Android SDK OK at $ANDROID_HOME"; "$sm" --version
|
||||||
|
|
||||||
|
- name: Compute version + native build env
|
||||||
|
id: prep
|
||||||
|
env:
|
||||||
|
PUBLIC_BASE_URL: ${{ vars.PROD_PUBLIC_BASE_URL }}
|
||||||
|
run: |
|
||||||
|
# A store release must sit on an exact vMAJOR.MINOR.PATCH tag (G tags before dispatch) so the
|
||||||
|
# versionCode is deterministic and strictly increasing across uploads. Refuse anything else
|
||||||
|
# rather than derive a versionCode from a "-N-gSHA" describe.
|
||||||
|
desc="$(git describe --tags --exact-match 2>/dev/null || true)"
|
||||||
|
case "$desc" in
|
||||||
|
v[0-9]*.[0-9]*.[0-9]*) ;;
|
||||||
|
*) echo "::error::HEAD is not on a clean vX.Y.Z tag (git describe --exact-match = '${desc:-none}'); tag the release first"; exit 1 ;;
|
||||||
|
esac
|
||||||
|
v="${desc#v}"
|
||||||
|
IFS=. read -r MA MI PA <<< "$v"
|
||||||
|
# 10# forces base-10 so a zero-padded part is never read as octal.
|
||||||
|
code=$(( 10#$MA * 1000000 + 10#$MI * 1000 + 10#$PA ))
|
||||||
|
# The native SPA talks to the production origin (reuse the prod public base URL); strip any
|
||||||
|
# trailing slash so the Connect endpoint never doubles it.
|
||||||
|
gateway="${PUBLIC_BASE_URL%/}"
|
||||||
|
{
|
||||||
|
echo "tag=$desc"
|
||||||
|
echo "name=$v"
|
||||||
|
echo "code=$code"
|
||||||
|
echo "gateway=$gateway"
|
||||||
|
} >> "$GITHUB_OUTPUT"
|
||||||
|
echo "release $desc -> versionName $v versionCode $code, gateway $gateway"
|
||||||
|
|
||||||
|
# Same release + fetch as the Go/UI jobs in ci.yaml — the bundled dicts come from this tarball,
|
||||||
|
# NOT the scrabble-solver sibling (ui is a Node project outside go.work).
|
||||||
|
- name: Fetch dictionary DAWGs
|
||||||
|
run: |
|
||||||
|
mkdir -p "${GITHUB_WORKSPACE}/dawg"
|
||||||
|
curl -fsSL -o /tmp/dawg.tar.gz "https://gitea.iliadenisov.ru/developer/scrabble-dictionary/releases/download/${DICT_VERSION}/scrabble-dawg-${DICT_VERSION}.tar.gz"
|
||||||
|
tar xzf /tmp/dawg.tar.gz -C "${GITHUB_WORKSPACE}/dawg"
|
||||||
|
ls -la "${GITHUB_WORKSPACE}/dawg"
|
||||||
|
|
||||||
|
- name: Set up Node
|
||||||
|
uses: actions/setup-node@v4
|
||||||
|
with:
|
||||||
|
node-version: 22
|
||||||
|
|
||||||
|
- name: Install pnpm
|
||||||
|
run: npm install -g pnpm@11.0.9
|
||||||
|
|
||||||
|
- name: Install deps
|
||||||
|
working-directory: ui
|
||||||
|
run: pnpm install --frozen-lockfile
|
||||||
|
|
||||||
|
- name: Build the SPA (native flavour)
|
||||||
|
working-directory: ui
|
||||||
|
env:
|
||||||
|
VITE_GATEWAY_URL: ${{ steps.prep.outputs.gateway }}
|
||||||
|
VITE_APP_VERSION: ${{ steps.prep.outputs.tag }}
|
||||||
|
run: pnpm run build
|
||||||
|
|
||||||
|
# Copy the release DAWGs into dist/dict/<variant>@<version>.dawg for the offline-first bundled tier
|
||||||
|
# (after the build, before cap sync copies dist/ into the native assets).
|
||||||
|
- name: Bundle the dictionaries
|
||||||
|
working-directory: ui
|
||||||
|
env:
|
||||||
|
DICT_DIR: ${{ github.workspace }}/dawg
|
||||||
|
run: node scripts/bundle-dicts.mjs
|
||||||
|
|
||||||
|
- name: Set up JDK 21
|
||||||
|
uses: actions/setup-java@v4
|
||||||
|
with:
|
||||||
|
distribution: temurin
|
||||||
|
java-version: "21"
|
||||||
|
|
||||||
|
# Local cap binary (dodges the corepack pre-flight flake); syncs dist/ (incl. dict/) + native deps.
|
||||||
|
- name: Sync the native project
|
||||||
|
working-directory: ui
|
||||||
|
run: node_modules/.bin/cap sync android
|
||||||
|
|
||||||
|
- name: Decode the release keystore
|
||||||
|
id: keystore
|
||||||
|
env:
|
||||||
|
ANDROID_KEYSTORE_BASE64: ${{ secrets.ANDROID_KEYSTORE_BASE64 }}
|
||||||
|
run: |
|
||||||
|
if [ -n "$ANDROID_KEYSTORE_BASE64" ]; then
|
||||||
|
printf '%s' "$ANDROID_KEYSTORE_BASE64" | base64 -d > "${GITHUB_WORKSPACE}/release.jks"
|
||||||
|
echo "file=${GITHUB_WORKSPACE}/release.jks" >> "$GITHUB_OUTPUT"
|
||||||
|
echo "keystore decoded -> signed release build"
|
||||||
|
else
|
||||||
|
echo "file=" >> "$GITHUB_OUTPUT"
|
||||||
|
echo "::warning::ANDROID_KEYSTORE_BASE64 is not set — building an UNSIGNED release APK (not installable/publishable)"
|
||||||
|
fi
|
||||||
|
|
||||||
|
- name: Assemble the release APK
|
||||||
|
working-directory: ui/android
|
||||||
|
env:
|
||||||
|
ANDROID_KEYSTORE_FILE: ${{ steps.keystore.outputs.file }}
|
||||||
|
ANDROID_KEYSTORE_PASSWORD: ${{ secrets.ANDROID_KEYSTORE_PASSWORD }}
|
||||||
|
ANDROID_KEY_ALIAS: ${{ secrets.ANDROID_KEY_ALIAS }}
|
||||||
|
ANDROID_KEY_PASSWORD: ${{ secrets.ANDROID_KEY_PASSWORD }}
|
||||||
|
run: ./gradlew assembleRelease -PversionCode=${{ steps.prep.outputs.code }} -PversionName=${{ steps.prep.outputs.name }} --console=plain
|
||||||
|
|
||||||
|
- name: Upload the APK artifact
|
||||||
|
uses: actions/upload-artifact@v4
|
||||||
|
with:
|
||||||
|
name: erudit-${{ steps.prep.outputs.name }}-apk
|
||||||
|
path: ui/android/app/build/outputs/apk/release/*.apk
|
||||||
|
if-no-files-found: error
|
||||||
@@ -353,6 +353,11 @@ 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 }}
|
||||||
@@ -510,19 +515,64 @@ jobs:
|
|||||||
- name: Probe the /offer/ public offer page is served
|
- name: Probe the /offer/ public offer page is served
|
||||||
run: |
|
run: |
|
||||||
set -u
|
set -u
|
||||||
# /offer/ is a static page baked into the landing image (rendered from
|
# /offer/ is rendered by the render sidecar: it splices the live catalog price list
|
||||||
# ui/legal/offer_ru.md). If the landing Caddyfile stops routing it, the request
|
# (fetched from the backend's internal endpoint) into the committed ui/legal/offer_ru.md.
|
||||||
# silently falls through to the landing shell (also 200) — so assert offer-specific
|
# If the @offer caddy route is missing, the request falls to the landing shell (also 200),
|
||||||
# content, never just the status.
|
# and if the backend fetch fails the sidecar returns 502 — so assert offer-specific content
|
||||||
out="$(docker run --rm --network edge alpine:3.20 wget -q -O - http://scrabble/offer/ 2>&1 || true)"
|
# (the seller INN, §11) AND that the pricing marker was substituted (proves the fetch +
|
||||||
if echo "$out" | grep -q "290210610742"; then
|
# splice ran), never just the status. Data-independent: an empty catalog still substitutes
|
||||||
echo "ok: /offer/ serves the public offer page"
|
# the marker with nothing, so "pricing_template" must be absent either way.
|
||||||
else
|
# Full body is needed (the INN is in §11 and the marker check spans the page), so this
|
||||||
echo "FAIL: /offer/ did not serve the offer page (fell through to the landing shell?)"
|
# 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
|
docker logs --tail 50 scrabble-landing || true
|
||||||
exit 1
|
exit 1
|
||||||
fi
|
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
|
- name: Probe the /pay/ callback route reaches the gateway
|
||||||
run: |
|
run: |
|
||||||
set -u
|
set -u
|
||||||
|
|||||||
@@ -99,14 +99,33 @@ 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
|
||||||
|
|||||||
@@ -61,9 +61,19 @@ jobs:
|
|||||||
# the SAME env.sh (email / VK login / Grafana alerts survive a rollback). TELEGRAM_MINIAPP_URL
|
# the SAME env.sh (email / VK login / Grafana alerts survive a rollback). TELEGRAM_MINIAPP_URL
|
||||||
# and GRAFANA_ROOT_URL are derived from PUBLIC_BASE_URL in deploy/write-prod-env.sh.
|
# and GRAFANA_ROOT_URL are derived from PUBLIC_BASE_URL in deploy/write-prod-env.sh.
|
||||||
GATEWAY_VK_APP_SECRET: ${{ secrets.GATEWAY_VK_APP_SECRET }}
|
GATEWAY_VK_APP_SECRET: ${{ secrets.GATEWAY_VK_APP_SECRET }}
|
||||||
|
# Robokassa direct-rail: the rollback re-renders the same runtime env (write-prod-env.sh), so
|
||||||
|
# it must carry the same credentials or the direct rail goes dark after a rollback.
|
||||||
|
ROBOKASSA_MERCHANT_LOGIN: ${{ secrets.PROD_BACKEND_ROBOKASSA_MERCHANT_LOGIN }}
|
||||||
|
ROBOKASSA_PASSWORD1: ${{ secrets.PROD_BACKEND_ROBOKASSA_PASSWORD1 }}
|
||||||
|
ROBOKASSA_PASSWORD2: ${{ secrets.PROD_BACKEND_ROBOKASSA_PASSWORD2 }}
|
||||||
|
ROBOKASSA_TEST: ${{ vars.PROD_BACKEND_ROBOKASSA_TEST }}
|
||||||
VITE_VK_APP_ID: ${{ vars.VITE_VK_APP_ID }}
|
VITE_VK_APP_ID: ${{ vars.VITE_VK_APP_ID }}
|
||||||
GATEWAY_VK_ID_CLIENT_SECRET: ${{ secrets.GATEWAY_VK_ID_CLIENT_SECRET }}
|
GATEWAY_VK_ID_CLIENT_SECRET: ${{ secrets.GATEWAY_VK_ID_CLIENT_SECRET }}
|
||||||
GATEWAY_HONEYTOKEN: ${{ secrets.PROD_GATEWAY_HONEYTOKEN }}
|
GATEWAY_HONEYTOKEN: ${{ secrets.PROD_GATEWAY_HONEYTOKEN }}
|
||||||
|
# Community IP blocklist — rendered on rollback too so a rollback keeps the same edge policy.
|
||||||
|
GATEWAY_BLOCKLIST_ENABLED: ${{ vars.PROD_GATEWAY_BLOCKLIST_ENABLED }}
|
||||||
|
GATEWAY_BLOCKLIST_URL: ${{ vars.PROD_GATEWAY_BLOCKLIST_URL }}
|
||||||
|
GATEWAY_BLOCKLIST_ALLOW: ${{ vars.PROD_GATEWAY_BLOCKLIST_ALLOW }}
|
||||||
EXPORT_SIGN_KEY: ${{ secrets.PROD_EXPORT_SIGN_KEY }}
|
EXPORT_SIGN_KEY: ${{ secrets.PROD_EXPORT_SIGN_KEY }}
|
||||||
SMTP_RELAY_USER: ${{ secrets.SMTP_RELAY_USER }}
|
SMTP_RELAY_USER: ${{ secrets.SMTP_RELAY_USER }}
|
||||||
SMTP_RELAY_PASS: ${{ secrets.SMTP_RELAY_PASS }}
|
SMTP_RELAY_PASS: ${{ secrets.SMTP_RELAY_PASS }}
|
||||||
|
|||||||
@@ -156,3 +156,11 @@ The `ui` module is a Node project (pnpm), **not** in `go.work`; it is the `ui` j
|
|||||||
the single `.gitea/workflows/ci.yaml`. Committed edge codegen under `ui/src/gen/`
|
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
|
||||||
|
|||||||
@@ -36,8 +36,8 @@ status — without re-deriving decisions.
|
|||||||
| E4 | Durability (PITR) | 2 | DONE |
|
| E4 | Durability (PITR) | 2 | DONE |
|
||||||
| E5 | Payment intake | 2 | DONE |
|
| E5 | Payment intake | 2 | DONE |
|
||||||
| E6 | Ads | 2 | DONE |
|
| E6 | Ads | 2 | DONE |
|
||||||
| E7 | Admin, reports & catalog | 2 | TODO |
|
| E7 | Admin, reports & catalog | 2 | DONE |
|
||||||
| E8 | Guest limits | — | TODO |
|
| E8 | Guest limits | — | DONE |
|
||||||
| E9 | Tournament fee | future | TODO |
|
| E9 | Tournament fee | future | TODO |
|
||||||
|
|
||||||
**Release 1** = full mechanics with no real money, exercised via `admin_grant` (E0→E1→E2→E3).
|
**Release 1** = full mechanics with no real money, exercised via `admin_grant` (E0→E1→E2→E3).
|
||||||
@@ -695,7 +695,7 @@ it tunes without a store release.
|
|||||||
|
|
||||||
## E7 — Admin, reports & catalog
|
## E7 — Admin, reports & catalog
|
||||||
|
|
||||||
**Status:** TODO · **Release 2** · depends on: E2 (ledger/grant/spend), E5 (payments/refunds),
|
**Status:** DONE · **Release 2** · depends on: E2 (ledger/grant/spend), E5 (payments/refunds),
|
||||||
E6 (D31 retired the legacy `hint_balance`/`paid_account`) · mechanics: PAYMENTS §11, §12, D32.
|
E6 (D31 retired the legacy `hint_balance`/`paid_account`) · mechanics: PAYMENTS §11, §12, D32.
|
||||||
|
|
||||||
**Delivery & baked decisions (this planning round).** A linear PR stack into `development`.
|
**Delivery & baked decisions (this planning round).** A linear PR stack into `development`.
|
||||||
@@ -763,15 +763,16 @@ ledger export — plus the **configurable product catalog editor** (D32).
|
|||||||
products + prices and delete only never-transacted ones; grant concrete values raw or by-product
|
products + prices and delete only never-transacted ones; grant concrete values raw or by-product
|
||||||
(origin-picked, never chips/tournament); refund an order in full; export the ledger.
|
(origin-picked, never chips/tournament); refund an order in full; export the ledger.
|
||||||
|
|
||||||
**PR stack (linear into `development`).**
|
**PR stack (linear into `development`, all merged).**
|
||||||
|
|
||||||
1. **Per-user financial panel** (read-only ledger/segments/benefits on the user card; retires the
|
1. ~~**Per-user financial panel**~~ (#230) — read-only ledger/segments/benefits on the user card;
|
||||||
`PaidAccount`/`HintBalance` display).
|
retired the `PaidAccount`/`HintBalance` display.
|
||||||
2. **Catalog editor** (product/atom/price CRUD + archive/unarchive + delete-if-clean + shape
|
2. ~~**Catalog editor**~~ (#231) — product/atom/price CRUD + archive/unarchive + delete-if-clean +
|
||||||
validation).
|
shape validation.
|
||||||
3. **Admin grant** (raw + by-product, refuse chips/tournament, origin-picked, `admin_grant` +
|
3. ~~**Admin grant**~~ (#232, + the D31 hint-wallet wire cleanup that surfaced there) — raw +
|
||||||
snapshot).
|
by-product, refuse chips/tournament, origin-picked, `admin_grant` + snapshot.
|
||||||
4. **Manual refund** (full order via E5 `Refund`) + **ledger export** (CSV/JSON).
|
4. ~~**Manual refund** (full order via E5 `Refund`) + **ledger CSV export**~~ — `RefundOrderFull`
|
||||||
|
(idempotent, floor-0 revoke) on each fund row; `/_gm/ledger.csv`.
|
||||||
|
|
||||||
**Notes/risks.** High-blast-radius (money, ledger — append-only, trigger-enforced). No mixed-in
|
**Notes/risks.** High-blast-radius (money, ledger — append-only, trigger-enforced). No mixed-in
|
||||||
refactors. The catalog editor becomes the source of truth for products; the contour SQL seeds
|
refactors. The catalog editor becomes the source of truth for products; the contour SQL seeds
|
||||||
@@ -781,38 +782,88 @@ become bootstrap-only.
|
|||||||
|
|
||||||
## E8 — Guest limits
|
## E8 — Guest limits
|
||||||
|
|
||||||
**Status:** TODO · **standalone** (game-behaviour change; can run in parallel) · depends on:
|
**Status:** DONE · **standalone** (game-behaviour change) · depends on: none · mechanics: PAYMENTS §6.
|
||||||
none · mechanics: PAYMENTS §6.
|
|
||||||
|
|
||||||
**Goal.** A registration funnel: cap what a guest can do, enforced **server-side** (today the
|
**Goal.** A registration funnel: cap what a guest can do, enforced **server-side** (today UI-only),
|
||||||
gating is UI-only).
|
with configurable per-tier × per-kind limits so the pressure tunes without a release.
|
||||||
|
|
||||||
**Finding (verified).** The friend/invitation paths do **not** check `is_guest` on the
|
**Finding (verified).** Two gaps. (a) The friend/invitation paths do **not** check `is_guest` on the
|
||||||
server — only the UI hides them: `social/friends.go:50` `SendFriendRequest`,
|
server — only the UI hides them: `social/friends.go`, `robotfriends.go`, `friendcodes.go`,
|
||||||
`robotfriends.go:41` `RequestInGame`, `friendcodes.go:67` `RedeemFriendCode`,
|
`lobby/invitations.go`. A guest with a valid `X-User-ID` can call them. (b) A **pre-existing** flat
|
||||||
`lobby/invitations.go:208` `CreateInvitation`. A guest with a valid `X-User-ID` can call them
|
cap already existed: `game.MaxActiveQuickGames`=10 — a **combined** cap on `active`+`open` quick
|
||||||
in the UI's stead.
|
games (vs_ai+random together, friend games excluded), counted by `CountActiveQuickGames`, enforced at
|
||||||
|
the handler (`ensureUnderGameLimit`) with **409 `game_limit_reached`**, surfaced as `at_game_limit`.
|
||||||
|
E8's per-tier × per-kind config **subsumes and replaces** it (decided: option A).
|
||||||
|
|
||||||
|
**Delivery & baked decisions (this planning round).** A linear PR stack; E8 is game-behaviour, no
|
||||||
|
payments mixed in.
|
||||||
|
|
||||||
|
- **`games.game_kind smallint DEFAULT 0`** (0=unknown — pre-E8 games, never gated; 1=vs_ai; 2=random;
|
||||||
|
3=friends), set on creation (`StartVsAI`=1, `Enqueue`=2, a friend invitation=3). Existing games
|
||||||
|
stay 0 and fall outside the gate.
|
||||||
|
- **Limits are per-tier × per-kind**, in a **new single-row `backend.config`** table:
|
||||||
|
`guest_{vs_ai,random,friends}_limit` + `durable_{vs_ai,random,friends}_limit` (smallint,
|
||||||
|
**`-1`=unlimited**), seeded `(1,1,0, 10,10,10)`. A guest is capped at 1 vs_ai + 1 random (friends
|
||||||
|
is moot — the guest gate blocks friend games); a durable account is **10 per kind** — the old flat
|
||||||
|
MaxActiveQuickGames=10, now split per kind. Editable in the admin.
|
||||||
|
- **The old flat cap is removed** (option A, owner-agreed): `MaxActiveQuickGames`,
|
||||||
|
`CountActiveQuickGames`, `atGameLimit` deleted; the per-tier/kind config is the single mechanism,
|
||||||
|
enforced at the **same handler gate** (`ensureUnderGameLimit(kind)` for `enqueue`
|
||||||
|
random/vs_ai) plus the durable **friends cap** inside `CreateInvitation`. The gate stays out of the
|
||||||
|
game domain — `game.Service.AtGameLimit(account, kind)` only resolves tier + counts.
|
||||||
|
- **A hot in-memory cache** fronts the config (read once on start, invalidated on the admin edit —
|
||||||
|
mirrors the payments read-cache) so a login / game-create never queries it.
|
||||||
|
- **"Active" = `open` + `active`** (an open, unmatched random game holds a slot); the limit is on
|
||||||
|
**creation** — an existing game is grandfathered, never interrupted.
|
||||||
|
- **Limits ride the wire (forward-compat, no double break):** `Profile.game_limits`
|
||||||
|
(`GameLimits{vs_ai, random, friends}` — the caller's tier resolved server-side) + `GameView.kind`,
|
||||||
|
so the client counts active games **per kind** from its lobby and locks the right start button. On
|
||||||
|
a guest → durable upgrade (register / link) the client **re-fetches the profile** so the new
|
||||||
|
(durable) limits apply. The client picks the lock's message by tier: a **guest** → the login funnel;
|
||||||
|
a **durable** account at its cap → a plain "finish a current game first" notice.
|
||||||
|
|
||||||
**Work.**
|
**Work.**
|
||||||
|
|
||||||
- **Server guest gate** on friend-request / redeem-code / invitation-create: refuse when the
|
- ~~**PR1 — backend + admin (server-side)** — DONE.~~ The migration (`game_kind` + `backend.config`,
|
||||||
caller `is_guest` (add an `ErrGuestForbidden`-style guard, as `feedback` already has).
|
seeded `1,1,0 / 10,10,10` + jetgen); `game_kind` set on creation and projected onto `game.Game`;
|
||||||
- **Active-game limits** for guests: at most **1 random-opponent game + 1 vs_ai** concurrently
|
the **guest gate** (`ErrGuestForbidden`, mapped to **403 `guest_forbidden`**) on friend-request /
|
||||||
(enforce on game/invitation creation in `lobby`/`game`; today no such limit exists).
|
redeem-code / befriend-in-game / invitation-create; the **active-limit enforce** — the old flat
|
||||||
- Keep the existing UI gating; this adds the server as the source of truth.
|
`MaxActiveQuickGames` mechanism removed and replaced by `ensureUnderGameLimit(kind)` on `enqueue`
|
||||||
|
(random/vs_ai) plus the durable friends cap in `CreateInvitation`, keyed off
|
||||||
|
`game.Service.AtGameLimit`; the **`internal/gamelimits` config + hot cache** (loaded at boot,
|
||||||
|
invalidated on edit); the admin **kind column** in both game lists (`/_gm/games` + the user card)
|
||||||
|
and a **config editor** (`/_gm/limits`) for the six limits.
|
||||||
|
- ~~**PR2 — wire + client** — DONE.~~ `Profile.game_limits` (the caller's tier) + `GameView.kind` (FBS
|
||||||
|
+ gateway transcode + client codec, committed regen); the client counts active games per kind from
|
||||||
|
the lobby cache (`gamelimits.ts`) and locks a capped new-game start — an **outline 🔒** button that
|
||||||
|
opens `GameLimitModal` instead of a game, native (Telegram `showPopup`) or the in-app `Modal`
|
||||||
|
elsewhere, with two messages by tier: a **guest** sign-in funnel ("Войдите или создайте учётную
|
||||||
|
запись…", Отмена / Вход→`/settings`) and, for a **durable** account at its cap, a plain notice
|
||||||
|
("Вы достигли лимита одновременных игр, сначала завершите текущие", ОК). The lock lifts via the
|
||||||
|
existing profile re-fetch after a guest→durable upgrade.
|
||||||
|
- **Decision (owner-agreed): the lobby's old `at_game_limit` New-Game tab-disable + notice is
|
||||||
|
removed.** The `at_game_limit` flag (now the random-kind cap) conflicted with the per-kind lock —
|
||||||
|
it hid the New-Game screen where the lock lives, and wrongly blocked starting an unfulfilled kind.
|
||||||
|
The tab is always enabled; the per-kind lock on the start button is the only gate. The wire field
|
||||||
|
`GameList.at_game_limit` stays (unused by the client) for a later cleanup.
|
||||||
|
|
||||||
**Tests.**
|
**Tests.**
|
||||||
|
|
||||||
- integration: guest is refused friend/redeem/invitation server-side; guest blocked from a
|
- integration (PR1, done): `game_kind` persisted per path; guest refused friend/redeem/invitation
|
||||||
2nd random / 2nd vs_ai; durable account unaffected.
|
(domain + HTTP 403); guest blocked from a 2nd vs_ai / 2nd random (+ `at_game_limit`); durable on the
|
||||||
- UI: guest sees the funnel (existing hides + any new messaging).
|
higher tier; the durable friends cap + config cache reflecting an admin edit; accept stays exempt.
|
||||||
|
- unit (PR1, done): the per-tier/kind limit resolution (`Cap`, `LimitsFor`).
|
||||||
|
- unit + UI (PR2, done): the client lock logic (`gamelimits.ts` — count/cap/lock), the codec kind +
|
||||||
|
game_limits roundtrip, the gateway transcode game_limits encode, the popup builders, and a mock e2e
|
||||||
|
(`gamelimit.spec.ts`) — a capped start shows 🔒, opens the modal without navigating, and the lock
|
||||||
|
clears when the profile refetch lifts the cap.
|
||||||
|
|
||||||
**Done-criteria.** Guests are server-limited to 1 random + 1 vs_ai and cannot friend/invite;
|
**Done-criteria.** A guest is server-capped (default 1 vs_ai + 1 random, configurable) and cannot
|
||||||
durable accounts unchanged; regression that this does not break existing durable flows.
|
friend/invite; a durable account is capped at 10 per kind (configurable); the client shows the lock +
|
||||||
|
the tier-appropriate modal and the cap lifts on registration; durable flows otherwise unchanged.
|
||||||
|
|
||||||
**Notes/risks.** This changes existing game behaviour — its own tests, its own PR, no
|
**Notes/risks.** Game-behaviour change — own PRs, own tests, no payments mixed in. The limit is on
|
||||||
mixed-in payments changes. Decide whether a guest may finish an already-started game (limit
|
creation (existing games grandfathered). The config cache is single-instance (matching the deploy).
|
||||||
on creation, not mid-game) at implementation and record it here.
|
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
|
|||||||
@@ -1,41 +1,18 @@
|
|||||||
# Erudit — site icons + Open Graph card
|
# Icons
|
||||||
|
|
||||||
The favicon set and the `og:image` link-preview card for the public landing
|
The whole shipped icon set is sliced from **one master** — see
|
||||||
(`ui/landing.html`) and the SPA shell (`ui/index.html`). Same design language as the
|
[`brand/`](brand/) for the reference generator, the pinned font and the committed
|
||||||
[VK loading-screen logo](../vk/README.md): the wooden Erudit «Э» tile (score `8`),
|
outputs, and [`../../docs/ICON_BRANDBOOK.md`](../../docs/ICON_BRANDBOOK.md) for how the
|
||||||
with the wordmark on the app's dark board green (`ui/src/app.css` tokens).
|
mark is constructed. The per-platform target map (which file goes where) is in
|
||||||
|
[`../../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 assets/icons
|
cd brand
|
||||||
|
npm i opentype.js
|
||||||
# 1. (optional) re-extract the glyphs — only if the font or the wordmark changes:
|
node build-set.mjs # web (ui/public) + Capacitor layers (ui/assets) + vk/ tg/ store/
|
||||||
# default font: /usr/share/fonts/truetype/liberation/LiberationSans-Regular.ttf
|
node build-android-res.mjs # Android launcher resources (all densities + monochrome)
|
||||||
node build/extract.js [/path/to/font.ttf] # -> build/glyphs.json
|
|
||||||
|
|
||||||
# 2. regenerate everything in ui/public/:
|
|
||||||
node build/generate.js
|
|
||||||
```
|
```
|
||||||
|
|
||||||
|
> The earlier `build/` pipeline (LiberationSans glyph outlines → favicon/apple-touch/OG)
|
||||||
|
> was replaced by `brand/` when the icon was rebranded onto the Spectral «Э» master.
|
||||||
|
> The separate VK loading-screen animation lives in [`../vk/`](../vk/) and is unrelated.
|
||||||
|
|||||||
@@ -0,0 +1,2 @@
|
|||||||
|
node_modules/
|
||||||
|
package-lock.json
|
||||||
@@ -0,0 +1,32 @@
|
|||||||
|
# Erudit icon — brand master
|
||||||
|
|
||||||
|
The reference construction of the Erudit app icon. The authoritative, human-readable
|
||||||
|
spec lives in [`docs/ICON_BRANDBOOK.md`](../../../docs/ICON_BRANDBOOK.md); this folder
|
||||||
|
holds the machine reproduction of exactly what that document describes.
|
||||||
|
|
||||||
|
| File | What |
|
||||||
|
|------|------|
|
||||||
|
| `build-icon.mjs` | reference generator — every dimension is a fraction of the side, matching the brand book |
|
||||||
|
| `render-png.mjs` | rasterises an icon SVG to PNG via the `ui` package's Playwright chromium |
|
||||||
|
| `Spectral-Bold.ttf` | the «Э» typeface (SIL OFL, `Spectral-OFL.txt`) — pinned so the letter never drifts |
|
||||||
|
| `icon-light.svg` / `.png` | the light master (1024) |
|
||||||
|
| `icon-dark.svg` / `.png` | the dark master (1024) |
|
||||||
|
| `icon-construction.svg` / `.png` | the percent-annotated construction scheme (figure in the brand book) |
|
||||||
|
|
||||||
|
## Regenerate
|
||||||
|
|
||||||
|
```sh
|
||||||
|
cd assets/icons/brand
|
||||||
|
npm i opentype.js # the only extra dep; render uses ui's chromium
|
||||||
|
|
||||||
|
node build-icon.mjs --variant light > icon-light.svg
|
||||||
|
node build-icon.mjs --variant dark > icon-dark.svg
|
||||||
|
node build-icon.mjs --variant light --scheme > icon-construction.svg
|
||||||
|
|
||||||
|
node render-png.mjs icon-light.svg icon-light.png 1024
|
||||||
|
node render-png.mjs icon-dark.svg icon-dark.png 1024
|
||||||
|
node render-png.mjs icon-construction.svg icon-construction.png 2048
|
||||||
|
```
|
||||||
|
|
||||||
|
The SVGs are resolution-free; render at any size. Downstream slicing (favicon / PWA /
|
||||||
|
iOS / Android) is a separate step — see [`docs/ICONS.md`](../../../docs/ICONS.md).
|
||||||
@@ -0,0 +1,93 @@
|
|||||||
|
Copyright 2017 The Spectral Project Authors (https://github.com/productiontype/Spectral)
|
||||||
|
|
||||||
|
This Font Software is licensed under the SIL Open Font License, Version 1.1.
|
||||||
|
This license is copied below, and is also available with a FAQ at:
|
||||||
|
https://openfontlicense.org
|
||||||
|
|
||||||
|
|
||||||
|
-----------------------------------------------------------
|
||||||
|
SIL OPEN FONT LICENSE Version 1.1 - 26 February 2007
|
||||||
|
-----------------------------------------------------------
|
||||||
|
|
||||||
|
PREAMBLE
|
||||||
|
The goals of the Open Font License (OFL) are to stimulate worldwide
|
||||||
|
development of collaborative font projects, to support the font creation
|
||||||
|
efforts of academic and linguistic communities, and to provide a free and
|
||||||
|
open framework in which fonts may be shared and improved in partnership
|
||||||
|
with others.
|
||||||
|
|
||||||
|
The OFL allows the licensed fonts to be used, studied, modified and
|
||||||
|
redistributed freely as long as they are not sold by themselves. The
|
||||||
|
fonts, including any derivative works, can be bundled, embedded,
|
||||||
|
redistributed and/or sold with any software provided that any reserved
|
||||||
|
names are not used by derivative works. The fonts and derivatives,
|
||||||
|
however, cannot be released under any other type of license. The
|
||||||
|
requirement for fonts to remain under this license does not apply
|
||||||
|
to any document created using the fonts or their derivatives.
|
||||||
|
|
||||||
|
DEFINITIONS
|
||||||
|
"Font Software" refers to the set of files released by the Copyright
|
||||||
|
Holder(s) under this license and clearly marked as such. This may
|
||||||
|
include source files, build scripts and documentation.
|
||||||
|
|
||||||
|
"Reserved Font Name" refers to any names specified as such after the
|
||||||
|
copyright statement(s).
|
||||||
|
|
||||||
|
"Original Version" refers to the collection of Font Software components as
|
||||||
|
distributed by the Copyright Holder(s).
|
||||||
|
|
||||||
|
"Modified Version" refers to any derivative made by adding to, deleting,
|
||||||
|
or substituting -- in part or in whole -- any of the components of the
|
||||||
|
Original Version, by changing formats or by porting the Font Software to a
|
||||||
|
new environment.
|
||||||
|
|
||||||
|
"Author" refers to any designer, engineer, programmer, technical
|
||||||
|
writer or other person who contributed to the Font Software.
|
||||||
|
|
||||||
|
PERMISSION & CONDITIONS
|
||||||
|
Permission is hereby granted, free of charge, to any person obtaining
|
||||||
|
a copy of the Font Software, to use, study, copy, merge, embed, modify,
|
||||||
|
redistribute, and sell modified and unmodified copies of the Font
|
||||||
|
Software, subject to the following conditions:
|
||||||
|
|
||||||
|
1) Neither the Font Software nor any of its individual components,
|
||||||
|
in Original or Modified Versions, may be sold by itself.
|
||||||
|
|
||||||
|
2) Original or Modified Versions of the Font Software may be bundled,
|
||||||
|
redistributed and/or sold with any software, provided that each copy
|
||||||
|
contains the above copyright notice and this license. These can be
|
||||||
|
included either as stand-alone text files, human-readable headers or
|
||||||
|
in the appropriate machine-readable metadata fields within text or
|
||||||
|
binary files as long as those fields can be easily viewed by the user.
|
||||||
|
|
||||||
|
3) No Modified Version of the Font Software may use the Reserved Font
|
||||||
|
Name(s) unless explicit written permission is granted by the corresponding
|
||||||
|
Copyright Holder. This restriction only applies to the primary font name as
|
||||||
|
presented to the users.
|
||||||
|
|
||||||
|
4) The name(s) of the Copyright Holder(s) or the Author(s) of the Font
|
||||||
|
Software shall not be used to promote, endorse or advertise any
|
||||||
|
Modified Version, except to acknowledge the contribution(s) of the
|
||||||
|
Copyright Holder(s) and the Author(s) or with their explicit written
|
||||||
|
permission.
|
||||||
|
|
||||||
|
5) The Font Software, modified or unmodified, in part or in whole,
|
||||||
|
must be distributed entirely under this license, and must not be
|
||||||
|
distributed under any other license. The requirement for fonts to
|
||||||
|
remain under this license does not apply to any document created
|
||||||
|
using the Font Software.
|
||||||
|
|
||||||
|
TERMINATION
|
||||||
|
This license becomes null and void if any of the above conditions are
|
||||||
|
not met.
|
||||||
|
|
||||||
|
DISCLAIMER
|
||||||
|
THE FONT SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND,
|
||||||
|
EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO ANY WARRANTIES OF
|
||||||
|
MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT
|
||||||
|
OF COPYRIGHT, PATENT, TRADEMARK, OR OTHER RIGHT. IN NO EVENT SHALL THE
|
||||||
|
COPYRIGHT HOLDER BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY,
|
||||||
|
INCLUDING ANY GENERAL, SPECIAL, INDIRECT, INCIDENTAL, OR CONSEQUENTIAL
|
||||||
|
DAMAGES, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING
|
||||||
|
FROM, OUT OF THE USE OR INABILITY TO USE THE FONT SOFTWARE OR FROM
|
||||||
|
OTHER DEALINGS IN THE FONT SOFTWARE.
|
||||||
|
After Width: | Height: | Size: 23 KiB |
@@ -0,0 +1,47 @@
|
|||||||
|
'use strict';
|
||||||
|
// Generate the Android launcher resources from the committed layers, at every
|
||||||
|
// density, with NO inset (our layers already fill the 108 dp canvas) and a
|
||||||
|
// monochrome (themed) layer. Run build-set.mjs first (it writes the layer PNGs).
|
||||||
|
//
|
||||||
|
// npm i opentype.js # (only build-icon/build-set need it; this reads PNGs)
|
||||||
|
// node build-set.mjs && node build-android-res.mjs
|
||||||
|
//
|
||||||
|
// Writes ui/android/app/src/main/res/mipmap-*/ic_launcher{,_round,_foreground,
|
||||||
|
// _background,_monochrome}.png. The two mipmap-anydpi-v26/*.xml are committed by hand.
|
||||||
|
import { readFileSync, writeFileSync } from 'node:fs';
|
||||||
|
import { fileURLToPath } from 'node:url';
|
||||||
|
import { dirname, join } from 'node:path';
|
||||||
|
|
||||||
|
const DIR = dirname(fileURLToPath(import.meta.url));
|
||||||
|
const ROOT = join(DIR, '..', '..', '..');
|
||||||
|
const RES = join(ROOT, 'ui', 'android', 'app', 'src', 'main', 'res');
|
||||||
|
const uri = p => 'data:image/png;base64,' + readFileSync(p).toString('base64');
|
||||||
|
const BG = uri(join(ROOT, 'ui', 'assets', 'icon-background.png'));
|
||||||
|
const FG = uri(join(ROOT, 'ui', 'assets', 'icon-foreground.png'));
|
||||||
|
const MONO = uri(join(DIR, 'android-monochrome.png'));
|
||||||
|
|
||||||
|
// density: [adaptive-layer px (108 dp), legacy launcher px (48 dp)]
|
||||||
|
const D = { ldpi: [81, 36], mdpi: [108, 48], hdpi: [162, 72], xhdpi: [216, 96], xxhdpi: [324, 144], xxxhdpi: [432, 192] };
|
||||||
|
|
||||||
|
const pw = await import(join(ROOT, 'ui', 'node_modules', '@playwright', 'test', 'index.js'));
|
||||||
|
const { chromium } = pw.default ?? pw;
|
||||||
|
const browser = await chromium.launch();
|
||||||
|
const page = await browser.newPage({ deviceScaleFactor: 1 });
|
||||||
|
const im = (u, s) => `<img src="${u}" width="${s}" height="${s}" style="display:block">`;
|
||||||
|
async function shot(html, s, transparent) {
|
||||||
|
await page.setViewportSize({ width: s, height: s });
|
||||||
|
await page.setContent(`<!doctype html><meta charset=utf8><style>*{margin:0;padding:0}#r{width:${s}px;height:${s}px}</style><div id=r>${html}</div>`, { waitUntil: 'networkidle' });
|
||||||
|
return page.locator('#r').screenshot({ omitBackground: !!transparent });
|
||||||
|
}
|
||||||
|
for (const [d, [fg, lg]] of Object.entries(D)) {
|
||||||
|
const dir = join(RES, `mipmap-${d}`);
|
||||||
|
writeFileSync(join(dir, 'ic_launcher_background.png'), await shot(im(BG, fg), fg, false));
|
||||||
|
writeFileSync(join(dir, 'ic_launcher_foreground.png'), await shot(im(FG, fg), fg, true));
|
||||||
|
writeFileSync(join(dir, 'ic_launcher_monochrome.png'), await shot(im(MONO, fg), fg, true));
|
||||||
|
const comp = `<div style="position:relative;width:${lg}px;height:${lg}px">${im(BG, lg)}<div style="position:absolute;inset:0">${im(FG, lg)}</div></div>`;
|
||||||
|
writeFileSync(join(dir, 'ic_launcher.png'), await shot(comp, lg, false));
|
||||||
|
const round = `<div style="width:${lg}px;height:${lg}px;border-radius:50%;overflow:hidden;position:relative">${im(BG, lg)}<div style="position:absolute;inset:0">${im(FG, lg)}</div></div>`;
|
||||||
|
writeFileSync(join(dir, 'ic_launcher_round.png'), await shot(round, lg, true));
|
||||||
|
console.log('mipmap-' + d, 'ok');
|
||||||
|
}
|
||||||
|
await browser.close();
|
||||||
@@ -0,0 +1,159 @@
|
|||||||
|
'use strict';
|
||||||
|
// Erudit app-icon — the reference generator. Every dimension below is a FRACTION
|
||||||
|
// of the icon side, so this file reads the same as docs/ICON_BRANDBOOK.md and the
|
||||||
|
// icon reproduces at any resolution. Emits one variant/layer's SVG to stdout.
|
||||||
|
//
|
||||||
|
// node build-icon.mjs --variant light|dark [--layer full|background|foreground] [--scheme] > icon.svg
|
||||||
|
//
|
||||||
|
// Layers (for Android adaptive icons):
|
||||||
|
// full the standalone master (rounded tile + mark) — iOS / PWA / favicon
|
||||||
|
// background wood + grain + light/shadow, full-bleed square (the OS applies the mask)
|
||||||
|
// foreground only «Э» + ✻, transparent, re-placed for the round mask (see FG_* below)
|
||||||
|
//
|
||||||
|
// Deps: opentype.js (npm i opentype.js). Font: ./Spectral-Bold.ttf (OFL, bundled).
|
||||||
|
import { readFileSync } from 'node:fs';
|
||||||
|
import { fileURLToPath } from 'node:url';
|
||||||
|
import { dirname, join } from 'node:path';
|
||||||
|
import opentype from 'opentype.js';
|
||||||
|
|
||||||
|
const DIR = dirname(fileURLToPath(import.meta.url));
|
||||||
|
const args = process.argv.slice(2);
|
||||||
|
const has = f => args.includes('--' + f);
|
||||||
|
const val = (f, d) => { const i = args.indexOf('--' + f); return i >= 0 ? args[i + 1] : d; };
|
||||||
|
|
||||||
|
// ---- the design, in fractions of the side ----
|
||||||
|
const S = 1024; // reference side (the icon is resolution-free)
|
||||||
|
const RADIUS = 0.17; // tile corner radius (full master)
|
||||||
|
const STAR_BULB = 0.22; // spoke-end (and hub) radius, as fraction of star radius
|
||||||
|
const STAR_SPOKES = 6;
|
||||||
|
const GRAIN_COLS = 6, GRAIN_W = 1 / 32, GRAIN_SHIFT = 1 / 16, GRAIN_TILT = 4;
|
||||||
|
const SHEEN = 0.15; // white, transparent bottom-left -> this alpha top-right
|
||||||
|
const SHADE = 0.15; // black, this alpha bottom-left -> transparent top-right
|
||||||
|
|
||||||
|
// The mark, placed for the standalone master (bottom-right biased, full-bleed):
|
||||||
|
const MASTER = { lh: 0.6055, lc: [0.4814, 0.4922], sd: 0.1729, sc: [0.7881, 0.7881] };
|
||||||
|
// The mark, placed for the Android foreground layer (centred-ish inside the 61% safe
|
||||||
|
// zone, nudged right 8% / down 3% for optical balance under the round mask; smaller ✻):
|
||||||
|
const FOREGROUND = { lh: 0.5391, lc: [0.5234, 0.4951], sd: 0.1318, sc: [0.7627, 0.7266] };
|
||||||
|
|
||||||
|
const PALETTE = {
|
||||||
|
light: { wood: '#e6b053', ink: '#5a3a12', grain: '#d8a54e' },
|
||||||
|
dark: { wood: '#4a3316', ink: '#f2d9a0', grain: '#432e14' },
|
||||||
|
};
|
||||||
|
|
||||||
|
// Layers:
|
||||||
|
// full rounded tile + master mark (favicon.svg — browser rounds nothing)
|
||||||
|
// flat square tile + master mark (apple-touch, PWA "any" — OS rounds)
|
||||||
|
// background square tile, no mark (Android adaptive background)
|
||||||
|
// foreground mark only, transparent (Android adaptive foreground)
|
||||||
|
// maskable square tile + foreground mark (PWA maskable — safe-zone aware)
|
||||||
|
// monochrome foreground mark only, flat colour (Android 13+ themed layer)
|
||||||
|
const LAYERS = ['full', 'flat', 'background', 'foreground', 'maskable', 'monochrome'];
|
||||||
|
const variant = val('variant', 'light');
|
||||||
|
const layer = val('layer', 'full');
|
||||||
|
const P = PALETTE[variant];
|
||||||
|
if (!P) { console.error(`unknown --variant ${variant} (light|dark)`); process.exit(1); }
|
||||||
|
if (!LAYERS.includes(layer)) { console.error(`unknown --layer ${layer} (${LAYERS.join('|')})`); process.exit(1); }
|
||||||
|
|
||||||
|
const usesForegroundPlacement = ['foreground', 'maskable', 'monochrome'].includes(layer);
|
||||||
|
const drawBg = ['full', 'flat', 'background', 'maskable'].includes(layer);
|
||||||
|
const drawMark = layer !== 'background';
|
||||||
|
const MARK = usesForegroundPlacement ? FOREGROUND : MASTER;
|
||||||
|
const inkColour = layer === 'monochrome' ? val('mono', '#ffffff') : P.ink;
|
||||||
|
const RX = layer === 'full' ? RADIUS * S : 0; // only the standalone master keeps rounded corners
|
||||||
|
const font = opentype.parse(readFileSync(join(DIR, 'Spectral-Bold.ttf')).buffer);
|
||||||
|
|
||||||
|
// «Э» outline scaled so its ink bounding box is MARK.lh of the side, box centred on MARK.lc.
|
||||||
|
function letterSvg() {
|
||||||
|
const g = font.charToGlyph('Э');
|
||||||
|
const h0 = (() => { const b = g.getPath(0, 0, 1000).getBoundingBox(); return b.y2 - b.y1; })();
|
||||||
|
const scale = (MARK.lh * S) / h0;
|
||||||
|
const p = g.getPath(0, 0, 1000 * scale);
|
||||||
|
const bb = p.getBoundingBox();
|
||||||
|
const w = bb.x2 - bb.x1, h = bb.y2 - bb.y1;
|
||||||
|
const tx = MARK.lc[0] * S - (bb.x1 + w / 2);
|
||||||
|
const ty = MARK.lc[1] * S - (bb.y1 + h / 2);
|
||||||
|
return { svg: `<path transform="translate(${tx.toFixed(2)} ${ty.toFixed(2)})" d="${p.toPathData(2)}" fill="${inkColour}"/>`,
|
||||||
|
box: { x: bb.x1 + tx, y: bb.y1 + ty, w, h } };
|
||||||
|
}
|
||||||
|
|
||||||
|
// Teardrop-spoked asterisk ✻: each spoke tapers to a point at the centre and ends
|
||||||
|
// in a round bulb; a central disc equal to the bulb fuses them.
|
||||||
|
function starPath() {
|
||||||
|
const cx = MARK.sc[0] * S, cy = MARK.sc[1] * S, Rout = MARK.sd * S / 2;
|
||||||
|
const rb = Rout * STAR_BULB, R = Rout - rb;
|
||||||
|
const L = Math.sqrt(R * R - rb * rb), theta = Math.asin(rb / R);
|
||||||
|
const rot = (v, t) => [v[0] * Math.cos(t) - v[1] * Math.sin(t), v[0] * Math.sin(t) + v[1] * Math.cos(t)];
|
||||||
|
let d = '';
|
||||||
|
for (let k = 0; k < STAR_SPOKES; k++) {
|
||||||
|
const a = -Math.PI / 2 + k * 2 * Math.PI / STAR_SPOKES;
|
||||||
|
const u = [Math.cos(a), Math.sin(a)];
|
||||||
|
const d1 = rot(u, theta), d2 = rot(u, -theta);
|
||||||
|
const T1 = [cx + L * d1[0], cy + L * d1[1]], T2 = [cx + L * d2[0], cy + L * d2[1]];
|
||||||
|
d += `M${cx.toFixed(2)} ${cy.toFixed(2)} L${T1[0].toFixed(2)} ${T1[1].toFixed(2)} A${rb.toFixed(2)} ${rb.toFixed(2)} 0 1 0 ${T2[0].toFixed(2)} ${T2[1].toFixed(2)} Z `;
|
||||||
|
}
|
||||||
|
d += `M${cx} ${cy} m${-rb} 0 a${rb} ${rb} 0 1 0 ${2 * rb} 0 a${rb} ${rb} 0 1 0 ${-2 * rb} 0 Z`;
|
||||||
|
return `<path d="${d}" fill="${inkColour}"/>`;
|
||||||
|
}
|
||||||
|
|
||||||
|
function grain() {
|
||||||
|
const colW = S / GRAIN_COLS, sw = GRAIN_W * S, shift = GRAIN_SHIFT * S;
|
||||||
|
const yTop = -0.2 * S, yBot = 1.2 * S, cy = S / 2;
|
||||||
|
let s = '';
|
||||||
|
for (let i = 1; i <= GRAIN_COLS; i++) {
|
||||||
|
const xr = i * colW - shift, x = xr - sw, cx = xr - sw / 2;
|
||||||
|
s += `<rect x="${x.toFixed(2)}" y="${yTop.toFixed(2)}" width="${sw.toFixed(2)}" height="${(yBot - yTop).toFixed(2)}" fill="${P.grain}" transform="rotate(${GRAIN_TILT} ${cx.toFixed(2)} ${cy.toFixed(2)})"/>`;
|
||||||
|
}
|
||||||
|
return `<g clip-path="url(#tile)">${s}</g>`;
|
||||||
|
}
|
||||||
|
|
||||||
|
let body = '';
|
||||||
|
const L = letterSvg();
|
||||||
|
if (drawBg) { // tile + grain + light/shadow (the background)
|
||||||
|
body += `<rect width="${S}" height="${S}" rx="${RX}" fill="${P.wood}"/>`
|
||||||
|
+ `<defs><clipPath id="tile"><rect width="${S}" height="${S}" rx="${RX}"/></clipPath>`
|
||||||
|
+ `<linearGradient id="sheen" gradientUnits="userSpaceOnUse" x1="0" y1="${S}" x2="${S}" y2="0"><stop offset="0" stop-color="#fff" stop-opacity="0"/><stop offset="1" stop-color="#fff" stop-opacity="${SHEEN}"/></linearGradient>`
|
||||||
|
+ `<linearGradient id="shade" gradientUnits="userSpaceOnUse" x1="0" y1="${S}" x2="${S}" y2="0"><stop offset="0" stop-color="#000" stop-opacity="${SHADE}"/><stop offset="1" stop-color="#000" stop-opacity="0"/></linearGradient>`
|
||||||
|
+ `</defs>`
|
||||||
|
+ grain()
|
||||||
|
+ `<rect width="${S}" height="${S}" fill="url(#sheen)" clip-path="url(#tile)"/>`
|
||||||
|
+ `<rect width="${S}" height="${S}" fill="url(#shade)" clip-path="url(#tile)"/>`;
|
||||||
|
}
|
||||||
|
if (drawMark) body += L.svg + starPath(); // the mark
|
||||||
|
if (has('scheme')) body += scheme(L);
|
||||||
|
|
||||||
|
process.stdout.write(`<svg xmlns="http://www.w3.org/2000/svg" width="${S}" height="${S}" viewBox="0 0 ${S} ${S}">${body}</svg>\n`);
|
||||||
|
|
||||||
|
// Percent-based construction overlay for the brand book (full master only).
|
||||||
|
function scheme(L) {
|
||||||
|
const pc = n => (100 * n / S).toFixed(1) + '%';
|
||||||
|
const GC = '#0f172a', BX = '#d81b60', AC = '#0d9488';
|
||||||
|
const halo = 'paint-order="stroke" stroke="#fff" stroke-width="4"';
|
||||||
|
const cx = MARK.sc[0] * S, cy = MARK.sc[1] * S, sd = MARK.sd * S;
|
||||||
|
let g = `<g font-family="'Helvetica Neue',Arial,sans-serif">`;
|
||||||
|
for (let f = 1; f <= 3; f++) {
|
||||||
|
const p = f * S / 4;
|
||||||
|
g += `<line x1="${p}" y1="0" x2="${p}" y2="${S}" stroke="${GC}" stroke-opacity="0.18" stroke-width="1.2"/>`;
|
||||||
|
g += `<line x1="0" y1="${p}" x2="${S}" y2="${p}" stroke="${GC}" stroke-opacity="0.18" stroke-width="1.2"/>`;
|
||||||
|
g += `<text x="${p + 4}" y="20" font-size="17" font-weight="700" fill="${GC}" ${halo}>${f * 25}%</text>`;
|
||||||
|
g += `<text x="4" y="${p - 4}" font-size="17" font-weight="700" fill="${GC}" ${halo}>${f * 25}%</text>`;
|
||||||
|
}
|
||||||
|
g += `<line x1="${0.14 * S}" y1="${0.86 * S}" x2="${0.86 * S}" y2="${0.14 * S}" stroke="${AC}" stroke-width="3" stroke-dasharray="3 8" stroke-linecap="round"/>`;
|
||||||
|
g += `<circle cx="${0.86 * S}" cy="${0.14 * S}" r="6" fill="${AC}"/><circle cx="${0.14 * S}" cy="${0.86 * S}" r="6" fill="${AC}"/>`;
|
||||||
|
g += `<text x="${0.86 * S - 6}" y="${0.14 * S - 12}" text-anchor="end" font-size="18" font-weight="700" fill="${AC}" ${halo}>light: white 0%→15%</text>`;
|
||||||
|
g += `<text x="${0.14 * S + 10}" y="${0.86 * S + 26}" font-size="18" font-weight="700" fill="${AC}" ${halo}>shadow: black 15%→0%</text>`;
|
||||||
|
const { x, y, w, h } = L.box;
|
||||||
|
g += `<rect x="${x.toFixed(1)}" y="${y.toFixed(1)}" width="${w.toFixed(1)}" height="${h.toFixed(1)}" fill="none" stroke="${BX}" stroke-width="2.5"/>`;
|
||||||
|
g += `<line x1="${MARK.lc[0] * S}" y1="${MARK.lc[1] * S - 16}" x2="${MARK.lc[0] * S}" y2="${MARK.lc[1] * S + 16}" stroke="${BX}" stroke-width="2.5"/><line x1="${MARK.lc[0] * S - 16}" y1="${MARK.lc[1] * S}" x2="${MARK.lc[0] * S + 16}" y2="${MARK.lc[1] * S}" stroke="${BX}" stroke-width="2.5"/>`;
|
||||||
|
g += `<text x="${x.toFixed(1)}" y="${(y - 10).toFixed(1)}" font-size="19" font-weight="700" fill="${BX}" ${halo}>«Э» Spectral Bold · box H ${pc(h)} · W ${pc(w)}</text>`;
|
||||||
|
g += `<text x="${x.toFixed(1)}" y="${(y + h + 24).toFixed(1)}" font-size="18" font-weight="700" fill="${BX}" ${halo}>box center ${pc(MARK.lc[0] * S)} / ${pc(MARK.lc[1] * S)}</text>`;
|
||||||
|
g += `<rect x="${cx - sd / 2}" y="${cy - sd / 2}" width="${sd}" height="${sd}" fill="none" stroke="${BX}" stroke-width="2.5"/>`;
|
||||||
|
g += `<line x1="${cx}" y1="${cy - 14}" x2="${cx}" y2="${cy + 14}" stroke="${BX}" stroke-width="2.5"/><line x1="${cx - 14}" y1="${cy}" x2="${cx + 14}" y2="${cy}" stroke="${BX}" stroke-width="2.5"/>`;
|
||||||
|
g += `<text x="${(cx + sd / 2).toFixed(1)}" y="${(cy - sd / 2 - 10).toFixed(1)}" text-anchor="end" font-size="18" font-weight="700" fill="${BX}" ${halo}>✻ Ø ${pc(sd)} · center ${pc(cx)} / ${pc(cy)}</text>`;
|
||||||
|
g += `<text x="${RX + 14}" y="${RX - 6}" font-size="17" font-weight="700" fill="${GC}" ${halo}>corner r = 17%</text>`;
|
||||||
|
g += `<path d="M4 ${RX} A ${RX} ${RX} 0 0 1 ${RX} 4" fill="none" stroke="${GC}" stroke-width="2" stroke-dasharray="5 5"/>`;
|
||||||
|
g += `<rect x="${0.03 * S}" y="${S - 96}" width="${0.94 * S}" height="74" rx="12" fill="#0f172a" fill-opacity="0.82"/>`;
|
||||||
|
g += `<text x="${0.055 * S}" y="${S - 64}" font-size="18" font-weight="600" fill="#fff">Wood grain: 6 equal columns; a vertical stripe on each column's right edge,</text>`;
|
||||||
|
g += `<text x="${0.055 * S}" y="${S - 40}" font-size="18" font-weight="600" fill="#fff">width 1/32 of side; the whole set shifted left 1/16; every stripe tilted 4°.</text>`;
|
||||||
|
return g + `</g>`;
|
||||||
|
}
|
||||||
@@ -0,0 +1,105 @@
|
|||||||
|
'use strict';
|
||||||
|
// Slice the whole shipped icon set from the brand master (build-icon.mjs). Renders
|
||||||
|
// with the ui package's Playwright chromium. See docs/ICONS.md for the target map.
|
||||||
|
//
|
||||||
|
// npm i opentype.js && node build-set.mjs
|
||||||
|
//
|
||||||
|
// Writes: ui/public/* (web), ui/assets/* (Capacitor layers), ./vk/* and ./store/*
|
||||||
|
// (manual-upload art). Wiring (manifest, html, Android res) is done separately.
|
||||||
|
import { execFileSync } from 'node:child_process';
|
||||||
|
import { readFileSync, writeFileSync, mkdirSync } from 'node:fs';
|
||||||
|
import { fileURLToPath } from 'node:url';
|
||||||
|
import { dirname, join } from 'node:path';
|
||||||
|
|
||||||
|
const DIR = dirname(fileURLToPath(import.meta.url));
|
||||||
|
const UI = join(DIR, '..', '..', '..', 'ui');
|
||||||
|
const PUB = join(UI, 'public'), ASSETS = join(UI, 'assets');
|
||||||
|
const VK = join(DIR, 'vk'), STORE = join(DIR, 'store'), TG = join(DIR, 'tg');
|
||||||
|
[VK, STORE, TG].forEach(d => mkdirSync(d, { recursive: true }));
|
||||||
|
|
||||||
|
// one SVG string for a variant+layer from the reference generator
|
||||||
|
const svgFor = (variant, layer) =>
|
||||||
|
execFileSync('node', [join(DIR, 'build-icon.mjs'), '--variant', variant, '--layer', layer], { encoding: 'utf8' });
|
||||||
|
|
||||||
|
const pw = await import(join(UI, 'node_modules', '@playwright', 'test', 'index.js'));
|
||||||
|
const { chromium } = pw.default ?? pw;
|
||||||
|
const browser = await chromium.launch();
|
||||||
|
const page = await browser.newPage();
|
||||||
|
|
||||||
|
async function pngFromSvg(svg, size, transparent) {
|
||||||
|
await page.setViewportSize({ width: size, height: size });
|
||||||
|
await page.setContent(`<!doctype html><meta charset=utf8><style>*{margin:0;padding:0}svg{display:block;width:${size}px;height:${size}px}</style>${svg}`, { waitUntil: 'networkidle' });
|
||||||
|
return page.locator('svg').screenshot({ omitBackground: !!transparent });
|
||||||
|
}
|
||||||
|
async function shootHtml(html, w, h) {
|
||||||
|
await page.setViewportSize({ width: w, height: h });
|
||||||
|
await page.setContent(html, { waitUntil: 'networkidle' });
|
||||||
|
return page.screenshot();
|
||||||
|
}
|
||||||
|
function icoFromPNG(png, sizePx) {
|
||||||
|
const h = Buffer.alloc(22);
|
||||||
|
h.writeUInt16LE(0, 0); h.writeUInt16LE(1, 2); h.writeUInt16LE(1, 4);
|
||||||
|
h.writeUInt8(sizePx, 6); h.writeUInt8(sizePx, 7);
|
||||||
|
h.writeUInt16LE(1, 10); h.writeUInt16LE(32, 12);
|
||||||
|
h.writeUInt32LE(png.length, 14); h.writeUInt32LE(22, 18);
|
||||||
|
return Buffer.concat([h, png]);
|
||||||
|
}
|
||||||
|
const write = (p, buf) => { writeFileSync(p, buf); console.log('wrote', p.replace(join(DIR, '..', '..', '..'), '.'), buf.length, 'b'); };
|
||||||
|
|
||||||
|
// ---- pre-render the SVG sources we need ----
|
||||||
|
const S = {
|
||||||
|
fullLight: svgFor('light', 'full'), fullDark: svgFor('dark', 'full'),
|
||||||
|
flatLight: svgFor('light', 'flat'), flatDark: svgFor('dark', 'flat'),
|
||||||
|
maskLight: svgFor('light', 'maskable'), maskDark: svgFor('dark', 'maskable'),
|
||||||
|
bgLight: svgFor('light', 'background'), fgLight: svgFor('light', 'foreground'),
|
||||||
|
monoLight: svgFor('light', 'monochrome'),
|
||||||
|
};
|
||||||
|
|
||||||
|
// ---- favicon.svg: light + dark in one file, toggled by prefers-color-scheme ----
|
||||||
|
const inner = svg => svg.replace(/^<svg[^>]*>/, '').replace(/<\/svg>\s*$/, '');
|
||||||
|
const faviconSvg =
|
||||||
|
`<svg xmlns="http://www.w3.org/2000/svg" width="1024" height="1024" viewBox="0 0 1024 1024">`
|
||||||
|
+ `<style>.d{display:none}@media(prefers-color-scheme:dark){.l{display:none}.d{display:inline}}</style>`
|
||||||
|
+ `<g class="l">${inner(S.fullLight)}</g><g class="d">${inner(S.fullDark)}</g></svg>\n`;
|
||||||
|
write(join(PUB, 'favicon.svg'), Buffer.from(faviconSvg));
|
||||||
|
|
||||||
|
// ---- web rasters ----
|
||||||
|
write(join(PUB, 'favicon.ico'), icoFromPNG(await pngFromSvg(S.flatLight, 32, false), 32));
|
||||||
|
write(join(PUB, 'apple-touch-icon.png'), await pngFromSvg(S.flatLight, 180, false));
|
||||||
|
write(join(PUB, 'icon-192.png'), await pngFromSvg(S.flatLight, 192, false));
|
||||||
|
write(join(PUB, 'icon-512.png'), await pngFromSvg(S.flatLight, 512, false));
|
||||||
|
write(join(PUB, 'icon-192-dark.png'), await pngFromSvg(S.flatDark, 192, false));
|
||||||
|
write(join(PUB, 'icon-512-dark.png'), await pngFromSvg(S.flatDark, 512, false));
|
||||||
|
write(join(PUB, 'icon-maskable-512.png'), await pngFromSvg(S.maskLight, 512, false));
|
||||||
|
write(join(PUB, 'icon-maskable-512-dark.png'), await pngFromSvg(S.maskDark, 512, false));
|
||||||
|
|
||||||
|
// ---- Capacitor layers (ui/assets) ----
|
||||||
|
write(join(ASSETS, 'icon.png'), await pngFromSvg(S.flatLight, 1024, false)); // legacy single
|
||||||
|
write(join(ASSETS, 'icon-foreground.png'), await pngFromSvg(S.fgLight, 1024, true)); // adaptive fg
|
||||||
|
write(join(ASSETS, 'icon-background.png'), await pngFromSvg(S.bgLight, 1024, false)); // adaptive bg
|
||||||
|
write(join(DIR, 'android-monochrome.png'), await pngFromSvg(S.monoLight, 1024, true)); // themed layer (wired manually)
|
||||||
|
|
||||||
|
// ---- VK (no rounding — flat light) ----
|
||||||
|
for (const px of [576, 278, 150, 32]) write(join(VK, `vk-${px}.png`), await pngFromSvg(S.flatLight, px, false));
|
||||||
|
|
||||||
|
// ---- Telegram bot: square, no rounding (TG crops the avatar to a circle) ----
|
||||||
|
write(join(TG, 'tg-512.png'), await pngFromSvg(S.flatLight, 512, false)); // bot avatar + Mini App icon
|
||||||
|
|
||||||
|
// ---- store art ----
|
||||||
|
write(join(STORE, 'rustore-512.png'), await pngFromSvg(S.flatLight, 512, false));
|
||||||
|
|
||||||
|
// ---- wordmark banner (board-green bg + master tile + «Эрудит» / Игра в слова) ----
|
||||||
|
const b64 = readFileSync(join(DIR, 'Spectral-Bold.ttf')).toString('base64');
|
||||||
|
const banner = (w, h, tile, t1, t2, gap, pad) => `<!doctype html><meta charset=utf8><style>
|
||||||
|
@font-face{font-family:Spectral;src:url(data:font/ttf;base64,${b64});font-weight:700}
|
||||||
|
*{margin:0;padding:0;box-sizing:border-box}
|
||||||
|
body{width:${w}px;height:${h}px;background:#2a3330;display:flex;align-items:center;gap:${gap}px;padding:0 ${pad}px;font-family:Spectral}
|
||||||
|
.tile{width:${tile}px;height:${tile}px;flex:none}.tile svg{width:${tile}px;height:${tile}px;display:block}
|
||||||
|
.wm{color:#e7ece8;text-align:center}.t1{font-weight:700;font-size:${t1}px;line-height:1.02;letter-spacing:-2px}
|
||||||
|
.t2{font-weight:700;font-size:${t2}px;line-height:1.1;color:#cdd6cf;margin-top:6px}
|
||||||
|
</style><div class=tile>${S.fullLight}</div><div class=wm><div class=t1>«Эрудит»</div><div class=t2>Игра в слова</div></div>`;
|
||||||
|
write(join(PUB, 'og-image.png'), await shootHtml(banner(1200, 630, 300, 150, 74, 64, 96), 1200, 630));
|
||||||
|
write(join(TG, 'tg-demo-640x360.png'), await shootHtml(banner(640, 360, 150, 72, 38, 30, 44), 640, 360));
|
||||||
|
|
||||||
|
await browser.close();
|
||||||
|
console.log('done');
|
||||||
|
After Width: | Height: | Size: 477 KiB |
|
After Width: | Height: | Size: 6.5 KiB |
|
After Width: | Height: | Size: 288 KiB |
@@ -0,0 +1 @@
|
|||||||
|
<svg xmlns="http://www.w3.org/2000/svg" width="1024" height="1024" viewBox="0 0 1024 1024"><rect width="1024" height="1024" rx="0" fill="#4a3316"/><defs><clipPath id="tile"><rect width="1024" height="1024" rx="0"/></clipPath><linearGradient id="sheen" gradientUnits="userSpaceOnUse" x1="0" y1="1024" x2="1024" y2="0"><stop offset="0" stop-color="#fff" stop-opacity="0"/><stop offset="1" stop-color="#fff" stop-opacity="0.15"/></linearGradient><linearGradient id="shade" gradientUnits="userSpaceOnUse" x1="0" y1="1024" x2="1024" y2="0"><stop offset="0" stop-color="#000" stop-opacity="0.15"/><stop offset="1" stop-color="#000" stop-opacity="0"/></linearGradient></defs><g clip-path="url(#tile)"><rect x="74.67" y="-204.80" width="32.00" height="1433.60" fill="#432e14" transform="rotate(4 90.67 512.00)"/><rect x="245.33" y="-204.80" width="32.00" height="1433.60" fill="#432e14" transform="rotate(4 261.33 512.00)"/><rect x="416.00" y="-204.80" width="32.00" height="1433.60" fill="#432e14" transform="rotate(4 432.00 512.00)"/><rect x="586.67" y="-204.80" width="32.00" height="1433.60" fill="#432e14" transform="rotate(4 602.67 512.00)"/><rect x="757.33" y="-204.80" width="32.00" height="1433.60" fill="#432e14" transform="rotate(4 773.33 512.00)"/><rect x="928.00" y="-204.80" width="32.00" height="1433.60" fill="#432e14" transform="rotate(4 944.00 512.00)"/></g><rect width="1024" height="1024" fill="url(#sheen)" clip-path="url(#tile)"/><rect width="1024" height="1024" fill="url(#shade)" clip-path="url(#tile)"/></svg>
|
||||||
|
After Width: | Height: | Size: 1.5 KiB |
|
After Width: | Height: | Size: 29 KiB |
@@ -0,0 +1 @@
|
|||||||
|
<svg xmlns="http://www.w3.org/2000/svg" width="1024" height="1024" viewBox="0 0 1024 1024"><path transform="translate(237.62 774.88)" d="M250.04-34.91Q326.35-34.91 368.57-91.33Q410.78-147.75 410.78-243.55Q410.78-249.23 410.78-254.91L254.10-254.91L208.64-185.91L195.65-185.91L195.65-368.57L208.64-368.57L254.10-299.56L407.53-299.56Q396.98-392.92 351.52-447.72Q306.06-502.52 241.11-502.52Q217.57-502.52 198.90-498.46Q180.22-494.40 162.36-482.22L83.62-354.77L69-354.77L77.93-505.76Q112.84-522.81 154.25-533.37Q195.65-543.92 259.78-543.92Q319.86-543.92 371.41-523.22Q422.96-502.52 460.71-465.58Q498.46-428.64 519.57-378.31Q540.67-327.98 540.67-267.90Q540.67-188.34 504.55-125.83Q468.42-63.32 405.10-27.60Q341.78 8.12 259.78 8.12Q200.52 8.12 150.59-3.25Q100.67-14.61 67.38-34.10L56.02-171.29L69.82-171.29L142.88-63.32Q165.61-50.33 191.59-42.62Q217.57-34.91 250.04-34.91" fill="#f2d9a0"/><path d="M781.00 744.04 L795.25 695.59 A14.85 14.85 0 1 0 766.76 695.59 Z M781.00 744.04 L830.08 732.15 A14.85 14.85 0 1 0 815.84 707.48 Z M781.00 744.04 L815.84 780.60 A14.85 14.85 0 1 0 830.08 755.93 Z M781.00 744.04 L766.76 792.49 A14.85 14.85 0 1 0 795.25 792.49 Z M781.00 744.04 L731.93 755.93 A14.85 14.85 0 1 0 746.17 780.60 Z M781.00 744.04 L746.17 707.48 A14.85 14.85 0 1 0 731.93 732.15 Z M781.0048 744.0384 m-14.845952 0 a14.845952 14.845952 0 1 0 29.691904 0 a14.845952 14.845952 0 1 0 -29.691904 0 Z" fill="#f2d9a0"/></svg>
|
||||||
|
After Width: | Height: | Size: 1.4 KiB |
|
After Width: | Height: | Size: 405 KiB |
@@ -0,0 +1 @@
|
|||||||
|
<svg xmlns="http://www.w3.org/2000/svg" width="1024" height="1024" viewBox="0 0 1024 1024"><rect width="1024" height="1024" rx="174.08" fill="#4a3316"/><defs><clipPath id="tile"><rect width="1024" height="1024" rx="174.08"/></clipPath><linearGradient id="sheen" gradientUnits="userSpaceOnUse" x1="0" y1="1024" x2="1024" y2="0"><stop offset="0" stop-color="#fff" stop-opacity="0"/><stop offset="1" stop-color="#fff" stop-opacity="0.15"/></linearGradient><linearGradient id="shade" gradientUnits="userSpaceOnUse" x1="0" y1="1024" x2="1024" y2="0"><stop offset="0" stop-color="#000" stop-opacity="0.15"/><stop offset="1" stop-color="#000" stop-opacity="0"/></linearGradient></defs><g clip-path="url(#tile)"><rect x="74.67" y="-204.80" width="32.00" height="1433.60" fill="#432e14" transform="rotate(4 90.67 512.00)"/><rect x="245.33" y="-204.80" width="32.00" height="1433.60" fill="#432e14" transform="rotate(4 261.33 512.00)"/><rect x="416.00" y="-204.80" width="32.00" height="1433.60" fill="#432e14" transform="rotate(4 432.00 512.00)"/><rect x="586.67" y="-204.80" width="32.00" height="1433.60" fill="#432e14" transform="rotate(4 602.67 512.00)"/><rect x="757.33" y="-204.80" width="32.00" height="1433.60" fill="#432e14" transform="rotate(4 773.33 512.00)"/><rect x="928.00" y="-204.80" width="32.00" height="1433.60" fill="#432e14" transform="rotate(4 944.00 512.00)"/></g><rect width="1024" height="1024" fill="url(#sheen)" clip-path="url(#tile)"/><rect width="1024" height="1024" fill="url(#shade)" clip-path="url(#tile)"/><path transform="translate(157.86 804.91)" d="M280.84-39.21Q366.55-39.21 413.96-102.58Q461.38-165.95 461.38-273.54Q461.38-279.93 461.38-286.31L285.40-286.31L234.34-208.80L219.75-208.80L219.75-413.96L234.34-413.96L285.40-336.46L457.73-336.46Q445.88-441.32 394.81-502.86Q343.75-564.41 270.81-564.41Q244.37-564.41 223.39-559.85Q202.42-555.29 182.36-541.62L93.92-398.46L77.50-398.46L87.53-568.06Q126.74-587.21 173.24-599.06Q219.75-610.91 291.78-610.91Q359.25-610.91 417.15-587.66Q475.05-564.41 517.45-522.92Q559.85-481.44 583.56-424.90Q607.27-368.37 607.27-300.90Q607.27-211.54 566.69-141.33Q526.12-71.12 454.99-31Q383.87 9.12 291.78 9.12Q225.22 9.12 169.14-3.65Q113.06-16.41 75.68-38.30L62.92-192.39L78.42-192.39L160.48-71.12Q186.01-56.53 215.19-47.87Q244.37-39.21 280.84-39.21" fill="#f2d9a0"/><path d="M807.01 807.01 L825.70 743.46 A19.48 19.48 0 1 0 788.33 743.46 Z M807.01 807.01 L871.40 791.42 A19.48 19.48 0 1 0 852.71 759.05 Z M807.01 807.01 L852.71 854.97 A19.48 19.48 0 1 0 871.40 822.61 Z M807.01 807.01 L788.33 870.57 A19.48 19.48 0 1 0 825.70 870.57 Z M807.01 807.01 L742.63 822.61 A19.48 19.48 0 1 0 761.32 854.97 Z M807.01 807.01 L761.32 759.05 A19.48 19.48 0 1 0 742.63 791.42 Z M807.0144 807.0144 m-19.475456 0 a19.475456 19.475456 0 1 0 38.950912 0 a19.475456 19.475456 0 1 0 -38.950912 0 Z" fill="#f2d9a0"/></svg>
|
||||||
|
After Width: | Height: | Size: 2.8 KiB |
|
After Width: | Height: | Size: 290 KiB |
@@ -0,0 +1 @@
|
|||||||
|
<svg xmlns="http://www.w3.org/2000/svg" width="1024" height="1024" viewBox="0 0 1024 1024"><rect width="1024" height="1024" rx="0" fill="#e6b053"/><defs><clipPath id="tile"><rect width="1024" height="1024" rx="0"/></clipPath><linearGradient id="sheen" gradientUnits="userSpaceOnUse" x1="0" y1="1024" x2="1024" y2="0"><stop offset="0" stop-color="#fff" stop-opacity="0"/><stop offset="1" stop-color="#fff" stop-opacity="0.15"/></linearGradient><linearGradient id="shade" gradientUnits="userSpaceOnUse" x1="0" y1="1024" x2="1024" y2="0"><stop offset="0" stop-color="#000" stop-opacity="0.15"/><stop offset="1" stop-color="#000" stop-opacity="0"/></linearGradient></defs><g clip-path="url(#tile)"><rect x="74.67" y="-204.80" width="32.00" height="1433.60" fill="#d8a54e" transform="rotate(4 90.67 512.00)"/><rect x="245.33" y="-204.80" width="32.00" height="1433.60" fill="#d8a54e" transform="rotate(4 261.33 512.00)"/><rect x="416.00" y="-204.80" width="32.00" height="1433.60" fill="#d8a54e" transform="rotate(4 432.00 512.00)"/><rect x="586.67" y="-204.80" width="32.00" height="1433.60" fill="#d8a54e" transform="rotate(4 602.67 512.00)"/><rect x="757.33" y="-204.80" width="32.00" height="1433.60" fill="#d8a54e" transform="rotate(4 773.33 512.00)"/><rect x="928.00" y="-204.80" width="32.00" height="1433.60" fill="#d8a54e" transform="rotate(4 944.00 512.00)"/></g><rect width="1024" height="1024" fill="url(#sheen)" clip-path="url(#tile)"/><rect width="1024" height="1024" fill="url(#shade)" clip-path="url(#tile)"/></svg>
|
||||||
|
After Width: | Height: | Size: 1.5 KiB |
|
After Width: | Height: | Size: 30 KiB |
@@ -0,0 +1 @@
|
|||||||
|
<svg xmlns="http://www.w3.org/2000/svg" width="1024" height="1024" viewBox="0 0 1024 1024"><path transform="translate(237.62 774.88)" d="M250.04-34.91Q326.35-34.91 368.57-91.33Q410.78-147.75 410.78-243.55Q410.78-249.23 410.78-254.91L254.10-254.91L208.64-185.91L195.65-185.91L195.65-368.57L208.64-368.57L254.10-299.56L407.53-299.56Q396.98-392.92 351.52-447.72Q306.06-502.52 241.11-502.52Q217.57-502.52 198.90-498.46Q180.22-494.40 162.36-482.22L83.62-354.77L69-354.77L77.93-505.76Q112.84-522.81 154.25-533.37Q195.65-543.92 259.78-543.92Q319.86-543.92 371.41-523.22Q422.96-502.52 460.71-465.58Q498.46-428.64 519.57-378.31Q540.67-327.98 540.67-267.90Q540.67-188.34 504.55-125.83Q468.42-63.32 405.10-27.60Q341.78 8.12 259.78 8.12Q200.52 8.12 150.59-3.25Q100.67-14.61 67.38-34.10L56.02-171.29L69.82-171.29L142.88-63.32Q165.61-50.33 191.59-42.62Q217.57-34.91 250.04-34.91" fill="#5a3a12"/><path d="M781.00 744.04 L795.25 695.59 A14.85 14.85 0 1 0 766.76 695.59 Z M781.00 744.04 L830.08 732.15 A14.85 14.85 0 1 0 815.84 707.48 Z M781.00 744.04 L815.84 780.60 A14.85 14.85 0 1 0 830.08 755.93 Z M781.00 744.04 L766.76 792.49 A14.85 14.85 0 1 0 795.25 792.49 Z M781.00 744.04 L731.93 755.93 A14.85 14.85 0 1 0 746.17 780.60 Z M781.00 744.04 L746.17 707.48 A14.85 14.85 0 1 0 731.93 732.15 Z M781.0048 744.0384 m-14.845952 0 a14.845952 14.845952 0 1 0 29.691904 0 a14.845952 14.845952 0 1 0 -29.691904 0 Z" fill="#5a3a12"/></svg>
|
||||||
|
After Width: | Height: | Size: 1.4 KiB |
|
After Width: | Height: | Size: 411 KiB |
@@ -0,0 +1 @@
|
|||||||
|
<svg xmlns="http://www.w3.org/2000/svg" width="1024" height="1024" viewBox="0 0 1024 1024"><rect width="1024" height="1024" rx="174.08" fill="#e6b053"/><defs><clipPath id="tile"><rect width="1024" height="1024" rx="174.08"/></clipPath><linearGradient id="sheen" gradientUnits="userSpaceOnUse" x1="0" y1="1024" x2="1024" y2="0"><stop offset="0" stop-color="#fff" stop-opacity="0"/><stop offset="1" stop-color="#fff" stop-opacity="0.15"/></linearGradient><linearGradient id="shade" gradientUnits="userSpaceOnUse" x1="0" y1="1024" x2="1024" y2="0"><stop offset="0" stop-color="#000" stop-opacity="0.15"/><stop offset="1" stop-color="#000" stop-opacity="0"/></linearGradient></defs><g clip-path="url(#tile)"><rect x="74.67" y="-204.80" width="32.00" height="1433.60" fill="#d8a54e" transform="rotate(4 90.67 512.00)"/><rect x="245.33" y="-204.80" width="32.00" height="1433.60" fill="#d8a54e" transform="rotate(4 261.33 512.00)"/><rect x="416.00" y="-204.80" width="32.00" height="1433.60" fill="#d8a54e" transform="rotate(4 432.00 512.00)"/><rect x="586.67" y="-204.80" width="32.00" height="1433.60" fill="#d8a54e" transform="rotate(4 602.67 512.00)"/><rect x="757.33" y="-204.80" width="32.00" height="1433.60" fill="#d8a54e" transform="rotate(4 773.33 512.00)"/><rect x="928.00" y="-204.80" width="32.00" height="1433.60" fill="#d8a54e" transform="rotate(4 944.00 512.00)"/></g><rect width="1024" height="1024" fill="url(#sheen)" clip-path="url(#tile)"/><rect width="1024" height="1024" fill="url(#shade)" clip-path="url(#tile)"/><path transform="translate(157.86 804.91)" d="M280.84-39.21Q366.55-39.21 413.96-102.58Q461.38-165.95 461.38-273.54Q461.38-279.93 461.38-286.31L285.40-286.31L234.34-208.80L219.75-208.80L219.75-413.96L234.34-413.96L285.40-336.46L457.73-336.46Q445.88-441.32 394.81-502.86Q343.75-564.41 270.81-564.41Q244.37-564.41 223.39-559.85Q202.42-555.29 182.36-541.62L93.92-398.46L77.50-398.46L87.53-568.06Q126.74-587.21 173.24-599.06Q219.75-610.91 291.78-610.91Q359.25-610.91 417.15-587.66Q475.05-564.41 517.45-522.92Q559.85-481.44 583.56-424.90Q607.27-368.37 607.27-300.90Q607.27-211.54 566.69-141.33Q526.12-71.12 454.99-31Q383.87 9.12 291.78 9.12Q225.22 9.12 169.14-3.65Q113.06-16.41 75.68-38.30L62.92-192.39L78.42-192.39L160.48-71.12Q186.01-56.53 215.19-47.87Q244.37-39.21 280.84-39.21" fill="#5a3a12"/><path d="M807.01 807.01 L825.70 743.46 A19.48 19.48 0 1 0 788.33 743.46 Z M807.01 807.01 L871.40 791.42 A19.48 19.48 0 1 0 852.71 759.05 Z M807.01 807.01 L852.71 854.97 A19.48 19.48 0 1 0 871.40 822.61 Z M807.01 807.01 L788.33 870.57 A19.48 19.48 0 1 0 825.70 870.57 Z M807.01 807.01 L742.63 822.61 A19.48 19.48 0 1 0 761.32 854.97 Z M807.01 807.01 L761.32 759.05 A19.48 19.48 0 1 0 742.63 791.42 Z M807.0144 807.0144 m-19.475456 0 a19.475456 19.475456 0 1 0 38.950912 0 a19.475456 19.475456 0 1 0 -38.950912 0 Z" fill="#5a3a12"/></svg>
|
||||||
|
After Width: | Height: | Size: 2.8 KiB |
@@ -0,0 +1,12 @@
|
|||||||
|
{
|
||||||
|
"name": "erudit-icon-brand",
|
||||||
|
"private": true,
|
||||||
|
"type": "module",
|
||||||
|
"description": "Reference generator for the Erudit app-icon set (see docs/ICON_BRANDBOOK.md).",
|
||||||
|
"scripts": {
|
||||||
|
"build": "node build-set.mjs && node build-android-res.mjs"
|
||||||
|
},
|
||||||
|
"dependencies": {
|
||||||
|
"opentype.js": "^1.3.4"
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,21 @@
|
|||||||
|
'use strict';
|
||||||
|
// Rasterise an icon SVG to PNG at a given size, using the `ui` package's cached
|
||||||
|
// Playwright chromium (no extra install). Usage:
|
||||||
|
// node render-png.mjs <in.svg> <out.png> [size=1024]
|
||||||
|
import { readFileSync, writeFileSync } from 'node:fs';
|
||||||
|
import { fileURLToPath } from 'node:url';
|
||||||
|
import { dirname, join } from 'node:path';
|
||||||
|
|
||||||
|
const DIR = dirname(fileURLToPath(import.meta.url));
|
||||||
|
const pw = await import(join(DIR, '..', '..', '..', 'ui', 'node_modules', '@playwright', 'test', 'index.js'));
|
||||||
|
const { chromium } = pw.default ?? pw;
|
||||||
|
|
||||||
|
const [, , svgPath, pngPath, size] = process.argv;
|
||||||
|
const S = Number(size) || 1024;
|
||||||
|
const svg = readFileSync(svgPath, 'utf8');
|
||||||
|
const b = await chromium.launch();
|
||||||
|
const pg = await b.newPage({ viewport: { width: S, height: S }, deviceScaleFactor: 1 });
|
||||||
|
await pg.setContent(`<!doctype html><meta charset=utf8><style>*{margin:0;padding:0}</style>${svg}`, { waitUntil: 'networkidle' });
|
||||||
|
writeFileSync(pngPath, await pg.locator('svg').screenshot({ omitBackground: true }));
|
||||||
|
await b.close();
|
||||||
|
console.log('wrote', pngPath, S + 'px');
|
||||||
|
After Width: | Height: | Size: 103 KiB |
|
After Width: | Height: | Size: 103 KiB |
|
After Width: | Height: | Size: 32 KiB |
|
After Width: | Height: | Size: 15 KiB |
|
After Width: | Height: | Size: 38 KiB |
|
After Width: | Height: | Size: 1.6 KiB |
|
After Width: | Height: | Size: 122 KiB |
@@ -1,78 +0,0 @@
|
|||||||
'use strict';
|
|
||||||
// Extract the glyph outlines the site icons and the og-image wordmark need from a
|
|
||||||
// grotesque font (LiberationSans = Arial-metric, matching the game's system-ui/Arial
|
|
||||||
// stack) and emit cubic-bezier contours per character, baseline at y=0, y-down,
|
|
||||||
// plus the advance width so generate.js can lay out words without the font.
|
|
||||||
// Same outline conversion as ../../vk/build/extract.js, generalised to a char set.
|
|
||||||
const opentype = require('opentype.js');
|
|
||||||
const fs = require('fs');
|
|
||||||
|
|
||||||
const FONT = process.argv[2] || '/usr/share/fonts/truetype/liberation/LiberationSans-Regular.ttf';
|
|
||||||
const b = fs.readFileSync(FONT);
|
|
||||||
const font = opentype.parse(b.buffer.slice(b.byteOffset, b.byteOffset + b.byteLength));
|
|
||||||
const FS = 1000; // em scale
|
|
||||||
|
|
||||||
// The tile glyphs («Э», «8») + every character of the og-image wordmark lines
|
|
||||||
// («Эрудит», «Скрэббл — игра в слова»). The space carries only an advance.
|
|
||||||
const CHARS = [...new Set('Э8рудитСкэббл—игра в слова')];
|
|
||||||
|
|
||||||
function glyphData(ch) {
|
|
||||||
const g = font.charToGlyph(ch);
|
|
||||||
const p = g.getPath(0, 0, FS); // baseline at y=0, y-down
|
|
||||||
const contours = [];
|
|
||||||
let cur = null, prev = null;
|
|
||||||
for (const c of p.commands) {
|
|
||||||
if (c.type === 'M') {
|
|
||||||
if (cur) contours.push(cur);
|
|
||||||
cur = [{ v: [c.x, c.y], i: [c.x, c.y], o: [c.x, c.y] }];
|
|
||||||
prev = { x: c.x, y: c.y };
|
|
||||||
} else if (c.type === 'L') {
|
|
||||||
cur.push({ v: [c.x, c.y], i: [c.x, c.y], o: [c.x, c.y] });
|
|
||||||
prev = { x: c.x, y: c.y };
|
|
||||||
} else if (c.type === 'C') {
|
|
||||||
cur[cur.length - 1].o = [c.x1, c.y1];
|
|
||||||
cur.push({ v: [c.x, c.y], i: [c.x2, c.y2], o: [c.x, c.y] });
|
|
||||||
prev = { x: c.x, y: c.y };
|
|
||||||
} else if (c.type === 'Q') {
|
|
||||||
const c1 = [prev.x + 2 / 3 * (c.x1 - prev.x), prev.y + 2 / 3 * (c.y1 - prev.y)];
|
|
||||||
const c2 = [c.x + 2 / 3 * (c.x1 - c.x), c.y + 2 / 3 * (c.y1 - c.y)];
|
|
||||||
cur[cur.length - 1].o = c1;
|
|
||||||
cur.push({ v: [c.x, c.y], i: c2, o: [c.x, c.y] });
|
|
||||||
prev = { x: c.x, y: c.y };
|
|
||||||
} else if (c.type === 'Z') {
|
|
||||||
if (cur && cur.length > 1) {
|
|
||||||
const last = cur[cur.length - 1], first = cur[0];
|
|
||||||
if (Math.hypot(last.v[0] - first.v[0], last.v[1] - first.v[1]) < 1e-3) {
|
|
||||||
first.i = last.i; // fold the duplicate closing point into the first
|
|
||||||
cur.pop();
|
|
||||||
}
|
|
||||||
}
|
|
||||||
if (cur) { contours.push(cur); cur = null; }
|
|
||||||
}
|
|
||||||
}
|
|
||||||
if (cur) contours.push(cur);
|
|
||||||
|
|
||||||
let minx = Infinity, miny = Infinity, maxx = -Infinity, maxy = -Infinity;
|
|
||||||
const out = contours.map(ct => {
|
|
||||||
const v = [], i = [], o = [];
|
|
||||||
ct.forEach(pt => {
|
|
||||||
v.push(pt.v);
|
|
||||||
i.push([pt.i[0] - pt.v[0], pt.i[1] - pt.v[1]]);
|
|
||||||
o.push([pt.o[0] - pt.v[0], pt.o[1] - pt.v[1]]);
|
|
||||||
minx = Math.min(minx, pt.v[0]); maxx = Math.max(maxx, pt.v[0]);
|
|
||||||
miny = Math.min(miny, pt.v[1]); maxy = Math.max(maxy, pt.v[1]);
|
|
||||||
});
|
|
||||||
return { i, o, v, c: true };
|
|
||||||
});
|
|
||||||
const bbox = out.length
|
|
||||||
? { x: minx, y: miny, w: maxx - minx, h: maxy - miny }
|
|
||||||
: { x: 0, y: 0, w: 0, h: 0 }; // the space has no outline
|
|
||||||
return { adv: g.advanceWidth * (FS / font.unitsPerEm), bbox, contours: out };
|
|
||||||
}
|
|
||||||
|
|
||||||
const glyphs = {};
|
|
||||||
for (const ch of CHARS) glyphs[ch] = glyphData(ch);
|
|
||||||
|
|
||||||
const out = __dirname + '/glyphs.json';
|
|
||||||
fs.writeFileSync(out, JSON.stringify({ em: FS, glyphs }));
|
|
||||||
console.log('wrote', out, fs.statSync(out).size, 'bytes;', CHARS.length, 'glyphs:', CHARS.join(''));
|
|
||||||
@@ -1,128 +0,0 @@
|
|||||||
'use strict';
|
|
||||||
// Site icons + the Open Graph card for the public landing, drawn from the same
|
|
||||||
// design as ../../vk (the wooden Erudit tile: face «Э», score «8») and the app's
|
|
||||||
// board palette (ui/src/app.css). Everything is composed as SVG from the committed
|
|
||||||
// glyph outlines (build/extract.js -> glyphs.json), so no font is needed at build
|
|
||||||
// time; the PNG/ICO rasters are screenshots taken with the ui package's Playwright
|
|
||||||
// chromium (@playwright/test re-exports the browser API). Outputs go straight to
|
|
||||||
// ui/public/:
|
|
||||||
// favicon.svg 96 viewBox, transparent, tile + «Э» (the score is illegible small)
|
|
||||||
// favicon.ico 32x32 PNG-in-ICO render of the same
|
|
||||||
// apple-touch-icon.png 180x180 opaque full-bleed tile (iOS masks its own corners)
|
|
||||||
// og-image.png 1200x630 card: tile + wordmark on the board green
|
|
||||||
const fs = require('fs');
|
|
||||||
const path = require('path');
|
|
||||||
|
|
||||||
const G = JSON.parse(fs.readFileSync(path.join(__dirname, 'glyphs.json'), 'utf8'));
|
|
||||||
const UI = path.resolve(__dirname, '../../../ui');
|
|
||||||
const OUT = path.join(UI, 'public');
|
|
||||||
|
|
||||||
// ---- palette (vk loader tile + app.css board tokens) ------------------------
|
|
||||||
const FACE = '#D9B978', BORDER = '#B49559', GLYPH = '#1A1A1A';
|
|
||||||
const BOARD_DARK = '#2a3330', TEXT = '#e7ece8', TEXT_MUTED = '#cdd6cf';
|
|
||||||
|
|
||||||
// ---- glyph outlines -> SVG path data ----------------------------------------
|
|
||||||
const r2 = n => Math.round(n * 100) / 100;
|
|
||||||
// pathD renders one glyph's contours scaled by sc and translated by (tx, ty).
|
|
||||||
function pathD(g, sc, tx, ty) {
|
|
||||||
const pt = (v, d) => `${r2(v[0] * sc + tx + d[0] * sc)} ${r2(v[1] * sc + ty + d[1] * sc)}`;
|
|
||||||
const Z = [0, 0];
|
|
||||||
return g.contours.map(ct => {
|
|
||||||
const n = ct.v.length;
|
|
||||||
let d = `M${pt(ct.v[0], Z)}`;
|
|
||||||
for (let k = 1; k <= n; k++) {
|
|
||||||
const a = k - 1, b = k % n;
|
|
||||||
d += `C${pt(ct.v[a], ct.o[a])} ${pt(ct.v[b], ct.i[b])} ${pt(ct.v[b], Z)}`;
|
|
||||||
}
|
|
||||||
return d + 'Z';
|
|
||||||
}).join('');
|
|
||||||
}
|
|
||||||
// glyphAt centres a glyph's bbox at (cx, cy) with the given pixel cap height, the
|
|
||||||
// same placement rule as the vk loader's glyph(). stroke fattens it slightly.
|
|
||||||
function glyphAt(ch, capPx, cx, cy, stroke, colour) {
|
|
||||||
const g = G.glyphs[ch], sc = capPx / g.bbox.h;
|
|
||||||
const d = pathD(g, sc, cx - (g.bbox.x + g.bbox.w / 2) * sc, cy - (g.bbox.y + g.bbox.h / 2) * sc);
|
|
||||||
return `<path d="${d}" fill="${colour}" stroke="${colour}" stroke-width="${r2(stroke)}"/>`;
|
|
||||||
}
|
|
||||||
// textLine lays out a string on a baseline from the per-glyph advances; capPx sets
|
|
||||||
// the capital height (measured on «Э»). Returns the combined path + the width.
|
|
||||||
function textLine(str, capPx, x, y, colour) {
|
|
||||||
const sc = capPx / G.glyphs['Э'].bbox.h;
|
|
||||||
let d = '', w = 0;
|
|
||||||
for (const ch of str) {
|
|
||||||
const g = G.glyphs[ch];
|
|
||||||
if (g.contours.length) d += pathD(g, sc, x + w, y);
|
|
||||||
w += g.adv * sc;
|
|
||||||
}
|
|
||||||
return { svg: `<path d="${d}" fill="${colour}"/>`, width: w };
|
|
||||||
}
|
|
||||||
// tile draws the rounded wooden tile centred at (cx, cy): size px wide/high, with
|
|
||||||
// the vk loader's corner (6/52) and rim proportions, «Э» and optionally the «8».
|
|
||||||
function tile(cx, cy, size, withScore) {
|
|
||||||
const h = size / 2, rx = size * (6 / 52), rim = size * (1.8 / 52);
|
|
||||||
let s = `<rect x="${r2(cx - h + rim / 2)}" y="${r2(cy - h + rim / 2)}" width="${r2(size - rim)}" height="${r2(size - rim)}" rx="${r2(rx)}" fill="${FACE}" stroke="${BORDER}" stroke-width="${r2(rim)}"/>`;
|
|
||||||
s += glyphAt('Э', size * (32 / 52), cx, cy - size * (1 / 52), size * (1 / 52), GLYPH);
|
|
||||||
if (withScore) s += glyphAt('8', size * (8.5 / 52), cx + size * (19.5 / 52), cy + size * (18.5 / 52), size * (0.5 / 52), GLYPH);
|
|
||||||
return s;
|
|
||||||
}
|
|
||||||
const svg = (w, h, body) => `<svg xmlns="http://www.w3.org/2000/svg" width="${w}" height="${h}" viewBox="0 0 ${w} ${h}">${body}</svg>`;
|
|
||||||
|
|
||||||
// ---- favicon.svg (the committed vector master) -------------------------------
|
|
||||||
const favicon = svg(96, 96, tile(48, 48, 88, false)) + '\n';
|
|
||||||
|
|
||||||
// ---- apple-touch-icon: opaque full bleed, iOS applies its own corner mask ----
|
|
||||||
const appleTouch = svg(180, 180,
|
|
||||||
`<rect width="180" height="180" fill="${FACE}"/>` +
|
|
||||||
`<rect x="8" y="8" width="164" height="164" rx="18" fill="none" stroke="${BORDER}" stroke-width="4"/>` +
|
|
||||||
glyphAt('Э', 100, 90, 88, 3, GLYPH) +
|
|
||||||
glyphAt('8', 26, 146, 142, 1.5, GLYPH));
|
|
||||||
|
|
||||||
// ---- og-image: tile + wordmark, centred as one group on the board green ------
|
|
||||||
function ogImage() {
|
|
||||||
const W = 1200, H = 630, tileSize = 340, gap = 84;
|
|
||||||
const l1 = textLine('Эрудит', 112, 0, 0, TEXT);
|
|
||||||
const l2 = textLine('Скрэббл — игра в слова', 44, 0, 0, TEXT_MUTED);
|
|
||||||
const textW = Math.max(l1.width, l2.width);
|
|
||||||
const left = (W - (tileSize + gap + textW)) / 2;
|
|
||||||
const tx = left + tileSize + gap;
|
|
||||||
// Two baselines around the vertical centre; the tile centre sits between them.
|
|
||||||
const b1 = 295, b2 = 408;
|
|
||||||
const body =
|
|
||||||
`<rect width="${W}" height="${H}" fill="${BOARD_DARK}"/>` +
|
|
||||||
tile(left + tileSize / 2, H / 2, tileSize, true) +
|
|
||||||
textLine('Эрудит', 112, tx, b1, TEXT).svg +
|
|
||||||
textLine('Скрэббл — игра в слова', 44, tx, b2, TEXT_MUTED).svg;
|
|
||||||
console.log(`og-image: text ${Math.round(textW)}px wide, group left ${Math.round(left)}px`);
|
|
||||||
return svg(W, H, body);
|
|
||||||
}
|
|
||||||
|
|
||||||
// ---- rasterisation (Playwright chromium from ui/node_modules) ----------------
|
|
||||||
async function shoot(page, markup, w, h, transparent) {
|
|
||||||
await page.setViewportSize({ width: w, height: h });
|
|
||||||
await page.setContent(`<body style="margin:0">${markup}</body>`);
|
|
||||||
return page.screenshot({ omitBackground: transparent });
|
|
||||||
}
|
|
||||||
// icoFromPNG wraps one PNG as a single-entry .ico (ICONDIR + ICONDIRENTRY + PNG).
|
|
||||||
function icoFromPNG(png, sizePx) {
|
|
||||||
const h = Buffer.alloc(22);
|
|
||||||
h.writeUInt16LE(0, 0); h.writeUInt16LE(1, 2); h.writeUInt16LE(1, 4); // icon, 1 image
|
|
||||||
h.writeUInt8(sizePx, 6); h.writeUInt8(sizePx, 7); // 32x32
|
|
||||||
h.writeUInt16LE(1, 10); h.writeUInt16LE(32, 12); // planes, 32bpp
|
|
||||||
h.writeUInt32LE(png.length, 14); h.writeUInt32LE(22, 18); // size, offset
|
|
||||||
return Buffer.concat([h, png]);
|
|
||||||
}
|
|
||||||
|
|
||||||
(async () => {
|
|
||||||
fs.writeFileSync(path.join(OUT, 'favicon.svg'), favicon);
|
|
||||||
const { chromium } = require(path.join(UI, 'node_modules', '@playwright/test'));
|
|
||||||
const browser = await chromium.launch();
|
|
||||||
const page = await browser.newPage();
|
|
||||||
const fav32 = await shoot(page, svg(32, 32, tile(16, 16, 29.33, false)), 32, 32, true);
|
|
||||||
fs.writeFileSync(path.join(OUT, 'favicon.ico'), icoFromPNG(fav32, 32));
|
|
||||||
fs.writeFileSync(path.join(OUT, 'apple-touch-icon.png'), await shoot(page, appleTouch, 180, 180, false));
|
|
||||||
fs.writeFileSync(path.join(OUT, 'og-image.png'), await shoot(page, ogImage(), 1200, 630, false));
|
|
||||||
await browser.close();
|
|
||||||
for (const f of ['favicon.svg', 'favicon.ico', 'apple-touch-icon.png', 'og-image.png']) {
|
|
||||||
console.log('wrote', path.join(OUT, f), fs.statSync(path.join(OUT, f)).size, 'bytes');
|
|
||||||
}
|
|
||||||
})();
|
|
||||||
@@ -33,11 +33,16 @@ real game seating the caller with an **empty opponent seat** (status `open`) or,
|
|||||||
another player already waits for the same variant and per-turn word rule, seats the
|
another player already waits for the same variant and per-turn word rule, seats the
|
||||||
caller into that open game and starts it — and
|
caller into that open game and starts it — and
|
||||||
friend-game invitations (invite → accept, starting a 2–4 player game once every
|
friend-game invitations (invite → accept, starting a 2–4 player game once every
|
||||||
invitee accepts). A **simultaneous-game cap** (`game.MaxActiveQuickGames` = 10) limits a
|
invitee accepts). **Per-tier, per-kind active-game caps** limit a player's simultaneous
|
||||||
player's active quick games — status `active`/`open`, excluding invitation-linked friend
|
unfinished games by kind — `vs_ai`, `random` (quick auto-match), `friends` — with separate
|
||||||
games (`game.Service.CountActiveQuickGames`); the server refuses `lobby/enqueue` and
|
guest and durable-account tiers held in the single-row `backend.config` table (a `-1` means
|
||||||
`invitations` creation with **409 `game_limit_reached`** at the cap (accepting an invitation
|
unlimited), read through an in-memory cache (`internal/gamelimits`) and tuned live in the admin
|
||||||
is exempt), and the `games.list` response carries an `at_game_limit` flag for the lobby.
|
(`/_gm/limits`, no redeploy). Each game is tagged with its `games.game_kind` on creation;
|
||||||
|
`game.Service.AtGameLimit` counts the account's `active`/`open` games of a kind against the
|
||||||
|
tier's cap. The server refuses `lobby/enqueue` (random/vs_ai) with **409 `game_limit_reached`**
|
||||||
|
at the cap, and the `invitations` (friends) path enforces the durable friends cap and refuses a
|
||||||
|
**guest** outright (guests cannot use friends); accepting an invitation is exempt. The
|
||||||
|
`games.list` response carries an `at_game_limit` flag (the random-kind cap) for the lobby.
|
||||||
`internal/social` owns the friend graph (request/accept),
|
`internal/social` owns the friend graph (request/accept),
|
||||||
per-user blocks, and per-game chat with nudges folded in as a message kind; chat
|
per-user blocks, and per-game chat with nudges folded in as a message kind; chat
|
||||||
messages are length-capped, content-filtered (no links/emails/phone numbers,
|
messages are length-capped, content-filtered (no links/emails/phone numbers,
|
||||||
@@ -146,7 +151,15 @@ funding segment, benefits per origin, the recorded refund risk, and the append-o
|
|||||||
(`/_gm/catalog`, `handlers_admin_catalog.go`) is the source of truth for products (D32): create /
|
(`/_gm/catalog`, `handlers_admin_catalog.go`) is the source of truth for products (D32): create /
|
||||||
edit / archive-unarchive (the `product.active` flag) products, their atoms and per-rail prices, and
|
edit / archive-unarchive (the `product.active` flag) products, their atoms and per-rail prices, and
|
||||||
hard-delete only a **never-transacted** product (an order/ledger reference forces archive-only,
|
hard-delete only a **never-transacted** product (an order/ledger reference forces archive-only,
|
||||||
backed by the FK); a `tournament`-bearing product is composable but not sellable yet. The shared wire
|
backed by the FK); a `tournament`-bearing product is composable but not sellable yet. The user card
|
||||||
|
also carries an admin **grant** panel: grant raw benefit atoms (hints / no-ads days / forever) or a
|
||||||
|
defined **value product** (a reward bundle, including an archived one), origin-picked; both write an
|
||||||
|
`admin_grant` ledger row via `payments.Grant` / `GrantProduct` and **refuse** a chips or `tournament`
|
||||||
|
atom (never grant currency; no tournament target yet). Each fund row in the panel carries a **Refund**
|
||||||
|
action (`payments.RefundOrderFull`): a full-order refund the operator records after refunding on the
|
||||||
|
rail — a `refund` ledger row + a floor-0 chip revoke, idempotent. A **ledger CSV export**
|
||||||
|
(`/_gm/ledger.csv`, `payments.LedgerExport`) dumps the whole append-only ledger for tax +
|
||||||
|
reconciliation. The shared wire
|
||||||
contracts live in the sibling [`../pkg`](../pkg) module.
|
contracts live in the sibling [`../pkg`](../pkg) module.
|
||||||
|
|
||||||
**Account linking & merge** (`/api/v1/user/link/*`). `internal/link`
|
**Account linking & merge** (`/api/v1/user/link/*`). `internal/link`
|
||||||
|
|||||||
@@ -28,6 +28,7 @@ import (
|
|||||||
"scrabble/backend/internal/engine"
|
"scrabble/backend/internal/engine"
|
||||||
"scrabble/backend/internal/feedback"
|
"scrabble/backend/internal/feedback"
|
||||||
"scrabble/backend/internal/game"
|
"scrabble/backend/internal/game"
|
||||||
|
"scrabble/backend/internal/gamelimits"
|
||||||
"scrabble/backend/internal/link"
|
"scrabble/backend/internal/link"
|
||||||
"scrabble/backend/internal/lobby"
|
"scrabble/backend/internal/lobby"
|
||||||
"scrabble/backend/internal/notify"
|
"scrabble/backend/internal/notify"
|
||||||
@@ -156,6 +157,17 @@ func run(ctx context.Context, cfg config.Config, logger *zap.Logger) error {
|
|||||||
logger.Info("active dictionary version", zap.String("version", games.ActiveVersion()))
|
logger.Info("active dictionary version", zap.String("version", games.ActiveVersion()))
|
||||||
games.SetNotifier(hub)
|
games.SetNotifier(hub)
|
||||||
games.SetMetrics(tel.MeterProvider().Meter("scrabble/backend/game"))
|
games.SetMetrics(tel.MeterProvider().Meter("scrabble/backend/game"))
|
||||||
|
|
||||||
|
// Active-game limit config: the per-tier, per-kind caps in backend.config, read once into an
|
||||||
|
// in-memory cache at boot and refreshed when the admin edits them. A boot-time load fails fast if
|
||||||
|
// the single config row is missing; the game domain reads the cache on every new-game gate.
|
||||||
|
gameLimits := gamelimits.NewService(gamelimits.NewStore(db))
|
||||||
|
if err := gameLimits.Load(ctx); err != nil {
|
||||||
|
return fmt.Errorf("load game-limit config: %w", err)
|
||||||
|
}
|
||||||
|
games.SetGameLimits(gameLimits)
|
||||||
|
logger.Info("game-limit config loaded")
|
||||||
|
|
||||||
go games.RunSweeper(ctx, cfg.Game.TimeoutSweepInterval)
|
go games.RunSweeper(ctx, cfg.Game.TimeoutSweepInterval)
|
||||||
logger.Info("game turn-timeout sweeper started",
|
logger.Info("game turn-timeout sweeper started",
|
||||||
zap.Duration("interval", cfg.Game.TimeoutSweepInterval))
|
zap.Duration("interval", cfg.Game.TimeoutSweepInterval))
|
||||||
@@ -271,6 +283,13 @@ func run(ctx context.Context, cfg config.Config, logger *zap.Logger) error {
|
|||||||
}
|
}
|
||||||
logger.Info("payments domain ready")
|
logger.Info("payments domain ready")
|
||||||
|
|
||||||
|
// Warm the public-offer price list cache so /offer/ serves the current catalog from the first
|
||||||
|
// request; it is reprojected lazily thereafter on any catalog edit. Non-fatal — a transient
|
||||||
|
// failure here only defers the projection to the first read.
|
||||||
|
if _, err := paymentsSvc.OfferPricing(ctx); err != nil {
|
||||||
|
logger.Warn("offer pricing warm failed; will project on first request", zap.Error(err))
|
||||||
|
}
|
||||||
|
|
||||||
// Wire the payments surface into the domains that consume it: the online-game hint wallet
|
// Wire the payments surface into the domains that consume it: the online-game hint wallet
|
||||||
// and the account-merge wallet fold. Done after the reachability check so a broken payments
|
// and the account-merge wallet fold. Done after the reachability check so a broken payments
|
||||||
// schema fails boot before anything depends on it.
|
// schema fails boot before anything depends on it.
|
||||||
@@ -305,6 +324,7 @@ func run(ctx context.Context, cfg config.Config, logger *zap.Logger) error {
|
|||||||
BanView: banView,
|
BanView: banView,
|
||||||
Ads: adsSvc,
|
Ads: adsSvc,
|
||||||
Payments: paymentsSvc,
|
Payments: paymentsSvc,
|
||||||
|
GameLimits: gameLimits,
|
||||||
Notifier: hub,
|
Notifier: hub,
|
||||||
ExportSignKey: cfg.ExportSignKey,
|
ExportSignKey: cfg.ExportSignKey,
|
||||||
Renderer: renderer,
|
Renderer: renderer,
|
||||||
|
|||||||
@@ -15,6 +15,7 @@
|
|||||||
<a href="/_gm/"{{if eq .ActiveNav "dashboard"}} class="active"{{end}}>Dashboard</a>
|
<a href="/_gm/"{{if eq .ActiveNav "dashboard"}} class="active"{{end}}>Dashboard</a>
|
||||||
<a href="/_gm/users"{{if eq .ActiveNav "users"}} class="active"{{end}}>Users</a>
|
<a href="/_gm/users"{{if eq .ActiveNav "users"}} class="active"{{end}}>Users</a>
|
||||||
<a href="/_gm/games"{{if eq .ActiveNav "games"}} class="active"{{end}}>Games</a>
|
<a href="/_gm/games"{{if eq .ActiveNav "games"}} class="active"{{end}}>Games</a>
|
||||||
|
<a href="/_gm/limits"{{if eq .ActiveNav "limits"}} class="active"{{end}}>Limits</a>
|
||||||
<a href="/_gm/complaints"{{if eq .ActiveNav "complaints"}} class="active"{{end}}>Complaints</a>
|
<a href="/_gm/complaints"{{if eq .ActiveNav "complaints"}} class="active"{{end}}>Complaints</a>
|
||||||
<a href="/_gm/feedback"{{if eq .ActiveNav "feedback"}} class="active"{{end}}>Feedback</a>
|
<a href="/_gm/feedback"{{if eq .ActiveNav "feedback"}} class="active"{{end}}>Feedback</a>
|
||||||
<a href="/_gm/messages"{{if eq .ActiveNav "messages"}} class="active"{{end}}>Messages</a>
|
<a href="/_gm/messages"{{if eq .ActiveNav "messages"}} class="active"{{end}}>Messages</a>
|
||||||
|
|||||||
@@ -21,7 +21,17 @@
|
|||||||
<div><button type="submit">Add</button></div>
|
<div><button type="submit">Add</button></div>
|
||||||
</form>
|
</form>
|
||||||
</section>
|
</section>
|
||||||
|
<section class="panel"><h2>Rewarded ads (watch for chips)</h2>
|
||||||
|
<p class="note">The chips a player earns per rewarded-video view (VK only), plus the per-day and per-hour caps that bound free chips — <strong>0 payout turns rewarded off</strong>. This is the shared reward config, not a product.</p>
|
||||||
|
<form class="form col" method="post" action="/_gm/catalog/reward">
|
||||||
|
<label>Chips per view <input type="number" name="reward_payout" min="0" value="{{.RewardPayout}}"></label>
|
||||||
|
<label>Daily cap <input type="number" name="reward_daily" min="0" value="{{.RewardDailyCap}}"></label>
|
||||||
|
<label>Hourly cap <input type="number" name="reward_hourly" min="0" value="{{.RewardHourlyCap}}"></label>
|
||||||
|
<div><button type="submit">Save</button></div>
|
||||||
|
</form>
|
||||||
|
</section>
|
||||||
<section class="panel"><h2>Products</h2>
|
<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">
|
<table class="list">
|
||||||
<thead><tr><th>Title</th><th>Status</th><th>Atoms</th><th>Prices</th><th></th></tr></thead>
|
<thead><tr><th>Title</th><th>Status</th><th>Atoms</th><th>Prices</th><th></th></tr></thead>
|
||||||
<tbody>
|
<tbody>
|
||||||
|
|||||||
@@ -8,6 +8,7 @@
|
|||||||
<li><b>Dictionary</b> {{.DictVersion}}</li>
|
<li><b>Dictionary</b> {{.DictVersion}}</li>
|
||||||
<li><b>Status</b> {{.Status}}{{if .EndReason}} ({{.EndReason}}){{end}}</li>
|
<li><b>Status</b> {{.Status}}{{if .EndReason}} ({{.EndReason}}){{end}}</li>
|
||||||
<li><b>AI game</b> {{if .VsAI}}🤖 yes{{else}}no{{end}}</li>
|
<li><b>AI game</b> {{if .VsAI}}🤖 yes{{else}}no{{end}}</li>
|
||||||
|
<li><b>Word rule</b> {{if .MultipleWordsPerTurn}}multiple words per turn{{else}}single word per turn{{end}}</li>
|
||||||
<li><b>Players</b> {{.Players}}</li>
|
<li><b>Players</b> {{.Players}}</li>
|
||||||
<li><b>To move</b> seat {{.ToMove}}</li>
|
<li><b>To move</b> seat {{.ToMove}}</li>
|
||||||
<li><b>Moves</b> {{.MoveCount}}</li>
|
<li><b>Moves</b> {{.MoveCount}}</li>
|
||||||
|
|||||||
@@ -8,11 +8,11 @@
|
|||||||
<a href="/_gm/games?status=finished"{{if eq .Status "finished"}} class="active"{{end}}>finished</a>
|
<a href="/_gm/games?status=finished"{{if eq .Status "finished"}} class="active"{{end}}>finished</a>
|
||||||
</nav>
|
</nav>
|
||||||
<table class="list">
|
<table class="list">
|
||||||
<thead><tr><th>Game</th><th>Variant</th><th>Status</th><th>🤖</th><th class="num">Players</th><th>Updated</th></tr></thead>
|
<thead><tr><th>Game</th><th>Variant</th><th>Kind</th><th>Status</th><th>🤖</th><th class="num">Players</th><th>Updated</th></tr></thead>
|
||||||
<tbody>
|
<tbody>
|
||||||
{{range .Items}}
|
{{range .Items}}
|
||||||
<tr><td><a href="/_gm/games/{{.ID}}">{{.ID}}</a></td><td>{{.Variant}}</td><td>{{.Status}}</td><td>{{if .VsAI}}🤖{{end}}</td><td class="num">{{.Players}}</td><td>{{.UpdatedAt}}</td></tr>
|
<tr><td><a href="/_gm/games/{{.ID}}">{{.ID}}</a></td><td>{{.Variant}}</td><td>{{.Kind}}</td><td>{{.Status}}</td><td>{{if .VsAI}}🤖{{end}}</td><td class="num">{{.Players}}</td><td>{{.UpdatedAt}}</td></tr>
|
||||||
{{else}}<tr><td colspan="6"><span class="note">no games</span></td></tr>{{end}}
|
{{else}}<tr><td colspan="7"><span class="note">no games</span></td></tr>{{end}}
|
||||||
</tbody>
|
</tbody>
|
||||||
</table>
|
</table>
|
||||||
<nav class="pager">
|
<nav class="pager">
|
||||||
|
|||||||
@@ -0,0 +1,19 @@
|
|||||||
|
{{define "content" -}}
|
||||||
|
<h1>Active-game limits</h1>
|
||||||
|
{{with .Data}}
|
||||||
|
<p class="note">Per-tier, per-kind caps on a player's simultaneous unfinished games. <strong>-1</strong> = unlimited, <strong>0</strong> = the kind is blocked, a positive number caps concurrent games of that kind. Guests are additionally blocked from friend games outright. Changes apply immediately (no redeploy); games already in progress are never affected.</p>
|
||||||
|
<section class="panel">
|
||||||
|
<form class="form col" method="post" action="/_gm/limits">
|
||||||
|
<h2>Guest</h2>
|
||||||
|
<label>vs AI <input type="number" name="guest_vs_ai" min="-1" value="{{.GuestVsAI}}" required></label>
|
||||||
|
<label>Random <input type="number" name="guest_random" min="-1" value="{{.GuestRandom}}" required></label>
|
||||||
|
<label>Friends <input type="number" name="guest_friends" min="-1" value="{{.GuestFriends}}" required></label>
|
||||||
|
<h2>Durable account</h2>
|
||||||
|
<label>vs AI <input type="number" name="durable_vs_ai" min="-1" value="{{.DurableVsAI}}" required></label>
|
||||||
|
<label>Random <input type="number" name="durable_random" min="-1" value="{{.DurableRandom}}" required></label>
|
||||||
|
<label>Friends <input type="number" name="durable_friends" min="-1" value="{{.DurableFriends}}" required></label>
|
||||||
|
<div><button type="submit">Save</button></div>
|
||||||
|
</form>
|
||||||
|
</section>
|
||||||
|
{{end}}
|
||||||
|
{{- end}}
|
||||||
@@ -73,18 +73,41 @@
|
|||||||
</ul>
|
</ul>
|
||||||
{{else}}<p class="note">no balances or benefits</p>{{end}}
|
{{else}}<p class="note">no balances or benefits</p>{{end}}
|
||||||
<h3>Ledger</h3>
|
<h3>Ledger</h3>
|
||||||
|
{{$uid := .ID}}
|
||||||
{{if .Finance.Ledger}}
|
{{if .Finance.Ledger}}
|
||||||
<table class="list">
|
<table class="list">
|
||||||
<thead><tr><th>Time</th><th>Kind</th><th>Source</th><th>Origin</th><th>Chips</th><th>Order</th><th>Provider</th><th>Detail</th></tr></thead>
|
<thead><tr><th>Time</th><th>Kind</th><th>Source</th><th>Origin</th><th>Chips</th><th>Order</th><th>Provider</th><th>Detail</th><th></th></tr></thead>
|
||||||
<tbody>
|
<tbody>
|
||||||
{{range .Finance.Ledger}}
|
{{range .Finance.Ledger}}
|
||||||
<tr><td>{{.At}}</td><td>{{.Kind}}</td><td>{{.Source}}</td><td>{{.Origin}}</td><td>{{.ChipsDelta}}</td><td>{{if .Order}}<code>{{.Order}}</code>{{end}}</td><td>{{.Provider}}</td><td>{{if .Snapshot}}<code>{{.Snapshot}}</code>{{end}}</td></tr>
|
<tr><td>{{.At}}</td><td>{{.Kind}}</td><td>{{.Source}}</td><td>{{.Origin}}</td><td>{{.ChipsDelta}}</td><td>{{if .Order}}<code>{{.Order}}</code>{{end}}</td><td>{{.Provider}}</td><td>{{if .Snapshot}}<code>{{.Snapshot}}</code>{{end}}</td>
|
||||||
|
<td>{{if and (eq .Kind "fund") .Order}}<form class="form" method="post" action="/_gm/users/{{$uid}}/refund" onsubmit="return confirm('Refund this order in full? Record the money refund on the rail first; this revokes the chips (floored at 0).')"><input type="hidden" name="order_id" value="{{.Order}}"><button type="submit">Refund</button></form>{{end}}</td></tr>
|
||||||
{{end}}
|
{{end}}
|
||||||
</tbody>
|
</tbody>
|
||||||
</table>
|
</table>
|
||||||
|
<p class="note"><a href="/_gm/ledger.csv">Export the full ledger (CSV)</a> — all accounts, for tax + reconciliation.</p>
|
||||||
{{else}}<p class="note">no ledger entries</p>{{end}}
|
{{else}}<p class="note">no ledger entries</p>{{end}}
|
||||||
{{else}}<p class="note">payments not enabled</p>{{end}}
|
{{else}}<p class="note">payments not enabled</p>{{end}}
|
||||||
</section>
|
</section>
|
||||||
|
<section class="panel"><h2>Grant benefits</h2>
|
||||||
|
{{if .Grant.Present}}
|
||||||
|
<p class="note">A zero-price admin sale of a value — <strong>never chips</strong>. The origin is your compliance choice. The by-product grant applies a defined bundle, including an archived reward product.</p>
|
||||||
|
<form class="form col" method="post" action="/_gm/users/{{.ID}}/grant">
|
||||||
|
<label>Origin <select name="origin">{{range .Grant.Origins}}<option value="{{.}}">{{.}}</option>{{end}}</select></label>
|
||||||
|
<label>Hints <input type="number" name="hints" min="0" value="0"></label>
|
||||||
|
<label>No-ads days <input type="number" name="noads" min="0" value="0"></label>
|
||||||
|
<label><input type="checkbox" name="forever" value="true"> No-ads forever</label>
|
||||||
|
<div><button type="submit">Grant</button></div>
|
||||||
|
</form>
|
||||||
|
{{if .Grant.Products}}
|
||||||
|
<h3>Grant a product</h3>
|
||||||
|
<form class="form col" method="post" action="/_gm/users/{{.ID}}/grant-product">
|
||||||
|
<label>Origin <select name="origin">{{range .Grant.Origins}}<option value="{{.}}">{{.}}</option>{{end}}</select></label>
|
||||||
|
<label>Product <select name="product_id">{{range .Grant.Products}}<option value="{{.ID}}">{{.Title}} ({{.Summary}}){{if .Archived}} — archived{{end}}</option>{{end}}</select></label>
|
||||||
|
<div><button type="submit">Grant product</button></div>
|
||||||
|
</form>
|
||||||
|
{{else}}<p class="note">no grantable products — create a value product in the <a href="/_gm/catalog">catalog</a></p>{{end}}
|
||||||
|
{{else}}<p class="note">payments not enabled</p>{{end}}
|
||||||
|
</section>
|
||||||
<section class="panel"><h2>Roles</h2>
|
<section class="panel"><h2>Roles</h2>
|
||||||
{{$id := .ID}}
|
{{$id := .ID}}
|
||||||
{{if .Roles}}
|
{{if .Roles}}
|
||||||
@@ -188,11 +211,11 @@
|
|||||||
{{end}}
|
{{end}}
|
||||||
<section class="panel"><h2>Games</h2>
|
<section class="panel"><h2>Games</h2>
|
||||||
<table class="list">
|
<table class="list">
|
||||||
<thead><tr><th>Game</th><th>Variant</th><th>Status</th><th class="num">Players</th><th>Updated</th></tr></thead>
|
<thead><tr><th>Game</th><th>Variant</th><th>Kind</th><th>Status</th><th class="num">Players</th><th>Updated</th></tr></thead>
|
||||||
<tbody>
|
<tbody>
|
||||||
{{range .Games}}
|
{{range .Games}}
|
||||||
<tr><td><a href="/_gm/games/{{.ID}}">{{.ID}}</a></td><td>{{.Variant}}</td><td>{{.Status}}</td><td class="num">{{.Players}}</td><td>{{.UpdatedAt}}</td></tr>
|
<tr><td><a href="/_gm/games/{{.ID}}">{{.ID}}</a></td><td>{{.Variant}}</td><td>{{.Kind}}</td><td>{{.Status}}</td><td class="num">{{.Players}}</td><td>{{.UpdatedAt}}</td></tr>
|
||||||
{{else}}<tr><td colspan="5"><span class="note">no games</span></td></tr>{{end}}
|
{{else}}<tr><td colspan="6"><span class="note">no games</span></td></tr>{{end}}
|
||||||
</tbody>
|
</tbody>
|
||||||
</table>
|
</table>
|
||||||
</section>
|
</section>
|
||||||
|
|||||||
@@ -198,6 +198,9 @@ type UserDetailView struct {
|
|||||||
// Finance is the account's payments picture (balances, benefits, refund risk, ledger). Present
|
// Finance is the account's payments picture (balances, benefits, refund risk, ledger). Present
|
||||||
// is false when the payments domain is unwired.
|
// is false when the payments domain is unwired.
|
||||||
Finance FinanceView
|
Finance FinanceView
|
||||||
|
// Grant is the admin-grant panel (origin picker + grantable products). Present is false when the
|
||||||
|
// payments domain is unwired.
|
||||||
|
Grant GrantFormView
|
||||||
}
|
}
|
||||||
|
|
||||||
// FinanceView is the account's payments picture on the user card: chip balances per funding
|
// FinanceView is the account's payments picture on the user card: chip balances per funding
|
||||||
@@ -309,6 +312,8 @@ type GameRow struct {
|
|||||||
UpdatedAt string
|
UpdatedAt string
|
||||||
// VsAI marks an honest-AI game (rendered as 🤖 in the list's AI column).
|
// VsAI marks an honest-AI game (rendered as 🤖 in the list's AI column).
|
||||||
VsAI bool
|
VsAI bool
|
||||||
|
// Kind is the game's origin tag label: vs_ai / random / friends / unknown.
|
||||||
|
Kind string
|
||||||
}
|
}
|
||||||
|
|
||||||
// GamesView is the paginated games list, optionally filtered by status.
|
// GamesView is the paginated games list, optionally filtered by status.
|
||||||
@@ -318,6 +323,17 @@ type GamesView struct {
|
|||||||
Pager Pager
|
Pager Pager
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// GameLimitsView is the per-tier, per-kind active-game limit form: each field is a cap where -1
|
||||||
|
// is unlimited, 0 blocks the kind, and a positive value caps concurrent games of that kind.
|
||||||
|
type GameLimitsView struct {
|
||||||
|
GuestVsAI int
|
||||||
|
GuestRandom int
|
||||||
|
GuestFriends int
|
||||||
|
DurableVsAI int
|
||||||
|
DurableRandom int
|
||||||
|
DurableFriends int
|
||||||
|
}
|
||||||
|
|
||||||
// GameDetailView is one game with its seats.
|
// GameDetailView is one game with its seats.
|
||||||
type GameDetailView struct {
|
type GameDetailView struct {
|
||||||
ID string
|
ID string
|
||||||
@@ -332,8 +348,12 @@ type GameDetailView struct {
|
|||||||
UpdatedAt string
|
UpdatedAt string
|
||||||
FinishedAt string
|
FinishedAt string
|
||||||
// VsAI marks an honest-AI game (shown as a 🤖 flag in the summary).
|
// VsAI marks an honest-AI game (shown as a 🤖 flag in the summary).
|
||||||
VsAI bool
|
VsAI bool
|
||||||
Seats []SeatRow
|
// MultipleWordsPerTurn is the game's cross-word rule: true = standard Scrabble (every cross-word
|
||||||
|
// is validated and scored), false = the single-word rule (only the main word along the play
|
||||||
|
// direction counts). Shown in the summary so an operator can tell the rule at a glance.
|
||||||
|
MultipleWordsPerTurn bool
|
||||||
|
Seats []SeatRow
|
||||||
// HasRobot is true when any seat is a robot, gating the robot-target caption;
|
// HasRobot is true when any seat is a robot, gating the robot-target caption;
|
||||||
// RobotTargetPct is the configured global play-to-win rate, in percent.
|
// RobotTargetPct is the configured global play-to-win rate, in percent.
|
||||||
HasRobot bool
|
HasRobot bool
|
||||||
@@ -657,6 +677,15 @@ type FeedbackDetailView struct {
|
|||||||
// CatalogView is the product-catalog list page.
|
// CatalogView is the product-catalog list page.
|
||||||
type CatalogView struct {
|
type CatalogView struct {
|
||||||
Products []ProductRow
|
Products []ProductRow
|
||||||
|
// ShowAll reports whether archived products are listed alongside active ones (the active/all
|
||||||
|
// toggle); it drives the toggle link and the list heading.
|
||||||
|
ShowAll bool
|
||||||
|
// RewardPayout / RewardDailyCap / RewardHourlyCap are the rewarded-video config (chips earned per
|
||||||
|
// view and the per-day / per-hour anti-abuse caps), shown in and edited from the page's
|
||||||
|
// rewarded-ads form. A 0 payout means rewarded is inert.
|
||||||
|
RewardPayout int
|
||||||
|
RewardDailyCap int
|
||||||
|
RewardHourlyCap int
|
||||||
}
|
}
|
||||||
|
|
||||||
// ProductRow is one product in the catalog list: its composition, prices, the archived flag
|
// ProductRow is one product in the catalog list: its composition, prices, the archived flag
|
||||||
@@ -701,3 +730,21 @@ type ProductFormView struct {
|
|||||||
PriceChip int64
|
PriceChip int64
|
||||||
Transacted bool
|
Transacted bool
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// GrantFormView is the admin-grant panel on the user card: the origin picker and the grantable
|
||||||
|
// products (value bundles — hints / no-ads days — including archived ones; chips and tournament
|
||||||
|
// products are excluded). Present is false when the payments domain is unwired.
|
||||||
|
type GrantFormView struct {
|
||||||
|
Present bool
|
||||||
|
Origins []string
|
||||||
|
Products []GrantProductOption
|
||||||
|
}
|
||||||
|
|
||||||
|
// GrantProductOption is one grantable product in the by-product picker: its id, title, an atom
|
||||||
|
// summary, and whether it is archived (the common case for a non-public reward bundle).
|
||||||
|
type GrantProductOption struct {
|
||||||
|
ID string
|
||||||
|
Title string
|
||||||
|
Summary string
|
||||||
|
Archived bool
|
||||||
|
}
|
||||||
|
|||||||
@@ -52,6 +52,7 @@ func gameSummary(g Game, names []string) notify.GameSummary {
|
|||||||
TurnTimeoutSecs: int(g.TurnTimeout.Seconds()),
|
TurnTimeoutSecs: int(g.TurnTimeout.Seconds()),
|
||||||
MultipleWordsPerTurn: g.MultipleWordsPerTurn,
|
MultipleWordsPerTurn: g.MultipleWordsPerTurn,
|
||||||
VsAI: g.VsAI,
|
VsAI: g.VsAI,
|
||||||
|
Kind: int(g.Kind),
|
||||||
MoveCount: g.MoveCount,
|
MoveCount: g.MoveCount,
|
||||||
EndReason: g.EndReason,
|
EndReason: g.EndReason,
|
||||||
Seats: seats,
|
Seats: seats,
|
||||||
@@ -74,7 +75,6 @@ func playerState(v StateView, names []string, includeAlphabet bool) (notify.Play
|
|||||||
Rack: rack,
|
Rack: rack,
|
||||||
BagLen: v.BagLen,
|
BagLen: v.BagLen,
|
||||||
HintsRemaining: v.HintsRemaining,
|
HintsRemaining: v.HintsRemaining,
|
||||||
WalletBalance: v.WalletBalance,
|
|
||||||
}
|
}
|
||||||
if includeAlphabet {
|
if includeAlphabet {
|
||||||
tab, err := engine.AlphabetTable(v.Game.Variant)
|
tab, err := engine.AlphabetTable(v.Game.Variant)
|
||||||
|
|||||||
@@ -55,16 +55,16 @@ func TestPayloadExchangeRoundTrip(t *testing.T) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
func TestHintsRemaining(t *testing.T) {
|
func TestHintsRemaining(t *testing.T) {
|
||||||
cases := []struct{ allowance, used, wallet, want int }{
|
cases := []struct{ allowance, used, want int }{
|
||||||
{1, 0, 3, 4},
|
{1, 0, 1},
|
||||||
{1, 1, 3, 3},
|
{1, 1, 0},
|
||||||
{1, 2, 3, 3}, // used past allowance clamps to 0
|
{1, 2, 0}, // used past allowance clamps to 0
|
||||||
{0, 0, 5, 5},
|
{3, 1, 2},
|
||||||
{2, 1, 0, 1},
|
{0, 0, 0},
|
||||||
}
|
}
|
||||||
for _, c := range cases {
|
for _, c := range cases {
|
||||||
if got := hintsRemaining(c.allowance, c.used, c.wallet); got != c.want {
|
if got := hintsRemaining(c.allowance, c.used); got != c.want {
|
||||||
t.Errorf("hintsRemaining(%d,%d,%d) = %d, want %d", c.allowance, c.used, c.wallet, got, c.want)
|
t.Errorf("hintsRemaining(%d,%d) = %d, want %d", c.allowance, c.used, got, c.want)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -17,6 +17,7 @@ import (
|
|||||||
|
|
||||||
"scrabble/backend/internal/account"
|
"scrabble/backend/internal/account"
|
||||||
"scrabble/backend/internal/engine"
|
"scrabble/backend/internal/engine"
|
||||||
|
"scrabble/backend/internal/gamelimits"
|
||||||
"scrabble/backend/internal/notify"
|
"scrabble/backend/internal/notify"
|
||||||
"scrabble/backend/internal/payments"
|
"scrabble/backend/internal/payments"
|
||||||
"scrabble/backend/internal/session"
|
"scrabble/backend/internal/session"
|
||||||
@@ -57,6 +58,9 @@ type Service struct {
|
|||||||
// nil, only the free per-game allowance is served (no purchased hints). vs_ai hints are
|
// nil, only the free per-game allowance is served (no purchased hints). vs_ai hints are
|
||||||
// wallet-free and never touch it.
|
// wallet-free and never touch it.
|
||||||
hintWallet HintWallet
|
hintWallet HintWallet
|
||||||
|
// limits is the per-tier active-game cap config (cached). Set by SetGameLimits during wiring;
|
||||||
|
// when nil, no active-game limit is enforced (a game is always creatable).
|
||||||
|
limits *gamelimits.Service
|
||||||
// clearNudges, when set, marks the actor's pending nudges in a game read once they
|
// clearNudges, when set, marks the actor's pending nudges in a game read once they
|
||||||
// have committed a move (a nudge answered by moving stops counting as unread). It is
|
// have committed a move (a nudge answered by moving stops counting as unread). It is
|
||||||
// best-effort and kept as a func so the game package never imports the social package.
|
// best-effort and kept as a func so the game package never imports the social package.
|
||||||
@@ -126,6 +130,37 @@ func (svc *Service) SetHintWallet(w HintWallet) {
|
|||||||
svc.hintWallet = w
|
svc.hintWallet = w
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// SetGameLimits installs the active-game limit config (cached), enabling the per-tier caps. When
|
||||||
|
// unset (nil), a game is always creatable.
|
||||||
|
func (svc *Service) SetGameLimits(l *gamelimits.Service) {
|
||||||
|
svc.limits = l
|
||||||
|
}
|
||||||
|
|
||||||
|
// AtGameLimit reports whether accountID has reached its tier's active-game cap for kind — the
|
||||||
|
// per-tier, per-kind limits held in backend.config, read from the in-memory cache. It resolves
|
||||||
|
// the caller's tier (guest vs durable) from the account, then counts its open+active games of that
|
||||||
|
// kind. It reports false (not at the limit) when the limits config is not wired, the account is nil,
|
||||||
|
// or the resolved cap is gamelimits.Unlimited. It backs the new-game gate (the handler aborts 409
|
||||||
|
// game_limit_reached) and the lobby's at-limit flag.
|
||||||
|
func (svc *Service) AtGameLimit(ctx context.Context, accountID uuid.UUID, kind gamelimits.Kind) (bool, error) {
|
||||||
|
if svc.limits == nil || accountID == uuid.Nil {
|
||||||
|
return false, nil
|
||||||
|
}
|
||||||
|
acc, err := svc.accounts.GetByID(ctx, accountID)
|
||||||
|
if err != nil {
|
||||||
|
return false, err
|
||||||
|
}
|
||||||
|
limit := svc.limits.LimitsFor(acc.IsGuest).Cap(kind)
|
||||||
|
if limit == gamelimits.Unlimited {
|
||||||
|
return false, nil
|
||||||
|
}
|
||||||
|
n, err := svc.store.CountActiveByKind(ctx, accountID, kind)
|
||||||
|
if err != nil {
|
||||||
|
return false, err
|
||||||
|
}
|
||||||
|
return n >= limit, nil
|
||||||
|
}
|
||||||
|
|
||||||
// walletContext resolves the payments gate inputs for an account on the current request: the
|
// walletContext resolves the payments gate inputs for an account on the current request: the
|
||||||
// trusted execution context (from the session platform carried on ctx; absent ⇒ untrusted) and
|
// trusted execution context (from the session platform carried on ctx; absent ⇒ untrusted) and
|
||||||
// the account's present identity sources (which chip/benefit segments are awake, §6).
|
// the account's present identity sources (which chip/benefit segments are awake, §6).
|
||||||
@@ -338,6 +373,7 @@ func (svc *Service) Create(ctx context.Context, params CreateParams) (Game, erro
|
|||||||
dropoutTiles: params.DropoutTiles.String(),
|
dropoutTiles: params.DropoutTiles.String(),
|
||||||
multipleWordsPerTurn: params.MultipleWordsPerTurn,
|
multipleWordsPerTurn: params.MultipleWordsPerTurn,
|
||||||
vsAI: params.VsAI,
|
vsAI: params.VsAI,
|
||||||
|
kind: params.Kind,
|
||||||
}
|
}
|
||||||
if err := svc.store.CreateGame(ctx, ins, seats, seeding.draws); err != nil {
|
if err := svc.store.CreateGame(ctx, ins, seats, seeding.draws); err != nil {
|
||||||
return Game{}, err
|
return Game{}, err
|
||||||
@@ -401,6 +437,7 @@ func (svc *Service) OpenOrJoin(ctx context.Context, accountID uuid.UUID, params
|
|||||||
multipleWordsPerTurn: params.MultipleWordsPerTurn,
|
multipleWordsPerTurn: params.MultipleWordsPerTurn,
|
||||||
status: StatusOpen,
|
status: StatusOpen,
|
||||||
openDeadline: &deadline,
|
openDeadline: &deadline,
|
||||||
|
kind: gamelimits.KindRandom,
|
||||||
}
|
}
|
||||||
// Decide the first move now by the official draw, with the not-yet-arrived opponent as a
|
// Decide the first move now by the official draw, with the not-yet-arrived opponent as a
|
||||||
// synthetic placeholder (uuid.Nil): the draw fixes who sits at seat 0 — and so moves
|
// synthetic placeholder (uuid.Nil): the draw fixes who sits at seat 0 — and so moves
|
||||||
@@ -1207,7 +1244,7 @@ func (svc *Service) Hint(ctx context.Context, gameID, accountID uuid.UUID) (Hint
|
|||||||
return HintResult{}, err
|
return HintResult{}, err
|
||||||
}
|
}
|
||||||
used++
|
used++
|
||||||
return HintResult{Move: move, HintsRemaining: hintsRemaining(pre.HintsPerPlayer, used, walletAfter), WalletBalance: walletAfter}, nil
|
return HintResult{Move: move, HintsRemaining: hintsRemaining(pre.HintsPerPlayer, used), WalletBalance: walletAfter}, nil
|
||||||
}
|
}
|
||||||
|
|
||||||
// Candidates returns the to-move player's legal plays for a seated player on
|
// Candidates returns the to-move player's legal plays for a seated player on
|
||||||
@@ -1290,11 +1327,9 @@ func (svc *Service) GameState(ctx context.Context, gameID, accountID uuid.UUID)
|
|||||||
Seat: seat,
|
Seat: seat,
|
||||||
Rack: g.Hand(seat),
|
Rack: g.Hand(seat),
|
||||||
BagLen: g.BagLen(),
|
BagLen: g.BagLen(),
|
||||||
// The hint wallet moved to payments (svc.hintWallet); the deprecated accounts.hint_balance
|
// HintsRemaining is the per-seat allowance only; the purchasable wallet lives on the profile
|
||||||
// is no longer read, so the wire wallet is 0 and HintsRemaining is the per-seat allowance.
|
// (payments) and the client adds it (lib/hints.hintsLeft).
|
||||||
// The client adds the profile's payments hint balance on top (lib/hints.hintsLeft).
|
HintsRemaining: hintsRemaining(pre.HintsPerPlayer, pre.Seats[seat].HintsUsed),
|
||||||
HintsRemaining: hintsRemaining(pre.HintsPerPlayer, pre.Seats[seat].HintsUsed, 0),
|
|
||||||
WalletBalance: 0,
|
|
||||||
// vs_ai idle-hint gate (seconds left; 0 for a human game / first move / not your turn).
|
// vs_ai idle-hint gate (seconds left; 0 for a human game / first move / not your turn).
|
||||||
HintUnlockLeftSeconds: hintUnlockLeftSeconds(pre, seat, svc.clock()),
|
HintUnlockLeftSeconds: hintUnlockLeftSeconds(pre, seat, svc.clock()),
|
||||||
}, nil
|
}, nil
|
||||||
@@ -1388,14 +1423,6 @@ func (svc *Service) ListForLobby(ctx context.Context, accountID uuid.UUID) ([]Ga
|
|||||||
return kept, nil
|
return kept, nil
|
||||||
}
|
}
|
||||||
|
|
||||||
// CountActiveQuickGames reports how many in-progress quick games the account holds —
|
|
||||||
// the count the simultaneous-game limit (MaxActiveQuickGames) is checked against. It
|
|
||||||
// counts active and still-open quick games (including honest-AI ones) and excludes
|
|
||||||
// friend games created by invitation and finished games. See Store.CountActiveQuickGames.
|
|
||||||
func (svc *Service) CountActiveQuickGames(ctx context.Context, accountID uuid.UUID) (int, error) {
|
|
||||||
return svc.store.CountActiveQuickGames(ctx, accountID)
|
|
||||||
}
|
|
||||||
|
|
||||||
// HideGame hides a finished game from accountID's own lobby (it stays visible to the other
|
// HideGame hides a finished game from accountID's own lobby (it stays visible to the other
|
||||||
// players); it is irreversible by design. Only a player of a finished game may hide it
|
// players); it is irreversible by design. Only a player of a finished game may hide it
|
||||||
// (ErrNotAPlayer / ErrGameActive otherwise); hiding an already-hidden game is a no-op.
|
// (ErrNotAPlayer / ErrGameActive otherwise); hiding an already-hidden game is a no-op.
|
||||||
@@ -1775,10 +1802,10 @@ func (svc *Service) DictBytes(variant engine.Variant, version string) ([]byte, e
|
|||||||
return svc.registry.DictBytes(variant, version)
|
return svc.registry.DictBytes(variant, version)
|
||||||
}
|
}
|
||||||
|
|
||||||
// hintsRemaining is a player's remaining hint budget: the unspent per-game
|
// hintsRemaining is the unspent per-game hint allowance. The purchasable wallet is separate,
|
||||||
// allowance plus the profile wallet.
|
// carried on the profile (payments), and the client adds it (lib/hints.hintsLeft).
|
||||||
func hintsRemaining(allowance, used, wallet int) int {
|
func hintsRemaining(allowance, used int) int {
|
||||||
return max(0, allowance-used) + wallet
|
return max(0, allowance-used)
|
||||||
}
|
}
|
||||||
|
|
||||||
// allowedTimeout reports whether d is one of the offered move clocks.
|
// allowedTimeout reports whether d is one of the offered move clocks.
|
||||||
|
|||||||
@@ -15,6 +15,7 @@ import (
|
|||||||
|
|
||||||
"scrabble/backend/internal/account"
|
"scrabble/backend/internal/account"
|
||||||
"scrabble/backend/internal/engine"
|
"scrabble/backend/internal/engine"
|
||||||
|
"scrabble/backend/internal/gamelimits"
|
||||||
"scrabble/backend/internal/postgres/jet/backend/model"
|
"scrabble/backend/internal/postgres/jet/backend/model"
|
||||||
"scrabble/backend/internal/postgres/jet/backend/table"
|
"scrabble/backend/internal/postgres/jet/backend/table"
|
||||||
)
|
)
|
||||||
@@ -45,6 +46,8 @@ type gameInsert struct {
|
|||||||
multipleWordsPerTurn bool
|
multipleWordsPerTurn bool
|
||||||
// vsAI marks an honest-AI game (games.vs_ai).
|
// vsAI marks an honest-AI game (games.vs_ai).
|
||||||
vsAI bool
|
vsAI bool
|
||||||
|
// kind tags the game's origin (games.game_kind) for the active-game limits.
|
||||||
|
kind gamelimits.Kind
|
||||||
// status is the lifecycle state to create the game in: StatusActive for a normal
|
// status is the lifecycle state to create the game in: StatusActive for a normal
|
||||||
// seated game, StatusOpen for an auto-match game still awaiting an opponent. An
|
// seated game, StatusOpen for an auto-match game still awaiting an opponent. An
|
||||||
// empty string defaults to StatusActive.
|
// empty string defaults to StatusActive.
|
||||||
@@ -138,6 +141,20 @@ func (s *Store) CreateGame(ctx context.Context, ins gameInsert, seats []seatInse
|
|||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// CountActiveByKind counts the account's active (open or in-progress) games of the given kind — the
|
||||||
|
// per-tier active-game limit is checked against it before a new game of that kind is created.
|
||||||
|
func (s *Store) CountActiveByKind(ctx context.Context, accountID uuid.UUID, kind gamelimits.Kind) (int, error) {
|
||||||
|
var n int
|
||||||
|
if err := s.db.QueryRowContext(ctx,
|
||||||
|
`SELECT count(*) FROM backend.games g
|
||||||
|
JOIN backend.game_players p ON p.game_id = g.game_id
|
||||||
|
WHERE p.account_id = $1 AND g.game_kind = $2 AND g.status IN ('open', 'active')`,
|
||||||
|
accountID, int16(kind)).Scan(&n); err != nil {
|
||||||
|
return 0, fmt.Errorf("game: count active by kind %s: %w", accountID, err)
|
||||||
|
}
|
||||||
|
return n, nil
|
||||||
|
}
|
||||||
|
|
||||||
// insertGameTx inserts the games row and one game_players row per seat (seat 0
|
// insertGameTx inserts the games row and one game_players row per seat (seat 0
|
||||||
// first) on tx, stamping each seat's display-name snapshot. A seat whose account id is
|
// first) on tx, stamping each seat's display-name snapshot. A seat whose account id is
|
||||||
// uuid.Nil is written with a NULL account_id (and an empty snapshot) — the still-empty
|
// uuid.Nil is written with a NULL account_id (and an empty snapshot) — the still-empty
|
||||||
@@ -155,9 +172,9 @@ func insertGameTx(ctx context.Context, tx *sql.Tx, ins gameInsert, seats []seatI
|
|||||||
table.Games.GameID, table.Games.Variant, table.Games.DictVersion, table.Games.Seed,
|
table.Games.GameID, table.Games.Variant, table.Games.DictVersion, table.Games.Seed,
|
||||||
table.Games.Status, table.Games.Players, table.Games.TurnTimeoutSecs,
|
table.Games.Status, table.Games.Players, table.Games.TurnTimeoutSecs,
|
||||||
table.Games.HintsAllowed, table.Games.HintsPerPlayer, table.Games.OpenDeadlineAt,
|
table.Games.HintsAllowed, table.Games.HintsPerPlayer, table.Games.OpenDeadlineAt,
|
||||||
table.Games.DropoutTiles, table.Games.MultipleWordsPerTurn, table.Games.VsAi,
|
table.Games.DropoutTiles, table.Games.MultipleWordsPerTurn, table.Games.VsAi, table.Games.GameKind,
|
||||||
).VALUES(ins.id, ins.variant, ins.dictVersion, ins.seed, status, ins.players,
|
).VALUES(ins.id, ins.variant, ins.dictVersion, ins.seed, status, ins.players,
|
||||||
ins.turnTimeoutSecs, ins.hintsAllowed, ins.hintsPerPlayer, deadline, ins.dropoutTiles, ins.multipleWordsPerTurn, ins.vsAI)
|
ins.turnTimeoutSecs, ins.hintsAllowed, ins.hintsPerPlayer, deadline, ins.dropoutTiles, ins.multipleWordsPerTurn, ins.vsAI, int16(ins.kind))
|
||||||
if _, err := gi.ExecContext(ctx, tx); err != nil {
|
if _, err := gi.ExecContext(ctx, tx); err != nil {
|
||||||
return fmt.Errorf("insert game: %w", err)
|
return fmt.Errorf("insert game: %w", err)
|
||||||
}
|
}
|
||||||
@@ -501,28 +518,6 @@ func (s *Store) ListGamesForAccount(ctx context.Context, accountID uuid.UUID) ([
|
|||||||
return out, nil
|
return out, nil
|
||||||
}
|
}
|
||||||
|
|
||||||
// CountActiveQuickGames counts the account's in-progress quick games — the ones the
|
|
||||||
// simultaneous-game limit (MaxActiveQuickGames) is checked against. It includes both
|
|
||||||
// active and still-open (awaiting-opponent) games, the honest-AI ones among them, and
|
|
||||||
// excludes friend games (those linked to a game_invitations row) and finished games.
|
|
||||||
// A hidden game still occupies a slot, so this is a dedicated count rather than a
|
|
||||||
// filter over ListGamesForAccount (which drops hidden games). Joining on the account's
|
|
||||||
// own seat counts each game once (an open game's empty opponent seat has no account).
|
|
||||||
func (s *Store) CountActiveQuickGames(ctx context.Context, accountID uuid.UUID) (int, error) {
|
|
||||||
// The status literals are game.StatusActive / game.StatusOpen, matching the
|
|
||||||
// games.status CHECK in the baseline migration.
|
|
||||||
const q = `
|
|
||||||
SELECT COUNT(*) FROM backend.games g
|
|
||||||
JOIN backend.game_players gp ON gp.game_id = g.game_id
|
|
||||||
LEFT JOIN backend.game_invitations gi ON gi.game_id = g.game_id
|
|
||||||
WHERE gp.account_id = $1 AND g.status IN ('active', 'open') AND gi.game_id IS NULL`
|
|
||||||
var n int
|
|
||||||
if err := s.db.QueryRowContext(ctx, q, accountID).Scan(&n); err != nil {
|
|
||||||
return 0, fmt.Errorf("game: count active quick games: %w", err)
|
|
||||||
}
|
|
||||||
return n, nil
|
|
||||||
}
|
|
||||||
|
|
||||||
// HideGame hides a game from the account's own lobby list (idempotent). The caller validates the
|
// HideGame hides a game from the account's own lobby list (idempotent). The caller validates the
|
||||||
// game is finished and the account is a player.
|
// game is finished and the account is a player.
|
||||||
func (s *Store) HideGame(ctx context.Context, accountID, gameID uuid.UUID) error {
|
func (s *Store) HideGame(ctx context.Context, accountID, gameID uuid.UUID) error {
|
||||||
@@ -1361,6 +1356,7 @@ func projectGame(g model.Games, seats []model.GamePlayers) (Game, error) {
|
|||||||
}
|
}
|
||||||
out.MultipleWordsPerTurn = g.MultipleWordsPerTurn
|
out.MultipleWordsPerTurn = g.MultipleWordsPerTurn
|
||||||
out.VsAI = g.VsAi
|
out.VsAI = g.VsAi
|
||||||
|
out.Kind = gamelimits.Kind(g.GameKind)
|
||||||
if g.EndReason != nil {
|
if g.EndReason != nil {
|
||||||
out.EndReason = *g.EndReason
|
out.EndReason = *g.EndReason
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -7,6 +7,7 @@ import (
|
|||||||
"github.com/google/uuid"
|
"github.com/google/uuid"
|
||||||
|
|
||||||
"scrabble/backend/internal/engine"
|
"scrabble/backend/internal/engine"
|
||||||
|
"scrabble/backend/internal/gamelimits"
|
||||||
)
|
)
|
||||||
|
|
||||||
// Status values persisted in the games.status column.
|
// Status values persisted in the games.status column.
|
||||||
@@ -94,15 +95,6 @@ const DefaultTurnTimeout = 24 * time.Hour
|
|||||||
// one of AllowedTurnTimeouts (never offered in the creation UI).
|
// one of AllowedTurnTimeouts (never offered in the creation UI).
|
||||||
const AIInactivityTimeout = 7 * 24 * time.Hour
|
const AIInactivityTimeout = 7 * 24 * time.Hour
|
||||||
|
|
||||||
// MaxActiveQuickGames is the cap on a player's simultaneous quick games (human
|
|
||||||
// auto-match and honest-AI), counting both in-progress (StatusActive) and
|
|
||||||
// still-open, awaiting-opponent (StatusOpen) games. Friend games created by
|
|
||||||
// invitation are not counted. At the cap the backend refuses to create a new game
|
|
||||||
// of any kind — quick or by invitation — and the lobby disables "New Game";
|
|
||||||
// accepting an incoming invitation is always allowed. See
|
|
||||||
// Store.CountActiveQuickGames.
|
|
||||||
const MaxActiveQuickGames = 10
|
|
||||||
|
|
||||||
// aiPlayerName labels the robot seat in an honest-AI game's GCG export, so a downloaded
|
// aiPlayerName labels the robot seat in an honest-AI game's GCG export, so a downloaded
|
||||||
// game file shows a clean "AI" rather than the robot's human-like pool name (the in-app
|
// game file shows a clean "AI" rather than the robot's human-like pool name (the in-app
|
||||||
// UI shows 🤖 from the game's vs_ai flag).
|
// UI shows 🤖 from the game's vs_ai flag).
|
||||||
@@ -125,6 +117,10 @@ type CreateParams struct {
|
|||||||
// robot is seated at once, the move clock is AIInactivityTimeout, and chat/nudge
|
// robot is seated at once, the move clock is AIInactivityTimeout, and chat/nudge
|
||||||
// and finish-time statistics are suppressed. Set by the lobby's AI-match path.
|
// and finish-time statistics are suppressed. Set by the lobby's AI-match path.
|
||||||
VsAI bool
|
VsAI bool
|
||||||
|
// Kind tags the game's origin (games.game_kind) for the active-game limits: vs_ai / random /
|
||||||
|
// friends. The lobby sets it; a zero (unknown) kind is never gated. The active-game limit itself
|
||||||
|
// is enforced at the new-game handler (Server.ensureUnderGameLimit), not here.
|
||||||
|
Kind gamelimits.Kind
|
||||||
}
|
}
|
||||||
|
|
||||||
// Game is the persisted state of a match: the games row joined with its seats.
|
// Game is the persisted state of a match: the games row joined with its seats.
|
||||||
@@ -151,6 +147,9 @@ type Game struct {
|
|||||||
// VsAI is true for an honest-AI game (games.vs_ai): the opponent is a robot the
|
// VsAI is true for an honest-AI game (games.vs_ai): the opponent is a robot the
|
||||||
// player knowingly chose, shown as 🤖, with chat/nudge disabled and no statistics.
|
// player knowingly chose, shown as 🤖, with chat/nudge disabled and no statistics.
|
||||||
VsAI bool
|
VsAI bool
|
||||||
|
// Kind is the game's origin tag (games.game_kind) for the active-game limits: vs_ai / random /
|
||||||
|
// friends, or unknown for an untagged game. Read-only projection; set once on creation.
|
||||||
|
Kind gamelimits.Kind
|
||||||
}
|
}
|
||||||
|
|
||||||
// Seat is one player's standing in a game.
|
// Seat is one player's standing in a game.
|
||||||
@@ -211,10 +210,9 @@ type MoveResult struct {
|
|||||||
BagLen int
|
BagLen int
|
||||||
}
|
}
|
||||||
|
|
||||||
// HintResult is a revealed hint and the requesting player's remaining hint
|
// HintResult is a revealed hint with the per-seat allowance remaining (HintsRemaining) and the
|
||||||
// budget (per-seat allowance plus profile wallet) after spending one. WalletBalance is
|
// purchasable hint wallet after spending one (WalletBalance, from payments). The client adopts
|
||||||
// the global wallet alone, so the client can keep its live wallet authoritative and
|
// WalletBalance into the profile so the badge stays live across games (lib/hints).
|
||||||
// re-derive the per-game allowance (HintsRemaining - WalletBalance).
|
|
||||||
type HintResult struct {
|
type HintResult struct {
|
||||||
Move engine.MoveRecord
|
Move engine.MoveRecord
|
||||||
HintsRemaining int
|
HintsRemaining int
|
||||||
@@ -241,9 +239,6 @@ type StateView struct {
|
|||||||
Rack []string
|
Rack []string
|
||||||
BagLen int
|
BagLen int
|
||||||
HintsRemaining int
|
HintsRemaining int
|
||||||
// WalletBalance is the player's global hint-wallet balance alone (HintsRemaining folds
|
|
||||||
// it in with the per-game allowance), so the client keeps the wallet live across games.
|
|
||||||
WalletBalance int
|
|
||||||
// HintUnlockLeftSeconds is, for a vs_ai game on the requesting player's turn, the seconds left
|
// HintUnlockLeftSeconds is, for a vs_ai game on the requesting player's turn, the seconds left
|
||||||
// until the idle hint unlocks (the robot's last move plus the idle window, from the server clock);
|
// until the idle hint unlocks (the robot's last move plus the idle window, from the server clock);
|
||||||
// 0 for the human's first move, when it is not their turn, or a non-vs_ai game. The vs_ai hint is
|
// 0 for the human's first move, when it is not their turn, or a non-vs_ai game. The vs_ai hint is
|
||||||
|
|||||||
@@ -0,0 +1,149 @@
|
|||||||
|
// Package gamelimits holds the per-tier, per-kind active-game caps (a guest funnel) with a hot
|
||||||
|
// in-memory cache over the single-row backend.config, so a login or a game-create never queries it.
|
||||||
|
package gamelimits
|
||||||
|
|
||||||
|
import (
|
||||||
|
"context"
|
||||||
|
"database/sql"
|
||||||
|
"fmt"
|
||||||
|
"sync"
|
||||||
|
|
||||||
|
"github.com/go-jet/jet/v2/postgres"
|
||||||
|
|
||||||
|
"scrabble/backend/internal/postgres/jet/backend/model"
|
||||||
|
"scrabble/backend/internal/postgres/jet/backend/table"
|
||||||
|
)
|
||||||
|
|
||||||
|
// Kind is a game's kind, mirroring backend.games.game_kind. 0 (unknown) is an untagged game, never gated.
|
||||||
|
type Kind int16
|
||||||
|
|
||||||
|
const (
|
||||||
|
KindUnknown Kind = 0
|
||||||
|
KindVsAI Kind = 1
|
||||||
|
KindRandom Kind = 2
|
||||||
|
KindFriends Kind = 3
|
||||||
|
)
|
||||||
|
|
||||||
|
// String returns the kind's label for admin game lists and logs.
|
||||||
|
func (k Kind) String() string {
|
||||||
|
switch k {
|
||||||
|
case KindVsAI:
|
||||||
|
return "vs_ai"
|
||||||
|
case KindRandom:
|
||||||
|
return "random"
|
||||||
|
case KindFriends:
|
||||||
|
return "friends"
|
||||||
|
default:
|
||||||
|
return "unknown"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Unlimited is the limit sentinel meaning no cap.
|
||||||
|
const Unlimited = -1
|
||||||
|
|
||||||
|
// Limits is a tier's active-game caps per kind (-1 = unlimited).
|
||||||
|
type Limits struct {
|
||||||
|
VsAI int
|
||||||
|
Random int
|
||||||
|
Friends int
|
||||||
|
}
|
||||||
|
|
||||||
|
// Cap returns the limit for kind; the unknown kind is never capped.
|
||||||
|
func (l Limits) Cap(kind Kind) int {
|
||||||
|
switch kind {
|
||||||
|
case KindVsAI:
|
||||||
|
return l.VsAI
|
||||||
|
case KindRandom:
|
||||||
|
return l.Random
|
||||||
|
case KindFriends:
|
||||||
|
return l.Friends
|
||||||
|
default:
|
||||||
|
return Unlimited
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Config is the per-tier limit config (the single backend.config row).
|
||||||
|
type Config struct {
|
||||||
|
Guest Limits
|
||||||
|
Durable Limits
|
||||||
|
}
|
||||||
|
|
||||||
|
// Store reads and writes the single-row backend.config.
|
||||||
|
type Store struct{ db *sql.DB }
|
||||||
|
|
||||||
|
// NewStore constructs a Store over db.
|
||||||
|
func NewStore(db *sql.DB) *Store { return &Store{db: db} }
|
||||||
|
|
||||||
|
func (s *Store) load(ctx context.Context) (Config, error) {
|
||||||
|
var c model.Config
|
||||||
|
if err := postgres.SELECT(table.Config.AllColumns).FROM(table.Config).LIMIT(1).
|
||||||
|
QueryContext(ctx, s.db, &c); err != nil {
|
||||||
|
return Config{}, fmt.Errorf("gamelimits: load config: %w", err)
|
||||||
|
}
|
||||||
|
return Config{
|
||||||
|
Guest: Limits{VsAI: int(c.GuestVsAiLimit), Random: int(c.GuestRandomLimit), Friends: int(c.GuestFriendsLimit)},
|
||||||
|
Durable: Limits{VsAI: int(c.DurableVsAiLimit), Random: int(c.DurableRandomLimit), Friends: int(c.DurableFriendsLimit)},
|
||||||
|
}, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func (s *Store) save(ctx context.Context, c Config) error {
|
||||||
|
if _, err := s.db.ExecContext(ctx,
|
||||||
|
`UPDATE backend.config SET guest_vs_ai_limit=$1, guest_random_limit=$2, guest_friends_limit=$3,
|
||||||
|
durable_vs_ai_limit=$4, durable_random_limit=$5, durable_friends_limit=$6 WHERE only_row`,
|
||||||
|
c.Guest.VsAI, c.Guest.Random, c.Guest.Friends, c.Durable.VsAI, c.Durable.Random, c.Durable.Friends); err != nil {
|
||||||
|
return fmt.Errorf("gamelimits: save config: %w", err)
|
||||||
|
}
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// Service fronts the config with an in-memory cache (single-instance, matching the deploy). Load
|
||||||
|
// once at startup; Update after an admin edit refreshes it in place.
|
||||||
|
type Service struct {
|
||||||
|
store *Store
|
||||||
|
mu sync.RWMutex
|
||||||
|
cfg Config
|
||||||
|
}
|
||||||
|
|
||||||
|
// NewService constructs a Service over store. Call Load before serving.
|
||||||
|
func NewService(store *Store) *Service { return &Service{store: store} }
|
||||||
|
|
||||||
|
// Load reads the config into the cache. Call once at startup.
|
||||||
|
func (s *Service) Load(ctx context.Context) error {
|
||||||
|
c, err := s.store.load(ctx)
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
s.set(c)
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func (s *Service) set(c Config) {
|
||||||
|
s.mu.Lock()
|
||||||
|
s.cfg = c
|
||||||
|
s.mu.Unlock()
|
||||||
|
}
|
||||||
|
|
||||||
|
// Get returns the cached config.
|
||||||
|
func (s *Service) Get() Config {
|
||||||
|
s.mu.RLock()
|
||||||
|
defer s.mu.RUnlock()
|
||||||
|
return s.cfg
|
||||||
|
}
|
||||||
|
|
||||||
|
// LimitsFor returns the cached limits for the account tier (guest or durable).
|
||||||
|
func (s *Service) LimitsFor(isGuest bool) Limits {
|
||||||
|
c := s.Get()
|
||||||
|
if isGuest {
|
||||||
|
return c.Guest
|
||||||
|
}
|
||||||
|
return c.Durable
|
||||||
|
}
|
||||||
|
|
||||||
|
// Update saves the config and refreshes the cache in place (the admin edit).
|
||||||
|
func (s *Service) Update(ctx context.Context, c Config) error {
|
||||||
|
if err := s.store.save(ctx, c); err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
s.set(c)
|
||||||
|
return nil
|
||||||
|
}
|
||||||
@@ -0,0 +1,44 @@
|
|||||||
|
package gamelimits
|
||||||
|
|
||||||
|
import "testing"
|
||||||
|
|
||||||
|
// TestLimitsCap checks Cap maps each kind to its field and leaves the unknown kind uncapped, so a
|
||||||
|
// untagged game (game_kind 0) is never gated.
|
||||||
|
func TestLimitsCap(t *testing.T) {
|
||||||
|
l := Limits{VsAI: 1, Random: 2, Friends: 3}
|
||||||
|
for _, tc := range []struct {
|
||||||
|
kind Kind
|
||||||
|
want int
|
||||||
|
}{
|
||||||
|
{KindVsAI, 1},
|
||||||
|
{KindRandom, 2},
|
||||||
|
{KindFriends, 3},
|
||||||
|
{KindUnknown, Unlimited},
|
||||||
|
{Kind(99), Unlimited},
|
||||||
|
} {
|
||||||
|
if got := l.Cap(tc.kind); got != tc.want {
|
||||||
|
t.Errorf("Cap(%d) = %d, want %d", tc.kind, got, tc.want)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// TestServiceLimitsForTier checks LimitsFor selects the guest or durable tier from the cached config.
|
||||||
|
func TestServiceLimitsForTier(t *testing.T) {
|
||||||
|
svc := NewService(nil)
|
||||||
|
svc.set(Config{
|
||||||
|
Guest: Limits{VsAI: 1, Random: 1, Friends: 0},
|
||||||
|
Durable: Limits{VsAI: 10, Random: 10, Friends: 10},
|
||||||
|
})
|
||||||
|
|
||||||
|
if got := svc.LimitsFor(true); got != (Limits{VsAI: 1, Random: 1, Friends: 0}) {
|
||||||
|
t.Errorf("guest limits = %+v, want {1 1 0}", got)
|
||||||
|
}
|
||||||
|
if got := svc.LimitsFor(false); got != (Limits{VsAI: 10, Random: 10, Friends: 10}) {
|
||||||
|
t.Errorf("durable limits = %+v, want {10 10 10}", got)
|
||||||
|
}
|
||||||
|
// The unlimited sentinel resolves through the tier too.
|
||||||
|
svc.set(Config{Durable: Limits{VsAI: Unlimited, Random: Unlimited, Friends: Unlimited}})
|
||||||
|
if got := svc.LimitsFor(false).Cap(KindVsAI); got != Unlimited {
|
||||||
|
t.Errorf("durable vs_ai cap = %d, want unlimited (-1)", got)
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,79 @@
|
|||||||
|
//go:build integration
|
||||||
|
|
||||||
|
package inttest
|
||||||
|
|
||||||
|
import (
|
||||||
|
"context"
|
||||||
|
"net/http"
|
||||||
|
"strings"
|
||||||
|
"testing"
|
||||||
|
|
||||||
|
"github.com/google/uuid"
|
||||||
|
|
||||||
|
"scrabble/backend/internal/payments"
|
||||||
|
)
|
||||||
|
|
||||||
|
// benefitFor returns the account's benefit on the given origin from its statement.
|
||||||
|
func benefitFor(t *testing.T, pay *payments.Service, id uuid.UUID, origin payments.Source) payments.OriginBenefit {
|
||||||
|
t.Helper()
|
||||||
|
stmt, err := pay.AccountStatement(context.Background(), id)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("statement: %v", err)
|
||||||
|
}
|
||||||
|
for _, b := range stmt.Benefits {
|
||||||
|
if b.Origin == origin {
|
||||||
|
return b
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return payments.OriginBenefit{}
|
||||||
|
}
|
||||||
|
|
||||||
|
// TestConsoleAdminGrant drives the admin grant: a raw benefit grant, a by-product grant of a reward
|
||||||
|
// bundle, and a refusal to grant a chips pack; the create is CSRF-guarded.
|
||||||
|
func TestConsoleAdminGrant(t *testing.T) {
|
||||||
|
ctx := context.Background()
|
||||||
|
srv, _, pay := bannerServer(t)
|
||||||
|
h := srv.Handler()
|
||||||
|
id := provisionAccount(t)
|
||||||
|
const origin = "http://admin.test"
|
||||||
|
base := "http://admin.test/_gm/users/" + id.String()
|
||||||
|
|
||||||
|
// CSRF: a grant without the origin header is refused.
|
||||||
|
if code, _ := consoleDo(h, http.MethodPost, base+"/grant", "origin=direct&hints=1", ""); code != http.StatusForbidden {
|
||||||
|
t.Fatalf("grant without origin = %d, want 403", code)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Raw grant: 5 hints + 30 no-ads days to the direct origin.
|
||||||
|
if code, body := consoleDo(h, http.MethodPost, base+"/grant", "origin=direct&hints=5&noads=30", origin); code != http.StatusOK || !strings.Contains(body, "Granted") {
|
||||||
|
t.Fatalf("raw grant = %d, has 'Granted' = %v", code, strings.Contains(body, "Granted"))
|
||||||
|
}
|
||||||
|
if b := benefitFor(t, pay, id, payments.SourceDirect); b.Hints != 5 || b.AdsPaidUntil.IsZero() {
|
||||||
|
t.Fatalf("after raw grant: hints=%d adsUntil-zero=%v, want 5 hints + a no-ads term", b.Hints, b.AdsPaidUntil.IsZero())
|
||||||
|
}
|
||||||
|
|
||||||
|
// By-product grant: an archived reward bundle (3 hints) to vk.
|
||||||
|
reward, err := pay.CreateProduct(ctx, payments.ProductInput{
|
||||||
|
Title: "reward-3-hints", Atoms: []payments.AtomLine{{Atom: "hints", Quantity: 3}},
|
||||||
|
}, false)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("create reward: %v", err)
|
||||||
|
}
|
||||||
|
if code, body := consoleDo(h, http.MethodPost, base+"/grant-product", "origin=vk&product_id="+reward.String(), origin); code != http.StatusOK || !strings.Contains(body, "Granted") {
|
||||||
|
t.Fatalf("product grant = %d, has 'Granted' = %v", code, strings.Contains(body, "Granted"))
|
||||||
|
}
|
||||||
|
if b := benefitFor(t, pay, id, payments.SourceVK); b.Hints != 3 {
|
||||||
|
t.Fatalf("after product grant: vk hints=%d, want 3", b.Hints)
|
||||||
|
}
|
||||||
|
|
||||||
|
// A chips pack cannot be granted.
|
||||||
|
pack, err := pay.CreateProduct(ctx, payments.ProductInput{
|
||||||
|
Title: "grant-pack", Atoms: []payments.AtomLine{{Atom: "chips", Quantity: 100}},
|
||||||
|
Prices: []payments.PriceLine{{Method: "direct", Currency: payments.CurrencyRUB, Amount: 14900}},
|
||||||
|
}, true)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("create pack: %v", err)
|
||||||
|
}
|
||||||
|
if code, body := consoleDo(h, http.MethodPost, base+"/grant-product", "origin=direct&product_id="+pack.String(), origin); code != http.StatusOK || !strings.Contains(body, "cannot grant chips") {
|
||||||
|
t.Fatalf("chips grant = %d, has 'cannot grant chips' = %v", code, strings.Contains(body, "cannot grant chips"))
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,77 @@
|
|||||||
|
//go:build integration
|
||||||
|
|
||||||
|
package inttest
|
||||||
|
|
||||||
|
import (
|
||||||
|
"context"
|
||||||
|
"net/http"
|
||||||
|
"strings"
|
||||||
|
"testing"
|
||||||
|
|
||||||
|
"scrabble/backend/internal/payments"
|
||||||
|
)
|
||||||
|
|
||||||
|
// TestConsoleRefundAndExport drives the manual refund and the ledger CSV export: a funded order is
|
||||||
|
// refunded in full (chips revoked, a refund row), the refund is idempotent, and the export carries
|
||||||
|
// the fund + refund rows; the refund POST is CSRF-guarded.
|
||||||
|
func TestConsoleRefundAndExport(t *testing.T) {
|
||||||
|
ctx := context.Background()
|
||||||
|
srv, _, pay := bannerServer(t)
|
||||||
|
h := srv.Handler()
|
||||||
|
id := provisionAccount(t)
|
||||||
|
const origin = "http://admin.test"
|
||||||
|
base := "http://admin.test/_gm/users/" + id.String()
|
||||||
|
|
||||||
|
// Fund a pack: 100 chips + a fund ledger row.
|
||||||
|
prod := seedPackProduct(t, 100, methodPrice{method: "direct", currency: "RUB", amount: 14900})
|
||||||
|
res, err := pay.CreateOrder(ctx, id, payments.NewContext("direct", "web"), []payments.Source{payments.SourceDirect}, prod, "robokassa")
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("order: %v", err)
|
||||||
|
}
|
||||||
|
paid, _ := payments.MoneyFromMinor(14900, payments.CurrencyRUB)
|
||||||
|
if _, err := pay.Fund(ctx, res.OrderID, "robokassa", res.OrderID.String(), paid); err != nil {
|
||||||
|
t.Fatalf("fund: %v", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
// CSRF: a refund without the origin header is refused.
|
||||||
|
if code, _ := consoleDo(h, http.MethodPost, base+"/refund", "order_id="+res.OrderID.String(), ""); code != http.StatusForbidden {
|
||||||
|
t.Fatalf("refund without origin = %d, want 403", code)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Refund the order in full → 100 chips revoked (none spent).
|
||||||
|
if code, body := consoleDo(h, http.MethodPost, base+"/refund", "order_id="+res.OrderID.String(), origin); code != http.StatusOK || !strings.Contains(body, "revoked 100 chips") {
|
||||||
|
t.Fatalf("refund = %d, has 'revoked 100 chips' = %v", code, strings.Contains(body, "revoked 100 chips"))
|
||||||
|
}
|
||||||
|
stmt, err := pay.AccountStatement(ctx, id)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("statement: %v", err)
|
||||||
|
}
|
||||||
|
for _, sg := range stmt.Segments {
|
||||||
|
if sg.Source == payments.SourceDirect && sg.Chips != 0 {
|
||||||
|
t.Errorf("after refund: direct chips = %d, want 0", sg.Chips)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
kinds := map[string]int{}
|
||||||
|
for _, e := range stmt.Ledger {
|
||||||
|
kinds[e.Kind]++
|
||||||
|
}
|
||||||
|
if kinds["fund"] != 1 || kinds["refund"] != 1 {
|
||||||
|
t.Errorf("ledger kinds = %v, want one fund + one refund", kinds)
|
||||||
|
}
|
||||||
|
|
||||||
|
// A second refund of the same order is idempotent.
|
||||||
|
if code, body := consoleDo(h, http.MethodPost, base+"/refund", "order_id="+res.OrderID.String(), origin); code != http.StatusOK || !strings.Contains(body, "Already refunded") {
|
||||||
|
t.Fatalf("second refund = %d, has 'Already refunded' = %v", code, strings.Contains(body, "Already refunded"))
|
||||||
|
}
|
||||||
|
|
||||||
|
// Export the whole ledger as CSV: the header, this account, and its fund + refund rows.
|
||||||
|
code, body := consoleDo(h, http.MethodGet, "http://admin.test/_gm/ledger.csv", "", "")
|
||||||
|
if code != http.StatusOK {
|
||||||
|
t.Fatalf("export = %d, want 200", code)
|
||||||
|
}
|
||||||
|
for _, want := range []string{"created_at,account_id,kind", id.String(), ",fund,", ",refund,"} {
|
||||||
|
if !strings.Contains(body, want) {
|
||||||
|
t.Errorf("ledger CSV missing %q", want)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -16,7 +16,7 @@ import (
|
|||||||
// productByTitle finds a catalog product by its (unique in the test) title.
|
// productByTitle finds a catalog product by its (unique in the test) title.
|
||||||
func productByTitle(t *testing.T, pay *payments.Service, title string) payments.AdminProduct {
|
func productByTitle(t *testing.T, pay *payments.Service, title string) payments.AdminProduct {
|
||||||
t.Helper()
|
t.Helper()
|
||||||
all, err := pay.AdminCatalog(context.Background())
|
all, err := pay.AdminCatalog(context.Background(), true)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
t.Fatalf("admin catalog: %v", err)
|
t.Fatalf("admin catalog: %v", err)
|
||||||
}
|
}
|
||||||
@@ -57,7 +57,7 @@ func TestConsoleCatalogEditor(t *testing.T) {
|
|||||||
if code, body := consoleDo(h, http.MethodPost, catalog, "title=cat-bad&chips=100&active=true", origin); code != http.StatusOK || !strings.Contains(body, "Invalid product") {
|
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"))
|
t.Fatalf("bad pack = %d, has 'Invalid product' = %v", code, strings.Contains(body, "Invalid product"))
|
||||||
}
|
}
|
||||||
if _, err := pay.AdminCatalog(ctx); err != nil {
|
if _, err := pay.AdminCatalog(ctx, true); err != nil {
|
||||||
t.Fatalf("catalog: %v", err)
|
t.Fatalf("catalog: %v", err)
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -95,3 +95,59 @@ func TestConsoleCatalogEditor(t *testing.T) {
|
|||||||
t.Fatalf("delete transacted = %d, has 'Cannot delete' = %v", code, strings.Contains(body, "Cannot delete"))
|
t.Fatalf("delete transacted = %d, has 'Cannot delete' = %v", code, strings.Contains(body, "Cannot delete"))
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// TestConsoleCatalogActiveToggle checks the catalog console lists only active products by default and
|
||||||
|
// includes the archived ones under ?all=1 (the active/all toggle).
|
||||||
|
func TestConsoleCatalogActiveToggle(t *testing.T) {
|
||||||
|
srv, _, _ := bannerServer(t)
|
||||||
|
h := srv.Handler()
|
||||||
|
const origin = "http://admin.test"
|
||||||
|
const catalog = "http://admin.test/_gm/catalog"
|
||||||
|
|
||||||
|
// An active value and an archived one (created without the active flag).
|
||||||
|
if code, _ := consoleDo(h, http.MethodPost, catalog, "title=toggle-active&hints=5&price_chip=50&active=true", origin); code != http.StatusOK {
|
||||||
|
t.Fatal("create active failed")
|
||||||
|
}
|
||||||
|
if code, _ := consoleDo(h, http.MethodPost, catalog, "title=toggle-archived&hints=9&price_chip=90", origin); code != http.StatusOK {
|
||||||
|
t.Fatal("create archived failed")
|
||||||
|
}
|
||||||
|
|
||||||
|
// Default view: active only.
|
||||||
|
if _, body := consoleDo(h, http.MethodGet, catalog, "", ""); !strings.Contains(body, "toggle-active") || strings.Contains(body, "toggle-archived") {
|
||||||
|
t.Errorf("default view: active listed=%v, archived listed=%v (want true/false)",
|
||||||
|
strings.Contains(body, "toggle-active"), strings.Contains(body, "toggle-archived"))
|
||||||
|
}
|
||||||
|
|
||||||
|
// ?all=1: active and archived.
|
||||||
|
if _, body := consoleDo(h, http.MethodGet, catalog+"?all=1", "", ""); !strings.Contains(body, "toggle-active") || !strings.Contains(body, "toggle-archived") {
|
||||||
|
t.Errorf("all view: active listed=%v, archived listed=%v (want true/true)",
|
||||||
|
strings.Contains(body, "toggle-active"), strings.Contains(body, "toggle-archived"))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// TestConsoleRewardConfig checks the rewarded-ads config form on the catalog page: a POST updates the
|
||||||
|
// payout and caps, the page pre-fills the current values, and a negative value is refused.
|
||||||
|
func TestConsoleRewardConfig(t *testing.T) {
|
||||||
|
srv, _, pay := bannerServer(t)
|
||||||
|
h := srv.Handler()
|
||||||
|
const origin = "http://admin.test"
|
||||||
|
const reward = "http://admin.test/_gm/catalog/reward"
|
||||||
|
|
||||||
|
// Set the payout and caps.
|
||||||
|
if code, body := consoleDo(h, http.MethodPost, reward, "reward_payout=7&reward_daily=40&reward_hourly=9", origin); code != http.StatusOK || !strings.Contains(body, "Saved") {
|
||||||
|
t.Fatalf("set reward = %d, has 'Saved' = %v", code, strings.Contains(body, "Saved"))
|
||||||
|
}
|
||||||
|
if payout, daily, hourly, err := pay.RewardConfig(context.Background()); err != nil || payout != 7 || daily != 40 || hourly != 9 {
|
||||||
|
t.Fatalf("reward config = %d/%d/%d (err %v), want 7/40/9", payout, daily, hourly, err)
|
||||||
|
}
|
||||||
|
|
||||||
|
// The catalog page renders the form pre-filled with the current payout.
|
||||||
|
if _, body := consoleDo(h, http.MethodGet, "http://admin.test/_gm/catalog", "", ""); !strings.Contains(body, "reward_payout") || !strings.Contains(body, `value="7"`) {
|
||||||
|
t.Error("catalog page did not render the reward form with the current payout")
|
||||||
|
}
|
||||||
|
|
||||||
|
// A negative value is refused (the service validates non-negativity).
|
||||||
|
if code, body := consoleDo(h, http.MethodPost, reward, "reward_payout=-1", origin); code != http.StatusOK || !strings.Contains(body, "non-negative") {
|
||||||
|
t.Errorf("negative payout = %d, has 'non-negative' = %v", code, strings.Contains(body, "non-negative"))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|||||||
@@ -5,6 +5,7 @@ package inttest
|
|||||||
import (
|
import (
|
||||||
"context"
|
"context"
|
||||||
"encoding/json"
|
"encoding/json"
|
||||||
|
"errors"
|
||||||
"fmt"
|
"fmt"
|
||||||
"net/http"
|
"net/http"
|
||||||
"net/http/httptest"
|
"net/http/httptest"
|
||||||
@@ -18,59 +19,119 @@ import (
|
|||||||
"scrabble/backend/internal/account"
|
"scrabble/backend/internal/account"
|
||||||
"scrabble/backend/internal/engine"
|
"scrabble/backend/internal/engine"
|
||||||
"scrabble/backend/internal/game"
|
"scrabble/backend/internal/game"
|
||||||
|
"scrabble/backend/internal/gamelimits"
|
||||||
|
"scrabble/backend/internal/lobby"
|
||||||
"scrabble/backend/internal/server"
|
"scrabble/backend/internal/server"
|
||||||
|
"scrabble/backend/internal/social"
|
||||||
)
|
)
|
||||||
|
|
||||||
// The game-limit suite covers the simultaneous quick-game cap (game.MaxActiveQuickGames):
|
// The game-limit suite covers the per-tier, per-kind active-game caps (backend.config): the
|
||||||
// the counting rule (Store/Service.CountActiveQuickGames) and the HTTP gate that refuses a
|
// game_kind tag persisted per creation path, the tier resolution + counting rule
|
||||||
// new game once the cap is reached, while accepting an incoming invitation stays allowed.
|
// (game.Service.AtGameLimit), the HTTP gate + lobby at_game_limit flag, the guest gate on friend
|
||||||
|
// actions, and the config hot-cache reflecting an admin edit. Accepting an invitation stays exempt.
|
||||||
|
|
||||||
// TestCountActiveQuickGames checks the count includes active and open quick games (the
|
// newGameLimits builds a gamelimits service over the shared pool and loads the seeded config
|
||||||
// honest-AI ones among them) and excludes finished games, friend games (created by
|
// (guest 1/1/0, durable 10/10/10).
|
||||||
// invitation) and games the account is not seated in.
|
func newGameLimits(t *testing.T) *gamelimits.Service {
|
||||||
func TestCountActiveQuickGames(t *testing.T) {
|
t.Helper()
|
||||||
|
gl := gamelimits.NewService(gamelimits.NewStore(testDB))
|
||||||
|
if err := gl.Load(context.Background()); err != nil {
|
||||||
|
t.Fatalf("load game limits: %v", err)
|
||||||
|
}
|
||||||
|
return gl
|
||||||
|
}
|
||||||
|
|
||||||
|
// restoreDefaultLimits resets the single config row to the migration seed on cleanup, so a test that
|
||||||
|
// edited it never leaks its values into another (the row is a shared singleton).
|
||||||
|
func restoreDefaultLimits(t *testing.T, gl *gamelimits.Service) {
|
||||||
|
t.Helper()
|
||||||
|
t.Cleanup(func() {
|
||||||
|
_ = gl.Update(context.Background(), gamelimits.Config{
|
||||||
|
Guest: gamelimits.Limits{VsAI: 1, Random: 1, Friends: 0},
|
||||||
|
Durable: gamelimits.Limits{VsAI: 10, Random: 10, Friends: 10},
|
||||||
|
})
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
// gameKind reads a game's persisted game_kind tag.
|
||||||
|
func gameKind(t *testing.T, gameID uuid.UUID) int16 {
|
||||||
|
t.Helper()
|
||||||
|
var k int16
|
||||||
|
if err := testDB.QueryRowContext(context.Background(),
|
||||||
|
`SELECT game_kind FROM backend.games WHERE game_id=$1`, gameID).Scan(&k); err != nil {
|
||||||
|
t.Fatalf("read game_kind: %v", err)
|
||||||
|
}
|
||||||
|
return k
|
||||||
|
}
|
||||||
|
|
||||||
|
// mustCreateKind creates an active game seating the accounts, tagged with kind, and returns its id.
|
||||||
|
func mustCreateKind(t *testing.T, games *game.Service, seats []uuid.UUID, kind gamelimits.Kind) uuid.UUID {
|
||||||
|
t.Helper()
|
||||||
|
g, err := games.Create(context.Background(), game.CreateParams{
|
||||||
|
Variant: engine.VariantEnglish, Seats: seats, TurnTimeout: 24 * time.Hour, Kind: kind,
|
||||||
|
})
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("create game (kind=%d): %v", kind, err)
|
||||||
|
}
|
||||||
|
return g.ID
|
||||||
|
}
|
||||||
|
|
||||||
|
// startFriendGameID has inviter invite invitee to a friend game and the invitee accept, returning
|
||||||
|
// the started game's id.
|
||||||
|
func startFriendGameID(t *testing.T, inv *lobby.InvitationService, inviter, invitee uuid.UUID) uuid.UUID {
|
||||||
|
t.Helper()
|
||||||
|
ctx := context.Background()
|
||||||
|
invitation, err := inv.CreateInvitation(ctx, inviter, []uuid.UUID{invitee}, englishInvite())
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("create invitation: %v", err)
|
||||||
|
}
|
||||||
|
got, err := inv.RespondInvitation(ctx, invitation.ID, invitee, true)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("accept invitation: %v", err)
|
||||||
|
}
|
||||||
|
if got.GameID == nil {
|
||||||
|
t.Fatal("accepted invitation has no game id")
|
||||||
|
}
|
||||||
|
return *got.GameID
|
||||||
|
}
|
||||||
|
|
||||||
|
// TestGameKindPersisted checks each creation path stamps the right game_kind: random (auto-match)
|
||||||
|
// =2, vs_ai =1, friend (by invitation) =3.
|
||||||
|
func TestGameKindPersisted(t *testing.T) {
|
||||||
ctx := context.Background()
|
ctx := context.Background()
|
||||||
clearOpenGames(t)
|
clearOpenGames(t)
|
||||||
games := newGameService()
|
games := newGameService()
|
||||||
human := provisionAccount(t)
|
inv := newInvitationService()
|
||||||
opp := provisionAccount(t)
|
human, opp := provisionAccount(t), provisionAccount(t)
|
||||||
|
|
||||||
if n := mustCount(t, games, human); n != 0 {
|
rnd, _, err := games.OpenOrJoin(ctx, human, game.CreateParams{Variant: engine.VariantEnglish, TurnTimeout: 24 * time.Hour}, time.Now().Add(time.Minute), nil)
|
||||||
t.Fatalf("fresh account count = %d, want 0", n)
|
if err != nil {
|
||||||
|
t.Fatalf("open random game: %v", err)
|
||||||
|
}
|
||||||
|
if k := gameKind(t, rnd.ID); k != int16(gamelimits.KindRandom) {
|
||||||
|
t.Errorf("random game_kind = %d, want %d", k, gamelimits.KindRandom)
|
||||||
}
|
}
|
||||||
|
|
||||||
// An open (awaiting-opponent) quick game counts.
|
ai := mustCreateKind(t, games, []uuid.UUID{human, opp}, gamelimits.KindVsAI)
|
||||||
if _, _, err := games.OpenOrJoin(ctx, human, game.CreateParams{
|
if k := gameKind(t, ai); k != int16(gamelimits.KindVsAI) {
|
||||||
Variant: engine.VariantEnglish, TurnTimeout: 24 * time.Hour,
|
t.Errorf("vs_ai game_kind = %d, want %d", k, gamelimits.KindVsAI)
|
||||||
}, time.Now().Add(time.Minute), nil); err != nil {
|
|
||||||
t.Fatalf("open quick game: %v", err)
|
|
||||||
}
|
}
|
||||||
// An active quick game and an honest-AI quick game both count (neither has an invitation row).
|
|
||||||
mustCreateQuick(t, games, []uuid.UUID{human, opp}, false, 1)
|
|
||||||
mustCreateQuick(t, games, []uuid.UUID{human, opp}, true, 2)
|
|
||||||
|
|
||||||
// A finished quick game does NOT count.
|
friend := startFriendGameID(t, inv, human, opp)
|
||||||
fin := mustCreateQuick(t, games, []uuid.UUID{human, opp}, false, 3)
|
if k := gameKind(t, friend); k != int16(gamelimits.KindFriends) {
|
||||||
if _, err := testDB.ExecContext(ctx, `UPDATE backend.games SET status='finished' WHERE game_id=$1`, fin); err != nil {
|
t.Errorf("friend game_kind = %d, want %d", k, gamelimits.KindFriends)
|
||||||
t.Fatalf("finish game: %v", err)
|
|
||||||
}
|
|
||||||
// A game the human is not seated in does NOT count.
|
|
||||||
mustCreateQuick(t, games, []uuid.UUID{opp, provisionAccount(t)}, false, 4)
|
|
||||||
// A friend game (created by invitation) does NOT count, even though it is active.
|
|
||||||
startFriendGame(t, human)
|
|
||||||
|
|
||||||
if n := mustCount(t, games, human); n != 3 {
|
|
||||||
t.Fatalf("active quick count = %d, want 3 (active + AI + open)", n)
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// TestGameLimitGate drives the cap through the assembled HTTP server: under the cap the lobby
|
// TestGuestActiveGameLimitHTTP drives the guest random cap through the assembled server: the first
|
||||||
// reports at_game_limit false; at the cap it flips to true and both new-game endpoints (quick
|
// auto-match opens a game, the lobby then flags at_game_limit, and a second enqueue is refused 409
|
||||||
// enqueue and invitation creation) are refused with 409 game_limit_reached, while accepting an
|
// game_limit_reached. It also checks the guest vs_ai and friends caps resolve at the domain level.
|
||||||
// incoming invitation is still allowed.
|
func TestGuestActiveGameLimitHTTP(t *testing.T) {
|
||||||
func TestGameLimitGate(t *testing.T) {
|
|
||||||
ctx := context.Background()
|
ctx := context.Background()
|
||||||
|
clearOpenGames(t)
|
||||||
|
gl := newGameLimits(t)
|
||||||
games := newGameService()
|
games := newGameService()
|
||||||
|
games.SetGameLimits(gl)
|
||||||
srv := server.New(":0", server.Deps{
|
srv := server.New(":0", server.Deps{
|
||||||
Logger: zaptest.NewLogger(t),
|
Logger: zaptest.NewLogger(t),
|
||||||
DB: testDB,
|
DB: testDB,
|
||||||
@@ -78,88 +139,110 @@ func TestGameLimitGate(t *testing.T) {
|
|||||||
Games: games,
|
Games: games,
|
||||||
Matchmaker: newMatchmaker(t, newRobotService(t, newGameService()), time.Minute, 0),
|
Matchmaker: newMatchmaker(t, newRobotService(t, newGameService()), time.Minute, 0),
|
||||||
Invitations: newInvitationService(),
|
Invitations: newInvitationService(),
|
||||||
|
GameLimits: gl,
|
||||||
})
|
})
|
||||||
|
|
||||||
human := provisionAccount(t)
|
guest := provisionGuest(t)
|
||||||
|
if gamesListAtLimit(t, srv, guest) {
|
||||||
|
t.Fatal("a fresh guest must be under the random game limit")
|
||||||
|
}
|
||||||
|
// The first auto-match opens a random game (guest random cap = 1).
|
||||||
|
if rec := userPost(t, srv, "/api/v1/user/lobby/enqueue", guest, `{"variant":"erudit_ru"}`); rec.Code != http.StatusOK {
|
||||||
|
t.Fatalf("first enqueue = %d (%s), want 200", rec.Code, rec.Body.String())
|
||||||
|
}
|
||||||
|
// At the cap: the lobby flags it and a second enqueue is refused with the stable code.
|
||||||
|
if !gamesListAtLimit(t, srv, guest) {
|
||||||
|
t.Fatal("after one random game the guest must be at the limit")
|
||||||
|
}
|
||||||
|
if rec := userPost(t, srv, "/api/v1/user/lobby/enqueue", guest, `{"variant":"erudit_ru"}`); rec.Code != http.StatusConflict || errorCode(t, rec) != "game_limit_reached" {
|
||||||
|
t.Fatalf("second enqueue = (%d, %q), want (409, game_limit_reached)", rec.Code, errorCode(t, rec))
|
||||||
|
}
|
||||||
|
|
||||||
|
// A guest is refused the friends flow outright at the HTTP edge (403 guest_forbidden).
|
||||||
opp := provisionAccount(t)
|
opp := provisionAccount(t)
|
||||||
|
|
||||||
// Under the cap: the lobby reports the player is not limited.
|
|
||||||
if gamesListAtLimit(t, srv, human) {
|
|
||||||
t.Fatal("a fresh account must be under the game limit")
|
|
||||||
}
|
|
||||||
|
|
||||||
// Reach the cap with active quick games seating the human (no invitation row → quick).
|
|
||||||
for i := 0; i < game.MaxActiveQuickGames; i++ {
|
|
||||||
mustCreateQuick(t, games, []uuid.UUID{human, opp}, false, int64(i+1))
|
|
||||||
}
|
|
||||||
|
|
||||||
// At the cap: the lobby flags it and both create paths are refused with the stable code.
|
|
||||||
if !gamesListAtLimit(t, srv, human) {
|
|
||||||
t.Fatalf("at %d games at_game_limit must be true", game.MaxActiveQuickGames)
|
|
||||||
}
|
|
||||||
// erudit_ru is in the default variant preferences, so the variant gate passes and the
|
|
||||||
// game-limit gate is what fires here.
|
|
||||||
if rec := userPost(t, srv, "/api/v1/user/lobby/enqueue", human, `{"variant":"erudit_ru"}`); rec.Code != http.StatusConflict || errorCode(t, rec) != "game_limit_reached" {
|
|
||||||
t.Fatalf("enqueue at limit = (%d, %q), want (409, game_limit_reached)", rec.Code, errorCode(t, rec))
|
|
||||||
}
|
|
||||||
invBody := fmt.Sprintf(`{"variant":"erudit_ru","invitee_ids":[%q]}`, opp.String())
|
invBody := fmt.Sprintf(`{"variant":"erudit_ru","invitee_ids":[%q]}`, opp.String())
|
||||||
if rec := userPost(t, srv, "/api/v1/user/invitations", human, invBody); rec.Code != http.StatusConflict || errorCode(t, rec) != "game_limit_reached" {
|
if rec := userPost(t, srv, "/api/v1/user/invitations", guest, invBody); rec.Code != http.StatusForbidden || errorCode(t, rec) != "guest_forbidden" {
|
||||||
t.Fatalf("invitation at limit = (%d, %q), want (409, game_limit_reached)", rec.Code, errorCode(t, rec))
|
t.Fatalf("guest invitation = (%d, %q), want (403, guest_forbidden)", rec.Code, errorCode(t, rec))
|
||||||
}
|
}
|
||||||
|
|
||||||
// Accepting an incoming invitation is never blocked, even at the cap: another player invites
|
// The guest vs_ai cap is 1: one vs_ai game puts the guest at the vs_ai limit.
|
||||||
// the capped human, who accepts over HTTP and the game starts (friend games do not count).
|
mustCreateKind(t, games, []uuid.UUID{guest, opp}, gamelimits.KindVsAI)
|
||||||
inviter := provisionAccount(t)
|
if at, err := games.AtGameLimit(ctx, guest, gamelimits.KindVsAI); err != nil || !at {
|
||||||
inv := newInvitationService()
|
t.Fatalf("guest vs_ai at-limit = (%v, %v), want (true, nil)", at, err)
|
||||||
invitation, err := inv.CreateInvitation(ctx, inviter, []uuid.UUID{human}, englishInvite())
|
|
||||||
if err != nil {
|
|
||||||
t.Fatalf("create invitation: %v", err)
|
|
||||||
}
|
}
|
||||||
if rec := userPost(t, srv, "/api/v1/user/invitations/"+invitation.ID.String()+"/accept", human, ""); rec.Code != http.StatusOK {
|
// The guest friends cap is 0: a guest is always at the friends limit (the 403 gate blocks first).
|
||||||
t.Fatalf("accept at limit = %d (%s), want 200 — accept must bypass the cap", rec.Code, rec.Body.String())
|
if at, err := games.AtGameLimit(ctx, guest, gamelimits.KindFriends); err != nil || !at {
|
||||||
|
t.Fatalf("guest friends at-limit = (%v, %v), want (true, nil)", at, err)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// mustCount returns the account's active-quick-game count, failing on error.
|
// TestDurableTierHigherLimit checks a durable account resolves the durable tier, not the guest one:
|
||||||
func mustCount(t *testing.T, games *game.Service, id uuid.UUID) int {
|
// one random game leaves it well under the durable cap (10).
|
||||||
t.Helper()
|
func TestDurableTierHigherLimit(t *testing.T) {
|
||||||
n, err := games.CountActiveQuickGames(context.Background(), id)
|
|
||||||
if err != nil {
|
|
||||||
t.Fatalf("count active quick games: %v", err)
|
|
||||||
}
|
|
||||||
return n
|
|
||||||
}
|
|
||||||
|
|
||||||
// mustCreateQuick creates an active quick game (no invitation) seating the given accounts and
|
|
||||||
// returns its id; vsAI flags an honest-AI game (the service then applies the 7-day clock).
|
|
||||||
func mustCreateQuick(t *testing.T, games *game.Service, seats []uuid.UUID, vsAI bool, seed int64) uuid.UUID {
|
|
||||||
t.Helper()
|
|
||||||
p := game.CreateParams{Variant: engine.VariantEnglish, Seats: seats, TurnTimeout: 24 * time.Hour, Seed: seed}
|
|
||||||
if vsAI {
|
|
||||||
p.VsAI = true
|
|
||||||
p.TurnTimeout = 0 // the service applies the 7-day AI inactivity clock for vs_ai games
|
|
||||||
}
|
|
||||||
g, err := games.Create(context.Background(), p)
|
|
||||||
if err != nil {
|
|
||||||
t.Fatalf("create quick game (vsAI=%v): %v", vsAI, err)
|
|
||||||
}
|
|
||||||
return g.ID
|
|
||||||
}
|
|
||||||
|
|
||||||
// startFriendGame has a fresh inviter invite the given account to a friend game and the invitee
|
|
||||||
// accept it, so the (active) friend game exists with its game_invitations row.
|
|
||||||
func startFriendGame(t *testing.T, invitee uuid.UUID) {
|
|
||||||
t.Helper()
|
|
||||||
ctx := context.Background()
|
ctx := context.Background()
|
||||||
|
gl := newGameLimits(t)
|
||||||
|
games := newGameService()
|
||||||
|
games.SetGameLimits(gl)
|
||||||
|
durable, opp := provisionAccount(t), provisionAccount(t)
|
||||||
|
|
||||||
|
mustCreateKind(t, games, []uuid.UUID{durable, opp}, gamelimits.KindRandom)
|
||||||
|
if at, err := games.AtGameLimit(ctx, durable, gamelimits.KindRandom); err != nil || at {
|
||||||
|
t.Fatalf("durable random at-limit after one game = (%v, %v), want (false, nil)", at, err)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// TestGuestForbiddenFriendActions checks a guest is refused every friend action server-side (the UI
|
||||||
|
// hides them; this is the source of truth): creating an invitation, sending a friend request, and
|
||||||
|
// redeeming a friend code.
|
||||||
|
func TestGuestForbiddenFriendActions(t *testing.T) {
|
||||||
|
ctx := context.Background()
|
||||||
|
guest := provisionGuest(t)
|
||||||
|
other := provisionAccount(t)
|
||||||
|
|
||||||
inv := newInvitationService()
|
inv := newInvitationService()
|
||||||
|
if _, err := inv.CreateInvitation(ctx, guest, []uuid.UUID{other}, englishInvite()); !errors.Is(err, lobby.ErrGuestForbidden) {
|
||||||
|
t.Fatalf("guest CreateInvitation err = %v, want ErrGuestForbidden", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
soc := newSocialService()
|
||||||
|
if err := soc.SendFriendRequest(ctx, guest, other); !errors.Is(err, social.ErrGuestForbidden) {
|
||||||
|
t.Fatalf("guest SendFriendRequest err = %v, want ErrGuestForbidden", err)
|
||||||
|
}
|
||||||
|
if _, err := soc.RedeemFriendCode(ctx, guest, "000000"); !errors.Is(err, social.ErrGuestForbidden) {
|
||||||
|
t.Fatalf("guest RedeemFriendCode err = %v, want ErrGuestForbidden", err)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// TestDurableFriendsCapAndConfigCache lowers the durable friends cap to 1 through the service (the
|
||||||
|
// admin-edit path), checks a durable inviter is refused a second friend game with 409, and that
|
||||||
|
// accepting an incoming invitation is still exempt from the cap.
|
||||||
|
func TestDurableFriendsCapAndConfigCache(t *testing.T) {
|
||||||
|
ctx := context.Background()
|
||||||
|
gl := newGameLimits(t)
|
||||||
|
restoreDefaultLimits(t, gl)
|
||||||
|
cfg := gl.Get()
|
||||||
|
cfg.Durable.Friends = 1
|
||||||
|
if err := gl.Update(ctx, cfg); err != nil {
|
||||||
|
t.Fatalf("update durable friends cap: %v", err)
|
||||||
|
}
|
||||||
|
games := newGameService()
|
||||||
|
games.SetGameLimits(gl)
|
||||||
|
inv := lobby.NewInvitationService(lobby.NewStore(testDB), games, account.NewStore(testDB), newSocialService())
|
||||||
|
|
||||||
inviter := provisionAccount(t)
|
inviter := provisionAccount(t)
|
||||||
invitation, err := inv.CreateInvitation(ctx, inviter, []uuid.UUID{invitee}, englishInvite())
|
d2, d3 := provisionAccount(t), provisionAccount(t)
|
||||||
if err != nil {
|
|
||||||
t.Fatalf("create invitation: %v", err)
|
// The inviter's first friend game reaches the (lowered) cap of 1.
|
||||||
|
startFriendGameID(t, inv, inviter, d2)
|
||||||
|
if at, err := games.AtGameLimit(ctx, inviter, gamelimits.KindFriends); err != nil || !at {
|
||||||
|
t.Fatalf("inviter friends at-limit = (%v, %v), want (true, nil)", at, err)
|
||||||
}
|
}
|
||||||
if _, err := inv.RespondInvitation(ctx, invitation.ID, invitee, true); err != nil {
|
// A second invitation is refused: the cache reflects the edit.
|
||||||
t.Fatalf("accept invitation: %v", err)
|
if _, err := inv.CreateInvitation(ctx, inviter, []uuid.UUID{d3}, englishInvite()); !errors.Is(err, game.ErrGameLimitReached) {
|
||||||
|
t.Fatalf("second invitation err = %v, want ErrGameLimitReached", err)
|
||||||
}
|
}
|
||||||
|
// Accept stays exempt: someone else invites the capped inviter, who accepts and the game starts.
|
||||||
|
startFriendGameID(t, inv, d3, inviter)
|
||||||
}
|
}
|
||||||
|
|
||||||
// gamesListAtLimit fetches /api/v1/user/games and returns its at_game_limit flag.
|
// gamesListAtLimit fetches /api/v1/user/games and returns its at_game_limit flag.
|
||||||
|
|||||||
@@ -424,13 +424,13 @@ func TestHintPolicy(t *testing.T) {
|
|||||||
t.Fatalf("first hint: %v", err)
|
t.Fatalf("first hint: %v", err)
|
||||||
}
|
}
|
||||||
// The allowance is spent before the wallet: with an empty wallet, the state now reports no
|
// The allowance is spent before the wallet: with an empty wallet, the state now reports no
|
||||||
// hints left and a zero wallet, so the per-game allowance (HintsRemaining-WalletBalance) is 0.
|
// per-game allowance left (HintsRemaining is the allowance alone; the wallet lives on the profile).
|
||||||
st, err := svc.GameState(ctx, g.ID, seats[0])
|
st, err := svc.GameState(ctx, g.ID, seats[0])
|
||||||
if err != nil {
|
if err != nil {
|
||||||
t.Fatalf("state: %v", err)
|
t.Fatalf("state: %v", err)
|
||||||
}
|
}
|
||||||
if st.HintsRemaining != 0 || st.WalletBalance != 0 {
|
if st.HintsRemaining != 0 {
|
||||||
t.Errorf("after allowance hint: hints=%d wallet=%d, want 0/0", st.HintsRemaining, st.WalletBalance)
|
t.Errorf("after allowance hint: hints=%d, want 0", st.HintsRemaining)
|
||||||
}
|
}
|
||||||
if _, err := svc.Hint(ctx, g.ID, seats[0]); !errors.Is(err, game.ErrNoHintsLeft) {
|
if _, err := svc.Hint(ctx, g.ID, seats[0]); !errors.Is(err, game.ErrNoHintsLeft) {
|
||||||
t.Fatalf("second hint = %v, want ErrNoHintsLeft", err)
|
t.Fatalf("second hint = %v, want ErrNoHintsLeft", err)
|
||||||
@@ -444,9 +444,10 @@ func TestHintPolicy(t *testing.T) {
|
|||||||
if err != nil {
|
if err != nil {
|
||||||
t.Fatalf("wallet hint: %v", err)
|
t.Fatalf("wallet hint: %v", err)
|
||||||
}
|
}
|
||||||
// The allowance stays exhausted; the wallet dropped 2->1, and WalletBalance carries it alone.
|
// The allowance stays exhausted (HintsRemaining is the allowance alone, so 0); the wallet dropped
|
||||||
if res.HintsRemaining != 1 || res.WalletBalance != 1 {
|
// 2->1 and WalletBalance carries it (the client adopts it into the profile).
|
||||||
t.Errorf("wallet hint: hints=%d wallet=%d, want 1/1", res.HintsRemaining, res.WalletBalance)
|
if res.HintsRemaining != 0 || res.WalletBalance != 1 {
|
||||||
|
t.Errorf("wallet hint: hints=%d wallet=%d, want 0/1", res.HintsRemaining, res.WalletBalance)
|
||||||
}
|
}
|
||||||
// game_players.hints_used counts BOTH hints (1 allowance + 1 wallet) — the per-game total
|
// game_players.hints_used counts BOTH hints (1 allowance + 1 wallet) — the per-game total
|
||||||
// that feeds the player's lifetime hint statistics, not just the allowance.
|
// that feeds the player's lifetime hint statistics, not just the allowance.
|
||||||
|
|||||||
@@ -4,6 +4,7 @@ package inttest
|
|||||||
|
|
||||||
import (
|
import (
|
||||||
"context"
|
"context"
|
||||||
|
"strings"
|
||||||
"testing"
|
"testing"
|
||||||
|
|
||||||
"github.com/google/uuid"
|
"github.com/google/uuid"
|
||||||
@@ -111,3 +112,44 @@ func TestPaymentsCatalogExcludesDeactivated(t *testing.T) {
|
|||||||
t.Error("deactivated product must not appear in the storefront")
|
t.Error("deactivated product must not appear in the storefront")
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// TestOfferPricingReflectsCatalogEdits verifies the public-offer price list (§4.4) is projected from
|
||||||
|
// the live catalog and its cache is invalidated on a catalog mutation: a newly created pack appears
|
||||||
|
// with its per-rail prices, and archiving it through the service drops it from the next read.
|
||||||
|
func TestOfferPricingReflectsCatalogEdits(t *testing.T) {
|
||||||
|
svc := newPaymentsService()
|
||||||
|
ctx := context.Background()
|
||||||
|
title := "OfferTest " + uuid.NewString()
|
||||||
|
id, err := svc.CreateProduct(ctx, payments.ProductInput{
|
||||||
|
Title: title,
|
||||||
|
Atoms: []payments.AtomLine{{Atom: "chips", Quantity: 50}},
|
||||||
|
Prices: []payments.PriceLine{
|
||||||
|
{Method: "direct", Currency: payments.CurrencyRUB, Amount: 20000},
|
||||||
|
{Method: "vk", Currency: payments.CurrencyVote, Amount: 30},
|
||||||
|
{Method: "telegram", Currency: payments.CurrencyStar, Amount: 100},
|
||||||
|
},
|
||||||
|
}, true)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("create pack: %v", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
md, err := svc.OfferPricing(ctx)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("offer pricing: %v", err)
|
||||||
|
}
|
||||||
|
if row := "| " + title + " | 200.00 | 30 | 100 |"; !strings.Contains(md, row) {
|
||||||
|
t.Fatalf("offer pricing missing the new pack row %q\n%s", row, md)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Archiving through the service marks the cache stale; the next read must reproject without it.
|
||||||
|
if err := svc.SetProductActive(ctx, id, false); err != nil {
|
||||||
|
t.Fatalf("archive: %v", err)
|
||||||
|
}
|
||||||
|
md, err = svc.OfferPricing(ctx)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("offer pricing after archive: %v", err)
|
||||||
|
}
|
||||||
|
if strings.Contains(md, title) {
|
||||||
|
t.Errorf("archived pack must drop from the offer pricing:\n%s", md)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|||||||
@@ -15,6 +15,7 @@ import (
|
|||||||
"scrabble/backend/internal/account"
|
"scrabble/backend/internal/account"
|
||||||
"scrabble/backend/internal/engine"
|
"scrabble/backend/internal/engine"
|
||||||
"scrabble/backend/internal/game"
|
"scrabble/backend/internal/game"
|
||||||
|
"scrabble/backend/internal/gamelimits"
|
||||||
"scrabble/backend/internal/notify"
|
"scrabble/backend/internal/notify"
|
||||||
"scrabble/backend/internal/postgres/jet/backend/model"
|
"scrabble/backend/internal/postgres/jet/backend/model"
|
||||||
"scrabble/backend/internal/postgres/jet/backend/table"
|
"scrabble/backend/internal/postgres/jet/backend/table"
|
||||||
@@ -218,6 +219,20 @@ func (svc *InvitationService) CreateInvitation(ctx context.Context, inviterID uu
|
|||||||
if !slices.Contains(game.AllowedTurnTimeouts, settings.TurnTimeout) {
|
if !slices.Contains(game.AllowedTurnTimeouts, settings.TurnTimeout) {
|
||||||
return Invitation{}, fmt.Errorf("%w: turn timeout %s not allowed", ErrInvalidInvitation, settings.TurnTimeout)
|
return Invitation{}, fmt.Errorf("%w: turn timeout %s not allowed", ErrInvalidInvitation, settings.TurnTimeout)
|
||||||
}
|
}
|
||||||
|
// A guest cannot invite: friend games are a durable-account feature. The UI hides the flow;
|
||||||
|
// this is the server source of truth.
|
||||||
|
if acc, err := svc.accounts.GetByID(ctx, inviterID); err != nil {
|
||||||
|
return Invitation{}, err
|
||||||
|
} else if acc.IsGuest {
|
||||||
|
return Invitation{}, ErrGuestForbidden
|
||||||
|
}
|
||||||
|
// A durable inviter is still capped: refuse a new friend game once the per-tier friends limit is
|
||||||
|
// reached. The guest branch above short-circuits before here, so this is the durable cap.
|
||||||
|
if atLimit, err := svc.games.AtGameLimit(ctx, inviterID, gamelimits.KindFriends); err != nil {
|
||||||
|
return Invitation{}, err
|
||||||
|
} else if atLimit {
|
||||||
|
return Invitation{}, game.ErrGameLimitReached
|
||||||
|
}
|
||||||
seen := map[uuid.UUID]bool{inviterID: true}
|
seen := map[uuid.UUID]bool{inviterID: true}
|
||||||
// suppressed collects invitees who have blocked the inviter: the invitation is still
|
// suppressed collects invitees who have blocked the inviter: the invitation is still
|
||||||
// created and persisted for them, but they are never notified and never see it (their
|
// created and persisted for them, but they are never notified and never see it (their
|
||||||
@@ -336,6 +351,7 @@ func (svc *InvitationService) startGame(ctx context.Context, invitationID uuid.U
|
|||||||
HintsPerPlayer: inv.Settings.HintsPerPlayer,
|
HintsPerPlayer: inv.Settings.HintsPerPlayer,
|
||||||
DropoutTiles: inv.Settings.DropoutTiles,
|
DropoutTiles: inv.Settings.DropoutTiles,
|
||||||
MultipleWordsPerTurn: inv.Settings.MultipleWordsPerTurn,
|
MultipleWordsPerTurn: inv.Settings.MultipleWordsPerTurn,
|
||||||
|
Kind: gamelimits.KindFriends,
|
||||||
})
|
})
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return err
|
return err
|
||||||
|
|||||||
@@ -15,17 +15,22 @@ import (
|
|||||||
|
|
||||||
"scrabble/backend/internal/engine"
|
"scrabble/backend/internal/engine"
|
||||||
"scrabble/backend/internal/game"
|
"scrabble/backend/internal/game"
|
||||||
|
"scrabble/backend/internal/gamelimits"
|
||||||
"scrabble/backend/internal/notify"
|
"scrabble/backend/internal/notify"
|
||||||
)
|
)
|
||||||
|
|
||||||
// GameCreator is the slice of the game domain the lobby needs: starting a seated
|
// GameCreator is the slice of the game domain the lobby needs: starting a seated
|
||||||
// game and reading a player's initial view of it. game.Service satisfies it.
|
// game, reading a player's initial view of it, and testing a caller's active-game
|
||||||
|
// cap. game.Service satisfies it.
|
||||||
type GameCreator interface {
|
type GameCreator interface {
|
||||||
Create(ctx context.Context, params game.CreateParams) (game.Game, error)
|
Create(ctx context.Context, params game.CreateParams) (game.Game, error)
|
||||||
// InitialState returns a seated player's full initial view of a started game, used
|
// InitialState returns a seated player's full initial view of a started game, used
|
||||||
// to enrich the game_started event so the client renders the new game without a
|
// to enrich the game_started event so the client renders the new game without a
|
||||||
// follow-up fetch.
|
// follow-up fetch.
|
||||||
InitialState(ctx context.Context, gameID, accountID uuid.UUID) (notify.PlayerState, error)
|
InitialState(ctx context.Context, gameID, accountID uuid.UUID) (notify.PlayerState, error)
|
||||||
|
// AtGameLimit reports whether accountID has reached its tier's active-game cap for kind. The
|
||||||
|
// invitation path uses it to enforce the friends limit before opening one.
|
||||||
|
AtGameLimit(ctx context.Context, accountID uuid.UUID, kind gamelimits.Kind) (bool, error)
|
||||||
}
|
}
|
||||||
|
|
||||||
// RobotProvider supplies a robot account to substitute for a missing human in
|
// RobotProvider supplies a robot account to substitute for a missing human in
|
||||||
@@ -62,6 +67,9 @@ var (
|
|||||||
// ErrInvalidInvitation is returned for a malformed invitation (bad player
|
// ErrInvalidInvitation is returned for a malformed invitation (bad player
|
||||||
// count, duplicate or self invitee, or unacceptable settings).
|
// count, duplicate or self invitee, or unacceptable settings).
|
||||||
ErrInvalidInvitation = errors.New("lobby: invalid invitation")
|
ErrInvalidInvitation = errors.New("lobby: invalid invitation")
|
||||||
|
// ErrGuestForbidden is returned when a guest attempts a durable-only action (invite a
|
||||||
|
// friend); the friend flow is gated to durable accounts.
|
||||||
|
ErrGuestForbidden = errors.New("lobby: guests cannot invite")
|
||||||
// ErrInvitationBlocked is returned when a block stands between the inviter and
|
// ErrInvitationBlocked is returned when a block stands between the inviter and
|
||||||
// an invitee.
|
// an invitee.
|
||||||
ErrInvitationBlocked = errors.New("lobby: invitation blocked between accounts")
|
ErrInvitationBlocked = errors.New("lobby: invitation blocked between accounts")
|
||||||
|
|||||||
@@ -10,6 +10,7 @@ import (
|
|||||||
|
|
||||||
"scrabble/backend/internal/engine"
|
"scrabble/backend/internal/engine"
|
||||||
"scrabble/backend/internal/game"
|
"scrabble/backend/internal/game"
|
||||||
|
"scrabble/backend/internal/gamelimits"
|
||||||
"scrabble/backend/internal/notify"
|
"scrabble/backend/internal/notify"
|
||||||
)
|
)
|
||||||
|
|
||||||
@@ -141,6 +142,7 @@ func (m *Matchmaker) StartVsAI(ctx context.Context, accountID uuid.UUID, variant
|
|||||||
if rand.IntN(2) == 1 {
|
if rand.IntN(2) == 1 {
|
||||||
params.Seats = []uuid.UUID{robotID, accountID}
|
params.Seats = []uuid.UUID{robotID, accountID}
|
||||||
}
|
}
|
||||||
|
params.Kind = gamelimits.KindVsAI
|
||||||
g, err := m.games.Create(ctx, params)
|
g, err := m.games.Create(ctx, params)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return EnqueueResult{}, err
|
return EnqueueResult{}, err
|
||||||
|
|||||||
@@ -37,6 +37,7 @@ func toWireGame(g GameSummary) wire.GameView {
|
|||||||
TurnTimeoutSecs: g.TurnTimeoutSecs,
|
TurnTimeoutSecs: g.TurnTimeoutSecs,
|
||||||
MultipleWordsPerTurn: g.MultipleWordsPerTurn,
|
MultipleWordsPerTurn: g.MultipleWordsPerTurn,
|
||||||
VsAI: g.VsAI,
|
VsAI: g.VsAI,
|
||||||
|
Kind: g.Kind,
|
||||||
MoveCount: g.MoveCount,
|
MoveCount: g.MoveCount,
|
||||||
EndReason: g.EndReason,
|
EndReason: g.EndReason,
|
||||||
Seats: seats,
|
Seats: seats,
|
||||||
@@ -83,7 +84,6 @@ func buildStateView(b *flatbuffers.Builder, s PlayerState) flatbuffers.UOffsetT
|
|||||||
Rack: s.Rack,
|
Rack: s.Rack,
|
||||||
BagLen: s.BagLen,
|
BagLen: s.BagLen,
|
||||||
HintsRemaining: s.HintsRemaining,
|
HintsRemaining: s.HintsRemaining,
|
||||||
WalletBalance: s.WalletBalance,
|
|
||||||
Alphabet: alphabet,
|
Alphabet: alphabet,
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -115,7 +115,7 @@ func TestGameOverPayloadRoundTrips(t *testing.T) {
|
|||||||
func TestOpponentMovedPayloadRoundTrips(t *testing.T) {
|
func TestOpponentMovedPayloadRoundTrips(t *testing.T) {
|
||||||
uid, gid := uuid.New(), uuid.New()
|
uid, gid := uuid.New(), uuid.New()
|
||||||
move := engine.MoveRecord{Player: 1, Action: engine.ActionPlay, Words: []string{"STOOL"}, Score: 24, Total: 130}
|
move := engine.MoveRecord{Player: 1, Action: engine.ActionPlay, Words: []string{"STOOL"}, Score: 24, Total: 130}
|
||||||
summary := notify.GameSummary{ID: gid.String(), MoveCount: 9, ToMove: 0, Seats: []notify.SeatStanding{{Seat: 1, Score: 130}}}
|
summary := notify.GameSummary{ID: gid.String(), MoveCount: 9, ToMove: 0, Kind: 2, Seats: []notify.SeatStanding{{Seat: 1, Score: 130}}}
|
||||||
in := notify.OpponentMoved(uid, gid, move, summary, 42)
|
in := notify.OpponentMoved(uid, gid, move, summary, 42)
|
||||||
if in.Kind != notify.KindOpponentMoved {
|
if in.Kind != notify.KindOpponentMoved {
|
||||||
t.Fatalf("kind = %q", in.Kind)
|
t.Fatalf("kind = %q", in.Kind)
|
||||||
@@ -132,7 +132,7 @@ func TestOpponentMovedPayloadRoundTrips(t *testing.T) {
|
|||||||
if m == nil || m.Player() != 1 || string(m.Action()) != "play" || m.Total() != 130 {
|
if m == nil || m.Player() != 1 || string(m.Action()) != "play" || m.Total() != 130 {
|
||||||
t.Fatalf("move wrong: %+v", m)
|
t.Fatalf("move wrong: %+v", m)
|
||||||
}
|
}
|
||||||
if g := ev.Game(nil); g == nil || g.MoveCount() != 9 || g.ToMove() != 0 {
|
if g := ev.Game(nil); g == nil || g.MoveCount() != 9 || g.ToMove() != 0 || g.Kind() != 2 {
|
||||||
t.Fatalf("game summary wrong: %+v", ev.Game(nil))
|
t.Fatalf("game summary wrong: %+v", ev.Game(nil))
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -35,6 +35,9 @@ type GameSummary struct {
|
|||||||
EndReason string
|
EndReason string
|
||||||
Seats []SeatStanding
|
Seats []SeatStanding
|
||||||
LastActivityUnix int64
|
LastActivityUnix int64
|
||||||
|
// Kind is the game's origin for the active-game limits (0 unknown, 1 vs_ai, 2 random, 3 friends);
|
||||||
|
// it rides live events so a lobby patch keeps the per-kind count correct.
|
||||||
|
Kind int
|
||||||
}
|
}
|
||||||
|
|
||||||
// AlphabetLetter is one variant alphabet entry (a display-only row) embedded in an
|
// AlphabetLetter is one variant alphabet entry (a display-only row) embedded in an
|
||||||
@@ -56,7 +59,6 @@ type PlayerState struct {
|
|||||||
Rack []int
|
Rack []int
|
||||||
BagLen int
|
BagLen int
|
||||||
HintsRemaining int
|
HintsRemaining int
|
||||||
WalletBalance int
|
|
||||||
Alphabet []AlphabetLetter
|
Alphabet []AlphabetLetter
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -38,6 +38,11 @@ const atomChips = "chips"
|
|||||||
// method are omitted (misconfigured or unavailable there). An untrusted context (empty Kind) has
|
// method are omitted (misconfigured or unavailable there). An untrusted context (empty Kind) has
|
||||||
// no method, so only values show; buying is gated server-side regardless.
|
// no method, so only values show; buying is gated server-side regardless.
|
||||||
func projectCatalog(entries []catalogEntry, cxt Context) CatalogView {
|
func projectCatalog(entries []catalogEntry, cxt Context) CatalogView {
|
||||||
|
// Present products in the canonical listing order shared with the offer and the admin console
|
||||||
|
// (packs first by rouble price, then values grouped hints → no-ads → combo and by chip price); the
|
||||||
|
// client renders them as received. Ordered in place — the caller passes a fresh loadCatalog result
|
||||||
|
// it does not reuse.
|
||||||
|
sortCatalogEntries(entries)
|
||||||
var out CatalogView
|
var out CatalogView
|
||||||
for _, e := range entries {
|
for _, e := range entries {
|
||||||
isPack := false
|
isPack := false
|
||||||
|
|||||||
@@ -4,6 +4,8 @@ import (
|
|||||||
"context"
|
"context"
|
||||||
"errors"
|
"errors"
|
||||||
"fmt"
|
"fmt"
|
||||||
|
"math"
|
||||||
|
"slices"
|
||||||
"strings"
|
"strings"
|
||||||
|
|
||||||
"github.com/google/uuid"
|
"github.com/google/uuid"
|
||||||
@@ -141,10 +143,60 @@ func validateProduct(in ProductInput, sellable bool) error {
|
|||||||
return nil
|
return nil
|
||||||
}
|
}
|
||||||
|
|
||||||
// AdminCatalog lists every product (active and archived) with its composition, prices and whether it
|
// AdminCatalog lists products with their composition, prices and whether each has been transacted,
|
||||||
// has been transacted, for the admin editor. Read uncached, straight from the catalog tables.
|
// for the admin editor. includeInactive adds the archived products to the active ones (the console's
|
||||||
func (s *Service) AdminCatalog(ctx context.Context) ([]AdminProduct, error) {
|
// active/all toggle); with it false only active products are returned. Read uncached, straight from
|
||||||
return s.store.adminCatalog(ctx)
|
// the catalog tables.
|
||||||
|
func (s *Service) AdminCatalog(ctx context.Context, includeInactive bool) ([]AdminProduct, error) {
|
||||||
|
return s.store.adminCatalog(ctx, includeInactive)
|
||||||
|
}
|
||||||
|
|
||||||
|
// SortAdminCatalog orders an admin catalog list in place the same way the public offer lists products
|
||||||
|
// ([projectOfferPricing]): chip packs first, ascending by rouble price; then chip-priced values,
|
||||||
|
// grouped (hints only, no-ads only, no-ads + hints, tournament) and ascending by chip price within a
|
||||||
|
// group. It is stable, so equal-keyed products keep their incoming order, and it keys archived
|
||||||
|
// products the same as active ones (the admin list shows both).
|
||||||
|
func SortAdminCatalog(products []AdminProduct) {
|
||||||
|
slices.SortStableFunc(products, func(a, b AdminProduct) int {
|
||||||
|
return compareCatalogRank(adminRank(a), adminRank(b))
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
// adminIsPack reports whether the product is a chip pack (it carries the chips atom).
|
||||||
|
func adminIsPack(p AdminProduct) bool {
|
||||||
|
for _, a := range p.Atoms {
|
||||||
|
if a.Atom == atomChips {
|
||||||
|
return true
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
|
||||||
|
// adminValueGroup ranks a chip-priced value into the shared listing groups (see [valueGroup]).
|
||||||
|
func adminValueGroup(p AdminProduct) int {
|
||||||
|
hasHints, hasNoAds, hasTournament := false, false, false
|
||||||
|
for _, a := range p.Atoms {
|
||||||
|
switch a.Atom {
|
||||||
|
case "hints":
|
||||||
|
hasHints = true
|
||||||
|
case "noads_days":
|
||||||
|
hasNoAds = true
|
||||||
|
case "tournament":
|
||||||
|
hasTournament = true
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return valueGroup(hasHints, hasNoAds, hasTournament)
|
||||||
|
}
|
||||||
|
|
||||||
|
// adminPriceAmount returns the minor-unit amount of the product's price for the method and currency,
|
||||||
|
// or math.MaxInt64 when it carries no such price (so a misconfigured product sorts last, not first).
|
||||||
|
func adminPriceAmount(p AdminProduct, method string, cur Currency) int64 {
|
||||||
|
for _, pr := range p.Prices {
|
||||||
|
if pr.Method == method && pr.Currency == cur {
|
||||||
|
return pr.Amount
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return math.MaxInt64
|
||||||
}
|
}
|
||||||
|
|
||||||
// CreateProduct validates and inserts a new product with its atoms and prices, returning its id. A
|
// CreateProduct validates and inserts a new product with its atoms and prices, returning its id. A
|
||||||
@@ -153,7 +205,11 @@ func (s *Service) CreateProduct(ctx context.Context, in ProductInput, active boo
|
|||||||
if err := validateProduct(in, active); err != nil {
|
if err := validateProduct(in, active); err != nil {
|
||||||
return uuid.Nil, err
|
return uuid.Nil, err
|
||||||
}
|
}
|
||||||
return s.store.createProduct(ctx, in, active, s.clock())
|
id, err := s.store.createProduct(ctx, in, active, s.clock())
|
||||||
|
if err == nil {
|
||||||
|
s.markOfferStale()
|
||||||
|
}
|
||||||
|
return id, err
|
||||||
}
|
}
|
||||||
|
|
||||||
// UpdateProduct validates and replaces a product's title, atoms and prices. An active product must
|
// UpdateProduct validates and replaces a product's title, atoms and prices. An active product must
|
||||||
@@ -166,7 +222,11 @@ func (s *Service) UpdateProduct(ctx context.Context, id uuid.UUID, in ProductInp
|
|||||||
if err := validateProduct(in, active); err != nil {
|
if err := validateProduct(in, active); err != nil {
|
||||||
return err
|
return err
|
||||||
}
|
}
|
||||||
return s.store.updateProduct(ctx, id, in, s.clock())
|
if err := s.store.updateProduct(ctx, id, in, s.clock()); err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
s.markOfferStale()
|
||||||
|
return nil
|
||||||
}
|
}
|
||||||
|
|
||||||
// SetProductActive archives (active=false) or unarchives a product. Unarchiving revalidates the
|
// SetProductActive archives (active=false) or unarchives a product. Unarchiving revalidates the
|
||||||
@@ -181,11 +241,19 @@ func (s *Service) SetProductActive(ctx context.Context, id uuid.UUID, active boo
|
|||||||
return err
|
return err
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
return s.store.setProductActive(ctx, id, active, s.clock())
|
if err := s.store.setProductActive(ctx, id, active, s.clock()); err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
s.markOfferStale()
|
||||||
|
return nil
|
||||||
}
|
}
|
||||||
|
|
||||||
// DeleteProduct hard-deletes a product only when it has never been transacted (no order or ledger
|
// DeleteProduct hard-deletes a product only when it has never been transacted (no order or ledger
|
||||||
// row references it); otherwise it returns ErrProductTransacted and the caller archives instead.
|
// row references it); otherwise it returns ErrProductTransacted and the caller archives instead.
|
||||||
func (s *Service) DeleteProduct(ctx context.Context, id uuid.UUID) error {
|
func (s *Service) DeleteProduct(ctx context.Context, id uuid.UUID) error {
|
||||||
return s.store.deleteProduct(ctx, id)
|
if err := s.store.deleteProduct(ctx, id); err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
s.markOfferStale()
|
||||||
|
return nil
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -0,0 +1,61 @@
|
|||||||
|
package payments
|
||||||
|
|
||||||
|
import (
|
||||||
|
"cmp"
|
||||||
|
"slices"
|
||||||
|
)
|
||||||
|
|
||||||
|
// catalogRank is the canonical listing key for a catalog product: whether it is a chip pack, its
|
||||||
|
// value subgroup (see [valueGroup]; unused for a pack) and the minor-unit amount its section sorts by
|
||||||
|
// (a pack's direct rouble price, a value's chip price; math.MaxInt64 when absent, so a misconfigured
|
||||||
|
// row sorts last rather than leading).
|
||||||
|
type catalogRank struct {
|
||||||
|
pack bool
|
||||||
|
group int
|
||||||
|
amount int64
|
||||||
|
}
|
||||||
|
|
||||||
|
// compareCatalogRank is the single canonical product order, shared by the storefront ([projectCatalog]),
|
||||||
|
// the public offer ([projectOfferPricing]) and the admin catalog ([SortAdminCatalog]): chip packs
|
||||||
|
// first, ascending by rouble price; then chip-priced values grouped hints → no-ads → combo →
|
||||||
|
// tournament and, within a group, ascending by chip price. A pack's group is unused (all packs share
|
||||||
|
// one section). Callers use it through [entryRank] / [adminRank], which build the key from each
|
||||||
|
// product shape, so the subgroup and ordering rules live in exactly one place.
|
||||||
|
func compareCatalogRank(a, b catalogRank) int {
|
||||||
|
if a.pack != b.pack {
|
||||||
|
if a.pack {
|
||||||
|
return -1 // packs (sales) before values (chip exchange)
|
||||||
|
}
|
||||||
|
return 1
|
||||||
|
}
|
||||||
|
if !a.pack {
|
||||||
|
if d := cmp.Compare(a.group, b.group); d != 0 {
|
||||||
|
return d
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return cmp.Compare(a.amount, b.amount)
|
||||||
|
}
|
||||||
|
|
||||||
|
// entryRank builds the canonical rank of a storefront / offer catalog entry.
|
||||||
|
func entryRank(e catalogEntry) catalogRank {
|
||||||
|
if isPackEntry(e) {
|
||||||
|
return catalogRank{pack: true, amount: offerSortAmount(e, string(SourceDirect), CurrencyRUB)}
|
||||||
|
}
|
||||||
|
return catalogRank{group: offerValueGroup(e), amount: offerSortAmount(e, "", CurrencyChip)}
|
||||||
|
}
|
||||||
|
|
||||||
|
// adminRank builds the canonical rank of an admin catalog product.
|
||||||
|
func adminRank(p AdminProduct) catalogRank {
|
||||||
|
if adminIsPack(p) {
|
||||||
|
return catalogRank{pack: true, amount: adminPriceAmount(p, string(SourceDirect), CurrencyRUB)}
|
||||||
|
}
|
||||||
|
return catalogRank{group: adminValueGroup(p), amount: adminPriceAmount(p, "", CurrencyChip)}
|
||||||
|
}
|
||||||
|
|
||||||
|
// sortCatalogEntries orders storefront / offer entries in place into the canonical listing order
|
||||||
|
// ([compareCatalogRank]). Stable, so equal-keyed products keep their incoming (creation) order.
|
||||||
|
func sortCatalogEntries(entries []catalogEntry) {
|
||||||
|
slices.SortStableFunc(entries, func(a, b catalogEntry) int {
|
||||||
|
return compareCatalogRank(entryRank(a), entryRank(b))
|
||||||
|
})
|
||||||
|
}
|
||||||
@@ -134,3 +134,97 @@ func TestProjectCatalog_Empty(t *testing.T) {
|
|||||||
t.Errorf("empty catalog projected %d products, want 0", len(got.Products))
|
t.Errorf("empty catalog projected %d products, want 0", len(got.Products))
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// TestProjectCatalog_CanonicalOrder checks the storefront lists products in the shared canonical order
|
||||||
|
// (compareCatalogRank): chip packs first ascending by rouble price, then chip-priced values grouped
|
||||||
|
// hints → no-ads → combo and ascending by chip price — the same order as the offer and the admin
|
||||||
|
// console, regardless of catalog creation order.
|
||||||
|
func TestProjectCatalog_CanonicalOrder(t *testing.T) {
|
||||||
|
pack := func(title string, rub int64) catalogEntry {
|
||||||
|
return catalogEntry{
|
||||||
|
id: uuid.New(),
|
||||||
|
title: title,
|
||||||
|
atoms: []atomQty{{atomType: atomChips, quantity: 1}},
|
||||||
|
prices: []priceRow{{method: "direct", currency: CurrencyRUB, amount: rub}},
|
||||||
|
}
|
||||||
|
}
|
||||||
|
value := func(title string, chips int64, atoms ...string) catalogEntry {
|
||||||
|
e := catalogEntry{id: uuid.New(), title: title, prices: []priceRow{{method: "", currency: CurrencyChip, amount: chips}}}
|
||||||
|
for _, a := range atoms {
|
||||||
|
e.atoms = append(e.atoms, atomQty{atomType: a, quantity: 1})
|
||||||
|
}
|
||||||
|
return e
|
||||||
|
}
|
||||||
|
// Deliberately shuffled on input; the projection must impose the canonical order.
|
||||||
|
entries := []catalogEntry{
|
||||||
|
pack("packDear", 30000),
|
||||||
|
value("bundle", 500, "hints", "noads_days"),
|
||||||
|
pack("packCheap", 10000),
|
||||||
|
value("adsOnly", 150, "noads_days"),
|
||||||
|
value("hintsBig", 200, "hints"),
|
||||||
|
value("hintsSmall", 50, "hints"),
|
||||||
|
}
|
||||||
|
got := projectCatalog(entries, Context{Kind: SourceDirect})
|
||||||
|
want := []string{"packCheap", "packDear", "hintsSmall", "hintsBig", "adsOnly", "bundle"}
|
||||||
|
if len(got.Products) != len(want) {
|
||||||
|
t.Fatalf("projected %d products, want %d", len(got.Products), len(want))
|
||||||
|
}
|
||||||
|
for i, p := range got.Products {
|
||||||
|
if p.Title != want[i] {
|
||||||
|
t.Fatalf("order[%d] = %q, want %q (full: %v)", i, p.Title, want[i], productTitles(got.Products))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// productTitles extracts the projected product titles for a failure message.
|
||||||
|
func productTitles(products []CatalogProduct) []string {
|
||||||
|
out := make([]string, len(products))
|
||||||
|
for i, p := range products {
|
||||||
|
out[i] = p.Title
|
||||||
|
}
|
||||||
|
return out
|
||||||
|
}
|
||||||
|
|
||||||
|
// TestSortAdminCatalog checks the admin list is ordered like the public offer: chip packs first,
|
||||||
|
// ascending by rouble price; then values, grouped (hints only -> no-ads only -> no-ads + hints) and
|
||||||
|
// ascending by chip price within a group. Stable and applied to active + archived alike.
|
||||||
|
func TestSortAdminCatalog(t *testing.T) {
|
||||||
|
pack := func(title string, rub int64) AdminProduct {
|
||||||
|
return AdminProduct{
|
||||||
|
Title: title,
|
||||||
|
Atoms: []AtomLine{{Atom: atomChips, Quantity: 1}},
|
||||||
|
Prices: []PriceLine{{Method: string(SourceDirect), Currency: CurrencyRUB, Amount: rub}},
|
||||||
|
}
|
||||||
|
}
|
||||||
|
value := func(title string, chips int64, atoms ...string) AdminProduct {
|
||||||
|
p := AdminProduct{Title: title, Prices: []PriceLine{{Currency: CurrencyChip, Amount: chips}}}
|
||||||
|
for _, a := range atoms {
|
||||||
|
p.Atoms = append(p.Atoms, AtomLine{Atom: a, Quantity: 1})
|
||||||
|
}
|
||||||
|
return p
|
||||||
|
}
|
||||||
|
products := []AdminProduct{
|
||||||
|
pack("packDear", 30000),
|
||||||
|
value("bundle", 500, "hints", "noads_days"),
|
||||||
|
pack("packCheap", 10000),
|
||||||
|
value("adsOnly", 150, "noads_days"),
|
||||||
|
value("hintsBig", 200, "hints"),
|
||||||
|
value("hintsSmall", 50, "hints"),
|
||||||
|
}
|
||||||
|
SortAdminCatalog(products)
|
||||||
|
want := []string{"packCheap", "packDear", "hintsSmall", "hintsBig", "adsOnly", "bundle"}
|
||||||
|
for i, p := range products {
|
||||||
|
if p.Title != want[i] {
|
||||||
|
t.Fatalf("order[%d] = %q, want %q (full: %v)", i, p.Title, want[i], titles(products))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// titles extracts the product titles for a failure message.
|
||||||
|
func titles(products []AdminProduct) []string {
|
||||||
|
out := make([]string, len(products))
|
||||||
|
for i, p := range products {
|
||||||
|
out[i] = p.Title
|
||||||
|
}
|
||||||
|
return out
|
||||||
|
}
|
||||||
|
|||||||
@@ -0,0 +1,210 @@
|
|||||||
|
package payments
|
||||||
|
|
||||||
|
import (
|
||||||
|
"context"
|
||||||
|
"fmt"
|
||||||
|
"math"
|
||||||
|
"slices"
|
||||||
|
"strings"
|
||||||
|
)
|
||||||
|
|
||||||
|
// pricingMarker is the token the owner-edited offer markdown (ui/legal/offer_ru.md, §4.4) carries
|
||||||
|
// where the price list belongs. The render sidecar replaces it with the markdown [Service.OfferPricing]
|
||||||
|
// returns before rendering the /offer/ page; the backend only produces the tables, never the marker.
|
||||||
|
const pricingMarker = "<#pricing_template#>"
|
||||||
|
|
||||||
|
// OfferPricing returns the public-offer price list (§4.4) as two markdown tables projected from the
|
||||||
|
// active catalog: first the chip packs (funding chips with money, priced per rail — roubles / VK
|
||||||
|
// votes / Telegram Stars), then the chip-priced values (what a player exchanges chips for). The
|
||||||
|
// result is cached in memory and reprojected only after a catalog mutation (see [Service.markOfferStale]),
|
||||||
|
// so a steady-state read issues no query — only the first read after an edit reprojects. The render
|
||||||
|
// sidecar fetches it and splices it into the offer markdown at the pricing marker.
|
||||||
|
func (s *Service) OfferPricing(ctx context.Context) (string, error) {
|
||||||
|
s.offerMu.Lock()
|
||||||
|
defer s.offerMu.Unlock()
|
||||||
|
if s.offerFresh {
|
||||||
|
return s.offerMD, nil
|
||||||
|
}
|
||||||
|
md, err := s.buildOfferPricing(ctx)
|
||||||
|
if err != nil {
|
||||||
|
return "", err
|
||||||
|
}
|
||||||
|
s.offerMD = md
|
||||||
|
s.offerFresh = true
|
||||||
|
return md, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// markOfferStale marks the cached offer price list for reprojection on the next [Service.OfferPricing]
|
||||||
|
// read. Every catalog mutation calls it; it takes no I/O, so it never fails the mutation that triggers it.
|
||||||
|
func (s *Service) markOfferStale() {
|
||||||
|
s.offerMu.Lock()
|
||||||
|
s.offerFresh = false
|
||||||
|
s.offerMu.Unlock()
|
||||||
|
}
|
||||||
|
|
||||||
|
// buildOfferPricing loads the active catalog and projects it into the offer tables.
|
||||||
|
func (s *Service) buildOfferPricing(ctx context.Context) (string, error) {
|
||||||
|
entries, err := s.store.loadCatalog(ctx)
|
||||||
|
if err != nil {
|
||||||
|
return "", err
|
||||||
|
}
|
||||||
|
return projectOfferPricing(entries), nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// projectOfferPricing renders the active catalog into the two offer tables. A chip pack (it carries
|
||||||
|
// the chips atom) lists its per-rail money price; a value (no chips atom) lists its uniform chip
|
||||||
|
// price. Packs are ordered by ascending rouble price; values are grouped by what they grant (hints
|
||||||
|
// only, then no-ads only, then no-ads + hints, then tournament — see [offerValueGroup]) and, within
|
||||||
|
// each group, ordered by ascending chip price. Price columns are right-aligned. An empty section is
|
||||||
|
// omitted. Amounts are rendered through [Money] so no floating point ever reaches the page (roubles
|
||||||
|
// show kopecks as "200.00", whole-unit rails as integers); a missing rail price shows an em dash.
|
||||||
|
func projectOfferPricing(entries []catalogEntry) string {
|
||||||
|
// Order the whole catalog once by the shared canonical rank, then split it into the two offer
|
||||||
|
// tables (packs first, then the chip-exchange values); each keeps that order. Sort a copy so the
|
||||||
|
// caller's slice is left untouched.
|
||||||
|
sorted := slices.Clone(entries)
|
||||||
|
sortCatalogEntries(sorted)
|
||||||
|
var packs, values []catalogEntry
|
||||||
|
for _, e := range sorted {
|
||||||
|
if isPackEntry(e) {
|
||||||
|
packs = append(packs, e)
|
||||||
|
} else {
|
||||||
|
values = append(values, e)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
var b strings.Builder
|
||||||
|
if len(packs) > 0 {
|
||||||
|
b.WriteString("Приобретение внутриигровой валюты «Фишка»:\n\n")
|
||||||
|
b.WriteString("| Наименование | Рубли | Голоса в VK | Stars в Telegram |\n")
|
||||||
|
b.WriteString("| --- | ---: | ---: | ---: |\n")
|
||||||
|
for _, e := range packs {
|
||||||
|
fmt.Fprintf(&b, "| %s | %s | %s | %s |\n",
|
||||||
|
offerCell(e.title),
|
||||||
|
offerPrice(e, string(SourceDirect), CurrencyRUB),
|
||||||
|
offerPrice(e, string(SourceVK), CurrencyVote),
|
||||||
|
offerPrice(e, string(SourceTelegram), CurrencyStar),
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if len(values) > 0 {
|
||||||
|
if len(packs) > 0 {
|
||||||
|
b.WriteString("\n")
|
||||||
|
}
|
||||||
|
b.WriteString("Использование внутриигровой валюты «Фишка»:\n\n")
|
||||||
|
b.WriteString("| Наименование | «Фишки» |\n")
|
||||||
|
b.WriteString("| --- | ---: |\n")
|
||||||
|
for _, e := range values {
|
||||||
|
fmt.Fprintf(&b, "| %s | %s |\n", offerCell(e.title), offerPrice(e, "", CurrencyChip))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return strings.TrimRight(b.String(), "\n")
|
||||||
|
}
|
||||||
|
|
||||||
|
// offerValueGroup ranks a chip-priced value into the usage groups (see [valueGroup]).
|
||||||
|
func offerValueGroup(e catalogEntry) int {
|
||||||
|
hasHints, hasNoAds, hasTournament := false, false, false
|
||||||
|
for _, a := range e.atoms {
|
||||||
|
switch a.atomType {
|
||||||
|
case "hints":
|
||||||
|
hasHints = true
|
||||||
|
case "noads_days":
|
||||||
|
hasNoAds = true
|
||||||
|
case "tournament":
|
||||||
|
hasTournament = true
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return valueGroup(hasHints, hasNoAds, hasTournament)
|
||||||
|
}
|
||||||
|
|
||||||
|
// valueGroup ranks a chip-priced value into the listing groups shared by the public offer and the
|
||||||
|
// admin catalog, in listing order: hints only (0), no-ads only (1), no-ads + hints (2), then anything
|
||||||
|
// carrying the tournament atom (3). Tournament products are not sellable yet (validateProduct forbids
|
||||||
|
// an active one), so group 3 is empty today; the rank reserves their place for when the tournament
|
||||||
|
// economy lands. A value with no recognised benefit atom sorts after the known groups (defensive —
|
||||||
|
// the catalog shape forbids it).
|
||||||
|
func valueGroup(hasHints, hasNoAds, hasTournament bool) int {
|
||||||
|
switch {
|
||||||
|
case hasTournament:
|
||||||
|
return 3
|
||||||
|
case hasHints && hasNoAds:
|
||||||
|
return 2
|
||||||
|
case hasNoAds:
|
||||||
|
return 1
|
||||||
|
case hasHints:
|
||||||
|
return 0
|
||||||
|
default:
|
||||||
|
return 4
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// offerSortAmount returns the entry's price in the given method and currency for ordering, or
|
||||||
|
// math.MaxInt64 when it carries no such price, so a misconfigured row sorts last rather than leading.
|
||||||
|
func offerSortAmount(e catalogEntry, method string, cur Currency) int64 {
|
||||||
|
if amt, ok := offerAmount(e, method, cur); ok {
|
||||||
|
return amt
|
||||||
|
}
|
||||||
|
return math.MaxInt64
|
||||||
|
}
|
||||||
|
|
||||||
|
// isPackEntry reports whether the catalog entry is a chip pack — it carries the chips atom (funds
|
||||||
|
// chips with money) rather than being a chip-priced value.
|
||||||
|
func isPackEntry(e catalogEntry) bool {
|
||||||
|
for _, a := range e.atoms {
|
||||||
|
if a.atomType == atomChips {
|
||||||
|
return true
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
|
||||||
|
// offerPrice formats the entry's price for the given payment method and currency as a major-unit
|
||||||
|
// string, or an em dash when the entry carries no such price.
|
||||||
|
func offerPrice(e catalogEntry, method string, cur Currency) string {
|
||||||
|
amt, ok := offerAmount(e, method, cur)
|
||||||
|
if !ok {
|
||||||
|
return "—"
|
||||||
|
}
|
||||||
|
m, err := MoneyFromMinor(amt, cur)
|
||||||
|
if err != nil {
|
||||||
|
return "—"
|
||||||
|
}
|
||||||
|
return m.Major()
|
||||||
|
}
|
||||||
|
|
||||||
|
// offerAmount returns the raw minor-unit amount of the entry's price for the payment method and
|
||||||
|
// currency, and whether such a price exists.
|
||||||
|
func offerAmount(e catalogEntry, method string, cur Currency) (int64, bool) {
|
||||||
|
for _, pr := range e.prices {
|
||||||
|
if pr.method == method && pr.currency == cur {
|
||||||
|
return pr.amount, true
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return 0, false
|
||||||
|
}
|
||||||
|
|
||||||
|
// offerCellReplacer neutralises every metacharacter of an admin-entered title so it renders as
|
||||||
|
// literal text in the public offer. The title is operator input (the /_gm catalog editor) that flows
|
||||||
|
// into a markdown table cell and then through marked into the /offer/ HTML, which is deliberately not
|
||||||
|
// sanitised — so escaping here is the trust boundary. It covers HTML (no tag or entity reaches the
|
||||||
|
// page), the markdown table pipe and the row newline, and the link brackets (a title must never
|
||||||
|
// become a "javascript:" link). marked passes the entities through unchanged, so the reader sees the
|
||||||
|
// exact title. NewReplacer scans once and never re-scans its own output, so "&" → "&" does not
|
||||||
|
// double-escape the entities the other rules emit.
|
||||||
|
var offerCellReplacer = strings.NewReplacer(
|
||||||
|
"&", "&",
|
||||||
|
"<", "<",
|
||||||
|
">", ">",
|
||||||
|
`"`, """,
|
||||||
|
"'", "'",
|
||||||
|
"|", `\|`,
|
||||||
|
"[", `\[`,
|
||||||
|
"]", `\]`,
|
||||||
|
"\n", " ",
|
||||||
|
)
|
||||||
|
|
||||||
|
// offerCell escapes an admin-entered title for safe, literal rendering in a markdown table cell of
|
||||||
|
// the public offer (see [offerCellReplacer]).
|
||||||
|
func offerCell(s string) string {
|
||||||
|
return offerCellReplacer.Replace(s)
|
||||||
|
}
|
||||||
@@ -0,0 +1,137 @@
|
|||||||
|
package payments
|
||||||
|
|
||||||
|
import (
|
||||||
|
"strings"
|
||||||
|
"testing"
|
||||||
|
|
||||||
|
"github.com/google/uuid"
|
||||||
|
)
|
||||||
|
|
||||||
|
// TestProjectOfferPricing checks the happy path: a chip pack priced on every rail and a chip-priced
|
||||||
|
// value render into the two tables, pack table first, money formatted through Money.
|
||||||
|
func TestProjectOfferPricing(t *testing.T) {
|
||||||
|
entries := []catalogEntry{
|
||||||
|
{
|
||||||
|
id: uuid.New(),
|
||||||
|
title: "50 «Фишек»",
|
||||||
|
atoms: []atomQty{{atomType: atomChips, quantity: 50}},
|
||||||
|
prices: []priceRow{
|
||||||
|
{method: string(SourceDirect), currency: CurrencyRUB, amount: 20000},
|
||||||
|
{method: string(SourceVK), currency: CurrencyVote, amount: 30},
|
||||||
|
{method: string(SourceTelegram), currency: CurrencyStar, amount: 100},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: uuid.New(),
|
||||||
|
title: "200 подсказок",
|
||||||
|
atoms: []atomQty{{atomType: "hints", quantity: 200}},
|
||||||
|
prices: []priceRow{{method: "", currency: CurrencyChip, amount: 50}},
|
||||||
|
},
|
||||||
|
}
|
||||||
|
md := projectOfferPricing(entries)
|
||||||
|
for _, want := range []string{
|
||||||
|
"| Наименование | Рубли | Голоса в VK | Stars в Telegram |",
|
||||||
|
"| --- | ---: | ---: | ---: |", // price columns right-aligned
|
||||||
|
"| 50 «Фишек» | 200.00 | 30 | 100 |",
|
||||||
|
"| Наименование | «Фишки» |",
|
||||||
|
"| --- | ---: |",
|
||||||
|
"| 200 подсказок | 50 |",
|
||||||
|
} {
|
||||||
|
if !strings.Contains(md, want) {
|
||||||
|
t.Errorf("projection missing %q\n---\n%s", want, md)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if strings.Index(md, "Приобретение") > strings.Index(md, "Использование") {
|
||||||
|
t.Errorf("the pack table must precede the values table:\n%s", md)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// TestProjectOfferPricingOrdering checks packs sort by ascending rouble price, and values sort by
|
||||||
|
// group (hints only → no-ads only → no-ads + hints) then ascending chip price within a group.
|
||||||
|
func TestProjectOfferPricingOrdering(t *testing.T) {
|
||||||
|
pack := func(title string, rub int64) catalogEntry {
|
||||||
|
return catalogEntry{
|
||||||
|
id: uuid.New(),
|
||||||
|
title: title,
|
||||||
|
atoms: []atomQty{{atomType: atomChips, quantity: 1}},
|
||||||
|
prices: []priceRow{{method: string(SourceDirect), currency: CurrencyRUB, amount: rub}},
|
||||||
|
}
|
||||||
|
}
|
||||||
|
value := func(title string, chips int64, atoms ...string) catalogEntry {
|
||||||
|
e := catalogEntry{id: uuid.New(), title: title, prices: []priceRow{{method: "", currency: CurrencyChip, amount: chips}}}
|
||||||
|
for _, a := range atoms {
|
||||||
|
e.atoms = append(e.atoms, atomQty{atomType: a, quantity: 1})
|
||||||
|
}
|
||||||
|
return e
|
||||||
|
}
|
||||||
|
// Deliberately out of order on input.
|
||||||
|
entries := []catalogEntry{
|
||||||
|
pack("packDear", 30000),
|
||||||
|
pack("packCheap", 10000),
|
||||||
|
value("bundle", 500, "hints", "noads_days"),
|
||||||
|
value("adsOnly", 150, "noads_days"),
|
||||||
|
value("hintsBig", 200, "hints"),
|
||||||
|
value("hintsSmall", 50, "hints"),
|
||||||
|
}
|
||||||
|
md := projectOfferPricing(entries)
|
||||||
|
order := []string{"packCheap", "packDear", "hintsSmall", "hintsBig", "adsOnly", "bundle"}
|
||||||
|
last := -1
|
||||||
|
for _, title := range order {
|
||||||
|
i := strings.Index(md, "| "+title+" |")
|
||||||
|
if i < 0 {
|
||||||
|
t.Fatalf("row %q missing:\n%s", title, md)
|
||||||
|
}
|
||||||
|
if i < last {
|
||||||
|
t.Errorf("row %q out of order (want sequence %v):\n%s", title, order, md)
|
||||||
|
}
|
||||||
|
last = i
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// TestProjectOfferPricingMissingRailAndEscaping checks a pack missing a rail shows an em dash and a
|
||||||
|
// title carrying a pipe is escaped so the table layout survives.
|
||||||
|
func TestProjectOfferPricingMissingRailAndEscaping(t *testing.T) {
|
||||||
|
entries := []catalogEntry{{
|
||||||
|
id: uuid.New(),
|
||||||
|
title: "Bonus | pack",
|
||||||
|
atoms: []atomQty{{atomType: atomChips, quantity: 10}},
|
||||||
|
// A roubles price only — no VK, no Telegram.
|
||||||
|
prices: []priceRow{{method: string(SourceDirect), currency: CurrencyRUB, amount: 9900}},
|
||||||
|
}}
|
||||||
|
md := projectOfferPricing(entries)
|
||||||
|
if want := `| Bonus \| pack | 99.00 | — | — |`; !strings.Contains(md, want) {
|
||||||
|
t.Errorf("want row %q in:\n%s", want, md)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// TestProjectOfferPricingEscapesHTMLAndLinks checks an admin title carrying HTML or a markdown link
|
||||||
|
// is neutralised so it cannot inject markup into the public offer: the tag becomes entities and the
|
||||||
|
// link brackets are escaped (so no "javascript:" anchor forms). The raw metacharacters must not
|
||||||
|
// survive into the projected markdown.
|
||||||
|
func TestProjectOfferPricingEscapesHTMLAndLinks(t *testing.T) {
|
||||||
|
entries := []catalogEntry{{
|
||||||
|
id: uuid.New(),
|
||||||
|
title: `<script>alert(1)</script> [x](javascript:alert(2)) & "q"`,
|
||||||
|
atoms: []atomQty{{atomType: "hints", quantity: 1}},
|
||||||
|
prices: []priceRow{{method: "", currency: CurrencyChip, amount: 5}},
|
||||||
|
}}
|
||||||
|
md := projectOfferPricing(entries)
|
||||||
|
for _, bad := range []string{"<script>", "</script>", "[x]", `& "q"`} {
|
||||||
|
if strings.Contains(md, bad) {
|
||||||
|
t.Errorf("unescaped %q survived into the projection:\n%s", bad, md)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
for _, want := range []string{"<script>", `\[x\]`, "&", ""q""} {
|
||||||
|
if !strings.Contains(md, want) {
|
||||||
|
t.Errorf("want escaped %q in:\n%s", want, md)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// TestProjectOfferPricingEmpty checks an empty catalog projects to the empty string (no stray table
|
||||||
|
// headers), so the offer's pricing marker is replaced with nothing.
|
||||||
|
func TestProjectOfferPricingEmpty(t *testing.T) {
|
||||||
|
if got := projectOfferPricing(nil); got != "" {
|
||||||
|
t.Errorf("empty catalog must project to empty string, got %q", got)
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,41 @@
|
|||||||
|
package payments
|
||||||
|
|
||||||
|
import (
|
||||||
|
"context"
|
||||||
|
|
||||||
|
"github.com/google/uuid"
|
||||||
|
)
|
||||||
|
|
||||||
|
// providerAdmin tags an operator-initiated refund in the ledger, distinct from a rail's own refund
|
||||||
|
// (robokassa / vk / telegram). The refund idempotency key (providerAdmin, order id) allows exactly
|
||||||
|
// one manual full refund per order.
|
||||||
|
const providerAdmin = "admin"
|
||||||
|
|
||||||
|
// RefundOrderFull refunds a paid order in full at the operator's request: it revokes the funded
|
||||||
|
// chips best-effort (floored at 0, never negative — D27), records a refund ledger row, and is
|
||||||
|
// idempotent (a second call reports AlreadyRefunded). The operator performs the actual money refund
|
||||||
|
// on the rail (Robokassa cabinet / VK support / Telegram refundStarPayment); this records it.
|
||||||
|
func (s *Service) RefundOrderFull(ctx context.Context, orderID uuid.UUID) (RefundOutcome, error) {
|
||||||
|
o, err := s.store.orderByID(ctx, orderID)
|
||||||
|
if err != nil {
|
||||||
|
return RefundOutcome{}, err
|
||||||
|
}
|
||||||
|
refunded, err := MoneyFromMinor(o.expectedAmount, Currency(o.currency))
|
||||||
|
if err != nil {
|
||||||
|
return RefundOutcome{}, err
|
||||||
|
}
|
||||||
|
return s.store.refund(ctx, orderID, providerAdmin, orderID.String(), refunded, s.clock())
|
||||||
|
}
|
||||||
|
|
||||||
|
// LedgerExportRow is one append-only ledger row for the tax / reconciliation export, carrying the
|
||||||
|
// account it belongs to alongside the entry fields.
|
||||||
|
type LedgerExportRow struct {
|
||||||
|
AccountID string
|
||||||
|
LedgerEntry
|
||||||
|
}
|
||||||
|
|
||||||
|
// LedgerExport reads the entire append-only ledger (all accounts, newest first) for a CSV/JSON
|
||||||
|
// export — tax reporting and future rail reconciliation. Uncached, admin-only.
|
||||||
|
func (s *Service) LedgerExport(ctx context.Context) ([]LedgerExportRow, error) {
|
||||||
|
return s.store.allLedger(ctx)
|
||||||
|
}
|
||||||
@@ -5,6 +5,7 @@ import (
|
|||||||
"database/sql"
|
"database/sql"
|
||||||
"encoding/json"
|
"encoding/json"
|
||||||
"fmt"
|
"fmt"
|
||||||
|
"sync"
|
||||||
"time"
|
"time"
|
||||||
|
|
||||||
"github.com/google/uuid"
|
"github.com/google/uuid"
|
||||||
@@ -19,6 +20,13 @@ import (
|
|||||||
type Service struct {
|
type Service struct {
|
||||||
store *Store
|
store *Store
|
||||||
clock func() time.Time
|
clock func() time.Time
|
||||||
|
|
||||||
|
// offerMu guards the cached public-offer price list (§4.4). offerMD holds the projected
|
||||||
|
// markdown tables and offerFresh whether they are current; a catalog mutation clears offerFresh
|
||||||
|
// (markOfferStale) and the next OfferPricing read reprojects, so a served render issues no query.
|
||||||
|
offerMu sync.Mutex
|
||||||
|
offerMD string
|
||||||
|
offerFresh bool
|
||||||
}
|
}
|
||||||
|
|
||||||
// NewService constructs a Service over store with a wall-clock time source.
|
// NewService constructs a Service over store with a wall-clock time source.
|
||||||
@@ -156,6 +164,41 @@ func (s *Service) Grant(ctx context.Context, accountID uuid.UUID, origin Source,
|
|||||||
return s.store.grant(ctx, accountID, origin, d, snapshot, s.clock())
|
return s.store.grant(ctx, accountID, origin, d, snapshot, s.clock())
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// GrantProduct grants a product's benefit atoms (hints, no-ads days) to an origin as a zero-price
|
||||||
|
// admin sale, recording the source product on the ledger row (auditable to it). It refuses a
|
||||||
|
// product carrying the chips atom (never granted — D16) or the tournament atom (no credit target
|
||||||
|
// yet), and one whose atoms yield no grantable benefit.
|
||||||
|
func (s *Service) GrantProduct(ctx context.Context, accountID uuid.UUID, origin Source, productID uuid.UUID) error {
|
||||||
|
if !origin.Valid() {
|
||||||
|
return fmt.Errorf("payments: invalid grant origin %q", origin)
|
||||||
|
}
|
||||||
|
in, err := s.store.productInput(ctx, productID)
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
var d benefitDelta
|
||||||
|
for _, a := range in.Atoms {
|
||||||
|
switch a.Atom {
|
||||||
|
case "hints":
|
||||||
|
d.hintsAdd += a.Quantity
|
||||||
|
case "noads_days":
|
||||||
|
d.noAdsDays += a.Quantity
|
||||||
|
case atomChips:
|
||||||
|
return ErrCannotGrantChips
|
||||||
|
case "tournament":
|
||||||
|
return ErrCannotGrantTournament
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if d.zero() {
|
||||||
|
return ErrNothingToGrant
|
||||||
|
}
|
||||||
|
snapshot, err := marshalGrantProduct(productID, in.Title, d)
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
return s.store.grantProduct(ctx, accountID, origin, productID, d, snapshot, s.clock())
|
||||||
|
}
|
||||||
|
|
||||||
// MergeTx merges the secondary account's segments and benefits into the primary inside the
|
// MergeTx merges the secondary account's segments and benefits into the primary inside the
|
||||||
// caller's transaction (the account-merge flow). The caller invalidates the affected caches
|
// caller's transaction (the account-merge flow). The caller invalidates the affected caches
|
||||||
// after committing (Invalidate).
|
// after committing (Invalidate).
|
||||||
@@ -244,3 +287,20 @@ func marshalGrant(d benefitDelta) ([]byte, error) {
|
|||||||
}
|
}
|
||||||
return b, nil
|
return b, nil
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// marshalGrantProduct builds the snapshot for an admin grant-by-product: the source product and the
|
||||||
|
// benefit atoms it granted (price 0).
|
||||||
|
func marshalGrantProduct(productID uuid.UUID, title string, d benefitDelta) ([]byte, error) {
|
||||||
|
atoms := map[string]int{}
|
||||||
|
if d.hintsAdd > 0 {
|
||||||
|
atoms["hints"] = d.hintsAdd
|
||||||
|
}
|
||||||
|
if d.noAdsDays > 0 {
|
||||||
|
atoms["noads_days"] = d.noAdsDays
|
||||||
|
}
|
||||||
|
b, err := json.Marshal(purchaseSnapshot{ProductID: productID.String(), Title: title, Atoms: atoms, PriceChips: 0})
|
||||||
|
if err != nil {
|
||||||
|
return nil, fmt.Errorf("payments: marshal product grant snapshot: %w", err)
|
||||||
|
}
|
||||||
|
return b, nil
|
||||||
|
}
|
||||||
|
|||||||
@@ -157,6 +157,22 @@ func (s *Service) RewardPayout(ctx context.Context, cxt Context, present []Sourc
|
|||||||
return payout, err
|
return payout, err
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// RewardConfig reads the rewarded-video config for the admin editor: the payout (chips per view) and
|
||||||
|
// the per-day and per-hour caps.
|
||||||
|
func (s *Service) RewardConfig(ctx context.Context) (payout, dailyCap, hourlyCap int, err error) {
|
||||||
|
return s.store.rewardConfig(ctx)
|
||||||
|
}
|
||||||
|
|
||||||
|
// SetRewardConfig updates the rewarded-video config (the payout and the two caps). All three must be
|
||||||
|
// non-negative; a 0 payout leaves rewarded inert. It is not a catalog product, so it does not mark the
|
||||||
|
// offer stale.
|
||||||
|
func (s *Service) SetRewardConfig(ctx context.Context, payout, dailyCap, hourlyCap int) error {
|
||||||
|
if payout < 0 || dailyCap < 0 || hourlyCap < 0 {
|
||||||
|
return errors.New("payments: reward payout and caps must be non-negative")
|
||||||
|
}
|
||||||
|
return s.store.setRewardConfig(ctx, payout, dailyCap, hourlyCap)
|
||||||
|
}
|
||||||
|
|
||||||
// CreditReward credits a rewarded-video view's chips to the VK segment, client-attested (VK Mini App
|
// CreditReward credits a rewarded-video view's chips to the VK segment, client-attested (VK Mini App
|
||||||
// ads expose no server verify — the client's watch result is trusted for an honest user; a forger who
|
// ads expose no server verify — the client's watch result is trusted for an honest user; a forger who
|
||||||
// skips the ad and calls the endpoint is bounded by the config daily cap). It is idempotent on the
|
// skips the ad and calls the endpoint is bounded by the config daily cap). It is idempotent on the
|
||||||
|
|||||||
@@ -19,11 +19,17 @@ import (
|
|||||||
// row references — it must be archived instead, to keep the append-only ledger resolvable.
|
// row references — it must be archived instead, to keep the append-only ledger resolvable.
|
||||||
var ErrProductTransacted = errors.New("payments: product has transactions; archive instead of delete")
|
var ErrProductTransacted = errors.New("payments: product has transactions; archive instead of delete")
|
||||||
|
|
||||||
// adminCatalog lists every product (active and archived) with its atoms, prices and transacted flag.
|
// adminCatalog lists products with their atoms, prices and transacted flag. includeInactive adds the
|
||||||
func (s *Store) adminCatalog(ctx context.Context) ([]AdminProduct, error) {
|
// archived products to the active ones; with it false only active products are returned.
|
||||||
|
func (s *Store) adminCatalog(ctx context.Context, includeInactive bool) ([]AdminProduct, error) {
|
||||||
|
where := table.Product.Active.IS_TRUE()
|
||||||
|
if includeInactive {
|
||||||
|
where = postgres.Bool(true)
|
||||||
|
}
|
||||||
var prods []model.Product
|
var prods []model.Product
|
||||||
if err := postgres.SELECT(table.Product.AllColumns).
|
if err := postgres.SELECT(table.Product.AllColumns).
|
||||||
FROM(table.Product).
|
FROM(table.Product).
|
||||||
|
WHERE(where).
|
||||||
ORDER_BY(table.Product.CreatedAt.ASC()).
|
ORDER_BY(table.Product.CreatedAt.ASC()).
|
||||||
QueryContext(ctx, s.db, &prods); err != nil {
|
QueryContext(ctx, s.db, &prods); err != nil {
|
||||||
return nil, fmt.Errorf("payments: admin list products: %w", err)
|
return nil, fmt.Errorf("payments: admin list products: %w", err)
|
||||||
|
|||||||
@@ -413,6 +413,18 @@ func (s *Store) rewardConfig(ctx context.Context) (payout, dailyCap, hourlyCap i
|
|||||||
return int(cfg.RewardedPayoutChips), int(cfg.RewardDailyCap), int(cfg.RewardHourlyCap), nil
|
return int(cfg.RewardedPayoutChips), int(cfg.RewardDailyCap), int(cfg.RewardHourlyCap), nil
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// setRewardConfig updates the rewarded payout and the per-day and per-hour caps on the singleton
|
||||||
|
// config row (no WHERE — the table holds exactly one row). Non-negativity is validated by the caller
|
||||||
|
// and also enforced by the config CHECK constraints.
|
||||||
|
func (s *Store) setRewardConfig(ctx context.Context, payout, dailyCap, hourlyCap int) error {
|
||||||
|
if _, err := s.db.ExecContext(ctx,
|
||||||
|
`UPDATE payments.config SET rewarded_payout_chips=$1, reward_daily_cap=$2, reward_hourly_cap=$3`,
|
||||||
|
payout, dailyCap, hourlyCap); err != nil {
|
||||||
|
return fmt.Errorf("payments: set reward config: %w", err)
|
||||||
|
}
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
// creditReward credits a rewarded-video view's chips to the funded segment, client-attested (VK Mini
|
// creditReward credits a rewarded-video view's chips to the funded segment, client-attested (VK Mini
|
||||||
// App ads expose no server verify). It reads the payout and daily cap from config: a 0 payout
|
// App ads expose no server verify). It reads the payout and daily cap from config: a 0 payout
|
||||||
// (unconfigured) or a reached cap credits nothing. It is idempotent on the client nonce (dedup on the
|
// (unconfigured) or a reached cap credits nothing. It is idempotent on the client nonce (dedup on the
|
||||||
|
|||||||
@@ -77,6 +77,36 @@ func (s *Store) accountStatement(ctx context.Context, accountID uuid.UUID) (Stat
|
|||||||
return out, nil
|
return out, nil
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// allLedger reads the entire append-only ledger (all accounts, newest first) for the admin export.
|
||||||
|
func (s *Store) allLedger(ctx context.Context) ([]LedgerExportRow, error) {
|
||||||
|
var rows []model.Ledger
|
||||||
|
if err := postgres.SELECT(table.Ledger.AllColumns).
|
||||||
|
FROM(table.Ledger).
|
||||||
|
ORDER_BY(table.Ledger.CreatedAt.DESC()).
|
||||||
|
QueryContext(ctx, s.db, &rows); err != nil {
|
||||||
|
return nil, fmt.Errorf("payments: export ledger: %w", err)
|
||||||
|
}
|
||||||
|
out := make([]LedgerExportRow, 0, len(rows))
|
||||||
|
for _, r := range rows {
|
||||||
|
out = append(out, LedgerExportRow{
|
||||||
|
AccountID: r.AccountID.String(),
|
||||||
|
LedgerEntry: LedgerEntry{
|
||||||
|
Kind: r.Kind,
|
||||||
|
Source: derefStr(r.Source),
|
||||||
|
Origin: derefStr(r.Origin),
|
||||||
|
ChipsDelta: int(r.ChipsDelta),
|
||||||
|
ProductID: derefUUID(r.ProductID),
|
||||||
|
OrderID: derefUUID(r.OrderID),
|
||||||
|
Provider: derefStr(r.Provider),
|
||||||
|
ProviderPaymentID: derefStr(r.ProviderPaymentID),
|
||||||
|
Snapshot: derefStr(r.Snapshot),
|
||||||
|
CreatedAt: r.CreatedAt,
|
||||||
|
},
|
||||||
|
})
|
||||||
|
}
|
||||||
|
return out, nil
|
||||||
|
}
|
||||||
|
|
||||||
// derefStr returns the pointed-to string, or "" when the pointer is nil (a NULL column).
|
// derefStr returns the pointed-to string, or "" when the pointer is nil (a NULL column).
|
||||||
func derefStr(p *string) string {
|
func derefStr(p *string) string {
|
||||||
if p == nil {
|
if p == nil {
|
||||||
|
|||||||
@@ -26,6 +26,14 @@ var (
|
|||||||
// ErrNotAValue means the product has no chip price (it is a chip pack or unpriced), so it
|
// ErrNotAValue means the product has no chip price (it is a chip pack or unpriced), so it
|
||||||
// cannot be bought with chips.
|
// cannot be bought with chips.
|
||||||
ErrNotAValue = errors.New("payments: product is not a chip-priced value")
|
ErrNotAValue = errors.New("payments: product is not a chip-priced value")
|
||||||
|
// ErrCannotGrantChips means an admin grant targeted a product carrying the chips atom — the
|
||||||
|
// admin never grants currency (a gifted balance would bypass the cash desk, D16).
|
||||||
|
ErrCannotGrantChips = errors.New("payments: cannot grant chips")
|
||||||
|
// ErrCannotGrantTournament means an admin grant targeted a product carrying the tournament atom,
|
||||||
|
// which has no credit target until the tournament stage.
|
||||||
|
ErrCannotGrantTournament = errors.New("payments: cannot grant a tournament atom yet")
|
||||||
|
// ErrNothingToGrant means the product's atoms yield no grantable benefit (hints / no-ads days).
|
||||||
|
ErrNothingToGrant = errors.New("payments: product has nothing to grant")
|
||||||
)
|
)
|
||||||
|
|
||||||
// withTx runs fn inside a transaction on db, rolling back on error or panic.
|
// withTx runs fn inside a transaction on db, rolling back on error or panic.
|
||||||
@@ -312,6 +320,22 @@ func (s *Store) grant(ctx context.Context, accountID uuid.UUID, origin Source, d
|
|||||||
return nil
|
return nil
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// grantProduct is grant with the source product recorded on the ledger row (product_id), for an
|
||||||
|
// admin grant-by-product — the benefit is the product's atoms, the ledger stays auditable to it.
|
||||||
|
func (s *Store) grantProduct(ctx context.Context, accountID uuid.UUID, origin Source, productID uuid.UUID, d benefitDelta, snapshot []byte, now time.Time) error {
|
||||||
|
err := withTx(ctx, s.db, func(tx *sql.Tx) error {
|
||||||
|
if err := insertLedgerTx(ctx, tx, accountID, "admin_grant", nil, &origin, 0, &productID, nil, nil, nil, snapshot, now); err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
return applyBenefitTx(ctx, tx, accountID, origin, d, now)
|
||||||
|
})
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
s.cache.invalidate(accountID)
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
// consumeHint decrements one hint from the first applicable origin (in the given priority order)
|
// consumeHint decrements one hint from the first applicable origin (in the given priority order)
|
||||||
// that has one, with a guarded update. It returns whether a hint was spent.
|
// that has one, with a guarded update. It returns whether a hint was spent.
|
||||||
func (s *Store) consumeHint(ctx context.Context, accountID uuid.UUID, origins []Source, now time.Time) (bool, error) {
|
func (s *Store) consumeHint(ctx context.Context, accountID uuid.UUID, origins []Source, now time.Time) (bool, error) {
|
||||||
|
|||||||
@@ -0,0 +1,18 @@
|
|||||||
|
//
|
||||||
|
// Code generated by go-jet DO NOT EDIT.
|
||||||
|
//
|
||||||
|
// WARNING: Changes to this file may cause incorrect behavior
|
||||||
|
// and will be lost if the code is regenerated
|
||||||
|
//
|
||||||
|
|
||||||
|
package model
|
||||||
|
|
||||||
|
type Config struct {
|
||||||
|
OnlyRow bool `sql:"primary_key"`
|
||||||
|
GuestVsAiLimit int16
|
||||||
|
GuestRandomLimit int16
|
||||||
|
GuestFriendsLimit int16
|
||||||
|
DurableVsAiLimit int16
|
||||||
|
DurableRandomLimit int16
|
||||||
|
DurableFriendsLimit int16
|
||||||
|
}
|
||||||
@@ -33,4 +33,5 @@ type Games struct {
|
|||||||
DropoutTiles string
|
DropoutTiles string
|
||||||
MultipleWordsPerTurn bool
|
MultipleWordsPerTurn bool
|
||||||
VsAi bool
|
VsAi bool
|
||||||
|
GameKind int16
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -0,0 +1,96 @@
|
|||||||
|
//
|
||||||
|
// Code generated by go-jet DO NOT EDIT.
|
||||||
|
//
|
||||||
|
// WARNING: Changes to this file may cause incorrect behavior
|
||||||
|
// and will be lost if the code is regenerated
|
||||||
|
//
|
||||||
|
|
||||||
|
package table
|
||||||
|
|
||||||
|
import (
|
||||||
|
"github.com/go-jet/jet/v2/postgres"
|
||||||
|
)
|
||||||
|
|
||||||
|
var Config = newConfigTable("backend", "config", "")
|
||||||
|
|
||||||
|
type configTable struct {
|
||||||
|
postgres.Table
|
||||||
|
|
||||||
|
// Columns
|
||||||
|
OnlyRow postgres.ColumnBool
|
||||||
|
GuestVsAiLimit postgres.ColumnInteger
|
||||||
|
GuestRandomLimit postgres.ColumnInteger
|
||||||
|
GuestFriendsLimit postgres.ColumnInteger
|
||||||
|
DurableVsAiLimit postgres.ColumnInteger
|
||||||
|
DurableRandomLimit postgres.ColumnInteger
|
||||||
|
DurableFriendsLimit postgres.ColumnInteger
|
||||||
|
|
||||||
|
AllColumns postgres.ColumnList
|
||||||
|
MutableColumns postgres.ColumnList
|
||||||
|
DefaultColumns postgres.ColumnList
|
||||||
|
}
|
||||||
|
|
||||||
|
type ConfigTable struct {
|
||||||
|
configTable
|
||||||
|
|
||||||
|
EXCLUDED configTable
|
||||||
|
}
|
||||||
|
|
||||||
|
// AS creates new ConfigTable with assigned alias
|
||||||
|
func (a ConfigTable) AS(alias string) *ConfigTable {
|
||||||
|
return newConfigTable(a.SchemaName(), a.TableName(), alias)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Schema creates new ConfigTable with assigned schema name
|
||||||
|
func (a ConfigTable) FromSchema(schemaName string) *ConfigTable {
|
||||||
|
return newConfigTable(schemaName, a.TableName(), a.Alias())
|
||||||
|
}
|
||||||
|
|
||||||
|
// WithPrefix creates new ConfigTable with assigned table prefix
|
||||||
|
func (a ConfigTable) WithPrefix(prefix string) *ConfigTable {
|
||||||
|
return newConfigTable(a.SchemaName(), prefix+a.TableName(), a.TableName())
|
||||||
|
}
|
||||||
|
|
||||||
|
// WithSuffix creates new ConfigTable with assigned table suffix
|
||||||
|
func (a ConfigTable) WithSuffix(suffix string) *ConfigTable {
|
||||||
|
return newConfigTable(a.SchemaName(), a.TableName()+suffix, a.TableName())
|
||||||
|
}
|
||||||
|
|
||||||
|
func newConfigTable(schemaName, tableName, alias string) *ConfigTable {
|
||||||
|
return &ConfigTable{
|
||||||
|
configTable: newConfigTableImpl(schemaName, tableName, alias),
|
||||||
|
EXCLUDED: newConfigTableImpl("", "excluded", ""),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func newConfigTableImpl(schemaName, tableName, alias string) configTable {
|
||||||
|
var (
|
||||||
|
OnlyRowColumn = postgres.BoolColumn("only_row")
|
||||||
|
GuestVsAiLimitColumn = postgres.IntegerColumn("guest_vs_ai_limit")
|
||||||
|
GuestRandomLimitColumn = postgres.IntegerColumn("guest_random_limit")
|
||||||
|
GuestFriendsLimitColumn = postgres.IntegerColumn("guest_friends_limit")
|
||||||
|
DurableVsAiLimitColumn = postgres.IntegerColumn("durable_vs_ai_limit")
|
||||||
|
DurableRandomLimitColumn = postgres.IntegerColumn("durable_random_limit")
|
||||||
|
DurableFriendsLimitColumn = postgres.IntegerColumn("durable_friends_limit")
|
||||||
|
allColumns = postgres.ColumnList{OnlyRowColumn, GuestVsAiLimitColumn, GuestRandomLimitColumn, GuestFriendsLimitColumn, DurableVsAiLimitColumn, DurableRandomLimitColumn, DurableFriendsLimitColumn}
|
||||||
|
mutableColumns = postgres.ColumnList{GuestVsAiLimitColumn, GuestRandomLimitColumn, GuestFriendsLimitColumn, DurableVsAiLimitColumn, DurableRandomLimitColumn, DurableFriendsLimitColumn}
|
||||||
|
defaultColumns = postgres.ColumnList{OnlyRowColumn, GuestVsAiLimitColumn, GuestRandomLimitColumn, GuestFriendsLimitColumn, DurableVsAiLimitColumn, DurableRandomLimitColumn, DurableFriendsLimitColumn}
|
||||||
|
)
|
||||||
|
|
||||||
|
return configTable{
|
||||||
|
Table: postgres.NewTable(schemaName, tableName, alias, allColumns...),
|
||||||
|
|
||||||
|
//Columns
|
||||||
|
OnlyRow: OnlyRowColumn,
|
||||||
|
GuestVsAiLimit: GuestVsAiLimitColumn,
|
||||||
|
GuestRandomLimit: GuestRandomLimitColumn,
|
||||||
|
GuestFriendsLimit: GuestFriendsLimitColumn,
|
||||||
|
DurableVsAiLimit: DurableVsAiLimitColumn,
|
||||||
|
DurableRandomLimit: DurableRandomLimitColumn,
|
||||||
|
DurableFriendsLimit: DurableFriendsLimitColumn,
|
||||||
|
|
||||||
|
AllColumns: allColumns,
|
||||||
|
MutableColumns: mutableColumns,
|
||||||
|
DefaultColumns: defaultColumns,
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -37,6 +37,7 @@ type gamesTable struct {
|
|||||||
DropoutTiles postgres.ColumnString
|
DropoutTiles postgres.ColumnString
|
||||||
MultipleWordsPerTurn postgres.ColumnBool
|
MultipleWordsPerTurn postgres.ColumnBool
|
||||||
VsAi postgres.ColumnBool
|
VsAi postgres.ColumnBool
|
||||||
|
GameKind postgres.ColumnInteger
|
||||||
|
|
||||||
AllColumns postgres.ColumnList
|
AllColumns postgres.ColumnList
|
||||||
MutableColumns postgres.ColumnList
|
MutableColumns postgres.ColumnList
|
||||||
@@ -98,9 +99,10 @@ func newGamesTableImpl(schemaName, tableName, alias string) gamesTable {
|
|||||||
DropoutTilesColumn = postgres.StringColumn("dropout_tiles")
|
DropoutTilesColumn = postgres.StringColumn("dropout_tiles")
|
||||||
MultipleWordsPerTurnColumn = postgres.BoolColumn("multiple_words_per_turn")
|
MultipleWordsPerTurnColumn = postgres.BoolColumn("multiple_words_per_turn")
|
||||||
VsAiColumn = postgres.BoolColumn("vs_ai")
|
VsAiColumn = postgres.BoolColumn("vs_ai")
|
||||||
allColumns = postgres.ColumnList{GameIDColumn, VariantColumn, DictVersionColumn, SeedColumn, StatusColumn, PlayersColumn, ToMoveColumn, TurnStartedAtColumn, TurnTimeoutSecsColumn, HintsAllowedColumn, HintsPerPlayerColumn, MoveCountColumn, EndReasonColumn, CreatedAtColumn, UpdatedAtColumn, FinishedAtColumn, OpenDeadlineAtColumn, DropoutTilesColumn, MultipleWordsPerTurnColumn, VsAiColumn}
|
GameKindColumn = postgres.IntegerColumn("game_kind")
|
||||||
mutableColumns = postgres.ColumnList{VariantColumn, DictVersionColumn, SeedColumn, StatusColumn, PlayersColumn, ToMoveColumn, TurnStartedAtColumn, TurnTimeoutSecsColumn, HintsAllowedColumn, HintsPerPlayerColumn, MoveCountColumn, EndReasonColumn, CreatedAtColumn, UpdatedAtColumn, FinishedAtColumn, OpenDeadlineAtColumn, DropoutTilesColumn, MultipleWordsPerTurnColumn, VsAiColumn}
|
allColumns = postgres.ColumnList{GameIDColumn, VariantColumn, DictVersionColumn, SeedColumn, StatusColumn, PlayersColumn, ToMoveColumn, TurnStartedAtColumn, TurnTimeoutSecsColumn, HintsAllowedColumn, HintsPerPlayerColumn, MoveCountColumn, EndReasonColumn, CreatedAtColumn, UpdatedAtColumn, FinishedAtColumn, OpenDeadlineAtColumn, DropoutTilesColumn, MultipleWordsPerTurnColumn, VsAiColumn, GameKindColumn}
|
||||||
defaultColumns = postgres.ColumnList{StatusColumn, ToMoveColumn, TurnStartedAtColumn, HintsAllowedColumn, HintsPerPlayerColumn, MoveCountColumn, CreatedAtColumn, UpdatedAtColumn, DropoutTilesColumn, MultipleWordsPerTurnColumn, VsAiColumn}
|
mutableColumns = postgres.ColumnList{VariantColumn, DictVersionColumn, SeedColumn, StatusColumn, PlayersColumn, ToMoveColumn, TurnStartedAtColumn, TurnTimeoutSecsColumn, HintsAllowedColumn, HintsPerPlayerColumn, MoveCountColumn, EndReasonColumn, CreatedAtColumn, UpdatedAtColumn, FinishedAtColumn, OpenDeadlineAtColumn, DropoutTilesColumn, MultipleWordsPerTurnColumn, VsAiColumn, GameKindColumn}
|
||||||
|
defaultColumns = postgres.ColumnList{StatusColumn, ToMoveColumn, TurnStartedAtColumn, HintsAllowedColumn, HintsPerPlayerColumn, MoveCountColumn, CreatedAtColumn, UpdatedAtColumn, DropoutTilesColumn, MultipleWordsPerTurnColumn, VsAiColumn, GameKindColumn}
|
||||||
)
|
)
|
||||||
|
|
||||||
return gamesTable{
|
return gamesTable{
|
||||||
@@ -127,6 +129,7 @@ func newGamesTableImpl(schemaName, tableName, alias string) gamesTable {
|
|||||||
DropoutTiles: DropoutTilesColumn,
|
DropoutTiles: DropoutTilesColumn,
|
||||||
MultipleWordsPerTurn: MultipleWordsPerTurnColumn,
|
MultipleWordsPerTurn: MultipleWordsPerTurnColumn,
|
||||||
VsAi: VsAiColumn,
|
VsAi: VsAiColumn,
|
||||||
|
GameKind: GameKindColumn,
|
||||||
|
|
||||||
AllColumns: allColumns,
|
AllColumns: allColumns,
|
||||||
MutableColumns: mutableColumns,
|
MutableColumns: mutableColumns,
|
||||||
|
|||||||
@@ -21,6 +21,7 @@ func UseSchema(schema string) {
|
|||||||
Blocks = Blocks.FromSchema(schema)
|
Blocks = Blocks.FromSchema(schema)
|
||||||
ChatMessages = ChatMessages.FromSchema(schema)
|
ChatMessages = ChatMessages.FromSchema(schema)
|
||||||
Complaints = Complaints.FromSchema(schema)
|
Complaints = Complaints.FromSchema(schema)
|
||||||
|
Config = Config.FromSchema(schema)
|
||||||
DictionaryState = DictionaryState.FromSchema(schema)
|
DictionaryState = DictionaryState.FromSchema(schema)
|
||||||
EmailConfirmations = EmailConfirmations.FromSchema(schema)
|
EmailConfirmations = EmailConfirmations.FromSchema(schema)
|
||||||
FeedbackMessages = FeedbackMessages.FromSchema(schema)
|
FeedbackMessages = FeedbackMessages.FromSchema(schema)
|
||||||
|
|||||||
@@ -0,0 +1,36 @@
|
|||||||
|
-- Guest-limit foundation (E8): tag each game with its kind so the per-tier, per-kind active-game
|
||||||
|
-- limits are enforceable, and add the single-row config that holds those limits (tuned in the admin,
|
||||||
|
-- no release). It replaces the old hardcoded MaxActiveQuickGames=10 combined cap with a per-tier,
|
||||||
|
-- per-kind config. game_kind: 0=unknown (pre-E8 games, never gated), 1=vs_ai, 2=random, 3=friends —
|
||||||
|
-- set on creation. The limits are smallint with -1 = unlimited; a guest defaults to 1 vs_ai + 1
|
||||||
|
-- random (friends 0, moot — the guest gate blocks friend games), a durable account to 10 per kind
|
||||||
|
-- (the old cap, now per kind). Additive only — applies forward via goose with no data rewrite (no
|
||||||
|
-- contour wipe); an image rollback ignores the column + table.
|
||||||
|
-- +goose Up
|
||||||
|
|
||||||
|
ALTER TABLE backend.games
|
||||||
|
ADD COLUMN game_kind smallint DEFAULT 0 NOT NULL;
|
||||||
|
ALTER TABLE backend.games
|
||||||
|
ADD CONSTRAINT games_game_kind_chk CHECK (game_kind >= 0 AND game_kind <= 3);
|
||||||
|
|
||||||
|
CREATE TABLE backend.config (
|
||||||
|
only_row boolean DEFAULT true NOT NULL,
|
||||||
|
guest_vs_ai_limit smallint DEFAULT 1 NOT NULL,
|
||||||
|
guest_random_limit smallint DEFAULT 1 NOT NULL,
|
||||||
|
guest_friends_limit smallint DEFAULT 0 NOT NULL,
|
||||||
|
durable_vs_ai_limit smallint DEFAULT 10 NOT NULL,
|
||||||
|
durable_random_limit smallint DEFAULT 10 NOT NULL,
|
||||||
|
durable_friends_limit smallint DEFAULT 10 NOT NULL,
|
||||||
|
CONSTRAINT config_pkey PRIMARY KEY (only_row),
|
||||||
|
CONSTRAINT config_single_row_chk CHECK (only_row),
|
||||||
|
CONSTRAINT config_limits_chk CHECK (
|
||||||
|
guest_vs_ai_limit >= -1 AND guest_random_limit >= -1 AND guest_friends_limit >= -1 AND
|
||||||
|
durable_vs_ai_limit >= -1 AND durable_random_limit >= -1 AND durable_friends_limit >= -1)
|
||||||
|
);
|
||||||
|
|
||||||
|
INSERT INTO backend.config (only_row) VALUES (true);
|
||||||
|
|
||||||
|
-- +goose Down
|
||||||
|
|
||||||
|
DROP TABLE backend.config;
|
||||||
|
ALTER TABLE backend.games DROP COLUMN game_kind;
|
||||||
@@ -72,6 +72,7 @@ func (s *Server) profileResponse(ctx context.Context, acc account.Account) profi
|
|||||||
}
|
}
|
||||||
r.Banner = s.bannerFor(ctx, acc, cxt, present)
|
r.Banner = s.bannerFor(ctx, acc, cxt, present)
|
||||||
r.Ads = s.adsFor(ctx, acc, cxt, present)
|
r.Ads = s.adsFor(ctx, acc, cxt, present)
|
||||||
|
r.GameLimits = s.gameLimitsDTOFor(acc.IsGuest)
|
||||||
s.fillLinkedIdentities(ctx, &r, acc.ID)
|
s.fillLinkedIdentities(ctx, &r, acc.ID)
|
||||||
r.DictVersions = s.currentDictVersions()
|
r.DictVersions = s.currentDictVersions()
|
||||||
return r
|
return r
|
||||||
|
|||||||
@@ -78,6 +78,18 @@ type profileResponse struct {
|
|||||||
// Filled outside the pure projection (it reads the dictionary registry), so it is empty
|
// Filled outside the pure projection (it reads the dictionary registry), so it is empty
|
||||||
// for callers that build the DTO without a Server. See Server.profileResponse.
|
// for callers that build the DTO without a Server. See Server.profileResponse.
|
||||||
DictVersions []dictVersion `json:"dict_versions,omitempty"`
|
DictVersions []dictVersion `json:"dict_versions,omitempty"`
|
||||||
|
// GameLimits carries the caller's tier active-game caps per kind (-1 = unlimited); the client
|
||||||
|
// counts its active games per kind from the lobby and locks a capped New-Game start. Filled
|
||||||
|
// outside the pure projection (it reads the limits config). See Server.profileResponse.
|
||||||
|
GameLimits *gameLimitsDTO `json:"game_limits,omitempty"`
|
||||||
|
}
|
||||||
|
|
||||||
|
// gameLimitsDTO is the caller's tier active-game caps per kind (-1 = unlimited), for the client's
|
||||||
|
// per-kind New-Game lock. profileResponse.GameLimits carries it.
|
||||||
|
type gameLimitsDTO struct {
|
||||||
|
VsAI int `json:"vs_ai"`
|
||||||
|
Random int `json:"random"`
|
||||||
|
Friends int `json:"friends"`
|
||||||
}
|
}
|
||||||
|
|
||||||
// dictVersion pairs a game variant's stable label (engine.Variant.String) with its current
|
// dictVersion pairs a game variant's stable label (engine.Variant.String) with its current
|
||||||
@@ -134,7 +146,11 @@ type gameDTO struct {
|
|||||||
MultipleWordsPerTurn bool `json:"multiple_words_per_turn"`
|
MultipleWordsPerTurn bool `json:"multiple_words_per_turn"`
|
||||||
// VsAI marks an honest-AI game: the opponent is shown as 🤖 and chat/nudge/add-friend
|
// VsAI marks an honest-AI game: the opponent is shown as 🤖 and chat/nudge/add-friend
|
||||||
// are suppressed in the client.
|
// are suppressed in the client.
|
||||||
VsAI bool `json:"vs_ai"`
|
VsAI bool `json:"vs_ai"`
|
||||||
|
// Kind is the game's origin for the active-game limits (game.Kind): 0 unknown (a pre-existing
|
||||||
|
// game), 1 vs_ai, 2 random, 3 friends. The lobby counts active games per kind to lock a capped
|
||||||
|
// New-Game start.
|
||||||
|
Kind int `json:"kind"`
|
||||||
MoveCount int `json:"move_count"`
|
MoveCount int `json:"move_count"`
|
||||||
EndReason string `json:"end_reason"`
|
EndReason string `json:"end_reason"`
|
||||||
// LastActivityUnix is the lobby sort key: the current turn's start for an active
|
// LastActivityUnix is the lobby sort key: the current turn's start for an active
|
||||||
@@ -177,7 +193,6 @@ type stateDTO struct {
|
|||||||
Rack []int `json:"rack"`
|
Rack []int `json:"rack"`
|
||||||
BagLen int `json:"bag_len"`
|
BagLen int `json:"bag_len"`
|
||||||
HintsRemaining int `json:"hints_remaining"`
|
HintsRemaining int `json:"hints_remaining"`
|
||||||
WalletBalance int `json:"wallet_balance"`
|
|
||||||
// HintUnlockLeftSeconds is the vs_ai idle-hint gate: seconds until the hint unlocks (0 for a human
|
// HintUnlockLeftSeconds is the vs_ai idle-hint gate: seconds until the hint unlocks (0 for a human
|
||||||
// game / first move / not your turn). The client anchors a monotonic countdown to it.
|
// game / first move / not your turn). The client anchors a monotonic countdown to it.
|
||||||
HintUnlockLeftSeconds int `json:"hint_unlock_left_seconds"`
|
HintUnlockLeftSeconds int `json:"hint_unlock_left_seconds"`
|
||||||
@@ -278,6 +293,7 @@ func gameDTOFromGame(g game.Game) gameDTO {
|
|||||||
TurnTimeoutSecs: int(g.TurnTimeout.Seconds()),
|
TurnTimeoutSecs: int(g.TurnTimeout.Seconds()),
|
||||||
MultipleWordsPerTurn: g.MultipleWordsPerTurn,
|
MultipleWordsPerTurn: g.MultipleWordsPerTurn,
|
||||||
VsAI: g.VsAI,
|
VsAI: g.VsAI,
|
||||||
|
Kind: int(g.Kind),
|
||||||
MoveCount: g.MoveCount,
|
MoveCount: g.MoveCount,
|
||||||
EndReason: g.EndReason,
|
EndReason: g.EndReason,
|
||||||
LastActivityUnix: last.Unix(),
|
LastActivityUnix: last.Unix(),
|
||||||
@@ -334,7 +350,6 @@ func stateDTOFrom(v game.StateView, includeAlphabet bool) (stateDTO, error) {
|
|||||||
Rack: rack,
|
Rack: rack,
|
||||||
BagLen: v.BagLen,
|
BagLen: v.BagLen,
|
||||||
HintsRemaining: v.HintsRemaining,
|
HintsRemaining: v.HintsRemaining,
|
||||||
WalletBalance: v.WalletBalance,
|
|
||||||
HintUnlockLeftSeconds: v.HintUnlockLeftSeconds,
|
HintUnlockLeftSeconds: v.HintUnlockLeftSeconds,
|
||||||
}
|
}
|
||||||
if includeAlphabet {
|
if includeAlphabet {
|
||||||
|
|||||||
@@ -74,6 +74,9 @@ func (s *Server) registerRoutes() {
|
|||||||
u.POST("/wallet/buy", s.handleWalletBuy)
|
u.POST("/wallet/buy", s.handleWalletBuy)
|
||||||
// A rewarded-video credit (VK ads): client-attested + a config daily cap.
|
// A rewarded-video credit (VK ads): client-attested + a config daily cap.
|
||||||
u.POST("/wallet/reward", s.handleWalletReward)
|
u.POST("/wallet/reward", s.handleWalletReward)
|
||||||
|
// The public-offer price list (§4.4) as markdown, for the render sidecar that serves the
|
||||||
|
// /offer/ page. Internal (off the edge allow-list); called by the renderer, not the gateway.
|
||||||
|
s.internal.GET("/offer/pricing", s.handleOfferPricing)
|
||||||
}
|
}
|
||||||
if s.payments != nil {
|
if s.payments != nil {
|
||||||
// The money order endpoint dispatches by rail (direct → Robokassa, vk → VK); an
|
// The money order endpoint dispatches by rail (direct → Robokassa, vk → VK); an
|
||||||
@@ -319,6 +322,8 @@ func statusForError(err error) (int, string) {
|
|||||||
return http.StatusConflict, "request_declined"
|
return http.StatusConflict, "request_declined"
|
||||||
case errors.Is(err, social.ErrFriendCodeInvalid):
|
case errors.Is(err, social.ErrFriendCodeInvalid):
|
||||||
return http.StatusUnprocessableEntity, "friend_code_invalid"
|
return http.StatusUnprocessableEntity, "friend_code_invalid"
|
||||||
|
case errors.Is(err, social.ErrGuestForbidden), errors.Is(err, lobby.ErrGuestForbidden):
|
||||||
|
return http.StatusForbidden, "guest_forbidden"
|
||||||
case errors.Is(err, feedback.ErrGuestForbidden):
|
case errors.Is(err, feedback.ErrGuestForbidden):
|
||||||
return http.StatusForbidden, "feedback_guest_forbidden"
|
return http.StatusForbidden, "feedback_guest_forbidden"
|
||||||
case errors.Is(err, feedback.ErrBanned):
|
case errors.Is(err, feedback.ErrBanned):
|
||||||
|
|||||||
@@ -1,11 +1,14 @@
|
|||||||
package server
|
package server
|
||||||
|
|
||||||
import (
|
import (
|
||||||
|
"context"
|
||||||
"errors"
|
"errors"
|
||||||
|
"fmt"
|
||||||
"strconv"
|
"strconv"
|
||||||
"strings"
|
"strings"
|
||||||
|
|
||||||
"github.com/gin-gonic/gin"
|
"github.com/gin-gonic/gin"
|
||||||
|
"github.com/google/uuid"
|
||||||
|
|
||||||
"scrabble/backend/internal/adminconsole"
|
"scrabble/backend/internal/adminconsole"
|
||||||
"scrabble/backend/internal/payments"
|
"scrabble/backend/internal/payments"
|
||||||
@@ -30,21 +33,45 @@ var priceFields = []struct {
|
|||||||
{"price_chip", "", payments.CurrencyChip},
|
{"price_chip", "", payments.CurrencyChip},
|
||||||
}
|
}
|
||||||
|
|
||||||
// consoleCatalog lists every product (active and archived) with its composition, prices, transacted
|
// consoleCatalog lists the catalog products with their composition, prices, transacted flag, and the
|
||||||
// flag, and the inline create form.
|
// inline create form. It shows active products by default; ?all=1 also lists the archived ones (the
|
||||||
|
// active/all toggle).
|
||||||
func (s *Server) consoleCatalog(c *gin.Context) {
|
func (s *Server) consoleCatalog(c *gin.Context) {
|
||||||
products, err := s.payments.AdminCatalog(c.Request.Context())
|
showAll := c.Query("all") == "1"
|
||||||
|
products, err := s.payments.AdminCatalog(c.Request.Context(), showAll)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
s.consoleError(c, err)
|
s.consoleError(c, err)
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
var view adminconsole.CatalogView
|
// Order the list like the public offer: sales (chip packs) first, then the chip-exchange values,
|
||||||
|
// grouped and price-sorted the same way, so the console mirrors what a buyer sees.
|
||||||
|
payments.SortAdminCatalog(products)
|
||||||
|
payout, daily, hourly, err := s.payments.RewardConfig(c.Request.Context())
|
||||||
|
if err != nil {
|
||||||
|
s.consoleError(c, err)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
view := adminconsole.CatalogView{ShowAll: showAll, RewardPayout: payout, RewardDailyCap: daily, RewardHourlyCap: hourly}
|
||||||
for _, p := range products {
|
for _, p := range products {
|
||||||
view.Products = append(view.Products, catalogRow(p))
|
view.Products = append(view.Products, catalogRow(p))
|
||||||
}
|
}
|
||||||
s.renderConsole(c, "catalog", "catalog", "Catalog", view)
|
s.renderConsole(c, "catalog", "catalog", "Catalog", view)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// consoleSetReward updates the rewarded-video config (chips per view plus the per-day and per-hour
|
||||||
|
// caps) from the catalog page's rewarded-ads form. A blank field reads as 0; a negative value is
|
||||||
|
// refused by the service.
|
||||||
|
func (s *Server) consoleSetReward(c *gin.Context) {
|
||||||
|
payout, _ := strconv.Atoi(strings.TrimSpace(c.PostForm("reward_payout")))
|
||||||
|
daily, _ := strconv.Atoi(strings.TrimSpace(c.PostForm("reward_daily")))
|
||||||
|
hourly, _ := strconv.Atoi(strings.TrimSpace(c.PostForm("reward_hourly")))
|
||||||
|
if err := s.payments.SetRewardConfig(c.Request.Context(), payout, daily, hourly); err != nil {
|
||||||
|
s.renderConsoleMessage(c, "Update failed", err.Error(), catalogBack)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
s.renderConsoleMessage(c, "Saved", "the rewarded-ads config was updated", catalogBack)
|
||||||
|
}
|
||||||
|
|
||||||
// catalogRow projects a product into its list row.
|
// catalogRow projects a product into its list row.
|
||||||
func catalogRow(p payments.AdminProduct) adminconsole.ProductRow {
|
func catalogRow(p payments.AdminProduct) adminconsole.ProductRow {
|
||||||
row := adminconsole.ProductRow{ID: p.ID.String(), Title: p.Title, Active: p.Active, Transacted: p.Transacted}
|
row := adminconsole.ProductRow{ID: p.ID.String(), Title: p.Title, Active: p.Active, Transacted: p.Transacted}
|
||||||
@@ -63,7 +90,7 @@ func (s *Server) consoleCatalogDetail(c *gin.Context) {
|
|||||||
if !ok {
|
if !ok {
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
products, err := s.payments.AdminCatalog(c.Request.Context())
|
products, err := s.payments.AdminCatalog(c.Request.Context(), true) // include archived: the detail form edits any product
|
||||||
if err != nil {
|
if err != nil {
|
||||||
s.consoleError(c, err)
|
s.consoleError(c, err)
|
||||||
return
|
return
|
||||||
@@ -181,3 +208,95 @@ func (s *Server) consoleDeleteProductAction(c *gin.Context) {
|
|||||||
}
|
}
|
||||||
s.renderConsoleMessage(c, "Deleted", "the product was deleted", catalogBack)
|
s.renderConsoleMessage(c, "Deleted", "the product was deleted", catalogBack)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// consoleGrant grants raw benefit atoms (hints / no-ads days / forever) to a chosen origin — a
|
||||||
|
// zero-price admin sale.
|
||||||
|
func (s *Server) consoleGrant(c *gin.Context) {
|
||||||
|
id, ok := s.consoleUUID(c, "/_gm/users")
|
||||||
|
if !ok {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
back := "/_gm/users/" + id.String()
|
||||||
|
if s.payments == nil {
|
||||||
|
s.renderConsoleMessage(c, "Unavailable", "payments are not enabled", back)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
origin := payments.Source(c.PostForm("origin"))
|
||||||
|
hints, _ := strconv.Atoi(strings.TrimSpace(c.PostForm("hints")))
|
||||||
|
noads, _ := strconv.Atoi(strings.TrimSpace(c.PostForm("noads")))
|
||||||
|
forever := c.PostForm("forever") != ""
|
||||||
|
if err := s.payments.Grant(c.Request.Context(), id, origin, hints, noads, forever); err != nil {
|
||||||
|
s.renderConsoleMessage(c, "Grant failed", err.Error(), back)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
s.publishBannerChange(id)
|
||||||
|
s.renderConsoleMessage(c, "Granted", "the benefit was granted", back)
|
||||||
|
}
|
||||||
|
|
||||||
|
// consoleGrantProduct grants a value product's atoms (a reward bundle, possibly archived) to a
|
||||||
|
// chosen origin. It refuses a product carrying chips or the tournament atom (payments enforces it).
|
||||||
|
func (s *Server) consoleGrantProduct(c *gin.Context) {
|
||||||
|
id, ok := s.consoleUUID(c, "/_gm/users")
|
||||||
|
if !ok {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
back := "/_gm/users/" + id.String()
|
||||||
|
if s.payments == nil {
|
||||||
|
s.renderConsoleMessage(c, "Unavailable", "payments are not enabled", back)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
origin := payments.Source(c.PostForm("origin"))
|
||||||
|
productID, err := uuid.Parse(strings.TrimSpace(c.PostForm("product_id")))
|
||||||
|
if err != nil {
|
||||||
|
s.renderConsoleMessage(c, "Grant failed", "choose a product", back)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
if err := s.payments.GrantProduct(c.Request.Context(), id, origin, productID); err != nil {
|
||||||
|
s.renderConsoleMessage(c, "Grant failed", err.Error(), back)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
s.publishBannerChange(id)
|
||||||
|
s.renderConsoleMessage(c, "Granted", "the product was granted", back)
|
||||||
|
}
|
||||||
|
|
||||||
|
// grantForm builds the admin-grant panel: the origin picker and the grantable products (value
|
||||||
|
// bundles, including archived ones — chips and tournament products are excluded).
|
||||||
|
func (s *Server) grantForm(ctx context.Context) adminconsole.GrantFormView {
|
||||||
|
fv := adminconsole.GrantFormView{Present: true, Origins: []string{"direct", "vk", "telegram"}}
|
||||||
|
products, err := s.payments.AdminCatalog(ctx, true)
|
||||||
|
if err != nil {
|
||||||
|
return fv
|
||||||
|
}
|
||||||
|
for _, p := range products {
|
||||||
|
if grantableProduct(p) {
|
||||||
|
fv.Products = append(fv.Products, adminconsole.GrantProductOption{
|
||||||
|
ID: p.ID.String(), Title: p.Title, Summary: atomSummary(p.Atoms), Archived: !p.Active,
|
||||||
|
})
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return fv
|
||||||
|
}
|
||||||
|
|
||||||
|
// grantableProduct reports whether a product can be admin-granted: it carries at least one benefit
|
||||||
|
// atom (hints / no-ads days) and no chips or tournament atom.
|
||||||
|
func grantableProduct(p payments.AdminProduct) bool {
|
||||||
|
benefit := false
|
||||||
|
for _, a := range p.Atoms {
|
||||||
|
switch a.Atom {
|
||||||
|
case "chips", "tournament":
|
||||||
|
return false
|
||||||
|
case "hints", "noads_days":
|
||||||
|
benefit = true
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return benefit
|
||||||
|
}
|
||||||
|
|
||||||
|
// atomSummary renders a product's atoms as "hints×5, noads_days×30".
|
||||||
|
func atomSummary(atoms []payments.AtomLine) string {
|
||||||
|
parts := make([]string, 0, len(atoms))
|
||||||
|
for _, a := range atoms {
|
||||||
|
parts = append(parts, fmt.Sprintf("%s×%d", a.Atom, a.Quantity))
|
||||||
|
}
|
||||||
|
return strings.Join(parts, ", ")
|
||||||
|
}
|
||||||
|
|||||||
@@ -58,6 +58,9 @@ func (s *Server) registerConsole(router *gin.Engine) {
|
|||||||
gm.POST("/users/:id/grant-role", s.consoleGrantRole)
|
gm.POST("/users/:id/grant-role", s.consoleGrantRole)
|
||||||
gm.POST("/users/:id/revoke-role", s.consoleRevokeRole)
|
gm.POST("/users/:id/revoke-role", s.consoleRevokeRole)
|
||||||
gm.POST("/users/:id/remove-email", s.consoleRemoveEmail)
|
gm.POST("/users/:id/remove-email", s.consoleRemoveEmail)
|
||||||
|
gm.POST("/users/:id/grant", s.consoleGrant)
|
||||||
|
gm.POST("/users/:id/grant-product", s.consoleGrantProduct)
|
||||||
|
gm.POST("/users/:id/refund", s.consoleRefund)
|
||||||
gm.POST("/users/:id/delete", s.consoleDeleteUser)
|
gm.POST("/users/:id/delete", s.consoleDeleteUser)
|
||||||
gm.GET("/reasons", s.consoleReasons)
|
gm.GET("/reasons", s.consoleReasons)
|
||||||
gm.POST("/reasons", s.consoleCreateReason)
|
gm.POST("/reasons", s.consoleCreateReason)
|
||||||
@@ -67,6 +70,10 @@ func (s *Server) registerConsole(router *gin.Engine) {
|
|||||||
gm.POST("/bans/unban", s.consoleUnban)
|
gm.POST("/bans/unban", s.consoleUnban)
|
||||||
gm.GET("/games", s.consoleGames)
|
gm.GET("/games", s.consoleGames)
|
||||||
gm.GET("/games/:id", s.consoleGameDetail)
|
gm.GET("/games/:id", s.consoleGameDetail)
|
||||||
|
if s.gamelimits != nil {
|
||||||
|
gm.GET("/limits", s.consoleLimits)
|
||||||
|
gm.POST("/limits", s.consoleUpdateLimits)
|
||||||
|
}
|
||||||
gm.GET("/complaints", s.consoleComplaints)
|
gm.GET("/complaints", s.consoleComplaints)
|
||||||
gm.GET("/complaints/:id", s.consoleComplaintDetail)
|
gm.GET("/complaints/:id", s.consoleComplaintDetail)
|
||||||
gm.POST("/complaints/:id/resolve", s.consoleResolveComplaint)
|
gm.POST("/complaints/:id/resolve", s.consoleResolveComplaint)
|
||||||
@@ -105,10 +112,12 @@ func (s *Server) registerConsole(router *gin.Engine) {
|
|||||||
if s.payments != nil {
|
if s.payments != nil {
|
||||||
gm.GET("/catalog", s.consoleCatalog)
|
gm.GET("/catalog", s.consoleCatalog)
|
||||||
gm.POST("/catalog", s.consoleCreateProduct)
|
gm.POST("/catalog", s.consoleCreateProduct)
|
||||||
|
gm.POST("/catalog/reward", s.consoleSetReward)
|
||||||
gm.GET("/catalog/:id", s.consoleCatalogDetail)
|
gm.GET("/catalog/:id", s.consoleCatalogDetail)
|
||||||
gm.POST("/catalog/:id", s.consoleUpdateProduct)
|
gm.POST("/catalog/:id", s.consoleUpdateProduct)
|
||||||
gm.POST("/catalog/:id/archive", s.consoleArchiveProduct)
|
gm.POST("/catalog/:id/archive", s.consoleArchiveProduct)
|
||||||
gm.POST("/catalog/:id/delete", s.consoleDeleteProductAction)
|
gm.POST("/catalog/:id/delete", s.consoleDeleteProductAction)
|
||||||
|
gm.GET("/ledger.csv", s.consoleLedgerExport)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -451,6 +460,7 @@ func (s *Server) consoleUserDetail(c *gin.Context) {
|
|||||||
} else {
|
} else {
|
||||||
s.log.Warn("console: account statement failed", zap.String("account", id.String()), zap.Error(err))
|
s.log.Warn("console: account statement failed", zap.String("account", id.String()), zap.Error(err))
|
||||||
}
|
}
|
||||||
|
view.Grant = s.grantForm(ctx)
|
||||||
}
|
}
|
||||||
s.renderConsole(c, "user_detail", "users", acc.DisplayName, view)
|
s.renderConsole(c, "user_detail", "users", acc.DisplayName, view)
|
||||||
}
|
}
|
||||||
@@ -557,6 +567,7 @@ func (s *Server) consoleGameDetail(c *gin.Context) {
|
|||||||
Status: g.Status, Players: g.Players, ToMove: g.ToMove, EndReason: g.EndReason,
|
Status: g.Status, Players: g.Players, ToMove: g.ToMove, EndReason: g.EndReason,
|
||||||
MoveCount: g.MoveCount, CreatedAt: fmtTime(g.CreatedAt), UpdatedAt: fmtTime(g.UpdatedAt),
|
MoveCount: g.MoveCount, CreatedAt: fmtTime(g.CreatedAt), UpdatedAt: fmtTime(g.UpdatedAt),
|
||||||
FinishedAt: fmtTimePtr(g.FinishedAt), VsAI: g.VsAI,
|
FinishedAt: fmtTimePtr(g.FinishedAt), VsAI: g.VsAI,
|
||||||
|
MultipleWordsPerTurn: g.MultipleWordsPerTurn,
|
||||||
}
|
}
|
||||||
// Resolve seats and detect robot seats; capture the human opponent's timezone, which
|
// Resolve seats and detect robot seats; capture the human opponent's timezone, which
|
||||||
// anchors the robot's sleep window for the next-move ETA.
|
// anchors the robot's sleep window for the next-move ETA.
|
||||||
@@ -1239,7 +1250,7 @@ func (s *Server) consoleUUID(c *gin.Context, back string) (uuid.UUID, bool) {
|
|||||||
|
|
||||||
// gameRow projects a game summary into its console row.
|
// gameRow projects a game summary into its console row.
|
||||||
func gameRow(g game.Game) adminconsole.GameRow {
|
func gameRow(g game.Game) adminconsole.GameRow {
|
||||||
return adminconsole.GameRow{ID: g.ID.String(), Variant: g.Variant.String(), Status: g.Status, Players: g.Players, UpdatedAt: fmtTime(g.UpdatedAt), VsAI: g.VsAI}
|
return adminconsole.GameRow{ID: g.ID.String(), Variant: g.Variant.String(), Status: g.Status, Players: g.Players, UpdatedAt: fmtTime(g.UpdatedAt), VsAI: g.VsAI, Kind: g.Kind.String()}
|
||||||
}
|
}
|
||||||
|
|
||||||
// trimForm returns the trimmed value of a posted form field.
|
// trimForm returns the trimmed value of a posted form field.
|
||||||
|
|||||||
@@ -0,0 +1,75 @@
|
|||||||
|
package server
|
||||||
|
|
||||||
|
import (
|
||||||
|
"fmt"
|
||||||
|
|
||||||
|
"github.com/gin-gonic/gin"
|
||||||
|
|
||||||
|
"scrabble/backend/internal/adminconsole"
|
||||||
|
"scrabble/backend/internal/gamelimits"
|
||||||
|
)
|
||||||
|
|
||||||
|
// gameLimitsDTOFor resolves the caller's tier active-game caps into the profile DTO, so the client
|
||||||
|
// can lock a capped New-Game start per kind. It returns nil when the limits config is not wired.
|
||||||
|
func (s *Server) gameLimitsDTOFor(isGuest bool) *gameLimitsDTO {
|
||||||
|
if s.gamelimits == nil {
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
l := s.gamelimits.LimitsFor(isGuest)
|
||||||
|
return &gameLimitsDTO{VsAI: l.VsAI, Random: l.Random, Friends: l.Friends}
|
||||||
|
}
|
||||||
|
|
||||||
|
// consoleLimits renders the per-tier, per-kind active-game limit form (backend.config), read from
|
||||||
|
// the in-memory cache.
|
||||||
|
func (s *Server) consoleLimits(c *gin.Context) {
|
||||||
|
cfg := s.gamelimits.Get()
|
||||||
|
s.renderConsole(c, "limits", "limits", "Game limits", adminconsole.GameLimitsView{
|
||||||
|
GuestVsAI: cfg.Guest.VsAI,
|
||||||
|
GuestRandom: cfg.Guest.Random,
|
||||||
|
GuestFriends: cfg.Guest.Friends,
|
||||||
|
DurableVsAI: cfg.Durable.VsAI,
|
||||||
|
DurableRandom: cfg.Durable.Random,
|
||||||
|
DurableFriends: cfg.Durable.Friends,
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
// consoleUpdateLimits saves the active-game limits and refreshes the hot cache in place. Each
|
||||||
|
// field is a per-kind cap: -1 is unlimited, 0 blocks the kind, a positive value caps concurrent games.
|
||||||
|
func (s *Server) consoleUpdateLimits(c *gin.Context) {
|
||||||
|
cfg := gamelimits.Config{
|
||||||
|
Guest: gamelimits.Limits{
|
||||||
|
VsAI: atoiForm(c, "guest_vs_ai"),
|
||||||
|
Random: atoiForm(c, "guest_random"),
|
||||||
|
Friends: atoiForm(c, "guest_friends"),
|
||||||
|
},
|
||||||
|
Durable: gamelimits.Limits{
|
||||||
|
VsAI: atoiForm(c, "durable_vs_ai"),
|
||||||
|
Random: atoiForm(c, "durable_random"),
|
||||||
|
Friends: atoiForm(c, "durable_friends"),
|
||||||
|
},
|
||||||
|
}
|
||||||
|
if err := validateGameLimits(cfg); err != nil {
|
||||||
|
s.renderConsoleMessage(c, "Invalid", err.Error(), "/_gm/limits")
|
||||||
|
return
|
||||||
|
}
|
||||||
|
if err := s.gamelimits.Update(c.Request.Context(), cfg); err != nil {
|
||||||
|
s.consoleError(c, err)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
s.renderConsoleMessage(c, "Saved", "game limits updated", "/_gm/limits")
|
||||||
|
}
|
||||||
|
|
||||||
|
// validateGameLimits rejects a limit below -1 (the unlimited sentinel); -1, 0 and any positive count
|
||||||
|
// are valid. It mirrors the backend.config CHECK so a bad value is refused with a clean message
|
||||||
|
// rather than a raw database error.
|
||||||
|
func validateGameLimits(cfg gamelimits.Config) error {
|
||||||
|
for _, v := range []int{
|
||||||
|
cfg.Guest.VsAI, cfg.Guest.Random, cfg.Guest.Friends,
|
||||||
|
cfg.Durable.VsAI, cfg.Durable.Random, cfg.Durable.Friends,
|
||||||
|
} {
|
||||||
|
if v < gamelimits.Unlimited {
|
||||||
|
return fmt.Errorf("a limit must be -1 (unlimited), 0, or a positive count")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return nil
|
||||||
|
}
|
||||||
@@ -0,0 +1,71 @@
|
|||||||
|
package server
|
||||||
|
|
||||||
|
import (
|
||||||
|
"encoding/csv"
|
||||||
|
"fmt"
|
||||||
|
"strconv"
|
||||||
|
"strings"
|
||||||
|
"time"
|
||||||
|
|
||||||
|
"github.com/gin-gonic/gin"
|
||||||
|
"github.com/google/uuid"
|
||||||
|
)
|
||||||
|
|
||||||
|
// consoleRefund refunds a paid order in full at the operator's request. The operator performs the
|
||||||
|
// actual money refund on the rail (Robokassa cabinet / VK support / Telegram refundStarPayment);
|
||||||
|
// this records it — a refund ledger row and a floor-0 chip revoke (never negative, D27). Idempotent.
|
||||||
|
func (s *Server) consoleRefund(c *gin.Context) {
|
||||||
|
id, ok := s.consoleUUID(c, "/_gm/users")
|
||||||
|
if !ok {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
back := "/_gm/users/" + id.String()
|
||||||
|
if s.payments == nil {
|
||||||
|
s.renderConsoleMessage(c, "Unavailable", "payments are not enabled", back)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
orderID, err := uuid.Parse(strings.TrimSpace(c.PostForm("order_id")))
|
||||||
|
if err != nil {
|
||||||
|
s.renderConsoleMessage(c, "Refund failed", "no order to refund", back)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
out, err := s.payments.RefundOrderFull(c.Request.Context(), orderID)
|
||||||
|
if err != nil {
|
||||||
|
s.renderConsoleMessage(c, "Refund failed", err.Error(), back)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
if out.AlreadyRefunded {
|
||||||
|
s.renderConsoleMessage(c, "Already refunded", "this order was already refunded", back)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
s.publishBannerChange(id)
|
||||||
|
msg := fmt.Sprintf("revoked %d chips", out.Revoked)
|
||||||
|
if out.Loss > 0 {
|
||||||
|
msg += fmt.Sprintf("; %d chips were already spent (recorded as a loss + abuse flag)", out.Loss)
|
||||||
|
}
|
||||||
|
s.renderConsoleMessage(c, "Refunded", msg, back)
|
||||||
|
}
|
||||||
|
|
||||||
|
// consoleLedgerExport streams the entire append-only ledger as a CSV attachment for tax reporting
|
||||||
|
// and rail reconciliation. The snapshot column carries the raw purchase/refund JSON.
|
||||||
|
func (s *Server) consoleLedgerExport(c *gin.Context) {
|
||||||
|
rows, err := s.payments.LedgerExport(c.Request.Context())
|
||||||
|
if err != nil {
|
||||||
|
s.consoleError(c, err)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
c.Header("Content-Type", "text/csv; charset=utf-8")
|
||||||
|
c.Header("Content-Disposition", `attachment; filename="ledger.csv"`)
|
||||||
|
w := csv.NewWriter(c.Writer)
|
||||||
|
_ = w.Write([]string{
|
||||||
|
"created_at", "account_id", "kind", "source", "origin", "chips_delta",
|
||||||
|
"product_id", "order_id", "provider", "provider_payment_id", "snapshot",
|
||||||
|
})
|
||||||
|
for _, r := range rows {
|
||||||
|
_ = w.Write([]string{
|
||||||
|
r.CreatedAt.UTC().Format(time.RFC3339), r.AccountID, r.Kind, r.Source, r.Origin,
|
||||||
|
strconv.Itoa(r.ChipsDelta), r.ProductID, r.OrderID, r.Provider, r.ProviderPaymentID, r.Snapshot,
|
||||||
|
})
|
||||||
|
}
|
||||||
|
w.Flush()
|
||||||
|
}
|
||||||
@@ -10,6 +10,7 @@ import (
|
|||||||
|
|
||||||
"scrabble/backend/internal/engine"
|
"scrabble/backend/internal/engine"
|
||||||
"scrabble/backend/internal/game"
|
"scrabble/backend/internal/game"
|
||||||
|
"scrabble/backend/internal/gamelimits"
|
||||||
)
|
)
|
||||||
|
|
||||||
// The handlers below cover the game and chat operations the UI needs. They follow
|
// The handlers below cover the game and chat operations the UI needs. They follow
|
||||||
@@ -46,10 +47,11 @@ type historyDTO struct {
|
|||||||
}
|
}
|
||||||
|
|
||||||
// gameListDTO is the caller's games (active and finished) for the lobby. AtGameLimit
|
// gameListDTO is the caller's games (active and finished) for the lobby. AtGameLimit
|
||||||
// reports whether the caller has reached the simultaneous quick-game cap
|
// reports whether the caller has reached its tier's active-game cap for the random
|
||||||
// (game.MaxActiveQuickGames); while it is true the lobby disables "New Game" and shows a
|
// (quick auto-match) kind (the per-tier, per-kind limits in backend.config); while
|
||||||
// notice. It rides the lobby response — which the lobby re-fetches on every game event —
|
// it is true the lobby disables "New Game" and shows a notice. It rides the lobby
|
||||||
// instead of a separate request.
|
// response — which the lobby re-fetches on every game event — instead of a separate
|
||||||
|
// request.
|
||||||
type gameListDTO struct {
|
type gameListDTO struct {
|
||||||
Games []gameDTO `json:"games"`
|
Games []gameDTO `json:"games"`
|
||||||
AtGameLimit bool `json:"at_game_limit"`
|
AtGameLimit bool `json:"at_game_limit"`
|
||||||
@@ -395,23 +397,13 @@ func (s *Server) handleSaveDraft(c *gin.Context) {
|
|||||||
c.JSON(http.StatusOK, okResponse{OK: true})
|
c.JSON(http.StatusOK, okResponse{OK: true})
|
||||||
}
|
}
|
||||||
|
|
||||||
// atGameLimit reports whether uid already holds the maximum number of simultaneous
|
// ensureUnderGameLimit aborts the request with 409 game_limit_reached when uid has reached its
|
||||||
// quick games (game.MaxActiveQuickGames). It backs both the lobby's at_game_limit flag
|
// tier's active-game cap for kind (the per-tier, per-kind limits in backend.config), and reports
|
||||||
// and the new-game gate; friend games created by invitation are not counted.
|
// whether the caller may proceed. It guards the quick new-game entry points — auto-match (random)
|
||||||
func (s *Server) atGameLimit(ctx context.Context, uid uuid.UUID) (bool, error) {
|
// and AI (vs_ai). Friend-invitation limits are enforced in the lobby's CreateInvitation, and
|
||||||
n, err := s.games.CountActiveQuickGames(ctx, uid)
|
// accepting an incoming invitation is deliberately exempt.
|
||||||
if err != nil {
|
func (s *Server) ensureUnderGameLimit(c *gin.Context, uid uuid.UUID, kind gamelimits.Kind) bool {
|
||||||
return false, err
|
atLimit, err := s.games.AtGameLimit(c.Request.Context(), uid, kind)
|
||||||
}
|
|
||||||
return n >= game.MaxActiveQuickGames, nil
|
|
||||||
}
|
|
||||||
|
|
||||||
// ensureUnderGameLimit aborts the request with 409 game_limit_reached when uid is at the
|
|
||||||
// simultaneous quick-game cap, and reports whether the caller may proceed. It guards
|
|
||||||
// every new-game entry point — quick auto-match/AI and invitation creation; accepting an
|
|
||||||
// incoming invitation is deliberately exempt.
|
|
||||||
func (s *Server) ensureUnderGameLimit(c *gin.Context, uid uuid.UUID) bool {
|
|
||||||
atLimit, err := s.atGameLimit(c.Request.Context(), uid)
|
|
||||||
if err != nil {
|
if err != nil {
|
||||||
s.abortErr(c, err)
|
s.abortErr(c, err)
|
||||||
return false
|
return false
|
||||||
@@ -437,7 +429,7 @@ func (s *Server) handleListGames(c *gin.Context) {
|
|||||||
s.abortErr(c, err)
|
s.abortErr(c, err)
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
atLimit, err := s.atGameLimit(c.Request.Context(), uid)
|
atLimit, err := s.games.AtGameLimit(c.Request.Context(), uid, gamelimits.KindRandom)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
s.abortErr(c, err)
|
s.abortErr(c, err)
|
||||||
return
|
return
|
||||||
|
|||||||
@@ -133,9 +133,6 @@ func (s *Server) handleCreateInvitation(c *gin.Context) {
|
|||||||
}
|
}
|
||||||
inviteeIDs = append(inviteeIDs, id)
|
inviteeIDs = append(inviteeIDs, id)
|
||||||
}
|
}
|
||||||
if !s.ensureUnderGameLimit(c, uid) {
|
|
||||||
return
|
|
||||||
}
|
|
||||||
inv, err := s.invitations.CreateInvitation(c.Request.Context(), uid, inviteeIDs, settings)
|
inv, err := s.invitations.CreateInvitation(c.Request.Context(), uid, inviteeIDs, settings)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
s.abortErr(c, err)
|
s.abortErr(c, err)
|
||||||
|
|||||||
@@ -8,6 +8,7 @@ import (
|
|||||||
"github.com/google/uuid"
|
"github.com/google/uuid"
|
||||||
|
|
||||||
"scrabble/backend/internal/engine"
|
"scrabble/backend/internal/engine"
|
||||||
|
"scrabble/backend/internal/gamelimits"
|
||||||
)
|
)
|
||||||
|
|
||||||
// The /api/v1/user/* endpoints require X-User-ID (RequireUserID middleware). The
|
// The /api/v1/user/* endpoints require X-User-ID (RequireUserID middleware). The
|
||||||
@@ -189,13 +190,15 @@ func (s *Server) handleEnqueue(c *gin.Context) {
|
|||||||
if !s.ensureVariantAllowed(c, uid, variant.String()) {
|
if !s.ensureVariantAllowed(c, uid, variant.String()) {
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
if !s.ensureUnderGameLimit(c, uid) {
|
kind := gamelimits.KindRandom
|
||||||
return
|
|
||||||
}
|
|
||||||
enter := s.matchmaker.Enqueue
|
enter := s.matchmaker.Enqueue
|
||||||
if req.VsAI {
|
if req.VsAI {
|
||||||
|
kind = gamelimits.KindVsAI
|
||||||
enter = s.matchmaker.StartVsAI
|
enter = s.matchmaker.StartVsAI
|
||||||
}
|
}
|
||||||
|
if !s.ensureUnderGameLimit(c, uid, kind) {
|
||||||
|
return
|
||||||
|
}
|
||||||
res, err := enter(c.Request.Context(), uid, variant, req.MultipleWordsPerTurn)
|
res, err := enter(c.Request.Context(), uid, variant, req.MultipleWordsPerTurn)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
s.abortErr(c, err)
|
s.abortErr(c, err)
|
||||||
|
|||||||