Compare commits

..

95 Commits

Author SHA1 Message Date
developer d94d56b461 Merge pull request 'release: promote development → master (v1.23.0)' (#276) from development into master 2026-07-15 00:32:54 +00:00
developer d48797e297 Merge pull request 'native polish 2: two-robots fix, guest offline-host UX, Guest###### name, silent+fast native boot, minSdk 29' (#275) from feature/native-polish-2 into development
CI / changes (push) Successful in 2s
CI / unit (push) Successful in 11s
CI / integration (push) Successful in 22s
CI / ui (push) Successful in 1m15s
CI / conformance (push) Successful in 10s
CI / changes (pull_request) Successful in 2s
CI / gate (push) Successful in 0s
CI / deploy (push) Successful in 1m57s
CI / unit (pull_request) Successful in 11s
CI / integration (pull_request) Successful in 21s
CI / ui (pull_request) Successful in 1m14s
CI / conformance (pull_request) Successful in 11s
CI / gate (pull_request) Successful in 0s
CI / deploy (pull_request) Has been skipped
2026-07-15 00:26:27 +00:00
Ilia Denisov fe29c6fe58 chore(android): raise minSdk to 29 (Android 10)
CI / changes (pull_request) Successful in 2s
CI / unit (pull_request) Successful in 11s
CI / integration (pull_request) Successful in 22s
CI / ui (pull_request) Successful in 1m15s
CI / conformance (pull_request) Successful in 10s
CI / gate (pull_request) Successful in 0s
CI / deploy (pull_request) Successful in 1m56s
The bundled WebView build is only compatible from Android 10 (API 29); on Android 9 it lands on
the in-app unsupported-engine screen. Declaring minSdk 29 lets RuStore/Google Play block older
devices at install rather than shipping a known-incompatible build.
2026-07-15 02:17:13 +02:00
Ilia Denisov 3429dbec5f fix(boot): native offline-first launch is silent and fast (Android 10)
Two native offline-first boot glitches (seen on Android 10):
- bootOffline forced offlineNoNetwork, so its first successful reconcile healed to online with a
  spurious "back online" toast on a first launch that actually had connectivity. The boot-assumed
  offline is now PROVISIONAL: the first probe confirms it — a success heals silently (nothing to
  come back from), a failure turns it into a real offline whose later recovery does toast.
- A native launch with a cached guest session (no email) skipped the offline short-circuit and hung
  ~25 s on adoptSession's retrying profile fetch in airplane mode. The native channel is offline-first,
  so it now takes the same reachability-gated fallback (~3 s bound) as an installed email PWA.
2026-07-15 02:17:13 +02:00
Ilia Denisov 74026223ee feat(account): give a fresh guest a distinct "Guest######" display name
Every guest showed a bare "Guest" to opponents (e.g. in a random match). Mint the display name
as "Guest" + a random six-digit suffix at provisioning, so guests are distinguishable. Not an
identifier and not unique — a label only, so collisions are harmless.
2026-07-15 02:17:13 +02:00
Ilia Denisov 896b7f57a7 feat(newgame): skip the take-a-seat prompt for a guest offline host
A guest host has no meaningful display name to seat, so the offline 'with friends' flow's
"do you take a seat?" prompt only produced a nameless seat. For a guest the prompt is skipped:
after the master PIN, the two empty player seats show directly. A durable host is still asked.
2026-07-15 02:17:13 +02:00
Ilia Denisov aa803260a1 fix(game): recognise the device-local guest as "me" in the game header
The game header identified the viewer's seat only by app.session.userId, but a device-local
game (vs_ai / hotseat) created offline is seated under the local guest id (the lobby already
matches both). After a merge switched the active account to a durable id, the human seat matched
neither, so a vs_ai game showed BOTH seats as robots (the turn still worked — it is seat-index
based). seatName now matches app.session.userId OR localGuestId, like the lobby. Completes the
v1.22.0 repointLocalGameSeats fix, which only covered games seated under the retired session id.
2026-07-15 02:17:13 +02:00
developer 904e92aac8 Merge pull request 'release: promote development → master (v1.22.0)' (#274) from development into master 2026-07-14 23:13:25 +00:00
developer 8758cbd35d Merge pull request 'native polish: guest-locale banner, seamless guest sign-in (+local-game fix), 'Opponent's turn' label, deploy-authoritative dictionary' (#273) from feature/native-polish-and-dict-deploy into development
CI / changes (push) Successful in 2s
CI / unit (push) Successful in 11s
CI / integration (push) Successful in 23s
CI / ui (push) Successful in 1m16s
CI / conformance (push) Successful in 10s
CI / changes (pull_request) Successful in 2s
CI / gate (push) Successful in 0s
CI / deploy (push) Successful in 1m49s
CI / unit (pull_request) Successful in 11s
CI / integration (pull_request) Successful in 25s
CI / ui (pull_request) Successful in 1m14s
CI / conformance (pull_request) Successful in 10s
CI / gate (pull_request) Successful in 0s
CI / deploy (pull_request) Has been skipped
2026-07-14 23:07:05 +00:00
Ilia Denisov 1d306ec0fc chore(ui): change hints symbol for backward unicode compatibility
CI / changes (pull_request) Successful in 3s
CI / unit (pull_request) Successful in 11s
CI / integration (pull_request) Successful in 23s
CI / ui (pull_request) Successful in 1m15s
CI / conformance (pull_request) Successful in 11s
CI / gate (pull_request) Successful in 0s
CI / deploy (pull_request) Successful in 1m49s
2026-07-15 00:56:47 +02:00
Ilia Denisov 513b772cd4 feat(game): show 'Opponent's turn' instead of the opponent's name in the turn strip
CI / changes (pull_request) Successful in 2s
CI / unit (pull_request) Successful in 12s
CI / integration (pull_request) Successful in 21s
CI / ui (pull_request) Successful in 1m15s
CI / conformance (pull_request) Successful in 10s
CI / gate (pull_request) Successful in 0s
CI / deploy (pull_request) Successful in 1m57s
When it is not the viewer's turn in an online PvP or vs_ai game, the under-board status
now reads the generic 'Opponent's turn' (game.opponentsTurn) rather than the opponent's
display name or the robot mark, so whose turn it is reads uniformly. The hotseat branch
still names the seat to move (whose device-turn it is matters there), and the seat row
below continues to name each player.
2026-07-15 00:34:58 +02:00
Ilia Denisov da25eac070 feat(link): auto-merge a guest initiator into its durable account (seamless sign-in)
When a guest links an identity (email/TG/VK) that already belongs to a durable account,
the guest-primary rule already made the durable account the survivor and switched the
session — but the client still showed an irreversible 'merge two accounts?' confirmation,
which is nonsense from the user's side (they are simply signing into their account). The
confirm step now merges inline for a GUEST initiator and returns the completed merge (the
switched token); a durable initiator still gets the explicit confirmation (consolidating
two real accounts is consequential). The active-game guard still refuses, surfaced as the
clear error.merge_active_game_conflict message rather than swallowed.

Also fixes the merged-away device-local games: their human/host seat was recorded under
the retired account id, so after the switch the lobby and game header could not identify
'me' and showed every seat as an opponent (the game still played — turn logic is
seat-index based). applyLinkResult now re-points local game seats from the retired id to
the survivor (repointLocalGameSeats).

Docs: FUNCTIONAL.md (+_ru).
2026-07-15 00:34:58 +02:00
Ilia Denisov 48614e622d feat(dict): make the build's dictionary version deploy-authoritative
The dictionary lives on a persistent volume seeded from the image once and never
re-seeded, so a redeploy that bumped BACKEND_DICT_VERSION left the new DAWGs
unreachable (the volume mount shadows the image copy) and the active version stuck
at whatever the admin console last installed — a native offline-first client then
fetched the server's older pinned version over the network instead of using its
bundled dict.

On boot the backend now DELIVERS the build version onto the volume add-only, from a
second unshadowed image copy at BACKEND_DICT_SEED_DIR, into DICT_DIR/<version>/ when
absent (the flat seed and prior uploads untouched, so in-flight games keep theirs),
and InitActiveVersion makes the build version active — except a console-installed
version NEWER than the build (compared numerically) is not downgraded by a restart.
The .seed_version guard still protects the flat seed's label (a bump is a new
subdirectory, never a relabel). The console stays for out-of-band updates.

Docs: ARCHITECTURE.md §5, deploy/README.md, seedmarker.go. Tests: engine.DeliverVersion
(add-only, idempotent, flat-seed skip), compareDictVersions, and the dictionary-update
integration test's deploy-delivers path.
2026-07-15 00:21:20 +02:00
Ilia Denisov f8aeec8008 fix(account): seed a fresh guest's interface language from the client locale
A guest was provisioned with only its detected time zone; its PreferredLanguage
fell to the 'en' column default. The client already sends its locale in the guest
login (GuestLoginRequest.locale), but the gateway dropped it and ProvisionGuest
took no language — so a Russian user's brand-new native guest got English
language-dependent server content (the ad banner, bot messages) until the client's
later language reconcile. Read the locale in the gateway guest handler, thread it
through GuestAuth -> ProvisionGuest, and seed PreferredLanguage from it (validated;
an unsupported/absent value keeps the 'en' default). Wire field already present —
no schema change.
2026-07-15 00:06:23 +02:00
developer e836dfefba Merge pull request 'release: promote development → master (v1.21.0)' (#272) from development into master 2026-07-14 20:29:04 +00:00
developer b5fe61279b Merge pull request 'android: shrink adaptive icon mark 25% + full-bleed legacy square + Erudit-only default for the profileless client' (#271) from feature/native-icons-variant-defaults into development
CI / changes (push) Successful in 2s
CI / unit (push) Has been skipped
CI / integration (push) Has been skipped
CI / ui (push) Successful in 1m15s
CI / conformance (push) Successful in 10s
CI / changes (pull_request) Successful in 2s
CI / gate (push) Successful in 0s
CI / deploy (push) Successful in 1m55s
CI / unit (pull_request) Successful in 11s
CI / integration (pull_request) Successful in 26s
CI / ui (pull_request) Successful in 1m15s
CI / conformance (pull_request) Successful in 10s
CI / gate (pull_request) Successful in 0s
CI / deploy (pull_request) Has been skipped
2026-07-14 20:23:04 +00:00
Ilia Denisov e07886ecb1 test(ui): update the native offline-first e2e for the Erudit-only guest default
CI / changes (pull_request) Successful in 2s
CI / unit (pull_request) Has been skipped
CI / integration (pull_request) Has been skipped
CI / ui (pull_request) Successful in 1m14s
CI / conformance (pull_request) Successful in 10s
CI / gate (pull_request) Successful in 0s
CI / deploy (pull_request) Successful in 1m57s
The offline native guest is now offered only Erudit (the profileless default
from the variants fix), so the specs that picked the English "Scrabble"
variant timed out. Play Erudit instead: the single `.variant` click both
selects it and asserts nothing else is offered; the vs_ai test plays НОЖ from
the pinned Erudit rack (ОЖЬЯНАО), and the bundled ru_erudit dawg drives the
robot reply.
2026-07-14 22:13:42 +02:00
Ilia Denisov 4a3e12c85a feat(icons): shrink the Android adaptive mark 25%, give old square launchers the full-bleed master
CI / changes (pull_request) Successful in 2s
CI / unit (pull_request) Has been skipped
CI / integration (pull_request) Has been skipped
CI / ui (pull_request) Failing after 2m10s
CI / conformance (pull_request) Successful in 10s
CI / gate (pull_request) Failing after 0s
CI / deploy (pull_request) Has been skipped
The adaptive foreground's asterisk grazed the round mask's safe zone. Scale the
foreground mark 0.75 about the icon centre (25% smaller, the letter-to-star
arrangement unchanged) so it sits well inside the 61% safe zone. Independently,
the legacy square ic_launcher.png now renders the full-bleed master instead of
the safe-zone composite, so old Android (< API 26) shows a large mark rather
than a shrunk one; the legacy round icon keeps the safe-zone composite (a round
mask would clip the master's corner star). Regenerate the layer set, Android
res and the brandbook foreground previews; update ICON_BRANDBOOK.md / ICONS.md.
2026-07-14 21:57:32 +02:00
Ilia Denisov 54bc31c619 fix(ui): default the profileless client to Erudit only, not every variant
availableVariants fell back to all three variants when the player had no
stored preferences. A fresh offline native launch boots with no profile, so
New Game exposed the English game before the player opted in — contrary to the
backend's Erudit-only new-account default (docs/FUNCTIONAL.md). Fall back to
DEFAULT_VARIANTS (Erudit only) instead; all dictionaries stay bundled.
2026-07-14 21:57:21 +02:00
developer d89438040b Merge pull request 'fix(gateway): allow the native WebView origin via CORS (native stuck-offline)' (#270) from feature/native-cors into development
CI / changes (push) Successful in 2s
CI / unit (push) Successful in 11s
CI / integration (push) Successful in 21s
CI / ui (push) Has been skipped
CI / conformance (push) Successful in 9s
CI / gate (push) Successful in 0s
CI / deploy (push) Successful in 1m56s
2026-07-14 19:38:27 +00:00
developer 363f1632b1 Merge pull request 'android: ANDROID_PLAN refresh + pin build-tools 36 (unblocks the release build)' (#269) from feature/android-plan-refresh into development
CI / changes (push) Successful in 2s
CI / unit (push) Has been skipped
CI / integration (push) Has been skipped
CI / ui (push) Successful in 1m15s
CI / conformance (push) Successful in 10s
CI / gate (push) Successful in 0s
CI / deploy (push) Successful in 1m57s
2026-07-14 19:38:13 +00:00
Ilia Denisov f0b7ad47d4 fix(gateway): allow the native WebView origin via CORS
CI / changes (pull_request) Successful in 2s
CI / unit (pull_request) Successful in 11s
CI / integration (pull_request) Successful in 23s
CI / ui (pull_request) Has been skipped
CI / conformance (pull_request) Successful in 11s
CI / gate (pull_request) Successful in 0s
CI / deploy (pull_request) Successful in 1m54s
The packaged native app (Capacitor) serves the bundled SPA from a localhost scheme, so its Connect calls to erudit-game.ru are cross-origin. The gateway had no CORS handling, so the preflight OPTIONS returned 405 with no Access-Control-Allow-Origin and the WebView blocked every RPC — a native build could never reach the gateway and stayed stuck offline (the native online path was never exercised on-device; on-device D was airplane-mode only). Add a CORS middleware that answers the preflight and sets the response headers for the native localhost origins (https/http/capacitor://localhost); web is same-origin and untouched. Fixes the native emulator 'starts offline, can't go online' report.
2026-07-14 21:21:53 +02:00
Ilia Denisov 0e11817654 fix(android): pin build-tools to 36.0.0 for all modules
CI / changes (pull_request) Successful in 2s
CI / unit (pull_request) Has been skipped
CI / integration (pull_request) Has been skipped
CI / ui (pull_request) Successful in 1m15s
CI / conformance (pull_request) Successful in 10s
CI / gate (pull_request) Successful in 0s
CI / deploy (pull_request) Successful in 1m51s
AGP 8.13 defaults to build-tools 35.0.0, but the provisioned / read-only CI SDK (/opt/android-sdk) has only 36.0.0 installed, so a gradle build (the app + the cap-sync-generated Capacitor modules) fails trying to auto-install 35 into a read-only dir. Pin buildToolsVersion 36.0.0 for every Android sub-project (compileSdk-matching) so the CI android-build signed release and local builds work. Verified: a local assembleDebug now builds app-debug.apk. ci.yaml does not build Android (only the manual android-build workflow does), so this is verified locally.
2026-07-14 20:43:58 +02:00
Ilia Denisov d0e473920c docs(android): refresh the ANDROID_PLAN RESUME block
CI / changes (pull_request) Successful in 2s
CI / unit (pull_request) Has been skipped
CI / integration (pull_request) Has been skipped
CI / ui (pull_request) Has been skipped
CI / conformance (pull_request) Has been skipped
CI / gate (pull_request) Successful in 0s
CI / deploy (pull_request) Has been skipped
Keystore + its Gitea secrets are set (owner) under the RuStore-specific ANDROID_RUSTORE_* names; the payments E10/E11 (#266/#267) + the signing-secret rename (#268) landed on development; the release gate is now the RuStore account + the promote/tag chain. Adds a native-notifications (push) track and the test-APK note for the next session.
2026-07-14 20:24:26 +02:00
developer 0e56e4de9a Merge pull request 'chore(android): rename RuStore signing secrets ANDROID_* -> ANDROID_RUSTORE_*' (#268) from feature/rustore-signing-secrets into development
CI / changes (push) Successful in 2s
CI / unit (push) Successful in 11s
CI / integration (push) Successful in 21s
CI / ui (push) Successful in 1m15s
CI / conformance (push) Successful in 10s
CI / gate (push) Successful in 0s
CI / deploy (push) Successful in 1m55s
2026-07-14 18:21:36 +00:00
Ilia Denisov 8d3f862b41 chore(android): rename the RuStore signing secrets ANDROID_* -> ANDROID_RUSTORE_*
CI / changes (pull_request) Successful in 2s
CI / unit (pull_request) Successful in 11s
CI / integration (pull_request) Successful in 23s
CI / ui (pull_request) Successful in 1m15s
CI / conformance (pull_request) Successful in 10s
CI / gate (pull_request) Successful in 0s
CI / deploy (pull_request) Successful in 1m59s
The signing key is store-specific: RuStore self-signs (the cert is the app cert), while Google Play re-signs via Play App Signing (the uploaded key is only the upload key). Keep a separate key per store and name the secrets accordingly. Only the Gitea secret names change; build.gradle keeps the store-agnostic env contract (ANDROID_KEYSTORE_*), so the same build serves a future Google key from its own workflow. New names: ANDROID_RUSTORE_KEYSTORE_BASE64 / _KEYSTORE_PASSWORD / _KEY_ALIAS / _KEY_PASSWORD.
2026-07-14 20:08:03 +02:00
developer 74842dc624 Merge pull request 'feat(payments): live payment-availability kill switch + per-account override' (#267) from feature/payment-kill-switch into development
CI / changes (push) Successful in 2s
CI / unit (push) Successful in 11s
CI / integration (push) Successful in 21s
CI / ui (push) Successful in 1m15s
CI / conformance (push) Successful in 10s
CI / gate (push) Successful in 0s
CI / deploy (push) Successful in 1m58s
2026-07-14 14:39:35 +00:00
Ilia Denisov 1507ceb793 feat(payments): live payment-availability kill switch + per-account override
CI / changes (pull_request) Successful in 3s
CI / unit (pull_request) Successful in 11s
CI / integration (pull_request) Successful in 21s
CI / ui (pull_request) Successful in 1m15s
CI / conformance (pull_request) Successful in 10s
CI / gate (pull_request) Successful in 0s
CI / deploy (pull_request) Successful in 2m0s
Let an operator disable purchases live from the admin — a whole rail/channel or
one account — and show the user a localized reason on their next attempt, so a
provider outage or a misconfig is explained instead of a silent dead button.

- rail kill switch (payments.rail_status, per rail direct:web / direct:android /
  vk / telegram): enabled + a per-language message, edited on the catalog page.
  Fail-open — a rail with no row stays enabled, so payments are never accidentally
  killed. The intake gate (CanPurchase in handleWalletOrder, before the order)
  returns payment_unavailable + the localized message, orthogonal to the security
  gates.
- per-account override (payments.account_payment_override, a row only for
  non-default): allow / deny / default, edited on the user card. "allow" bypasses
  ONLY the ops rail switch, never the security gates (trusted platform, the email
  anchor, the VK-iOS freeze, the min client version).
- wire: an additive ExecuteResponse.message envelope field (frozen-contract-safe);
  the gateway forwards a backend domain-error message; the client shows it on a
  payment_unavailable buy attempt.
- admin: rail toggles on the catalog page, the override control on the user card.
- tests: the pure gate (unit, TDD), the store + gate + override end-to-end
  (integration, migration 00016), the client (svelte-check / vitest).
- docs: PAYMENTS (+ru), the decisions log (D45/D46). Fiscalization stays
  cabinet-side (owner decision) — no itemized-receipt code.

Contour-safe: additive migration (two new tables, no wipe), the wire add is
additive, and fail-open so nothing is disabled until an operator acts.
2026-07-14 16:29:41 +02:00
developer 4b3c9d4fdd Merge pull request 'feat(payments): per-channel Robokassa shops on the direct rail' (#266) from feature/robokassa-multishop into development
CI / changes (push) Successful in 2s
CI / unit (push) Successful in 11s
CI / integration (push) Successful in 23s
CI / ui (push) Successful in 1m15s
CI / conformance (push) Successful in 9s
CI / gate (push) Successful in 0s
CI / deploy (push) Successful in 2m2s
2026-07-14 13:16:35 +00:00
Ilia Denisov eef90a152e feat(payments): per-channel Robokassa shops on the direct rail
CI / changes (pull_request) Successful in 2s
CI / unit (pull_request) Successful in 11s
CI / integration (pull_request) Successful in 19s
CI / ui (pull_request) Successful in 1m16s
CI / conformance (pull_request) Successful in 10s
CI / gate (pull_request) Successful in 0s
CI / deploy (pull_request) Successful in 2m0s
Split the single Robokassa direct rail into one merchant shop per channel
(web / android; ios later), chosen by the trusted X-Platform subtype, while
every shop still credits the one `direct` wallet — merchant-account separation
for accounting and receipts, not a new wallet.

- config: a channel-keyed shops registry (web seeds from the legacy vars or
  _WEB_*, android from _ANDROID_*); an empty shop leaves the rail dormant;
  per-shop validation.
- intake: the order picks its shop by subtype (unknown falls back to web); the
  per-shop Result callback is verified by that shop's own Password2 at
  /pay/robokassa/result/<channel> (the gateway extracts the channel; Caddy's
  /pay/* glob already forwards it — no Caddyfile change).
- persistence: an additive `shop` column on the order (migration 00015),
  recorded from the payment context, surfaced per entry in the admin report.
- standalone apps sign in by email only, so a direct purchase keeps its email
  anchor.
- docs: PAYMENTS (+ru) topology, deploy env vars + compose mapping, the
  decisions log (D41 revised for the ИП / 54-ФЗ move; D42-D44), the plan.

Contour-safe: dormant until shops are configured; the migration is additive
(no wipe); no client wire change. Fiscalization (Receipt/Email) and the gateway
`direct/android` subtype follow when the ИП / RuStore are live.
2026-07-14 14:57:34 +02:00
developer 39e03d22e4 Merge pull request 'release: v1.20.1 — real client IP to backend (last-login fix) + host UTC' (#265) from development into master 2026-07-14 09:44:43 +00:00
developer d8d1b06eee Merge pull request 'fix(gateway): forward the real client IP to the backend on every call (last-login IP was the docker addr)' (#264) from feature/forward-client-ip into development
CI / changes (push) Successful in 2s
CI / unit (push) Successful in 12s
CI / integration (push) Successful in 21s
CI / ui (push) Successful in 1m14s
CI / conformance (push) Successful in 11s
CI / changes (pull_request) Successful in 2s
CI / gate (push) Successful in 0s
CI / deploy (push) Successful in 1m55s
CI / unit (pull_request) Successful in 11s
CI / integration (pull_request) Successful in 20s
CI / ui (pull_request) Successful in 1m15s
CI / conformance (pull_request) Successful in 11s
CI / gate (pull_request) Successful in 0s
CI / deploy (pull_request) Has been skipped
2026-07-14 09:38:10 +00:00
Ilia Denisov c16008e67a chore(ansible): pin every host's system timezone to UTC
CI / changes (pull_request) Successful in 3s
CI / unit (pull_request) Successful in 11s
CI / integration (pull_request) Successful in 21s
CI / ui (pull_request) Successful in 1m14s
CI / conformance (pull_request) Successful in 10s
CI / gate (pull_request) Successful in 0s
CI / deploy (pull_request) Successful in 2m0s
The tg (bot) VPS came up on Europe/Moscow while the main host is UTC, so host-level
timestamps (journald, file mtimes, cron) sat 3h apart across the fleet. Add a
community.general.timezone task to the common role so every provisioned host is UTC
(idempotent — a no-op on the already-UTC main host). Applied live to the tg host in
the same change; the containerised services run in UTC regardless, so no restart.
2026-07-14 11:33:39 +02:00
Ilia Denisov ff55d5de83 fix(gateway): forward the real client IP to the backend on every call
CI / changes (pull_request) Successful in 2s
CI / unit (pull_request) Successful in 12s
CI / integration (pull_request) Successful in 21s
CI / ui (pull_request) Has been skipped
CI / conformance (pull_request) Successful in 10s
CI / gate (pull_request) Successful in 0s
CI / deploy (pull_request) Successful in 1m58s
The backend recorded 172.19.0.9 — the gateway's own docker connection address — as
the client IP for all users: the account's last-login IP shown in the admin console,
and it never reached the backend access log. The gateway forwarded the client IP as
X-Forwarded-For only on chat/feedback calls; every other backend call (including the
profile fetch that stamps last_login_ip) sent none, so the backend fell back to the
peer address.

Carry the client IP on the request context (WithClientIP, mirroring WithPlatform) and
set it once per request in the Connect edge, so the backend client injects
X-Forwarded-For on every downstream REST call. Also add the resolved client IP to the
backend access log.

Test: WithClientIP rides a non-chat call (Profile) as X-Forwarded-For, and is absent
when no IP is set. Docs updated (ARCHITECTURE gateway↔backend + edge).
2026-07-14 11:23:32 +02:00
developer aaf162de40 Merge pull request 'release: v1.20.0 — offline-UX, catalog order + admin toggles, reward-config admin' (#263) from development into master 2026-07-14 09:02:54 +00:00
developer 7cc2b50d23 Merge pull request 'feat(payments): edit the rewarded-ads config from the admin catalog page' (#262) from feature/admin-reward-config into development
CI / ui (pull_request) Successful in 1m16s
CI / deploy (pull_request) Has been skipped
CI / conformance (pull_request) Successful in 10s
CI / gate (pull_request) Successful in 0s
CI / conformance (push) Successful in 10s
CI / gate (push) Successful in 0s
CI / deploy (push) Successful in 1m58s
CI / changes (push) Successful in 2s
CI / unit (push) Successful in 12s
CI / integration (push) Successful in 25s
CI / ui (push) Has been skipped
CI / changes (pull_request) Successful in 2s
CI / unit (pull_request) Successful in 11s
CI / integration (pull_request) Successful in 21s
2026-07-14 08:46:02 +00:00
Ilia Denisov eb7fa98426 feat(payments): edit the rewarded-ads config from the admin catalog page
CI / changes (pull_request) Successful in 2s
CI / unit (pull_request) Successful in 12s
CI / integration (pull_request) Successful in 24s
CI / ui (pull_request) Has been skipped
CI / conformance (pull_request) Successful in 10s
CI / gate (pull_request) Successful in 1s
CI / deploy (pull_request) Successful in 1m57s
The "watch for chips" rewarded payout and its per-day / per-hour caps live in the
shared payments.config row and had no admin surface — they could only be changed by
SQL, contrary to D32 (the rewarded rate is meant to be admin-configurable). Add a
"Rewarded ads" form on the /_gm/catalog page: RewardConfig / SetRewardConfig on the
service (non-negative validated), a setRewardConfig store writer (the singleton
config row), the consoleSetReward handler + POST /_gm/catalog/reward route, and the
form pre-filled from the current config. No migration — the columns already exist.

The reward rate is a config value, not a sellable catalog atom (so a "no-ads forever"
style product is still out of scope by D32/D33).

Test: an integration test sets the config, checks the page pre-fill, and refuses a
negative value.
2026-07-14 10:40:27 +02:00
developer 81d5383c42 Merge pull request 'refactor(payments): one canonical catalog order (storefront/offer/admin) + admin active/all toggle' (#261) from feature/wallet-catalog-order into development
CI / changes (push) Successful in 2s
CI / unit (push) Successful in 11s
CI / integration (push) Successful in 21s
CI / ui (push) Has been skipped
CI / conformance (push) Successful in 11s
CI / gate (push) Successful in 0s
CI / deploy (push) Successful in 1m55s
2026-07-14 08:30:31 +00:00
Ilia Denisov b6af682381 refactor(payments): one canonical catalog order for storefront, offer, admin; admin active/all toggle
CI / changes (pull_request) Successful in 2s
CI / unit (pull_request) Successful in 11s
CI / integration (pull_request) Successful in 23s
CI / ui (pull_request) Has been skipped
CI / conformance (pull_request) Successful in 11s
CI / gate (pull_request) Successful in 0s
CI / deploy (pull_request) Successful in 1m49s
The wallet storefront listed products in creation order — its purchases and its
chip-exchange values were unsorted, unlike the admin console and the public offer.
Extract the ordering (chip packs first by rouble price, then chip-priced values
grouped hints → no-ads → combo → tournament and by chip price) into one comparator,
compareCatalogRank over a small rank key, and apply it at all three sites
(projectCatalog, projectOfferPricing, SortAdminCatalog) — so the subgroup ranking
(valueGroup) and the full order now live in one place. The rewarded "watch for
chips" CTA is a wallet-driven element rendered above the packs, so it is unaffected
and stays on top.

Also add the admin catalog active/all toggle: AdminCatalog takes includeInactive,
the console shows active products by default and ?all=1 lists archived ones too
(the detail and grant forms keep loading all products).

Tests: a storefront canonical-order unit test (reproduced the unsorted bug first);
an integration test for the active/all toggle. Existing offer/admin order tests
unchanged — behaviour preserved.
2026-07-14 10:23:42 +02:00
developer ed79c30d2a Merge pull request 'feat(ui): explicit offline state inside an online game (+ offline word check)' (#260) from feature/offline-ingame-ux into development
CI / changes (push) Successful in 2s
CI / unit (push) Successful in 12s
CI / integration (push) Successful in 25s
CI / ui (push) Successful in 1m16s
CI / conformance (push) Successful in 10s
CI / gate (push) Successful in 0s
CI / deploy (push) Successful in 1m57s
2026-07-14 07:51:08 +00:00
Ilia Denisov 471fadc6a4 fix(ui): offline in-game — usable dictionary check + hide (not disable) resign/chat
CI / changes (pull_request) Successful in 2s
CI / unit (pull_request) Successful in 11s
CI / integration (pull_request) Successful in 20s
CI / ui (pull_request) Successful in 1m15s
CI / conformance (pull_request) Successful in 12s
CI / gate (pull_request) Successful in 0s
CI / deploy (pull_request) Successful in 2m5s
Two fixes from contour testing of the offline-in-game UX:

- The dictionary word check was unusable offline (Check button disabled) when the
  panel was reached while already offline: CheckScreen fetched the variant + pinned
  version over the network in onMount, which fails offline, leaving the default
  variant so input sanitising stripped every letter. Seed both from the cached game
  (present once the board has opened) so the input and the on-device dawg fallback
  work without a round-trip; the network refresh still runs when online and on a
  cold deep-link.
- The resign and chat controls were only functionally disabled (chat) or not gated
  at all (resign) offline, so they still looked active. Hide both while offline,
  matching the frozen social controls.

Tests: the in-game offline e2e now asserts the drawer action icons are hidden (not
disabled); a new e2e guards the offline dictionary flow. Docs updated.
2026-07-14 09:38:42 +02:00
Ilia Denisov 564abdcf88 chore: fix broken telegram links
CI / changes (pull_request) Successful in 3s
CI / unit (pull_request) Successful in 11s
CI / integration (pull_request) Successful in 21s
CI / ui (pull_request) Successful in 1m14s
CI / conformance (pull_request) Successful in 10s
CI / gate (pull_request) Successful in 0s
CI / deploy (pull_request) Successful in 2m7s
2026-07-14 09:06:03 +02:00
Ilia Denisov c522e77599 fix(ui): lazy-load the offline word-check helper to keep it out of the entry bundle
CI / changes (pull_request) Successful in 2s
CI / unit (pull_request) Has been skipped
CI / integration (pull_request) Has been skipped
CI / ui (pull_request) Successful in 1m14s
CI / conformance (pull_request) Successful in 11s
CI / gate (pull_request) Successful in 0s
CI / deploy (pull_request) Successful in 2m42s
CheckScreen's static import of dict/check pulled the dawg loader/reader into the app
entry bundle, pushing it 0.8 KB over the 130 KB budget (CI bundle-size gate). The
dict subsystem is lazy everywhere else, so import localWordCheck dynamically in the
offline branch — it loads only when an offline check actually runs. Main entry back
to 129.0 KB.
2026-07-14 08:59:41 +02:00
Ilia Denisov 28afcff551 feat(ui): explicit offline state inside an online game (+ offline word check)
CI / changes (pull_request) Successful in 3s
CI / unit (pull_request) Has been skipped
CI / integration (pull_request) Has been skipped
CI / ui (pull_request) Failing after 12s
CI / conformance (pull_request) Successful in 10s
CI / gate (pull_request) Failing after 1s
CI / deploy (pull_request) Has been skipped
When an online game loses the connection the game screen now says so and freezes
instead of silently greying out: a "connection lost" banner appears and the rack,
the move controls, the add-friend/block controls and the chat/dictionary entry all
disable (a started move stays a draft, committed by the player on reconnect). It is
driven off the net-state machine (netState.offline), so it also covers the
Telegram/VK mini-apps, where a lost connection was previously mute in-game.

If the player is already in the dictionary when the drop happens, the word check
falls back to the game's pinned on-device dictionary (exact when that dawg is
cached; "unavailable offline" otherwise) and the network-only complaint + external
look-up hide. Chat, when already open, keeps its existing read-only degrade
(send/nudge disable). New pure helper localWordCheck is unit-tested; the in-game
gating gets an e2e.
2026-07-14 08:53:18 +02:00
developer c34e2a8dbf Merge pull request 'fix(net): detect a silently-dead live stream via a heartbeat watchdog' (#259) from feature/stream-heartbeat-watchdog into development
CI / changes (push) Successful in 2s
CI / unit (push) Has been skipped
CI / integration (push) Has been skipped
CI / ui (push) Successful in 1m13s
CI / conformance (push) Successful in 10s
CI / gate (push) Successful in 0s
CI / deploy (push) Successful in 1m48s
2026-07-14 06:36:20 +00:00
Ilia Denisov 25381f70a3 fix(net): detect a silently-dead live stream via a heartbeat watchdog
CI / changes (pull_request) Successful in 2s
CI / unit (pull_request) Has been skipped
CI / integration (pull_request) Has been skipped
CI / ui (pull_request) Successful in 1m14s
CI / conformance (pull_request) Successful in 12s
CI / gate (pull_request) Successful in 0s
CI / deploy (pull_request) Successful in 1m59s
An idle live stream whose connection dies without an error (airplane mode
cutting the radio) left the net-state machine stuck "online": the streaming
fetch neither errored nor delivered, no unary call was made, and the OS
`navigator` offline hint is unreliable in the Telegram/VK WebViews — so the
"Connecting…"/offline state only surfaced on the next foreground resync, not
while idle.

The gateway already pushes a keep-alive `heartbeat` every 10s, so add a
client-side watchdog (streamwatchdog.ts) that resets on every delivered event
and, after ~25s of silence, treats the stream as dropped (reportOffline +
reconnect). It is background-aware (paused while suspended) so a throttled
foreground return does not false-trip. The mock client mirrors the heartbeat
so an idle e2e session stays online.
2026-07-14 08:27:38 +02:00
developer 5ce9f7e9b4 Merge pull request 'chore(release): promote development to master' (#258) from development into master 2026-07-13 22:21:59 +00:00
developer 522beec82e Merge pull request 'feat(tg-bot): unpin auto-forwarded channel posts' (#257) from feature/tg-unpin-linked-channel-post into development
CI / changes (push) Successful in 2s
CI / unit (push) Successful in 10s
CI / integration (push) Successful in 22s
CI / ui (push) Has been skipped
CI / conformance (push) Successful in 10s
CI / changes (pull_request) Successful in 2s
CI / integration (pull_request) Successful in 20s
CI / gate (push) Successful in 0s
CI / deploy (push) Successful in 1m54s
CI / unit (pull_request) Successful in 11s
CI / ui (pull_request) Successful in 1m15s
CI / conformance (pull_request) Successful in 10s
CI / gate (pull_request) Successful in 0s
CI / deploy (pull_request) Has been skipped
2026-07-13 22:13:15 +00:00
Ilia Denisov 3b485883ee feat(tg-bot): unpin auto-forwarded channel posts
CI / changes (pull_request) Successful in 2s
CI / unit (pull_request) Successful in 11s
CI / integration (pull_request) Successful in 20s
CI / ui (pull_request) Has been skipped
CI / conformance (pull_request) Successful in 10s
CI / gate (pull_request) Successful in 0s
CI / deploy (pull_request) Successful in 1m56s
Telegram non-disableably auto-pins each channel post it auto-forwards
into the linked discussion group. The bot now detects that message by
Message.is_automatic_forward in the moderated chat (TELEGRAM_CHAT_ID)
and unpins it by id, so a pin set by a human admin — or by the bot for
another message — is never touched (no unpinAllChatMessages).

Needs the can_pin_messages right in the chat; the startup self-check
now also warns when it is missing. Bot-only; no wire/schema/DB change.
2026-07-14 00:07:46 +02:00
developer efeed17abc Merge pull request 'feat(vk): launch diagnostic on a direct /vk/ open instead of a guest' (#256) from feature/vk-launch-diagnostic into development
CI / changes (push) Successful in 2s
CI / unit (push) Has been skipped
CI / integration (push) Has been skipped
CI / ui (push) Successful in 1m14s
CI / conformance (push) Successful in 10s
CI / gate (push) Successful in 0s
CI / deploy (push) Successful in 1m55s
2026-07-13 21:42:51 +00:00
Ilia Denisov cc34622630 feat(vk): show a launch diagnostic on a direct /vk/ open instead of a guest
CI / changes (pull_request) Successful in 2s
CI / unit (pull_request) Has been skipped
CI / integration (pull_request) Has been skipped
CI / ui (pull_request) Successful in 1m13s
CI / conformance (pull_request) Successful in 10s
CI / gate (pull_request) Successful in 0s
CI / deploy (pull_request) Successful in 2m7s
Opening the dedicated /vk/ entry directly in an ordinary browser (no signed VK
launch) fell through to the web flow and silently started a throwaway guest,
which is wrong for a VK-only entry. Mirror the existing /telegram/ launch-error
behaviour: render a compact, shareable, privacy-safe diagnostic screen instead.

- Boot: a new branch `onVKPath() && !insideVK()` sets app.launchError and stops
  the fall-through, the VK counterpart of the /telegram/ diagnostic guard.
- Diagnostic (passive, no VK Bridge round-trip): reads the URL launch-parameter
  NAMES (never the `sign` value — auth material), whether the URL was signed,
  `vk_platform`, the iframe/referrer context, plus the shared client-environment
  lines. VK signs the launch URL at load, so — unlike Telegram's initData — the
  parameters never arrive late; hence the VK screen offers Share only, no Retry.
- Generalise the screen: TelegramLaunchError.svelte -> LaunchError.svelte, which
  renders a neutral pre-formatted report and a per-platform title, with Retry
  gated on a `retry` flag (Telegram true, VK false). app.launchError is now a
  neutral LaunchDiag { platform, report, retry }.
- New lib/launchdiag.ts holds the shared pieces both platforms reuse: the
  LaunchDiag shape, the client-environment lines, and the query field-NAME
  reader (moved out of telegram.ts so VK does not duplicate them).

Tests: pure vkDiagLines units, incl. a guard that the `sign` value never leaks
into the report. i18n: launch.errorTitleVk (en/ru). Docs: FUNCTIONAL (+ru) user
story and ARCHITECTURE entry-path note.
2026-07-13 23:25:26 +02:00
developer f6d256215a Merge pull request 'fix(login): rate-limit no longer latches a phantom offline on the login screen' (#255) from feature/login-rate-limit-offline into development
CI / changes (push) Successful in 2s
CI / unit (push) Successful in 11s
CI / integration (push) Successful in 20s
CI / ui (push) Successful in 1m18s
CI / conformance (push) Successful in 10s
CI / gate (push) Successful in 0s
CI / deploy (push) Successful in 1m47s
2026-07-13 20:59:22 +00:00
Ilia Denisov fe5a3d6d3b fix(login): stop a rate-limit from latching a phantom offline on the login screen
CI / changes (pull_request) Successful in 2s
CI / unit (pull_request) Successful in 11s
CI / integration (pull_request) Successful in 22s
CI / ui (pull_request) Successful in 1m13s
CI / conformance (pull_request) Successful in 11s
CI / gate (pull_request) Successful in 0s
CI / deploy (pull_request) Successful in 2m7s
A fresh email login that hit the per-IP email rate limit stranded the user in
an unrecoverable "offline" state that survived a full PWA restart, even though
the network was fine.

Root cause: the gateway email class (auth.email.request + auth.email.login,
keyed per IP, 5/10min burst 2) trips on the third event, which in the natural
"request code -> wrong code -> correct code" sequence is the correct-code login.
The gateway returns ResourceExhausted; the client mapped it to 'rate_limited',
which retry.ts classified as retryable + a connection code, so exec() called
reportOffline(). That pushes the net-state machine into connecting, whose
recovery probe is an authenticated profile.get -- but the login screen has no
session, so the probe can never succeed and the machine latches offline. The
transport kill switch (assertOnline) then refuses the very login that would fix
it, and because the IP's email bucket refills only 1 token / 120s, each fresh
attempt after a restart is rate-limited again -> offline again.

Fix (client, narrow -- the trigger):
- A rate-limit is no longer treated as connectivity. retry.ts no longer marks
  'rate_limited' retryable or a connection code, so exec() never reports it
  offline; it surfaces as the existing error.rate_limited "slow down" message.
  Not auto-retrying it also stops the ~20s button freeze and avoids feeding the
  gateway's ban tripwire with 6 extra rejections per attempt.

Fix (server):
- Raise the email-code burst 2 -> 4 so the honest request + a mistyped code +
  the correct one is not throttled mid-login. Defence-in-depth over the
  backend's own per-code guards (5-attempt cap + 15-min TTL + send throttle).

Tests: retry classification units updated to the new semantics; a gateway
regression guard asserts the honest three-event email flow passes under the
default policy. gateway/README.md rate-limit note updated.

The deeper gap (the net-state recovery probe is session-gated, so any real
transport failure on the session-less login/confirm screens still cannot
self-heal) is left as a known residual, deferred by the owner.
2026-07-13 22:48:59 +02:00
developer 7e142d471c Merge pull request 'Android release prep: de-anchor ANDROID_PLAN.md, re-enable the APK workflow, add ICONS.md' (#254) from feature/android-release-prep into development
CI / changes (push) Successful in 3s
CI / unit (push) Successful in 10s
CI / integration (push) Successful in 22s
CI / ui (push) Successful in 1m14s
CI / conformance (push) Successful in 10s
CI / gate (push) Successful in 0s
CI / deploy (push) Successful in 1m55s
2026-07-13 20:13:26 +00:00
Ilia Denisov dbe2c4cb94 feat(icons): rebrand app icon; slice the full set from one master
CI / changes (pull_request) Successful in 3s
CI / unit (pull_request) Successful in 11s
CI / integration (pull_request) Successful in 23s
CI / ui (pull_request) Successful in 1m13s
CI / conformance (pull_request) Successful in 9s
CI / gate (pull_request) Successful in 0s
CI / deploy (pull_request) Successful in 1m54s
The SVG tracer export failed, so the icon was designed collaboratively from
scratch: a wooden «Э» tile with a ✻ score-subscript in Spectral Bold, wood
grain and diagonal light/shadow. Construction is specified in fractions of the
side in docs/ICON_BRANDBOOK.md; the reference generator, pinned Spectral font
and committed masters live in assets/icons/brand/.

One master -> the whole set (assets/icons/brand/build-set.mjs + build-android-res.mjs):
- web (ui/public): favicon.svg (light+dark via prefers-color-scheme), favicon.ico,
  apple-touch, PWA any + maskable (+ dark variants), og-image reskinned to
  «Эрудит» / Игра в слова
- Capacitor layers: ui/assets/{icon,icon-foreground,icon-background}.png
- Android launcher res, all densities, NO inset, + monochrome themed layer
  (anydpi xml: background+foreground+monochrome)
- manual-upload art: assets/icons/brand/{vk,tg,store} (square, no rounding)

Primary variant light; dark where the platform can theme (favicon.svg). The old
LiberationSans generator (assets/icons/build) is removed; docs/ICONS.md retargeted
onto the single master. ANDROID_PLAN icon-rebrand item marked done.
2026-07-13 21:49:29 +02:00
Ilia Denisov 8c5b659ddf docs(android): record documents-track + release-prep progress in the plan
CI / changes (pull_request) Successful in 2s
CI / unit (pull_request) Successful in 11s
CI / integration (pull_request) Successful in 21s
CI / ui (pull_request) Successful in 1m14s
CI / conformance (pull_request) Successful in 11s
CI / gate (pull_request) Successful in 0s
CI / deploy (pull_request) Successful in 1m54s
Add a RESUME block to ANDROID_PLAN.md capturing the current state so a fresh session continues without re-asking: the legal-documents track (PR #253, merged into development) and the release-prep hygiene (PR #254, open, this branch) are done; the icon vector, keystore and RuStore account remain (owner-side). Records the locked legal decisions and the exact remaining release chain, and points the icon work at docs/ICONS.md.
2026-07-13 14:31:03 +02:00
Ilia Denisov a067a2fd01 chore(android): de-anchor ANDROID_PLAN.md from the docs, re-enable the APK workflow
CI / changes (pull_request) Successful in 2s
CI / unit (pull_request) Successful in 11s
CI / integration (pull_request) Successful in 19s
CI / ui (pull_request) Successful in 1m14s
CI / conformance (pull_request) Successful in 11s
CI / gate (pull_request) Successful in 0s
CI / deploy (pull_request) Successful in 1m50s
Reword every code comment and doc that referenced ANDROID_PLAN.md or its stage anchors (§E, G-step-0, O1) so the living docs no longer depend on the plan file: .claude/CLAUDE.md, deploy/README.md, docs/ARCHITECTURE.md, docs/TESTING.md, ui/README.md, ui/android/.gitignore, ui/e2e/native.spec.ts, ui/src/lib/netstate.ts.

Re-enable the manual signed-APK workflow (android-build.yaml.disabled -> android-build.yaml; the CI host already has the Android SDK; workflow_dispatch-only, so it does not auto-run on this PR). Add docs/ICONS.md — the single-master icon/logo format reference (web, PWA, Android, future iOS, store listings).
2026-07-13 14:05:43 +02:00
developer 8becdb45e4 Merge pull request 'Legal pages: privacy policy + EULA at /privacy/ and /eula/' (#253) from feature/legal-pages into development
CI / changes (push) Successful in 2s
CI / unit (push) Successful in 11s
CI / integration (push) Successful in 22s
CI / ui (push) Successful in 1m14s
CI / conformance (push) Successful in 10s
CI / gate (push) Successful in 0s
CI / deploy (push) Successful in 1m55s
2026-07-13 11:55:44 +00:00
Ilia Denisov 25b5ed5516 fix(legal): translate the offer price-list headings in the English view
CI / changes (pull_request) Successful in 2s
CI / unit (pull_request) Successful in 11s
CI / integration (pull_request) Successful in 20s
CI / ui (pull_request) Successful in 1m14s
CI / conformance (pull_request) Successful in 10s
CI / gate (pull_request) Successful in 0s
CI / deploy (pull_request) Successful in 1m54s
The price list is spliced in Russian; the client-side offer transliteration turned its section headings and column headers into transliteration too. Translate the known backend-projected strings (the two section headings and the column headers Наименование/Рубли/Голоса в VK/Stars в Telegram/«Фишки») to English and transliterate only the product names, as requested.
2026-07-13 13:49:01 +02:00
Ilia Denisov de5ab9186c feat(legal): bilingual (RU + EN) legal pages with a language + theme switcher
CI / changes (pull_request) Successful in 2s
CI / unit (pull_request) Successful in 10s
CI / integration (pull_request) Successful in 21s
CI / ui (pull_request) Successful in 1m14s
CI / conformance (pull_request) Successful in 10s
CI / gate (pull_request) Successful in 0s
CI / deploy (pull_request) Successful in 1m48s
Add English versions of the privacy policy, EULA and offer (ui/legal/*_en.md) and render each legal page as one self-contained bilingual document: both language bodies plus a header language toggle and theme toggle (no back), a small inline ES5 script that switches language client-side (no reload, default Russian + persisted) and applies the theme (system default + persisted override) — mirroring the landing. The offer's English view transliterates the Russian product names spliced from the live catalog.

renderLegalHtml now takes both language sources; renderOffer splices the price list into both; the renderer bakes and reads the _en sources and pre-renders the static pages at boot. Also wrap the /offer/ contour probe in the same retry+timeout as the legal probe (the offer page is now bilingual and larger). Docs + renderer/ui tests updated.
2026-07-13 13:22:56 +02:00
Ilia Denisov 7df078f5ed fix(legal): wrap the privacy table's first column, top-align cells
CI / changes (pull_request) Successful in 2s
CI / unit (pull_request) Successful in 11s
CI / integration (pull_request) Successful in 21s
CI / ui (pull_request) Successful in 1m13s
CI / conformance (pull_request) Successful in 11s
CI / gate (pull_request) Successful in 0s
CI / deploy (pull_request) Successful in 1m54s
The shared legal-page template inherited the offer's shrink-to-fit product-name column (width:1% + white-space:nowrap), which stopped the privacy data table's long prose first column from wrapping and blew the table out horizontally. Scope that rule to the offer (body.offer) and top-align all legal-table cells for the multi-line privacy rows.
2026-07-13 13:00:40 +02:00
Ilia Denisov 1d77ee83b3 fix(ci): make the legal-page probe size-independent
CI / changes (pull_request) Successful in 2s
CI / unit (pull_request) Successful in 11s
CI / integration (pull_request) Successful in 21s
CI / ui (pull_request) Successful in 1m13s
CI / conformance (pull_request) Successful in 10s
CI / gate (pull_request) Successful in 0s
CI / deploy (pull_request) Successful in 1m56s
The /eula/ probe fetched the full ~129 KB page. While the monitoring stack boots after the rolling deploy and the CPU-capped (1 CPU / 192 MB) renderer is starved, that transfer exceeded the wget timeout for the entire 60s retry window, while the 3x-smaller /privacy/ passed — pre-rendering the pages did not help because the bottleneck is the transfer, not the render. Fetch only the <head> (head -c 4096) and assert the page's canonical URL, which uniquely identifies the rendered legal doc reaching the edge (the landing's canonical is erudit-game.ru/); the check now transfers ~4 KB regardless of page size.
2026-07-13 12:45:38 +02:00
Ilia Denisov 5a4c460268 fix(renderer): pre-render the static legal pages at boot
CI / changes (pull_request) Successful in 2s
CI / unit (pull_request) Successful in 11s
CI / integration (pull_request) Successful in 19s
CI / ui (pull_request) Successful in 1m13s
CI / conformance (pull_request) Successful in 11s
CI / gate (pull_request) Successful in 0s
CI / deploy (pull_request) Failing after 2m28s
Serving /eula/ re-parsed the large (~122 KB) EULA markdown on every request. Under the rolling deploy's host contention (the renderer is capped at 1 CPU / 192 MB) that was slow enough that the contour /eula/ probe failed for its whole 60s retry window, while the smaller /privacy/ squeaked through; both serve fine once the host is idle. Render privacy + eula once at boot and serve the cached HTML (now ~1.5 ms, a memcpy). The offer stays per-request for its live price splice; the probe retry stays as a readiness backstop.
2026-07-13 12:36:33 +02:00
Ilia Denisov 807edff327 fix(ci): retry the /privacy/ and /eula/ contour probe with a timeout
CI / changes (pull_request) Successful in 2s
CI / unit (pull_request) Successful in 11s
CI / integration (pull_request) Successful in 23s
CI / ui (pull_request) Successful in 1m14s
CI / conformance (pull_request) Successful in 10s
CI / gate (pull_request) Successful in 0s
CI / deploy (pull_request) Failing after 2m31s
The single-shot probe raced the render sidecar during the rolling deploy: across two attempts a different route flaked each time (/privacy/, then /eula/) against an otherwise-healthy renderer, while the contour served all three pages correctly — a deploy-time transient (a slow first render of the large EULA while every container recreates, or a connection blip). Wrap the check in the same 20x3s retry the landing/gateway/backend probe uses, with a 10s per-attempt wget timeout, so it waits the transient out instead of failing the deploy.
2026-07-13 12:25:30 +02:00
Ilia Denisov 0f3b4dbcff feat(legal): host the privacy policy and EULA at /privacy/ and /eula/
CI / changes (pull_request) Successful in 2s
CI / unit (pull_request) Successful in 11s
CI / integration (pull_request) Successful in 22s
CI / ui (pull_request) Successful in 1m14s
CI / conformance (pull_request) Successful in 10s
CI / gate (pull_request) Successful in 0s
CI / deploy (pull_request) Failing after 40s
Author ui/legal/{privacy,eula}_ru.md, reworked from the source documents: the seller INN is unified (290210610742), contacts unified to the Telegram bot + email + postal address, the EULA governing-law clause leads with Russian law + jurisdiction as residence tiers, the single-binding-language clause is dropped, data collection is scoped to voluntary/support provision, and "Компания" is no longer shout-cased.

Serve both as static pages through the render sidecar, reusing a shared renderLegalHtml generalised from renderOfferHtml: new GET /privacy/ and GET /eula/ (301 from the slashless form), the markdown baked into the image, no backend fetch. Route them at the edge via the @legal caddy matcher with a CI probe asserting each page's canonical URL + the seller INN, so a missing route cannot silently fall through to the landing. Add the two links to the landing footer.

Docs baked in: ARCHITECTURE (renderer legal pages + edge routing), FUNCTIONAL (+_ru) footer, renderer/README routes. Tests: renderer legal.test.mjs, ui renderLegalHtml unit, landing footer e2e.
2026-07-13 12:08:27 +02:00
developer 3456a0b3ff Merge pull request 'release: v1.18.0 — /support command in the Telegram bot' (#252) from development into master 2026-07-13 07:39:06 +00:00
developer 93ccc8c449 Merge pull request 'feat(telegram): add /support command with support-desk info reply' (#251) from feature/telegram-support-command into development
CI / changes (push) Successful in 2s
CI / unit (push) Successful in 11s
CI / conformance (push) Successful in 9s
CI / gate (push) Successful in 0s
CI / changes (pull_request) Successful in 2s
CI / integration (push) Successful in 20s
CI / ui (push) Has been skipped
CI / deploy (push) Successful in 1m44s
CI / integration (pull_request) Successful in 27s
CI / ui (pull_request) Has been skipped
CI / unit (pull_request) Successful in 10s
CI / conformance (pull_request) Successful in 9s
CI / gate (pull_request) Successful in 0s
CI / deploy (pull_request) Has been skipped
2026-07-13 07:32:59 +00:00
Ilia Denisov e0360c98e2 feat(telegram): add /support command with support-desk info reply
CI / changes (pull_request) Successful in 2s
CI / unit (pull_request) Successful in 12s
CI / integration (pull_request) Successful in 21s
CI / ui (pull_request) Has been skipped
CI / conformance (pull_request) Successful in 11s
CI / gate (pull_request) Successful in 0s
CI / deploy (pull_request) Successful in 1m55s
Intercept /support in the main bot with a dedicated handler (registered
like /start), replying with a fixed support-desk info message: the
operators' working hours and what to include (a description and, when
possible, screenshots). The reply is Russian or English by the sender's
reported Telegram language, and the command is listed in the bot's
command menu (localized).

Being a dedicated handler, it intercepts /support before the support
relay, so the command line itself is not forwarded into an operator
topic while the user's following description still is.

Update the telegram README and FUNCTIONAL.md (+ _ru mirror).
2026-07-13 09:25:20 +02:00
developer a41281c495 Merge pull request 'release: v1.17.0 — implicit offline model + two-tier client-version gate + unified lobby' (#250) from development into master 2026-07-13 01:06:35 +00:00
developer 11f89f6477 Merge pull request 'feat(offline): implicit net-state model, two-tier version gate, unified lobby' (#249) from feature/android-native into development
CI / changes (push) Successful in 2s
CI / unit (push) Successful in 11s
CI / integration (push) Successful in 19s
CI / ui (push) Successful in 1m13s
CI / conformance (push) Successful in 10s
CI / gate (push) Successful in 0s
CI / deploy (push) Successful in 1m53s
CI / changes (pull_request) Successful in 1s
CI / unit (pull_request) Successful in 11s
CI / integration (pull_request) Successful in 19s
CI / ui (pull_request) Successful in 1m13s
CI / conformance (pull_request) Successful in 10s
CI / gate (pull_request) Successful in 0s
CI / deploy (pull_request) Has been skipped
2026-07-13 00:59:16 +00:00
developer 516ffbe5f0 Merge pull request 'release: v1.16.0 — offer live pricing, bot health telemetry, edge blocklist' (#247) from development into master 2026-07-11 11:56:09 +00:00
developer 6badc20078 Merge pull request 'Release v1.15.0 — in-game UX + wallet redesign + WAL-alert fix' (#243) from development into master 2026-07-10 16:15:20 +00:00
developer 0ca01133b5 Merge pull request 'Release v1.14.1 — ansible certs-dir fix + Robokassa go-live' (#239) from development into master 2026-07-10 10:58:38 +00:00
developer 45f0b34881 Merge pull request 'Release v1.14.0 — monetization launch (E5-E8)' (#237) from development into master 2026-07-10 10:11:02 +00:00
developer 18785efc8c Merge pull request 'release v1.13.0: payments wallet mechanics + database point-in-time recovery' (#220) from development into master 2026-07-08 23:42:15 +00:00
developer 780ff68ec2 Merge pull request 'release: offline mode + local pass-and-play (hotseat) — proposed v1.12.0' (#212) from development into master 2026-07-07 14:40:43 +00:00
developer 57ff2d03f8 Merge pull request 'Release v1.11.0: PWA install + code-only PWA login + email/metrics fixes' (#187) from development into master 2026-07-05 21:02:08 +00:00
developer a9d0986e74 Merge pull request 'Release v1.10.0: banner colours + urgent, and 3 UI fixes' (#183) from development into master 2026-07-05 14:10:25 +00:00
developer 45957bdcd6 Merge pull request 'Release: promote development → master (old Android WebView support + unsupported-engine telemetry)' (#178) from development into master 2026-07-04 21:23:29 +00:00
developer 829e29a726 Merge pull request 'Release v1.8.0: prod build fix (re-promote)' (#175) from development into master 2026-07-03 21:48:13 +00:00
developer 399508f2f0 Merge pull request 'Release v1.8.0: promote development → master' (#173) from development into master 2026-07-03 21:31:25 +00:00
developer 3a18e683ca Merge pull request 'release: VK Mini App + landscape UI + dict v1.3.1 seed (development→master)' (#144) from development into master 2026-06-30 05:37:43 +00:00
developer 93d086a8a3 Merge pull request 'release: v1.7.0 — Telegram Mini App embedding enhancements' (#138) from development into master 2026-06-24 11:49:44 +00:00
developer 8fe1bdba6b Merge pull request 'release: v1.6.0 — promo deep-link seeds EN variant (+ UI nits)' (#135) from development into master 2026-06-23 21:02:19 +00:00
developer 7923b3cc09 Merge pull request 'release v1.5.1: support-relay card + topic-reopen fixes' (#133) from development into master 2026-06-23 16:54:01 +00:00
developer 4891216749 Merge pull request 'release v1.5.0: Telegram bot support relay' (#131) from development into master 2026-06-23 16:16:04 +00:00
developer f1b8769c89 Merge pull request 'release: v1.4.1 — Telegram nav (windowed, own back button, debug panel)' (#129) from development into master 2026-06-23 13:27:31 +00:00
developer b6f28a2423 Merge pull request 'release: v1.4.0 — Telegram launch diagnostic + dynamic SDK load' (#127) from development into master 2026-06-23 08:40:09 +00:00
developer e32ee9ce68 Merge pull request 'Release: development → master' (#125) from development into master 2026-06-22 22:36:42 +00:00
developer dc946a1faf Merge pull request 'release v1.2.2: edge HTTP/3 stall fix + db-size dashboard threshold' (#121) from development into master 2026-06-22 19:50:58 +00:00
developer 384bd143d0 Merge pull request 'Promote development → master: banner tip set + banner/push language fix' (#114) from development into master 2026-06-22 18:28:00 +00:00
developer c5d22fceca Merge pull request 'Promote development → master: Erudit blank star + dictionary v1.3.0 pin' (#111) from development into master 2026-06-22 13:12:01 +00:00
developer deaa7a29c5 Merge pull request 'Promote development → master (docs finalize + UI tweaks + Telegram name fallback)' (#108) from development into master 2026-06-22 07:27:40 +00:00
developer 24017bcb7f Merge pull request 'Promote development → master (deploy v2: versioning + visible jobs + rollback)' (#106) from development into master 2026-06-22 06:01:03 +00:00
developer 2c4f4b10dc Merge pull request 'Promote development → master (initial production release: pre-release line + Stage 18)' (#104) from development into master 2026-06-22 05:05:48 +00:00
236 changed files with 5930 additions and 837 deletions
+4 -4
View File
@@ -30,7 +30,7 @@ on it**, and prefer the authoritative docs where they overlap. Add to this file
## Native Android build (Capacitor)
The native Android app (`ANDROID_PLAN.md`) is a Capacitor 8 wrapper of the `ui` SPA, scaffolded under
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
@@ -234,9 +234,9 @@ The native Android app (`ANDROID_PLAN.md`) is a Capacitor 8 wrapper of the `ui`
`--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 (see `ANDROID_PLAN.md`).
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** — see `ANDROID_PLAN.md` (Capacitor bundle model, client-version gate,
offline-first, RuStore).
- **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).
@@ -3,11 +3,11 @@
# 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
# Signing degrades gracefully: with the ANDROID_RUSTORE_* secrets present the APK is signed; without
# them build.gradle produces an UNSIGNED release APK (so a dry run still proves the whole pipeline).
# The keystore is a publication prerequisite — see deploy/README.md (Android build/release runbook) and
# ANDROID_PLAN.md §E. The toolchain is self-provisioned here (JDK 21 + a cached Android SDK), so the
# runner host needs nothing pre-installed.
# 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 }}"
@@ -144,7 +144,7 @@ jobs:
- name: Decode the release keystore
id: keystore
env:
ANDROID_KEYSTORE_BASE64: ${{ secrets.ANDROID_KEYSTORE_BASE64 }}
ANDROID_KEYSTORE_BASE64: ${{ secrets.ANDROID_RUSTORE_KEYSTORE_BASE64 }}
run: |
if [ -n "$ANDROID_KEYSTORE_BASE64" ]; then
printf '%s' "$ANDROID_KEYSTORE_BASE64" | base64 -d > "${GITHUB_WORKSPACE}/release.jks"
@@ -152,16 +152,16 @@ jobs:
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)"
echo "::warning::ANDROID_RUSTORE_KEYSTORE_BASE64 secret is not set — building an UNSIGNED release APK (not installable/publishable)"
fi
- name: Assemble the release APK
working-directory: ui/android
env:
ANDROID_KEYSTORE_FILE: ${{ steps.keystore.outputs.file }}
ANDROID_KEYSTORE_PASSWORD: ${{ secrets.ANDROID_KEYSTORE_PASSWORD }}
ANDROID_KEY_ALIAS: ${{ secrets.ANDROID_KEY_ALIAS }}
ANDROID_KEY_PASSWORD: ${{ secrets.ANDROID_KEY_PASSWORD }}
ANDROID_KEYSTORE_PASSWORD: ${{ secrets.ANDROID_RUSTORE_KEYSTORE_PASSWORD }}
ANDROID_KEY_ALIAS: ${{ secrets.ANDROID_RUSTORE_KEY_ALIAS }}
ANDROID_KEY_PASSWORD: ${{ secrets.ANDROID_RUSTORE_KEY_PASSWORD }}
run: ./gradlew assembleRelease -PversionCode=${{ steps.prep.outputs.code }} -PversionName=${{ steps.prep.outputs.name }} --console=plain
- name: Upload the APK artifact
+44 -4
View File
@@ -522,10 +522,21 @@ jobs:
# (the seller INN, §11) AND that the pricing marker was substituted (proves the fetch +
# splice ran), never just the status. Data-independent: an empty catalog still substitutes
# the marker with nothing, so "pricing_template" must be absent either way.
out="$(docker run --rm --network edge alpine:3.20 wget -q -O - http://scrabble/offer/ 2>&1 || true)"
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"
else
# Full body is needed (the INN is in §11 and the marker check spans the page), so this
# cannot be a head-only probe like the legal one. Retry with a timeout instead: the offer is
# now bilingual (RU + EN, larger), and a single-shot fetch can race the renderer's restart in
# the same deploy.
ok=
for i in $(seq 1 20); do
out="$(docker run --rm --network edge alpine:3.20 wget -q -T 10 -O - http://scrabble/offer/ 2>&1 || true)"
if echo "$out" | grep -q "290210610742" && ! echo "$out" | grep -q "pricing_template"; then
echo "ok: /offer/ serves the rendered offer with the price list spliced in"
ok=1
break
fi
sleep 3
done
if [ -z "$ok" ]; then
echo "FAIL: /offer/ did not serve the rendered offer (route fell through, or the price splice failed)"
docker logs --tail 50 scrabble-renderer || true
docker logs --tail 50 scrabble-backend || true
@@ -533,6 +544,35 @@ jobs:
exit 1
fi
- name: Probe the /privacy/ and /eula/ legal pages are served
run: |
set -u
# /privacy/ and /eula/ are static legal markdown rendered by the render sidecar. If the
# @legal caddy route is missing they fall to the landing shell (also 200, canonical
# erudit-game.ru/), so assert the page-specific canonical URL — it uniquely identifies the
# rendered legal doc reaching the edge, not the landing catch-all. Read only the <head>
# (head -c 4096; the canonical link sits in the first bytes): the check is then
# size-independent, so the large EULA page does not exceed the timeout while the monitoring
# stack is still booting post-deploy and starving the CPU-capped renderer. Retry like the
# landing/gateway/backend probe to ride out that window.
for route in privacy eula; do
ok=
for i in $(seq 1 20); do
head_out="$(docker run --rm --network edge alpine:3.20 sh -c "wget -q -T 10 -O - http://scrabble/${route}/ 2>/dev/null | head -c 4096" || true)"
if printf '%s' "$head_out" | grep -qF "erudit-game.ru/${route}/"; then
echo "ok: /${route}/ serves the rendered legal page"
ok=1
break
fi
sleep 3
done
if [ -z "$ok" ]; then
echo "FAIL: /${route}/ did not serve the rendered legal page (@legal route missing?)"
docker logs --tail 50 scrabble-renderer || true
exit 1
fi
done
- name: Probe the /pay/ callback route reaches the gateway
run: |
set -u
+99 -10
View File
@@ -62,15 +62,97 @@ client-only (no server change).
Kept current as parts land so a fresh session resumes without re-deriving. Verify against code.
> ### ▶ RESUME HERE — 2026-07-14 (keystore secrets set; payments landed; gate = RuStore account + merge/tag)
>
> **Where we are.** The legal-documents track, the release-prep hygiene, the icon rebrand **and the
> release keystore** are DONE (the keystore + its Gitea secrets set by the owner — see below). The
> RuStore MVP is now gated on **one owner-side item** — the **RuStore developer account** (owner going
> ИП; RuStore updated its requirements) — plus the agent's **promote/tag → signed-build** chain. Also
> landed on `development` since: the payments **E10 multi-shop split** (#266) + **E11 kill switch**
> (#267), both dormant / fail-open (no bearing on the Android build), and the signing-secret rename
> `ANDROID_*` → **`ANDROID_RUSTORE_*`** (#268). Do not re-do the finished work or re-ask the locked
> decisions.
>
> **Branches / PRs.** All merged to `development` (contour-green): legal documents (#253); release-prep
> hygiene + the re-enabled `android-build` workflow (#254); the payments E10/E11 (#266/#267); the
> signing-secret rename (#268). No open Android branch.
>
> **Done this session.**
> - **Legal documents** (satisfies the RuStore *hosted privacy-policy URL* prerequisite): authored
> `ui/legal/{privacy,eula,offer}_{ru,en}.md`; served as **bilingual (RU/EN)** standalone pages by the
> render sidecar at `/privacy/`, `/eula/`, `/offer/` via the shared `ui/src/lib/offer.ts`
> `renderLegalHtml`, with a client-side **language + theme switcher** (🌐 + ☼/☾, no "back", default
> Russian + persisted, mirroring the landing). The offer's EN view **transliterates** the Russian
> product names and **translates** the price-list section headings + column headers (`PRICE_TR`).
> Edge: `@legal` caddy matcher (`/offer/ /privacy/ /eula/`) + CI probes (size-independent head-only
> for the legal pages, retry for the offer). Landing footer links: Пользовательское соглашение |
> Политика конфиденциальности | Публичная оферта | Обратная связь (→ the Telegram bot). Docs baked
> into `docs/ARCHITECTURE.md`, `docs/FUNCTIONAL.md` (+`_ru`), `renderer/README.md`.
> **Locked legal decisions — do NOT re-litigate:** seller **INN `290210610742`**; EULA §14 leads
> with **RF law + jurisdiction** as residence tiers (France / England&Wales retained as foreign
> tiers); the "only the English version is legally binding" clause is **removed**; data collection is
> kept but **scoped to voluntary/support provision** (passport/payment/ID only when the user provides
> them); «Компания» is not shout-cased; the offer's EN catalog product names are **transliterated**
> (owner-chosen), while its headings/columns are translated.
> - **Release-prep hygiene (PR #254):** de-anchored `ANDROID_PLAN.md` from all code/docs — **no
> reference to this file remains anywhere outside it**; **re-enabled the `android-build` workflow**
> (`.gitea/workflows/android-build.yaml.disabled` → `.yaml`; it is `workflow_dispatch`-only + gated
> `if master`, so it never auto-runs; the CI host already has the Android SDK at `/opt/android-sdk`);
> added [`docs/ICONS.md`](docs/ICONS.md) (the single-master icon/logo format reference).
>
> **Remaining to release (owner-gated unless the agent is named).**
> 1. **Icon rebrand — DONE.** The SVG-tracer export failed, so the icon was designed **collaboratively
> from scratch** (wooden «Э» tile + ✻ subscript, Spectral Bold). The construction is fully specified
> in fractions of the side in [`docs/ICON_BRANDBOOK.md`](docs/ICON_BRANDBOOK.md), with the reference
> generator + pinned font + masters in [`assets/icons/brand/`](assets/icons/brand/). One master →
> the whole set sliced by `brand/build-set.mjs` (+ `build-android-res.mjs`): web (favicon light+dark,
> apple-touch, PWA any + maskable, og-image reskinned to «Эрудит» / Игра в слова), the Capacitor
> layers `ui/assets/{icon,icon-foreground,icon-background}.png`, the Android launcher res (all
> densities, **no inset**, **+ monochrome** themed layer), and manual-upload art (`brand/{vk,tg,store}`).
> Primary variant **light**; dark where the platform can theme (favicon.svg). The old LiberationSans
> generator (`assets/icons/build/`) is removed; targets are mapped in `docs/ICONS.md`.
> 2. **Merge PR #254** → `development` (owner approval), then **promote `development` → `master`** and
> **tag `vX.Y.Z`** for the release (the version stamps the APK `versionName`/`versionCode` and the
> `X-Client-Version` header).
> 3. **Release keystore — DONE (owner, 2026-07-14).** `erudit-release.jks` (RSA 4096, alias `erudit`,
> PKCS12) generated + backed up off-host; the four Gitea secrets are set under the RuStore-specific
> names `ANDROID_RUSTORE_KEYSTORE_BASE64` / `ANDROID_RUSTORE_KEYSTORE_PASSWORD` /
> `ANDROID_RUSTORE_KEY_ALIAS` / `ANDROID_RUSTORE_KEY_PASSWORD` (loss ⇒ the app can never be updated —
> the cert is pinned at the first RuStore upload). Google Play, later, would use its own
> `ANDROID_PLAY_*` upload key (Play App Signing) — a separate key.
> 4. **RuStore developer account.** OWNER (verified legal entity or self-employed / самозанятый). Gates
> publication, not the build.
> 5. **Dispatch the signed build.** AGENT: on `master`, dispatch `android-build` (`confirm=build`) via
> `tea`, watch to green, retrieve the signed APK artifact (verify Gitea 1.26 `upload-artifact@v4` at
> the dispatch).
> 6. **On-device smoke.** OWNER installs the signed APK and runs the airplane-mode first-launch
> checklist (`docs/TESTING.md`). The AGENT can pre-run the emulator smoke.
> 7. **Store listing.** Reserve `ru.eruditgame.app`, title «Эрудит», RU description, phone screenshots,
> icon / feature graphic, age rating, the **privacy-policy URL `https://erudit-game.ru/privacy/`**
> (now available), the content declaration.
> 8. **Upload to RuStore.** OWNER. After publication, set the `ANDROID_RUSTORE_URL` Gitea variable to
> the store deep-link so the update button works on future gated releases (empty until then — the
> gate is dormant in the MVP).
>
> **Next tracks (design in a future session — interview the owner first).**
> - **Native notifications (push).** Promote FCM/push from "Out of scope" to a real track: a Capacitor
> push plugin, the Android send path (FCM — but **check RuStore's own push service vs FCM on RuStore
> devices**, since Google Play Services may be absent), token registration, and reusing the existing
> server notification dispatcher. Owner will bring specifics.
> - **A test APK for the emulator** (owner asked): the signed APK comes from `android-build` (needs the
> promote/tag chain, step 2). A quick **debug** APK is buildable without the release chain if the host
> has the Android SDK + JDK 21 (`pnpm build` → `cap sync` → `assembleDebug`).
> - Possibly other native polish (owner to flag).
- **A. Capacitor scaffolding — ✅ DONE & verified (2026-07-12).** Capacitor 8 native project generated
under `ui/android/` (tracked), `@capacitor/*` deps + `capacitor.config.ts`, hardware Back in
`ui/src/lib/native.ts` wired from `App.svelte`. `pnpm build``cap sync``./gradlew assembleDebug`
builds a debug APK that installs and **cold-launches to the Login screen** on the Pixel_10 emulator.
`svelte-check` 0 errors, `vitest` 584 passed. Build recipe + toolchain gotchas live in
`.claude/CLAUDE.md` → "Native Android build (Capacitor)".
**Launcher icon:** a **temporary** placeholder is in place — `ui/assets/icon.png` (the maskable «Э»
brand mark upscaled 512→1024) → `pnpm android:assets` regenerated the Android launcher/adaptive
icons + splashes. Superseded by the icon rebrand below.
**Launcher icon:** the temporary placeholder was **superseded by the icon rebrand** — the launcher
now uses the two-layer adaptive icon (background + foreground + monochrome) generated from the brand
master; see the RESUME block (item 1), `docs/ICON_BRANDBOOK.md` and `docs/ICONS.md`.
- **B. Native web-build correctness — ✅ DONE & verified (2026-07-12).** New `ui/src/lib/origin.ts`
`gatewayOrigin()`; the two `location.origin` landmines fixed (`game/Game.svelte` export/share URL,
`screens/Wallet.svelte` site-root) — `transport.ts:32` already resolves via `VITE_GATEWAY_URL`, left
@@ -181,11 +263,10 @@ Kept current as parts land so a fresh session resumes without re-deriving. Verif
then O2O7; the release chain (PR→development→contour→master→tag→dispatch `android-build`) follows. None of
O1O7 is started. Branch `feature/android-native`, local/unpushed.
Open logistics (not code): **mandatory icon rebrand (future)** — author ONE vector master and generate
*every* icon from it (web `favicon.svg` / PWA `icon-*` / maskable, Android adaptive, future iOS). Today
the formats drift — `favicon.svg` is a rounded bordered tile, `icon-maskable-512.png` a full-bleed
square, visibly different — and the Android launcher icon is only a temporary upscale of the maskable
(`capacitor-assets` insets it 16.7% into the adaptive safe zone). `~/.claude/bin/gitea-ci-watch.py` is present.
Icon rebrand — **DONE** (was "one master → every icon"): a single Spectral «Э» master now drives web
(favicon light+dark / PWA any + maskable / apple-touch / og), the Capacitor layers and the Android
adaptive res (no inset, + monochrome), plus VK/TG/store art. Specced in `docs/ICON_BRANDBOOK.md`,
mapped in `docs/ICONS.md`, generated from `assets/icons/brand/`. `~/.claude/bin/gitea-ci-watch.py` is present.
RuStore publication prerequisites gate release only.
---
@@ -334,8 +415,8 @@ is: scaffold → native-correct → gate → offline-first → CI → docs → r
```
Call once from bootstrap. `atNavigationRoot` = current route is `lobby`/`login`
(mirror `routeDepth(...) === 0` from `App.svelte:50-54`, reading `router.svelte.ts`).
- **Launcher icon (owner asset):** owner supplies a 1024×1024 «Э» icon at `ui/assets/icon.png`
(+ optional `splash.png`); `pnpm android:assets` generates adaptive icons.
- **Launcher icon:** DONE — generated from the brand master into the Capacitor layers
`ui/assets/{icon,icon-foreground,icon-background}.png` and the Android res; see `docs/ICONS.md`.
**Done when:** `npx cap sync android` succeeds against a real `pnpm build`, and (from `ui/android/`)
`./gradlew assembleDebug` produces a debug APK that installs and cold-launches to the login screen.
@@ -695,6 +776,14 @@ Wire in `ui/android/app/build.gradle` `defaultConfig`:
### G. Release + owner handoff
> **Status 2026-07-13 — see the ▶ RESUME block at the top of §Progress.** Since this chain was
> written, a **legal-documents track** was added and merged (PR #253 — the RuStore hosted
> privacy-policy URL and the `/privacy/ /eula/ /offer/` pages), and a **release-prep hygiene** patch is
> in flight (PR #254, open), and the **icon rebrand is DONE** (brand master → full set; see the RESUME
> block). The remaining gates are the **keystore** and the **RuStore account** (owner-side). The steps
> below are the original release chain; the resume
> block supersedes them with the current, exhaustive state.
0. **Land the offline-model redesign — ✅ DONE (O1O7, 2026-07-13; staged detail below).** Resolved the deferred
native local-game-visibility decision — a native guest now sees / resumes their device-local games once online
(the **unified lobby**, O5) — as part of the owner-approved change: the explicit offline toggle is gone, offline
+121 -1
View File
@@ -39,10 +39,13 @@ status — without re-deriving decisions.
| E7 | Admin, reports & catalog | 2 | DONE |
| E8 | Guest limits | — | DONE |
| E9 | Tournament fee | future | TODO |
| E10 | Multi-shop direct rail + ИП fiscalization | 2+ | DONE |
| E11 | Payment availability kill switch + per-account override | 2 | DONE |
**Release 1** = full mechanics with no real money, exercised via `admin_grant` (E0→E1→E2→E3).
**Release 2** = money (E4→E5→E6→E7). E8 is standalone (game-behaviour change, can run in
parallel). E9 is future.
parallel). E9 is future. E10 splits the `direct` rail into per-channel Robokassa shops + ИП
fiscalization (post-launch, backend-first).
---
@@ -892,6 +895,123 @@ tournament-entry storage + pricing design.
---
## E10 — Multi-shop direct rail + ИП fiscalization
**Status:** DONE (merged — the multi-shop PR) · **Release 2+ (post-launch, backend-first)** · depends on: E5 (intake/`Fund`,
the `robokassa` adapter, the Result callback), E7 (the per-user report — channel breakdown) ·
mechanics: PAYMENTS §9, §12; decisions D41 (rev), D42D44.
**Goal.** Split the single Robokassa `direct` rail into **per-channel merchant shops** (web,
android; ios later) — separate merchant accounts / withdrawal / accounting and a per-channel
breakdown in the E7 report — while keeping **one `direct` wallet** (D42). Land the wallet/identity
rules the native apps need (email-only anchor, D43) and revise fiscalization for the owner's move
to **ИП / 54-ФЗ** (D41 rev). All **additive / contour-safe** and **money-live** → expand-contract
throughout.
**Locked decisions (owner interview 2026-07-14): D41 (rev), D42, D43, D44.** No wallet-model /
spend-wall change; the split is merchant-account routing under one `direct` segment. Route the shop
by the **trusted** `X-Platform` subtype, never a client field; unknown → `web`.
**B1 — Config: one shop → a set of shops.**
- `robokassa.Config` (single) → a **shops registry** keyed by channel (`web`, `android`; `ios`
later), each 4 fields (`MerchantLogin`/`Password1`/`Password2`/`IsTest`). New env
`BACKEND_ROBOKASSA_WEB_*`, `BACKEND_ROBOKASSA_ANDROID_*`.
- **Expand-contract, no flag-day:** the legacy `BACKEND_ROBOKASSA_*` seeds the `web` shop; add the
new vars; retire the legacy set after the prod rollout sets the split vars. `validate()`: a shop
with a login must carry both passwords (mirror the current check), per shop.
- Config/README + `deploy/.env.example` + the deploy ansible vars.
**B2 — Route the shop by channel (intake).**
- `handleWalletOrder` `SourceDirect` branch: pick the shop by `cxt.Subtype` (`web`/`android`;
unknown → `web`). Build the payment request from that shop. The subtype rides the trusted
gateway-injected `X-Platform` (`<kind>/<subtype>`, `parsePlatformHeader`) — no spoofable client
field. **Verify the gateway emits `direct/android` for the native build**; if it sends a bare
`direct/`, add the android subtype (small gateway change).
- Record the chosen `shop` on the order (feeds B5).
**B3 — Per-shop Result callbacks.**
- The callback must select the right `Password2` → each shop gets its own Result URL:
**per-shop public edge routes** (mirror the existing single Robokassa Result route, e.g.
`…/result/web`, `…/result/android`), each forwarded to the backend and verified with that shop's
config, then the same `Fund` (source stays `direct`). Add the routes to the **Caddyfile
`@gateway` matcher** (else they fall to the landing catch-all) **+ a CI probe per route**.
- Keep the legacy single route alive (shop = `web`) through the expand-contract window until the
cabinet Result URLs are cut over.
**B4 — ИП fiscalization (D41 rev) — RESOLVED (owner 2026-07-14): cabinet-side only.** The owner keeps
Robokassa's cabinet auto-fiscalization (a generic чек is acceptable for the ИП); the optional
itemized-`Receipt` / `Email` code below is **dropped** — not needed. (Kept for context.)
- **Owner/cabinet:** enable the 54-ФЗ cloud kassa in the Robokassa ЛКК (kassa + ОФД + СНО).
Required for ИП regardless of code.
- **Code (optional, itemized чеки):** send a one-line `Receipt` (pack name, qty 1, `sum`, `tax`
per СНО, `payment_object`/`payment_method`) + the customer `Email` (the D36 confirmed anchor) in
the payment request; extend the signature to `MerchantLogin:OutSum:InvId:Receipt:Password1`
(URL-encode `Receipt`). One line fits the current GET redirect; POST-form only as a URL-length
fallback. One feature, all shops. **Sequenced last** — the split (B1B3, B5, B6) needs no
fiscalization. Supersedes the E5 §12 shop-side НПД receipt note.
**B5 — Channel in the report (D44).**
- Additive `shop` column on the order (default `web` / backfill from `origin`); a per-channel
breakdown in the E7 per-user report (D40). Expand-contract migration (nullable/defaulted column),
**contour-safe** (a schema touch → note the contour `DROP SCHEMA` step in `PRERELEASE.md`).
**B6 — Tests + docs.**
- unit: the `robokassa` shops registry + per-shop `VerifyResult` (right `Password2`);
signature-with-`Receipt` (B4); intake shop routing by subtype (web/android/unknown→web).
- integration: order→per-shop callback→credit (each route), a duplicate credits once, an expired
order still honoured; the `shop` recorded + reported.
- docs: `PAYMENTS.md` (+`_ru`) the multi-shop topology + the ИП/54-ФЗ receipt revision;
`deploy/README` + `.env.example` the new vars + the per-shop Result routes + probes;
`PRERELEASE.md` the `shop` column contour step.
**Done-criteria.** A `direct/web` order pays through the web shop and `direct/android` through the
android shop, each verified with its own `Password2`, both crediting one `direct` wallet exactly
once; the E7 report breaks payments down by channel; the split stays contour-safe (the legacy shop
still works until the cabinet cutover). B4 (fiscalization) verified when the owner's ИП/ОФД/СНО are
live.
**Notes/risks.** Edge routes fall through if not in the Caddyfile `@gateway` matcher — add a CI
probe (field note). The prod rolling deploy skips caddy on config-only changes — force-recreate on a
Caddyfile change. Money-live: expand-contract only, image rollback DB-safe. Confirm the gateway
emits `direct/android` for the native build before relying on subtype routing.
---
## E11 — Payment availability kill switch + per-account override
**Status:** DONE · **Release 2** · depends on: E5 (intake/order), E7 (admin console) · mechanics:
PAYMENTS §9; decisions D45, D46.
**Delivered.** An operator disables purchases live from `/_gm` — a whole rail/channel or one account
— and the user sees a localized reason on the next purchase attempt. Motivation (owner): real apps
show broken payments with no explanation; this gives ops a live switch + a clear user message.
- **Rail kill switch** — `payments.rail_status` (per rail `direct:web` / `direct:android` / `vk` /
`telegram`): `enabled` + a per-language message, edited on the catalog page. **Fail-open** (no row
⇒ enabled, so payments are never accidentally killed). The intake gate — `CanPurchase` in
`handleWalletOrder`, before the order — returns `payment_unavailable` + the localized message;
orthogonal to the security gates.
- **Per-account override** — `payments.account_payment_override` (a row only for non-default; default
= no row, cleared by delete): allow / deny / default, edited on the user card. `allow` bypasses
**only** the ops rail switch, never the security gates (D46).
- **Wire** — the message rides an additive `ExecuteResponse.message` (envelope layer,
frozen-contract-safe; the gateway forwards a backend domain-error message via `DomainMessage`); the
client reads it into `GatewayError.message` and shows it on a `payment_unavailable` buy attempt.
- **Tests** — the pure gate `PurchaseGate` (unit, TDD); the store + gate + override end-to-end
(integration, migration `00016`); the client (svelte-check / vitest). **Docs** — PAYMENTS (+`_ru`),
decisions D45 / D46.
**Contour-safe:** additive migration (two new tables, no wipe); the wire add is additive; fail-open,
so nothing is disabled until an operator acts.
---
## Verification & CI (all stages)
- Per-stage tests at the layers above; **compliance-gate regression is mandatory** (a
+14 -37
View File
@@ -1,41 +1,18 @@
# Erudit — site icons + Open Graph card
# Icons
The favicon set and the `og:image` link-preview card for the public landing
(`ui/landing.html`) and the SPA shell (`ui/index.html`). Same design language as the
[VK loading-screen logo](../vk/README.md): the wooden Erudit «Э» tile (score `8`),
with the wordmark on the app's dark board green (`ui/src/app.css` tokens).
| Output (committed to `ui/public/`) | Purpose |
|------|---------|
| `favicon.svg` | Vector favicon, transparent; tile + «Э» only (the score is illegible below ~32 px). |
| `favicon.ico` | 32×32 PNG-in-ICO fallback (also answers the browsers' blind `/favicon.ico` probe). |
| `apple-touch-icon.png` | 180×180 opaque full-bleed tile; iOS masks its own corners. |
| `og-image.png` | 1200×630 card: tile + «Эрудит / Скрэббл — игра в слова». Referenced absolutely as `https://erudit-game.ru/og-image.png`. |
## How it works
`build/extract.js` extracts the needed glyph outlines (tile glyphs + every wordmark
character) from LiberationSans (Arial-metric, the game's font stack) with their
advance widths into `build/glyphs.json` (committed). `build/generate.js` composes
plain SVG from those outlines — no font is needed at generation time — writes
`favicon.svg` and rasterises the PNG/ICO outputs by screenshotting the SVGs with the
`ui` package's Playwright chromium (`@playwright/test`); the `.ico` container is
assembled in-script (a single PNG entry). Raster bytes therefore depend on the
installed chromium version; the SVG sources are deterministic.
## Regenerate
Requirements: Node ≥ 18, `ui` installed (`pnpm install`, provides Playwright).
`extract.js` additionally needs `opentype.js` (`npm i opentype.js`); **`generate.js`
needs no extra packages**.
The whole shipped icon set is sliced from **one master** — see
[`brand/`](brand/) for the reference generator, the pinned font and the committed
outputs, and [`../../docs/ICON_BRANDBOOK.md`](../../docs/ICON_BRANDBOOK.md) for how the
mark is constructed. The per-platform target map (which file goes where) is in
[`../../docs/ICONS.md`](../../docs/ICONS.md).
```sh
cd assets/icons
# 1. (optional) re-extract the glyphs — only if the font or the wordmark changes:
# default font: /usr/share/fonts/truetype/liberation/LiberationSans-Regular.ttf
node build/extract.js [/path/to/font.ttf] # -> build/glyphs.json
# 2. regenerate everything in ui/public/:
node build/generate.js
cd brand
npm i opentype.js
node build-set.mjs # web (ui/public) + Capacitor layers (ui/assets) + vk/ tg/ store/
node build-android-res.mjs # Android launcher resources (all densities + monochrome)
```
> The earlier `build/` pipeline (LiberationSans glyph outlines → favicon/apple-touch/OG)
> was replaced by `brand/` when the icon was rebranded onto the Spectral «Э» master.
> The separate VK loading-screen animation lives in [`../vk/`](../vk/) and is unrelated.
+2
View File
@@ -0,0 +1,2 @@
node_modules/
package-lock.json
+32
View File
@@ -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).
Binary file not shown.
+93
View File
@@ -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.
Binary file not shown.

After

Width:  |  Height:  |  Size: 19 KiB

+53
View File
@@ -0,0 +1,53 @@
'use strict';
// Generate the Android launcher resources from the committed layers, at every
// density, with NO inset (our layers already fill the 108 dp canvas) and a
// monochrome (themed) layer. Run build-set.mjs first (it writes the layer PNGs).
//
// npm i opentype.js # (only build-icon/build-set need it; this reads PNGs)
// node build-set.mjs && node build-android-res.mjs
//
// Writes ui/android/app/src/main/res/mipmap-*/ic_launcher{,_round,_foreground,
// _background,_monochrome}.png. The two mipmap-anydpi-v26/*.xml are committed by hand.
import { readFileSync, writeFileSync } from 'node:fs';
import { fileURLToPath } from 'node:url';
import { dirname, join } from 'node:path';
const DIR = dirname(fileURLToPath(import.meta.url));
const ROOT = join(DIR, '..', '..', '..');
const RES = join(ROOT, 'ui', 'android', 'app', 'src', 'main', 'res');
const uri = p => 'data:image/png;base64,' + readFileSync(p).toString('base64');
const BG = uri(join(ROOT, 'ui', 'assets', 'icon-background.png'));
const FG = uri(join(ROOT, 'ui', 'assets', 'icon-foreground.png'));
const MONO = uri(join(DIR, 'android-monochrome.png'));
// The full-bleed master (square tile + master mark) — the legacy SQUARE launcher (API < 26)
// uses it so old Android shows a large mark rather than the safe-zone foreground shrunk into
// the middle. The round legacy icon keeps the safe-zone composite (a round mask would clip
// the master's corner ✻).
const MASTER = uri(join(ROOT, 'ui', 'assets', 'icon.png'));
// density: [adaptive-layer px (108 dp), legacy launcher px (48 dp)]
const D = { ldpi: [81, 36], mdpi: [108, 48], hdpi: [162, 72], xhdpi: [216, 96], xxhdpi: [324, 144], xxxhdpi: [432, 192] };
const pw = await import(join(ROOT, 'ui', 'node_modules', '@playwright', 'test', 'index.js'));
const { chromium } = pw.default ?? pw;
const browser = await chromium.launch();
const page = await browser.newPage({ deviceScaleFactor: 1 });
const im = (u, s) => `<img src="${u}" width="${s}" height="${s}" style="display:block">`;
async function shot(html, s, transparent) {
await page.setViewportSize({ width: s, height: s });
await page.setContent(`<!doctype html><meta charset=utf8><style>*{margin:0;padding:0}#r{width:${s}px;height:${s}px}</style><div id=r>${html}</div>`, { waitUntil: 'networkidle' });
return page.locator('#r').screenshot({ omitBackground: !!transparent });
}
for (const [d, [fg, lg]] of Object.entries(D)) {
const dir = join(RES, `mipmap-${d}`);
writeFileSync(join(dir, 'ic_launcher_background.png'), await shot(im(BG, fg), fg, false));
writeFileSync(join(dir, 'ic_launcher_foreground.png'), await shot(im(FG, fg), fg, true));
writeFileSync(join(dir, 'ic_launcher_monochrome.png'), await shot(im(MONO, fg), fg, true));
// Square launcher: the full-bleed master (large mark). Round launcher: the safe-zone
// composite, circle-clipped (the master's corner ✻ would be clipped by the round mask).
writeFileSync(join(dir, 'ic_launcher.png'), await shot(im(MASTER, lg), lg, false));
const round = `<div style="width:${lg}px;height:${lg}px;border-radius:50%;overflow:hidden;position:relative">${im(BG, lg)}<div style="position:absolute;inset:0">${im(FG, lg)}</div></div>`;
writeFileSync(join(dir, 'ic_launcher_round.png'), await shot(round, lg, true));
console.log('mipmap-' + d, 'ok');
}
await browser.close();
+163
View File
@@ -0,0 +1,163 @@
'use strict';
// Erudit app-icon — the reference generator. Every dimension below is a FRACTION
// of the icon side, so this file reads the same as docs/ICON_BRANDBOOK.md and the
// icon reproduces at any resolution. Emits one variant/layer's SVG to stdout.
//
// node build-icon.mjs --variant light|dark [--layer full|background|foreground] [--scheme] > icon.svg
//
// Layers (for Android adaptive icons):
// full the standalone master (rounded tile + mark) — iOS / PWA / favicon
// background wood + grain + light/shadow, full-bleed square (the OS applies the mask)
// foreground only «Э» + ✻, transparent, re-placed for the round mask (see FG_* below)
//
// Deps: opentype.js (npm i opentype.js). Font: ./Spectral-Bold.ttf (OFL, bundled).
import { readFileSync } from 'node:fs';
import { fileURLToPath } from 'node:url';
import { dirname, join } from 'node:path';
import opentype from 'opentype.js';
const DIR = dirname(fileURLToPath(import.meta.url));
const args = process.argv.slice(2);
const has = f => args.includes('--' + f);
const val = (f, d) => { const i = args.indexOf('--' + f); return i >= 0 ? args[i + 1] : d; };
// ---- the design, in fractions of the side ----
const S = 1024; // reference side (the icon is resolution-free)
const RADIUS = 0.17; // tile corner radius (full master)
const STAR_BULB = 0.22; // spoke-end (and hub) radius, as fraction of star radius
const STAR_SPOKES = 6;
const GRAIN_COLS = 6, GRAIN_W = 1 / 32, GRAIN_SHIFT = 1 / 16, GRAIN_TILT = 4;
const SHEEN = 0.15; // white, transparent bottom-left -> this alpha top-right
const SHADE = 0.15; // black, this alpha bottom-left -> transparent top-right
// The mark, placed for the standalone master (bottom-right biased, full-bleed):
const MASTER = { lh: 0.6055, lc: [0.4814, 0.4922], sd: 0.1729, sc: [0.7881, 0.7881] };
// The mark, placed for the Android foreground layer (centred-ish inside the 61% safe
// zone, nudged right 8% / down 3% for optical balance under the round mask; smaller ✻).
// Every value is the earlier placement scaled 0.75 about the icon centre (0.5, 0.5): the
// whole mark is 25% smaller, its «Э»↔✻ arrangement unchanged, so the ✻ that used to graze
// the round mask now sits well inside the safe zone. Round-mask launchers (adaptive + the
// legacy round icon) get more breathing room; the full-bleed master is untouched.
const FOREGROUND = { lh: 0.4043, lc: [0.5176, 0.4963], sd: 0.0989, sc: [0.6970, 0.6700] };
const PALETTE = {
light: { wood: '#e6b053', ink: '#5a3a12', grain: '#d8a54e' },
dark: { wood: '#4a3316', ink: '#f2d9a0', grain: '#432e14' },
};
// Layers:
// full rounded tile + master mark (favicon.svg — browser rounds nothing)
// flat square tile + master mark (apple-touch, PWA "any" — OS rounds)
// background square tile, no mark (Android adaptive background)
// foreground mark only, transparent (Android adaptive foreground)
// maskable square tile + foreground mark (PWA maskable — safe-zone aware)
// monochrome foreground mark only, flat colour (Android 13+ themed layer)
const LAYERS = ['full', 'flat', 'background', 'foreground', 'maskable', 'monochrome'];
const variant = val('variant', 'light');
const layer = val('layer', 'full');
const P = PALETTE[variant];
if (!P) { console.error(`unknown --variant ${variant} (light|dark)`); process.exit(1); }
if (!LAYERS.includes(layer)) { console.error(`unknown --layer ${layer} (${LAYERS.join('|')})`); process.exit(1); }
const usesForegroundPlacement = ['foreground', 'maskable', 'monochrome'].includes(layer);
const drawBg = ['full', 'flat', 'background', 'maskable'].includes(layer);
const drawMark = layer !== 'background';
const MARK = usesForegroundPlacement ? FOREGROUND : MASTER;
const inkColour = layer === 'monochrome' ? val('mono', '#ffffff') : P.ink;
const RX = layer === 'full' ? RADIUS * S : 0; // only the standalone master keeps rounded corners
const font = opentype.parse(readFileSync(join(DIR, 'Spectral-Bold.ttf')).buffer);
// «Э» outline scaled so its ink bounding box is MARK.lh of the side, box centred on MARK.lc.
function letterSvg() {
const g = font.charToGlyph('Э');
const h0 = (() => { const b = g.getPath(0, 0, 1000).getBoundingBox(); return b.y2 - b.y1; })();
const scale = (MARK.lh * S) / h0;
const p = g.getPath(0, 0, 1000 * scale);
const bb = p.getBoundingBox();
const w = bb.x2 - bb.x1, h = bb.y2 - bb.y1;
const tx = MARK.lc[0] * S - (bb.x1 + w / 2);
const ty = MARK.lc[1] * S - (bb.y1 + h / 2);
return { svg: `<path transform="translate(${tx.toFixed(2)} ${ty.toFixed(2)})" d="${p.toPathData(2)}" fill="${inkColour}"/>`,
box: { x: bb.x1 + tx, y: bb.y1 + ty, w, h } };
}
// Teardrop-spoked asterisk ✻: each spoke tapers to a point at the centre and ends
// in a round bulb; a central disc equal to the bulb fuses them.
function starPath() {
const cx = MARK.sc[0] * S, cy = MARK.sc[1] * S, Rout = MARK.sd * S / 2;
const rb = Rout * STAR_BULB, R = Rout - rb;
const L = Math.sqrt(R * R - rb * rb), theta = Math.asin(rb / R);
const rot = (v, t) => [v[0] * Math.cos(t) - v[1] * Math.sin(t), v[0] * Math.sin(t) + v[1] * Math.cos(t)];
let d = '';
for (let k = 0; k < STAR_SPOKES; k++) {
const a = -Math.PI / 2 + k * 2 * Math.PI / STAR_SPOKES;
const u = [Math.cos(a), Math.sin(a)];
const d1 = rot(u, theta), d2 = rot(u, -theta);
const T1 = [cx + L * d1[0], cy + L * d1[1]], T2 = [cx + L * d2[0], cy + L * d2[1]];
d += `M${cx.toFixed(2)} ${cy.toFixed(2)} L${T1[0].toFixed(2)} ${T1[1].toFixed(2)} A${rb.toFixed(2)} ${rb.toFixed(2)} 0 1 0 ${T2[0].toFixed(2)} ${T2[1].toFixed(2)} Z `;
}
d += `M${cx} ${cy} m${-rb} 0 a${rb} ${rb} 0 1 0 ${2 * rb} 0 a${rb} ${rb} 0 1 0 ${-2 * rb} 0 Z`;
return `<path d="${d}" fill="${inkColour}"/>`;
}
function grain() {
const colW = S / GRAIN_COLS, sw = GRAIN_W * S, shift = GRAIN_SHIFT * S;
const yTop = -0.2 * S, yBot = 1.2 * S, cy = S / 2;
let s = '';
for (let i = 1; i <= GRAIN_COLS; i++) {
const xr = i * colW - shift, x = xr - sw, cx = xr - sw / 2;
s += `<rect x="${x.toFixed(2)}" y="${yTop.toFixed(2)}" width="${sw.toFixed(2)}" height="${(yBot - yTop).toFixed(2)}" fill="${P.grain}" transform="rotate(${GRAIN_TILT} ${cx.toFixed(2)} ${cy.toFixed(2)})"/>`;
}
return `<g clip-path="url(#tile)">${s}</g>`;
}
let body = '';
const L = letterSvg();
if (drawBg) { // tile + grain + light/shadow (the background)
body += `<rect width="${S}" height="${S}" rx="${RX}" fill="${P.wood}"/>`
+ `<defs><clipPath id="tile"><rect width="${S}" height="${S}" rx="${RX}"/></clipPath>`
+ `<linearGradient id="sheen" gradientUnits="userSpaceOnUse" x1="0" y1="${S}" x2="${S}" y2="0"><stop offset="0" stop-color="#fff" stop-opacity="0"/><stop offset="1" stop-color="#fff" stop-opacity="${SHEEN}"/></linearGradient>`
+ `<linearGradient id="shade" gradientUnits="userSpaceOnUse" x1="0" y1="${S}" x2="${S}" y2="0"><stop offset="0" stop-color="#000" stop-opacity="${SHADE}"/><stop offset="1" stop-color="#000" stop-opacity="0"/></linearGradient>`
+ `</defs>`
+ grain()
+ `<rect width="${S}" height="${S}" fill="url(#sheen)" clip-path="url(#tile)"/>`
+ `<rect width="${S}" height="${S}" fill="url(#shade)" clip-path="url(#tile)"/>`;
}
if (drawMark) body += L.svg + starPath(); // the mark
if (has('scheme')) body += scheme(L);
process.stdout.write(`<svg xmlns="http://www.w3.org/2000/svg" width="${S}" height="${S}" viewBox="0 0 ${S} ${S}">${body}</svg>\n`);
// Percent-based construction overlay for the brand book (full master only).
function scheme(L) {
const pc = n => (100 * n / S).toFixed(1) + '%';
const GC = '#0f172a', BX = '#d81b60', AC = '#0d9488';
const halo = 'paint-order="stroke" stroke="#fff" stroke-width="4"';
const cx = MARK.sc[0] * S, cy = MARK.sc[1] * S, sd = MARK.sd * S;
let g = `<g font-family="'Helvetica Neue',Arial,sans-serif">`;
for (let f = 1; f <= 3; f++) {
const p = f * S / 4;
g += `<line x1="${p}" y1="0" x2="${p}" y2="${S}" stroke="${GC}" stroke-opacity="0.18" stroke-width="1.2"/>`;
g += `<line x1="0" y1="${p}" x2="${S}" y2="${p}" stroke="${GC}" stroke-opacity="0.18" stroke-width="1.2"/>`;
g += `<text x="${p + 4}" y="20" font-size="17" font-weight="700" fill="${GC}" ${halo}>${f * 25}%</text>`;
g += `<text x="4" y="${p - 4}" font-size="17" font-weight="700" fill="${GC}" ${halo}>${f * 25}%</text>`;
}
g += `<line x1="${0.14 * S}" y1="${0.86 * S}" x2="${0.86 * S}" y2="${0.14 * S}" stroke="${AC}" stroke-width="3" stroke-dasharray="3 8" stroke-linecap="round"/>`;
g += `<circle cx="${0.86 * S}" cy="${0.14 * S}" r="6" fill="${AC}"/><circle cx="${0.14 * S}" cy="${0.86 * S}" r="6" fill="${AC}"/>`;
g += `<text x="${0.86 * S - 6}" y="${0.14 * S - 12}" text-anchor="end" font-size="18" font-weight="700" fill="${AC}" ${halo}>light: white 0%→15%</text>`;
g += `<text x="${0.14 * S + 10}" y="${0.86 * S + 26}" font-size="18" font-weight="700" fill="${AC}" ${halo}>shadow: black 15%→0%</text>`;
const { x, y, w, h } = L.box;
g += `<rect x="${x.toFixed(1)}" y="${y.toFixed(1)}" width="${w.toFixed(1)}" height="${h.toFixed(1)}" fill="none" stroke="${BX}" stroke-width="2.5"/>`;
g += `<line x1="${MARK.lc[0] * S}" y1="${MARK.lc[1] * S - 16}" x2="${MARK.lc[0] * S}" y2="${MARK.lc[1] * S + 16}" stroke="${BX}" stroke-width="2.5"/><line x1="${MARK.lc[0] * S - 16}" y1="${MARK.lc[1] * S}" x2="${MARK.lc[0] * S + 16}" y2="${MARK.lc[1] * S}" stroke="${BX}" stroke-width="2.5"/>`;
g += `<text x="${x.toFixed(1)}" y="${(y - 10).toFixed(1)}" font-size="19" font-weight="700" fill="${BX}" ${halo}>«Э» Spectral Bold · box H ${pc(h)} · W ${pc(w)}</text>`;
g += `<text x="${x.toFixed(1)}" y="${(y + h + 24).toFixed(1)}" font-size="18" font-weight="700" fill="${BX}" ${halo}>box center ${pc(MARK.lc[0] * S)} / ${pc(MARK.lc[1] * S)}</text>`;
g += `<rect x="${cx - sd / 2}" y="${cy - sd / 2}" width="${sd}" height="${sd}" fill="none" stroke="${BX}" stroke-width="2.5"/>`;
g += `<line x1="${cx}" y1="${cy - 14}" x2="${cx}" y2="${cy + 14}" stroke="${BX}" stroke-width="2.5"/><line x1="${cx - 14}" y1="${cy}" x2="${cx + 14}" y2="${cy}" stroke="${BX}" stroke-width="2.5"/>`;
g += `<text x="${(cx + sd / 2).toFixed(1)}" y="${(cy - sd / 2 - 10).toFixed(1)}" text-anchor="end" font-size="18" font-weight="700" fill="${BX}" ${halo}>✻ Ø ${pc(sd)} · center ${pc(cx)} / ${pc(cy)}</text>`;
g += `<text x="${RX + 14}" y="${RX - 6}" font-size="17" font-weight="700" fill="${GC}" ${halo}>corner r = 17%</text>`;
g += `<path d="M4 ${RX} A ${RX} ${RX} 0 0 1 ${RX} 4" fill="none" stroke="${GC}" stroke-width="2" stroke-dasharray="5 5"/>`;
g += `<rect x="${0.03 * S}" y="${S - 96}" width="${0.94 * S}" height="74" rx="12" fill="#0f172a" fill-opacity="0.82"/>`;
g += `<text x="${0.055 * S}" y="${S - 64}" font-size="18" font-weight="600" fill="#fff">Wood grain: 6 equal columns; a vertical stripe on each column's right edge,</text>`;
g += `<text x="${0.055 * S}" y="${S - 40}" font-size="18" font-weight="600" fill="#fff">width 1/32 of side; the whole set shifted left 1/16; every stripe tilted 4°.</text>`;
return g + `</g>`;
}
+105
View File
@@ -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');
Binary file not shown.

After

Width:  |  Height:  |  Size: 477 KiB

File diff suppressed because one or more lines are too long

After

Width:  |  Height:  |  Size: 6.5 KiB

Binary file not shown.

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

Binary file not shown.

After

Width:  |  Height:  |  Size: 24 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

Binary file not shown.

After

Width:  |  Height:  |  Size: 405 KiB

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

Binary file not shown.

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

Binary file not shown.

After

Width:  |  Height:  |  Size: 24 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

Binary file not shown.

After

Width:  |  Height:  |  Size: 411 KiB

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

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

After

Width:  |  Height:  |  Size: 103 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 103 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 32 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 15 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 38 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.6 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 122 KiB

-78
View File
@@ -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(''));
-128
View File
@@ -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');
}
})();
File diff suppressed because one or more lines are too long
+7 -2
View File
@@ -49,9 +49,14 @@ COPY --from=build /out/backend /usr/local/bin/backend
# Own the seed dictionary as the nonroot runtime user (UID 65532): a named volume
# mounted at /opt/dawg inherits this ownership on first use, so the admin console
# can write new version subdirectories at runtime. The volume preserves uploaded
# versions across deploys and, once seeded, is not re-seeded — so after bootstrap
# every dictionary change goes through the console, not a rebuild (ARCHITECTURE.md §5).
# versions across deploys and, once seeded, is not re-seeded (ARCHITECTURE.md §5).
COPY --from=dawg --chown=65532:65532 /dawg /opt/dawg
# A second, UNSHADOWED copy of the build's DAWGs: the /opt/dawg volume mount hides the
# image's copy there, so this is where engine.DeliverVersion reads the build version from
# to add it (add-only) to the volume on boot — the deploy-delivers path that lets a bumped
# BACKEND_DICT_VERSION go live for new games without an admin upload (ARCHITECTURE.md §5).
COPY --from=dawg --chown=65532:65532 /dawg /opt/dawg-seed
ENV BACKEND_DICT_DIR=/opt/dawg
ENV BACKEND_DICT_SEED_DIR=/opt/dawg-seed
ENV BACKEND_DICT_VERSION=${DICT_VERSION}
ENTRYPOINT ["/usr/local/bin/backend"]
+11 -4
View File
@@ -111,6 +111,12 @@ func run(ctx context.Context, cfg config.Config, logger *zap.Logger) error {
}
logger.Info("database migrations applied")
// Deliver the build's dictionary version onto the persistent volume if a redeploy
// bumped it (add-only; old versions in-flight games pin are untouched), so the new
// dictionary is resident and can be activated below without an admin upload.
if err := engine.DeliverVersion(cfg.Game.DictDir, cfg.Game.DictSeedDir, cfg.Game.DictVersion); err != nil {
return fmt.Errorf("deliver dictionary version: %w", err)
}
registry, err := engine.OpenWithVersions(cfg.Game.DictDir, cfg.Game.DictVersion)
if err != nil {
return fmt.Errorf("load dictionaries: %w", err)
@@ -118,6 +124,7 @@ func run(ctx context.Context, cfg config.Config, logger *zap.Logger) error {
defer func() { _ = registry.Close() }()
logger.Info("dictionaries loaded",
zap.String("dir", cfg.Game.DictDir),
zap.String("seed_dir", cfg.Game.DictSeedDir),
zap.String("version", cfg.Game.DictVersion))
// Admin console: an optional backend client to the Telegram connector
@@ -147,10 +154,10 @@ func run(ctx context.Context, cfg config.Config, logger *zap.Logger) error {
accounts := account.NewStore(db)
accounts.SetMetrics(tel.MeterProvider().Meter("scrabble/backend/account"))
games := game.NewService(game.NewStore(db), accounts, registry, cfg.Game, logger)
// Reconcile the persisted active dictionary version with the registry: a
// version activated through the admin console (and written to the dictionary
// volume) is adopted again after a restart; otherwise the configured seed
// version is kept and persisted (docs/ARCHITECTURE.md §5).
// Reconcile the active dictionary version with the registry: the build version
// (delivered above) becomes active so a redeploy that bumped it goes live for new
// games, except a newer version installed out-of-band through the admin console,
// which is not downgraded by a restart (docs/ARCHITECTURE.md §5).
if err := games.InitActiveVersion(ctx); err != nil {
return fmt.Errorf("init active dictionary version: %w", err)
}
+19 -5
View File
@@ -10,6 +10,7 @@ import (
"database/sql"
"errors"
"fmt"
"math/rand/v2"
"strings"
"time"
@@ -525,8 +526,13 @@ func (s *Store) create(ctx context.Context, kind, externalID string, seed provis
return created, nil
}
// guestDisplayName is the display name stamped on a freshly provisioned guest.
const guestDisplayName = "Guest"
// guestDisplayName mints the display name stamped on a freshly provisioned guest: "Guest" plus a
// random six-digit suffix (e.g. "Guest042317"), so a guest is distinguishable to opponents — in a
// random match the other player sees a distinct name rather than every guest reading a bare
// "Guest". Not an identifier and not unique (collisions are harmless — it is a label only).
func guestDisplayName() string {
return fmt.Sprintf("Guest%06d", rand.IntN(1_000_000))
}
// ProvisionGuest creates a fresh ephemeral guest account: a durable row carrying
// no identity, flagged is_guest, so it can hold a session and a game seat (both
@@ -534,7 +540,7 @@ const guestDisplayName = "Guest"
// and history. Guests are not reused — each bootstrap mints a new account. browserTZ
// (the client's detected "±HH:MM" UTC offset) seeds the guest's time zone, falling
// back to the 'UTC' default when empty or malformed.
func (s *Store) ProvisionGuest(ctx context.Context, browserTZ string) (Account, error) {
func (s *Store) ProvisionGuest(ctx context.Context, browserTZ, language string) (Account, error) {
accountID, err := uuid.NewV7()
if err != nil {
return Account{}, fmt.Errorf("account: new guest id: %w", err)
@@ -543,9 +549,17 @@ func (s *Store) ProvisionGuest(ctx context.Context, browserTZ string) (Account,
if tz == "" {
tz = "UTC"
}
// Seed the interface language from the client's detected locale (validated to a supported
// one), so a fresh guest's language-dependent server content — the ad banner, bot messages —
// is right from first contact rather than the 'en' column default until the client's later
// language reconcile catches up. An unsupported or absent code keeps the 'en' default.
lang := supportedLanguage(language)
if lang == "" {
lang = "en"
}
stmt := table.Accounts.
INSERT(table.Accounts.AccountID, table.Accounts.DisplayName, table.Accounts.IsGuest, table.Accounts.TimeZone).
VALUES(accountID, guestDisplayName, true, tz).
INSERT(table.Accounts.AccountID, table.Accounts.DisplayName, table.Accounts.IsGuest, table.Accounts.TimeZone, table.Accounts.PreferredLanguage).
VALUES(accountID, guestDisplayName(), true, tz, lang).
RETURNING(table.Accounts.AllColumns)
var row model.Accounts
@@ -21,7 +21,29 @@
<div><button type="submit">Add</button></div>
</form>
</section>
<section class="panel"><h2>Rewarded ads (watch for chips)</h2>
<p class="note">The chips a player earns per rewarded-video view (VK only), plus the per-day and per-hour caps that bound free chips — <strong>0 payout turns rewarded off</strong>. This is the shared reward config, not a product.</p>
<form class="form col" method="post" action="/_gm/catalog/reward">
<label>Chips per view <input type="number" name="reward_payout" min="0" value="{{.RewardPayout}}"></label>
<label>Daily cap <input type="number" name="reward_daily" min="0" value="{{.RewardDailyCap}}"></label>
<label>Hourly cap <input type="number" name="reward_hourly" min="0" value="{{.RewardHourlyCap}}"></label>
<div><button type="submit">Save</button></div>
</form>
</section>
<section class="panel"><h2>Payment availability (kill switch)</h2>
<p class="note">Turn purchases off on a rail/channel and show the user a localized reason on their next attempt. Unchecked = off; an empty message shows a built-in &ldquo;temporarily unavailable&rdquo;. Fail-open: a rail you never touch stays on. A per-user &ldquo;allow&rdquo; override (on a user card) bypasses this switch.</p>
{{range .Rails}}
<form class="form col" method="post" action="/_gm/catalog/rail-status">
<input type="hidden" name="rail" value="{{.Rail}}">
<label><input type="checkbox" name="enabled" {{if .Enabled}}checked{{end}}> <code>{{.Rail}}</code> &mdash; purchases enabled</label>
<label>Message RU <input type="text" name="message_ru" maxlength="200" value="{{.MessageRU}}"></label>
<label>Message EN <input type="text" name="message_en" maxlength="200" value="{{.MessageEN}}"></label>
<div><button type="submit">Save</button></div>
</form>
{{end}}
</section>
<section class="panel"><h2>Products</h2>
<p class="note">Showing {{if .ShowAll}}<strong>all</strong> products (active + archived) — <a href="/_gm/catalog">active only</a>{{else}}<strong>active</strong> products — <a href="/_gm/catalog?all=1">show all (incl. archived)</a>{{end}}.</p>
<table class="list">
<thead><tr><th>Title</th><th>Status</th><th>Atoms</th><th>Prices</th><th></th></tr></thead>
<tbody>
@@ -72,14 +72,24 @@
{{if or .Finance.Abuse .Finance.Loss}}<li><b>Refund risk</b> <span class="warn">{{if .Finance.Abuse}}abuse-flagged{{end}}{{if .Finance.Loss}} · loss {{.Finance.Loss}} chips{{end}}</span></li>{{end}}
</ul>
{{else}}<p class="note">no balances or benefits</p>{{end}}
<h3>Payment override</h3>
<form class="form" method="post" action="/_gm/users/{{.ID}}/purchase-override">
<label>Purchases for this account
<select name="override">
<option value="default" {{if eq .PurchaseOverride "default"}}selected{{end}}>Default (follow the rail switch)</option>
<option value="allow" {{if eq .PurchaseOverride "allow"}}selected{{end}}>Always allow (bypasses the rail switch only, not security)</option>
<option value="deny" {{if eq .PurchaseOverride "deny"}}selected{{end}}>Always deny</option>
</select></label>
<div><button type="submit">Save</button></div>
</form>
<h3>Ledger</h3>
{{$uid := .ID}}
{{if .Finance.Ledger}}
<table class="list">
<thead><tr><th>Time</th><th>Kind</th><th>Source</th><th>Origin</th><th>Chips</th><th>Order</th><th>Provider</th><th>Detail</th><th></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>Shop</th><th>Detail</th><th></th></tr></thead>
<tbody>
{{range .Finance.Ledger}}
<tr><td>{{.At}}</td><td>{{.Kind}}</td><td>{{.Source}}</td><td>{{.Origin}}</td><td>{{.ChipsDelta}}</td><td>{{if .Order}}<code>{{.Order}}</code>{{end}}</td><td>{{.Provider}}</td><td>{{if .Snapshot}}<code>{{.Snapshot}}</code>{{end}}</td>
<tr><td>{{.At}}</td><td>{{.Kind}}</td><td>{{.Source}}</td><td>{{.Origin}}</td><td>{{.ChipsDelta}}</td><td>{{if .Order}}<code>{{.Order}}</code>{{end}}</td><td>{{.Provider}}</td><td>{{.Shop}}</td><td>{{if .Snapshot}}<code>{{.Snapshot}}</code>{{end}}</td>
<td>{{if and (eq .Kind "fund") .Order}}<form class="form" method="post" action="/_gm/users/{{$uid}}/refund" onsubmit="return confirm('Refund this order in full? Record the money refund on the rail first; this revokes the chips (floored at 0).')"><input type="hidden" name="order_id" value="{{.Order}}"><button type="submit">Refund</button></form>{{end}}</td></tr>
{{end}}
</tbody>
+27 -2
View File
@@ -198,6 +198,9 @@ type UserDetailView struct {
// Finance is the account's payments picture (balances, benefits, refund risk, ledger). Present
// is false when the payments domain is unwired.
Finance FinanceView
// PurchaseOverride is the account's per-account purchase override ("default"/"allow"/"deny"),
// shown in and edited from the user card's payment-override control.
PurchaseOverride string
// Grant is the admin-grant panel (origin picker + grantable products). Present is false when the
// payments domain is unwired.
Grant GrantFormView
@@ -232,8 +235,8 @@ type BenefitRow struct {
}
// LedgerRow is one append-only ledger entry: its kind, funding source / benefit origin, signed chip
// delta, the product / order / provider it references (empty when none), the raw snapshot JSON and
// the pre-formatted time.
// delta, the product / order / provider / direct-rail shop it references (empty when none), the raw
// snapshot JSON and the pre-formatted time.
type LedgerRow struct {
Kind string
Source string
@@ -242,6 +245,7 @@ type LedgerRow struct {
Product string
Order string
Provider string
Shop string
Snapshot string
At string
}
@@ -677,6 +681,27 @@ type FeedbackDetailView struct {
// CatalogView is the product-catalog list page.
type CatalogView struct {
Products []ProductRow
// ShowAll reports whether archived products are listed alongside active ones (the active/all
// toggle); it drives the toggle link and the list heading.
ShowAll bool
// RewardPayout / RewardDailyCap / RewardHourlyCap are the rewarded-video config (chips earned per
// view and the per-day / per-hour anti-abuse caps), shown in and edited from the page's
// rewarded-ads form. A 0 payout means rewarded is inert.
RewardPayout int
RewardDailyCap int
RewardHourlyCap int
// Rails is the per-rail operational kill-switch state (enabled + per-language off-message), shown
// in and edited from the page's payment-availability form.
Rails []RailStatusRow
}
// RailStatusRow is one payment rail's operational availability in the kill-switch editor: the rail
// key, whether purchases are enabled, and the operator's per-language off-message shown to the user.
type RailStatusRow struct {
Rail string
Enabled bool
MessageRU string
MessageEN string
}
// ProductRow is one product in the catalog list: its composition, prices, the archived flag
+34 -11
View File
@@ -65,9 +65,9 @@ type Config struct {
// RendererURL is the base URL of the internal image-render sidecar (e.g.
// http://renderer:8090). Empty disables the PNG export artifact.
RendererURL string
// Robokassa configures the direct-rail (RUB) payment provider. An empty MerchantLogin
// leaves the direct order and Result-callback endpoints unregistered.
Robokassa robokassa.Config
// Robokassa configures the direct-rail (RUB) payment provider — one merchant shop per channel
// (D42). An empty set leaves the direct order and Result-callback endpoints unregistered.
Robokassa robokassa.Shops
}
// Defaults applied when the corresponding environment variable is unset.
@@ -106,6 +106,7 @@ func Load() (Config, error) {
gm := game.DefaultConfig()
gm.DictDir = envOr("BACKEND_DICT_DIR", gm.DictDir)
gm.DictVersion = envOr("BACKEND_DICT_VERSION", gm.DictVersion)
gm.DictSeedDir = envOr("BACKEND_DICT_SEED_DIR", gm.DictSeedDir)
if gm.TimeoutSweepInterval, err = envDuration("BACKEND_GAME_TIMEOUT_SWEEP_INTERVAL", gm.TimeoutSweepInterval); err != nil {
return Config{}, err
}
@@ -157,11 +158,19 @@ func Load() (Config, error) {
AdminTo: os.Getenv("BACKEND_ADMIN_EMAIL"),
}
robo := robokassa.Config{
MerchantLogin: os.Getenv("BACKEND_ROBOKASSA_MERCHANT_LOGIN"),
Password1: os.Getenv("BACKEND_ROBOKASSA_PASSWORD1"),
Password2: os.Getenv("BACKEND_ROBOKASSA_PASSWORD2"),
IsTest: os.Getenv("BACKEND_ROBOKASSA_TEST") == "1",
// Robokassa direct rail: one merchant shop per channel (D42). The legacy single-shop vars seed
// the web channel so existing deploys keep working; the per-channel vars add the rest. A shop
// with no MerchantLogin is dropped (the rail stays dormant when none is configured).
shops := robokassa.Shops{}
web := robokassaShop("BACKEND_ROBOKASSA_WEB")
if web.MerchantLogin == "" {
web = robokassaShop("BACKEND_ROBOKASSA") // legacy single-shop credentials seed the web channel
}
if web.MerchantLogin != "" {
shops[robokassa.ChannelWeb] = web
}
if android := robokassaShop("BACKEND_ROBOKASSA_ANDROID"); android.MerchantLogin != "" {
shops[robokassa.ChannelAndroid] = android
}
c := Config{
@@ -181,7 +190,7 @@ func Load() (Config, error) {
GuestRetention: guestRetention,
ExportSignKey: os.Getenv("BACKEND_EXPORT_SIGN_KEY"),
RendererURL: os.Getenv("BACKEND_RENDERER_URL"),
Robokassa: robo,
Robokassa: shops,
}
if err := c.validate(); err != nil {
return Config{}, err
@@ -234,8 +243,10 @@ func (c Config) validate() error {
return fmt.Errorf("config: BACKEND_PUBLIC_BASE_URL %q must be an absolute URL (scheme://host)", c.PublicBaseURL)
}
}
if c.Robokassa.MerchantLogin != "" && (c.Robokassa.Password1 == "" || c.Robokassa.Password2 == "") {
return fmt.Errorf("config: BACKEND_ROBOKASSA_PASSWORD1 and BACKEND_ROBOKASSA_PASSWORD2 must be set when BACKEND_ROBOKASSA_MERCHANT_LOGIN is")
for channel, shop := range c.Robokassa {
if shop.Password1 == "" || shop.Password2 == "" {
return fmt.Errorf("config: robokassa shop %q: password1 and password2 must be set when its merchant login is", channel)
}
}
return nil
}
@@ -276,3 +287,15 @@ func envDuration(key string, fallback time.Duration) (time.Duration, error) {
}
return d, nil
}
// robokassaShop reads a Robokassa shop's four credentials from the environment under prefix (e.g.
// "BACKEND_ROBOKASSA_WEB" → _MERCHANT_LOGIN / _PASSWORD1 / _PASSWORD2 / _TEST). A missing
// MerchantLogin yields a zero Config the caller drops.
func robokassaShop(prefix string) robokassa.Config {
return robokassa.Config{
MerchantLogin: os.Getenv(prefix + "_MERCHANT_LOGIN"),
Password1: os.Getenv(prefix + "_PASSWORD1"),
Password2: os.Getenv(prefix + "_PASSWORD2"),
IsTest: os.Getenv(prefix+"_TEST") == "1",
}
}
+92
View File
@@ -0,0 +1,92 @@
package engine
import (
"errors"
"fmt"
"io"
"os"
"path/filepath"
)
// DeliverVersion makes the build's dictionary version resident on the persistent
// dictionary volume without disturbing the versions in-flight games already pin.
//
// The volume is seeded from the image once and never re-seeded, and the image's
// /opt/dawg is shadowed by that volume mount, so a redeploy that bumped
// BACKEND_DICT_VERSION cannot otherwise make the new DAWGs reachable at runtime.
// The image therefore keeps an unshadowed read-only copy at seedDir, and this
// copies it into dictDir/<version>/ when the version is not already present —
// neither the flat seed's own recorded label nor an existing version
// subdirectory. It is add-only and idempotent: the flat seed and any prior admin
// uploads are never touched, so old games keep the version they pin while the new
// one becomes resident and activatable (see game.Service.InitActiveVersion). A
// blank seedDir disables delivery (the pre-existing seed-only behaviour).
func DeliverVersion(dictDir, seedDir, version string) error {
if seedDir == "" || version == "" {
return nil
}
target := filepath.Join(dictDir, version)
if _, err := os.Stat(target); err == nil {
return nil // already delivered on a prior boot
} else if !errors.Is(err, os.ErrNotExist) {
return fmt.Errorf("engine: stat delivered dictionary %s: %w", target, err)
}
// A fresh volume is populated from the image and recorded as this version by the
// marker: it is the flat dir, so there is nothing to deliver. resolveSeedVersion
// records the label on first use, matching OpenWithVersions.
seed, err := resolveSeedVersion(dictDir, version)
if err != nil {
return err
}
if seed == version {
return nil
}
// Stage into a dot-prefixed directory (skipped by OpenWithVersions' version scan)
// then rename, so a crash mid-copy never leaves a half-populated version subdir.
staging := filepath.Join(dictDir, ".staging-deliver-"+version)
if err := os.RemoveAll(staging); err != nil {
return fmt.Errorf("engine: clear deliver staging %s: %w", staging, err)
}
if err := os.MkdirAll(staging, 0o755); err != nil {
return fmt.Errorf("engine: create deliver staging %s: %w", staging, err)
}
for _, file := range dictFiles {
src := filepath.Join(seedDir, file)
if _, err := os.Stat(src); errors.Is(err, os.ErrNotExist) {
continue // a variant absent from the seed is simply absent under this version
} else if err != nil {
_ = os.RemoveAll(staging)
return fmt.Errorf("engine: stat seed dawg %s: %w", src, err)
}
if err := copyFile(src, filepath.Join(staging, file)); err != nil {
_ = os.RemoveAll(staging)
return err
}
}
if err := os.Rename(staging, target); err != nil {
_ = os.RemoveAll(staging)
return fmt.Errorf("engine: publish delivered dictionary %s: %w", target, err)
}
return nil
}
// copyFile copies a single file, truncating the destination.
func copyFile(src, dst string) error {
in, err := os.Open(src)
if err != nil {
return fmt.Errorf("engine: open %s: %w", src, err)
}
defer func() { _ = in.Close() }()
out, err := os.OpenFile(dst, os.O_WRONLY|os.O_CREATE|os.O_TRUNC, 0o644)
if err != nil {
return fmt.Errorf("engine: create %s: %w", dst, err)
}
if _, err := io.Copy(out, in); err != nil {
_ = out.Close()
return fmt.Errorf("engine: copy %s -> %s: %w", src, dst, err)
}
if err := out.Close(); err != nil {
return fmt.Errorf("engine: finalise %s: %w", dst, err)
}
return nil
}
+68
View File
@@ -0,0 +1,68 @@
package engine
import (
"os"
"path/filepath"
"testing"
)
func TestDeliverVersion(t *testing.T) {
// A blank seed directory disables delivery (the pre-existing seed-only behaviour).
if err := DeliverVersion(t.TempDir(), "", "v1.3.1"); err != nil {
t.Fatalf("blank seedDir: %v", err)
}
// An existing volume flat-seeded as v1.3.0 gets v1.3.1 delivered as a subdirectory,
// add-only: the flat seed's marker is left intact and the new version's DAWGs land.
dict := t.TempDir()
seed := t.TempDir()
if err := os.WriteFile(filepath.Join(dict, seedMarkerFile), []byte("v1.3.0\n"), 0o644); err != nil {
t.Fatal(err)
}
for _, f := range dictFiles {
if err := os.WriteFile(filepath.Join(seed, f), []byte("dawg:"+f), 0o644); err != nil {
t.Fatal(err)
}
}
if err := DeliverVersion(dict, seed, "v1.3.1"); err != nil {
t.Fatalf("deliver v1.3.1: %v", err)
}
for _, f := range dictFiles {
got, err := os.ReadFile(filepath.Join(dict, "v1.3.1", f))
if err != nil {
t.Fatalf("delivered %s: %v", f, err)
}
if string(got) != "dawg:"+f {
t.Errorf("delivered %s = %q, want the seed bytes", f, got)
}
}
if m, _ := os.ReadFile(filepath.Join(dict, seedMarkerFile)); string(m) != "v1.3.0\n" {
t.Errorf("flat seed marker relabelled to %q (delivery must be add-only)", m)
}
// Idempotent: a second call is a no-op and leaves no staging directory behind.
if err := DeliverVersion(dict, seed, "v1.3.1"); err != nil {
t.Fatalf("re-deliver v1.3.1: %v", err)
}
if entries, _ := os.ReadDir(dict); hasStaging(entries) {
t.Errorf("a staging directory survived delivery")
}
// On a fresh directory the build version becomes the flat seed, so nothing is delivered
// as a redundant subdirectory.
fresh := t.TempDir()
if err := DeliverVersion(fresh, seed, "v1.3.1"); err != nil {
t.Fatalf("fresh deliver: %v", err)
}
if _, err := os.Stat(filepath.Join(fresh, "v1.3.1")); !os.IsNotExist(err) {
t.Errorf("fresh volume got a redundant v1.3.1 subdir; it should be the flat seed")
}
}
func hasStaging(entries []os.DirEntry) bool {
for _, e := range entries {
if len(e.Name()) >= len(".staging") && e.Name()[:len(".staging")] == ".staging" {
return true
}
}
return false
}
+5 -5
View File
@@ -25,11 +25,11 @@ const seedMarkerFile = ".seed_version"
// BACKEND_DICT_VERSION) and return it — the seed of a fresh volume;
// - already-seeded directory: return the recorded marker and ignore bootVersion.
//
// So bumping the build seed on a live volume is a harmless no-op (it only takes
// effect on a future fresh volume) instead of relabelling the already-seeded bytes —
// which would void games pinned to the prior label and mis-serve new ones. New games
// still pin the active version (DB-persisted, set by the admin console), which is the
// real way a running contour moves to a new release.
// So a later build seed never relabels the already-seeded flat bytes — which would void
// games pinned to the prior label and mis-serve new ones. The new build version is instead
// delivered as a NEW subdirectory (DeliverVersion) and made active (Service.InitActiveVersion),
// so a redeploy still moves new games to it while old games keep the flat label. New games
// pin the active version (DB-persisted); a console upload can also set it out-of-band.
//
// A directory that cannot be written makes the first record fail; that also breaks
// the admin console (which writes version subdirectories here), so the error is
+7
View File
@@ -18,6 +18,13 @@ type Config struct {
// DictVersion labels the dictionary version new games pin. Sourced from
// BACKEND_DICT_VERSION.
DictVersion string
// DictSeedDir holds the image's read-only copy of the build's DictVersion DAWGs,
// on a path the persistent DictDir volume does NOT shadow. On boot the backend
// delivers this version into DictDir/<DictVersion>/ if absent (add-only), so a
// redeploy that bumped DICT_VERSION makes the new dictionary resident and activatable
// without disturbing the versions in-flight games already pin. Sourced from
// BACKEND_DICT_SEED_DIR; empty disables delivery (the pre-existing seed-only behaviour).
DictSeedDir string
// TimeoutSweepInterval is how often the sweeper scans for overdue turns.
// Sourced from BACKEND_GAME_TIMEOUT_SWEEP_INTERVAL.
TimeoutSweepInterval time.Duration
+19 -9
View File
@@ -232,21 +232,31 @@ func (svc *Service) ActiveVersion() string {
}
// InitActiveVersion reconciles the persisted active dictionary version with the
// registry at startup. If a version was persisted and is resident, it becomes the
// active version; otherwise the configured seed version is kept and persisted as
// the initial active version. Call once during wiring, before serving traffic.
// registry at startup and makes the build's version (BACKEND_DICT_VERSION, delivered
// resident by engine.DeliverVersion) authoritative: a redeploy that bumped the build
// version thus activates it for new games without any admin step, while old games keep
// the version they pin. The one exception is a version installed out-of-band through
// the admin console that is NEWER than the build version and still resident — it is
// kept, so a restart on an older image never downgrades a hotfix. A build version that
// is not resident (delivery disabled or a partial state) falls back to a resident
// persisted version. The chosen version is persisted so the admin console reflects it.
// Call once during wiring, before serving traffic.
func (svc *Service) InitActiveVersion(ctx context.Context) error {
persisted, ok, err := svc.store.GetActiveDictVersion(ctx)
if err != nil {
return err
}
if ok && svc.versionResident(persisted) {
svc.verMu.Lock()
svc.version = persisted
svc.verMu.Unlock()
return nil
target := svc.activeVersion() // the build seed version (config.DictVersion)
if ok && svc.versionResident(persisted) && compareDictVersions(persisted, target) > 0 {
target = persisted // a newer out-of-band install wins over the build version
}
return svc.store.SetActiveDictVersion(ctx, svc.activeVersion())
if !svc.versionResident(target) && ok && svc.versionResident(persisted) {
target = persisted // the build version is unloadable — keep a resident one
}
svc.verMu.Lock()
svc.version = target
svc.verMu.Unlock()
return svc.store.SetActiveDictVersion(ctx, target)
}
// SetActiveVersion records version as the active dictionary version, persisting it
+45
View File
@@ -0,0 +1,45 @@
package game
import (
"strconv"
"strings"
)
// compareDictVersions orders two dictionary version labels of the shape "vMAJOR.MINOR.PATCH"
// numerically, returning -1, 0 or +1 as a is less than, equal to or greater than b. Labels
// that do not parse fall back to a byte comparison, so the ordering is always total (a mixed
// or malformed pair never crashes the boot-time active-version reconcile).
func compareDictVersions(a, b string) int {
pa, oka := parseDictVersion(a)
pb, okb := parseDictVersion(b)
if !oka || !okb {
return strings.Compare(a, b)
}
for i := range pa {
if pa[i] != pb[i] {
if pa[i] < pb[i] {
return -1
}
return 1
}
}
return 0
}
// parseDictVersion parses a "vMAJOR.MINOR.PATCH" label into its three numeric parts, reporting
// whether it matched that shape.
func parseDictVersion(v string) ([3]int, bool) {
parts := strings.Split(strings.TrimPrefix(v, "v"), ".")
if len(parts) != 3 {
return [3]int{}, false
}
var out [3]int
for i, p := range parts {
n, err := strconv.Atoi(p)
if err != nil {
return [3]int{}, false
}
out[i] = n
}
return out, true
}
+37
View File
@@ -0,0 +1,37 @@
package game
import "testing"
func TestCompareDictVersions(t *testing.T) {
cases := []struct {
a, b string
want int
}{
{"v1.3.0", "v1.3.1", -1},
{"v1.3.1", "v1.3.0", 1},
{"v1.3.1", "v1.3.1", 0},
{"v1.3.9", "v1.3.10", -1}, // numeric, not lexical
{"v1.10.0", "v1.9.0", 1},
{"v2.0.0", "v1.9.9", 1},
{"1.3.1", "v1.3.1", 0}, // the leading v is optional
// Non-semver falls back to a byte comparison, keeping the ordering total.
{"weird", "weird", 0},
{"a", "b", -1},
}
for _, c := range cases {
if got := compareDictVersions(c.a, c.b); sign(got) != c.want {
t.Errorf("compareDictVersions(%q, %q) = %d, want sign %d", c.a, c.b, got, c.want)
}
}
}
func sign(n int) int {
switch {
case n < 0:
return -1
case n > 0:
return 1
default:
return 0
}
}
+23 -3
View File
@@ -5,6 +5,7 @@ package inttest
import (
"context"
"errors"
"regexp"
"testing"
"time"
@@ -229,21 +230,40 @@ func TestProvisionSeedsTimeZone(t *testing.T) {
t.Errorf("TimeZone = %q, want UTC fallback for a malformed offset", bad.TimeZone)
}
// A guest is seeded its detected offset; an empty one keeps the UTC default.
guest, err := store.ProvisionGuest(ctx, "-05:30")
// A guest is seeded its detected offset and interface language; an empty offset keeps the
// UTC default and an empty/unsupported language keeps the 'en' default.
guest, err := store.ProvisionGuest(ctx, "-05:30", "ru")
if err != nil {
t.Fatalf("provision guest: %v", err)
}
if guest.TimeZone != "-05:30" {
t.Errorf("guest TimeZone = %q, want the seeded -05:30", guest.TimeZone)
}
plainGuest, err := store.ProvisionGuest(ctx, "")
if guest.PreferredLanguage != "ru" {
t.Errorf("guest PreferredLanguage = %q, want the seeded ru", guest.PreferredLanguage)
}
// A guest's display name is "Guest" + a random six-digit suffix (distinct to opponents).
if m, _ := regexp.MatchString(`^Guest\d{6}$`, guest.DisplayName); !m {
t.Errorf("guest DisplayName = %q, want Guest + 6 digits", guest.DisplayName)
}
plainGuest, err := store.ProvisionGuest(ctx, "", "")
if err != nil {
t.Fatalf("provision plain guest: %v", err)
}
if plainGuest.TimeZone != "UTC" {
t.Errorf("plain guest TimeZone = %q, want UTC default", plainGuest.TimeZone)
}
if plainGuest.PreferredLanguage != "en" {
t.Errorf("plain guest PreferredLanguage = %q, want en default", plainGuest.PreferredLanguage)
}
// An unsupported locale falls back to the 'en' default rather than persisting a junk value.
frGuest, err := store.ProvisionGuest(ctx, "", "fr-FR")
if err != nil {
t.Fatalf("provision fr guest: %v", err)
}
if frGuest.PreferredLanguage != "en" {
t.Errorf("unsupported-locale guest PreferredLanguage = %q, want en default", frGuest.PreferredLanguage)
}
}
// TestProvisionTelegramUnknownLanguageDefaults checks an unsupported Telegram
@@ -16,7 +16,7 @@ import (
// productByTitle finds a catalog product by its (unique in the test) title.
func productByTitle(t *testing.T, pay *payments.Service, title string) payments.AdminProduct {
t.Helper()
all, err := pay.AdminCatalog(context.Background())
all, err := pay.AdminCatalog(context.Background(), true)
if err != nil {
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") {
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)
}
@@ -95,3 +95,59 @@ func TestConsoleCatalogEditor(t *testing.T) {
t.Fatalf("delete transacted = %d, has 'Cannot delete' = %v", code, strings.Contains(body, "Cannot delete"))
}
}
// TestConsoleCatalogActiveToggle checks the catalog console lists only active products by default and
// includes the archived ones under ?all=1 (the active/all toggle).
func TestConsoleCatalogActiveToggle(t *testing.T) {
srv, _, _ := bannerServer(t)
h := srv.Handler()
const origin = "http://admin.test"
const catalog = "http://admin.test/_gm/catalog"
// An active value and an archived one (created without the active flag).
if code, _ := consoleDo(h, http.MethodPost, catalog, "title=toggle-active&hints=5&price_chip=50&active=true", origin); code != http.StatusOK {
t.Fatal("create active failed")
}
if code, _ := consoleDo(h, http.MethodPost, catalog, "title=toggle-archived&hints=9&price_chip=90", origin); code != http.StatusOK {
t.Fatal("create archived failed")
}
// Default view: active only.
if _, body := consoleDo(h, http.MethodGet, catalog, "", ""); !strings.Contains(body, "toggle-active") || strings.Contains(body, "toggle-archived") {
t.Errorf("default view: active listed=%v, archived listed=%v (want true/false)",
strings.Contains(body, "toggle-active"), strings.Contains(body, "toggle-archived"))
}
// ?all=1: active and archived.
if _, body := consoleDo(h, http.MethodGet, catalog+"?all=1", "", ""); !strings.Contains(body, "toggle-active") || !strings.Contains(body, "toggle-archived") {
t.Errorf("all view: active listed=%v, archived listed=%v (want true/true)",
strings.Contains(body, "toggle-active"), strings.Contains(body, "toggle-archived"))
}
}
// TestConsoleRewardConfig checks the rewarded-ads config form on the catalog page: a POST updates the
// payout and caps, the page pre-fills the current values, and a negative value is refused.
func TestConsoleRewardConfig(t *testing.T) {
srv, _, pay := bannerServer(t)
h := srv.Handler()
const origin = "http://admin.test"
const reward = "http://admin.test/_gm/catalog/reward"
// Set the payout and caps.
if code, body := consoleDo(h, http.MethodPost, reward, "reward_payout=7&reward_daily=40&reward_hourly=9", origin); code != http.StatusOK || !strings.Contains(body, "Saved") {
t.Fatalf("set reward = %d, has 'Saved' = %v", code, strings.Contains(body, "Saved"))
}
if payout, daily, hourly, err := pay.RewardConfig(context.Background()); err != nil || payout != 7 || daily != 40 || hourly != 9 {
t.Fatalf("reward config = %d/%d/%d (err %v), want 7/40/9", payout, daily, hourly, err)
}
// The catalog page renders the form pre-filled with the current payout.
if _, body := consoleDo(h, http.MethodGet, "http://admin.test/_gm/catalog", "", ""); !strings.Contains(body, "reward_payout") || !strings.Contains(body, `value="7"`) {
t.Error("catalog page did not render the reward form with the current payout")
}
// A negative value is refused (the service validates non-negativity).
if code, body := consoleDo(h, http.MethodPost, reward, "reward_payout=-1", origin); code != http.StatusOK || !strings.Contains(body, "non-negative") {
t.Errorf("negative payout = %d, has 'non-negative' = %v", code, strings.Contains(body, "non-negative"))
}
}
+1 -1
View File
@@ -216,7 +216,7 @@ func TestConfirmCodeClearsGuest(t *testing.T) {
mailer := &capturingMailer{}
svc := account.NewEmailService(store, mailer, "https://erudit-game.ru")
guest, err := store.ProvisionGuest(ctx, "")
guest, err := store.ProvisionGuest(ctx, "", "")
if err != nil {
t.Fatalf("provision guest: %v", err)
}
@@ -103,14 +103,31 @@ func TestDictionaryUpdateFlow(t *testing.T) {
t.Errorf("installed dawg missing on disk: %v", err)
}
// The choice survives a restart: a fresh service over the same DB and registry
// re-adopts the persisted active version.
// The choice survives a restart on the SAME build: an out-of-band admin install newer
// than the build version is not downgraded (a restart on an older image keeps the hotfix).
restarted := newGameServiceOn(dir, "v1.0.0", reg)
if err := restarted.InitActiveVersion(ctx); err != nil {
t.Fatalf("restart init: %v", err)
}
if got := restarted.ActiveVersion(); got != "v9.9.9" {
t.Errorf("restarted active version = %q, want v9.9.9 (persisted)", got)
t.Errorf("restarted active version = %q, want v9.9.9 (a newer out-of-band install is kept)", got)
}
// A redeploy that delivered a build version NEWER than the out-of-band one activates it
// for new games without any admin step (the deploy-delivers path — docs/ARCHITECTURE.md §5).
if _, err := reg.LoadAvailable(dir, "v10.0.0"); err != nil {
t.Fatalf("make v10.0.0 resident: %v", err)
}
deployed := newGameServiceOn(dir, "v10.0.0", reg)
if err := deployed.InitActiveVersion(ctx); err != nil {
t.Fatalf("deploy init: %v", err)
}
if got := deployed.ActiveVersion(); got != "v10.0.0" {
t.Errorf("deployed active version = %q, want v10.0.0 (a newer build version wins)", got)
}
// Restore the out-of-band active version so the assertions below still read v9.9.9.
if err := restarted.SetActiveVersion(ctx, "v9.9.9"); err != nil {
t.Fatalf("restore active: %v", err)
}
// New games pin the new version; the in-progress game keeps its own, still resident.
+1 -1
View File
@@ -431,7 +431,7 @@ func TestConfirmByTokenLinkClearsGuest(t *testing.T) {
store := account.NewStore(testDB)
mailer := &capturingMailer{}
svc := account.NewEmailService(store, mailer, "https://erudit-game.ru")
guest, err := store.ProvisionGuest(ctx, "")
guest, err := store.ProvisionGuest(ctx, "", "")
if err != nil {
t.Fatalf("provision guest: %v", err)
}
+1 -1
View File
@@ -154,7 +154,7 @@ func provisionAccount(t *testing.T) uuid.UUID {
// provisionGuest creates a fresh ephemeral guest account and returns its id.
func provisionGuest(t *testing.T) uuid.UUID {
t.Helper()
acc, err := account.NewStore(testDB).ProvisionGuest(context.Background(), "")
acc, err := account.NewStore(testDB).ProvisionGuest(context.Background(), "", "")
if err != nil {
t.Fatalf("provision guest: %v", err)
}
+39 -9
View File
@@ -347,8 +347,10 @@ func TestAccountLinkEmailMergeIntoCaller(t *testing.T) {
}
}
// TestAccountLinkGuestInversion merges a guest initiator into the durable account
// that owns the email: the durable account wins and a fresh session is minted.
// TestAccountLinkGuestInversion auto-merges a guest initiator into the durable account that
// owns the email AT THE CONFIRM STEP (no merge confirmation): the guest is retired, the durable
// account wins and a fresh session is minted for it (the client adopts the switch and lands on
// the durable account as if it simply logged in).
func TestAccountLinkGuestInversion(t *testing.T) {
ctx := context.Background()
store := account.NewStore(testDB)
@@ -364,17 +366,18 @@ func TestAccountLinkGuestInversion(t *testing.T) {
t.Fatalf("request: %v", err)
}
code := sixDigit.FindString(mailer.lastBody)
if _, err := links.ConfirmEmail(ctx, guest, email, code); err != nil {
confirm, err := links.ConfirmEmail(ctx, guest, email, code)
if err != nil {
t.Fatalf("confirm: %v", err)
}
merge, err := links.MergeEmail(ctx, guest, email, code)
if err != nil {
t.Fatalf("merge: %v", err)
// A guest initiator does not get a MergeRequired confirmation — the merge runs inline.
if !confirm.Merged || confirm.MergeRequired {
t.Fatalf("confirm = %+v, want an inline (auto) merge", confirm)
}
if merge.PrimaryID != durable {
t.Fatalf("primary = %s, want durable %s", merge.PrimaryID, durable)
if confirm.Merge.PrimaryID != durable {
t.Fatalf("primary = %s, want durable %s", confirm.Merge.PrimaryID, durable)
}
if merge.SwitchedToken == "" {
if confirm.Merge.SwitchedToken == "" {
t.Error("a guest initiator whose durable counterpart wins must get a switched session token")
}
if mergedInto(t, guest) != durable {
@@ -385,6 +388,33 @@ func TestAccountLinkGuestInversion(t *testing.T) {
}
}
// TestAccountLinkGuestAutoMergeActiveGameConflict guards that a guest's auto-merge is REFUSED
// (not silently swallowed) at the confirm step when the guest and the durable account share an
// active game: the caller surfaces ErrActiveGameConflict (mapped to a clear "finish that game
// first" message) rather than seating one player against themselves.
func TestAccountLinkGuestAutoMergeActiveGameConflict(t *testing.T) {
ctx := context.Background()
mailer := &capturingMailer{}
links := newLinkService(mailer)
durable := provisionAccount(t)
email := "conflict-" + uuid.NewString() + "@example.com"
bindEmailIdentity(t, durable, email)
guest := provisionGuest(t)
seatGame(t, []uuid.UUID{durable, guest}, 24*time.Hour) // an active game the two share
if err := links.RequestEmail(ctx, guest, email); err != nil {
t.Fatalf("request: %v", err)
}
code := sixDigit.FindString(mailer.lastBody)
if _, err := links.ConfirmEmail(ctx, guest, email, code); err != accountmerge.ErrActiveGameConflict {
t.Fatalf("confirm = %v, want ErrActiveGameConflict surfaced by the auto-merge", err)
}
if mergedInto(t, guest) != uuid.Nil {
t.Error("a refused auto-merge must not tombstone the guest")
}
}
// TestAccountLinkFreeVK binds a free VK identity (gateway-validated, no code) and
// promotes a guest to durable — the ConfirmVK counterpart of the free-email case.
func TestAccountLinkFreeVK(t *testing.T) {
@@ -0,0 +1,83 @@
//go:build integration
package inttest
import (
"context"
"testing"
"github.com/google/uuid"
"scrabble/backend/internal/payments"
)
// TestPaymentAvailabilityKillSwitch exercises the per-rail kill switch + the per-account override
// end-to-end against Postgres: fail-open, a disabled rail with a localized message, per-rail
// granularity, and the allow/deny/default overrides.
func TestPaymentAvailabilityKillSwitch(t *testing.T) {
ctx := context.Background()
svc := newPaymentsService()
acc := uuid.New()
// Fail-open: an untouched rail is enabled.
if ok, _, err := svc.CanPurchase(ctx, acc, payments.RailDirectWeb, "en"); err != nil || !ok {
t.Fatalf("fail-open: CanPurchase = %v/%v, want true", ok, err)
}
// Disable the web rail with a per-language message → blocked with that message, localized.
if err := svc.SetRailStatus(ctx, payments.RailDirectWeb, payments.RailAvailability{MessageRU: "Чиним", MessageEN: "Fixing"}); err != nil {
t.Fatalf("set rail status: %v", err)
}
if ok, reason, err := svc.CanPurchase(ctx, acc, payments.RailDirectWeb, "ru"); err != nil || ok || reason != "Чиним" {
t.Fatalf("disabled rail (ru): CanPurchase = %v/%q/%v, want false/Чиним", ok, reason, err)
}
if ok, reason, _ := svc.CanPurchase(ctx, acc, payments.RailDirectWeb, "en"); ok || reason != "Fixing" {
t.Fatalf("disabled rail (en): = %v/%q, want false/Fixing", ok, reason)
}
// Per-rail granularity: another rail stays enabled.
if ok, _, _ := svc.CanPurchase(ctx, acc, payments.RailDirectAndroid, "en"); !ok {
t.Fatalf("android rail should stay enabled")
}
// A per-account "allow" override bypasses the disabled rail.
if err := svc.SetPurchaseOverride(ctx, acc, payments.OverrideAllow); err != nil {
t.Fatalf("set override allow: %v", err)
}
if ok, _, _ := svc.CanPurchase(ctx, acc, payments.RailDirectWeb, "en"); !ok {
t.Fatalf("allow override should bypass the disabled rail")
}
if ov, err := svc.PurchaseOverrideFor(ctx, acc); err != nil || ov != payments.OverrideAllow {
t.Fatalf("PurchaseOverrideFor = %v/%v, want allow", ov, err)
}
// A "deny" override blocks even an enabled rail.
if err := svc.SetPurchaseOverride(ctx, acc, payments.OverrideDeny); err != nil {
t.Fatalf("set override deny: %v", err)
}
if ok, reason, _ := svc.CanPurchase(ctx, acc, payments.RailDirectAndroid, "en"); ok || reason == "" {
t.Fatalf("deny override should block the enabled android rail with a reason, got %v/%q", ok, reason)
}
// Clearing the override (default) restores rail-driven behaviour.
if err := svc.SetPurchaseOverride(ctx, acc, payments.OverrideDefault); err != nil {
t.Fatalf("clear override: %v", err)
}
if ov, _ := svc.PurchaseOverrideFor(ctx, acc); ov != payments.OverrideDefault {
t.Fatalf("after clear, override = %v, want default", ov)
}
if ok, _, _ := svc.CanPurchase(ctx, acc, payments.RailDirectAndroid, "en"); !ok {
t.Fatalf("after clearing override, android rail should be enabled again")
}
// RailStatuses reflects the stored web-rail change and fills the rest fail-open.
all, err := svc.RailStatuses(ctx)
if err != nil {
t.Fatalf("rail statuses: %v", err)
}
if all[payments.RailDirectWeb].Enabled {
t.Fatalf("web rail should read disabled")
}
if !all[payments.RailVK].Enabled {
t.Fatalf("untouched vk rail should read enabled")
}
}
+1 -1
View File
@@ -26,7 +26,7 @@ func TestUserListFilter(t *testing.T) {
if err != nil {
t.Fatalf("provision robot: %v", err)
}
guest, err := st.ProvisionGuest(ctx, "")
guest, err := st.ProvisionGuest(ctx, "", "")
if err != nil {
t.Fatalf("provision guest: %v", err)
}
+32 -6
View File
@@ -31,13 +31,39 @@ func NewService(emails *account.EmailService, accounts *account.Store, merger *a
return &Service{emails: emails, accounts: accounts, merger: merger, sessions: sessions}
}
// ConfirmResult reports the outcome of a confirm step. Exactly one of Linked or
// MergeRequired is set; SecondaryID is the account to be retired when a merge is
// required (the caller renders an irreversible-merge confirmation from it).
// ConfirmResult reports the outcome of a confirm step. Exactly one of Linked,
// MergeRequired or Merged is set. SecondaryID is the account to be retired when a merge
// is required (the caller renders an irreversible-merge confirmation from it). Merged is
// set when the caller was a guest, so the merge ran inline (see confirmOrAutoMerge) and
// Merge carries its result (the switched-session token for the surviving durable account).
type ConfirmResult struct {
Linked bool
MergeRequired bool
SecondaryID uuid.UUID
Merged bool
Merge MergeResult
}
// confirmOrAutoMerge decides a required merge at the confirm step. A durable caller gets
// the explicit MergeRequired confirmation (consolidating two real accounts is irreversible
// and consequential — the user must see it). A GUEST caller does not: by the guest-primary
// rule the durable other account survives and the ephemeral guest is retired, folding its
// games/wallet/stats in — from the user's side it is simply "logged into my account", so the
// merge runs inline and the client just adopts the switched session. The active-game guard can
// still refuse (accountmerge.ErrActiveGameConflict), surfaced to the caller unchanged.
func (s *Service) confirmOrAutoMerge(ctx context.Context, callerID, owner uuid.UUID) (ConfirmResult, error) {
caller, err := s.accounts.GetByID(ctx, callerID)
if err != nil {
return ConfirmResult{}, err
}
if !caller.IsGuest {
return ConfirmResult{MergeRequired: true, SecondaryID: owner}, nil
}
res, err := s.merge(ctx, callerID, owner)
if err != nil {
return ConfirmResult{}, err
}
return ConfirmResult{Merged: true, Merge: res}, nil
}
// MergeResult reports a completed merge. PrimaryID is the surviving account.
@@ -67,7 +93,7 @@ func (s *Service) ConfirmEmail(ctx context.Context, accountID uuid.UUID, email,
}
return ConfirmResult{Linked: true}, nil
}
return ConfirmResult{MergeRequired: true, SecondaryID: owner}, nil
return s.confirmOrAutoMerge(ctx, accountID, owner)
}
// MergeEmail re-verifies the code and merges the address's account into the
@@ -103,7 +129,7 @@ func (s *Service) ConfirmTelegram(ctx context.Context, callerID uuid.UUID, exter
if owner == callerID {
return ConfirmResult{Linked: true}, nil
}
return ConfirmResult{MergeRequired: true, SecondaryID: owner}, nil
return s.confirmOrAutoMerge(ctx, callerID, owner)
}
// MergeTelegram merges the account owning a gateway-validated Telegram identity
@@ -150,7 +176,7 @@ func (s *Service) ConfirmVK(ctx context.Context, callerID uuid.UUID, externalID
if owner == callerID {
return ConfirmResult{Linked: true}, nil
}
return ConfirmResult{MergeRequired: true, SecondaryID: owner}, nil
return s.confirmOrAutoMerge(ctx, callerID, owner)
}
// MergeVK merges the account owning a gateway-validated VK identity into the caller's
+107
View File
@@ -0,0 +1,107 @@
package payments
import "slices"
// PurchaseOverride is a per-account purchase override that forces purchases allowed or denied for
// one account regardless of the operational rail switch, or is absent (OverrideDefault) when the
// account follows the rail switch. The zero value is OverrideDefault, so a missing override row maps
// to it. OverrideAllow bypasses ONLY the operational rail switch — never the security / compliance
// gates (trusted platform, the D36 email anchor, the VK-iOS spend freeze, the min client version),
// which CreateOrder enforces separately.
type PurchaseOverride int
const (
// OverrideDefault follows the rail switch (no override row for the account).
OverrideDefault PurchaseOverride = iota
// OverrideAllow always allows the purchase, bypassing only the operational rail switch.
OverrideAllow
// OverrideDeny always denies the purchase for the account.
OverrideDeny
)
// RailAvailability is a payment rail's operational availability: whether purchases are enabled and,
// when disabled, the operator's explanation in each language, shown to the user on a purchase
// attempt. A rail with no status row is treated as enabled (fail-open — see the store), so the
// zero value is never used as a live "enabled" default.
type RailAvailability struct {
Enabled bool
MessageRU string
MessageEN string
}
// PurchaseGate decides whether an account may open a purchase order on a rail, from the per-account
// override and the rail's operational availability. It is the operational layer only — the caller
// still enforces the security gates. On a block it returns a reason localized to lang ("ru", else
// English). Fail-open: OverrideDefault on an enabled rail allows.
func PurchaseGate(override PurchaseOverride, rail RailAvailability, lang string) (ok bool, reason string) {
switch override {
case OverrideAllow:
return true, "" // bypasses only the ops switch; the security gates still apply upstream
case OverrideDeny:
return false, defaultUnavailable(lang) // a per-account block — a neutral reason
default: // OverrideDefault — follow the rail switch
if rail.Enabled {
return true, ""
}
return false, railMessage(rail, lang)
}
}
// railMessage returns the operator's rail-off message in lang, falling back to the other language
// and then to the built-in default when the operator left both blank.
func railMessage(rail RailAvailability, lang string) string {
if lang == "ru" {
if rail.MessageRU != "" {
return rail.MessageRU
}
if rail.MessageEN != "" {
return rail.MessageEN
}
} else {
if rail.MessageEN != "" {
return rail.MessageEN
}
if rail.MessageRU != "" {
return rail.MessageRU
}
}
return defaultUnavailable(lang)
}
// defaultUnavailable is the built-in localized "payments unavailable" reason used when the operator
// set no custom message, and for a per-account deny.
func defaultUnavailable(lang string) string {
if lang == "ru" {
return "Оплата временно недоступна. Попробуйте позже."
}
return "Payments are temporarily unavailable. Please try again later."
}
// Rail keys name the operational payment rails the kill switch and the per-account override key on.
// The direct rail is split per channel (D42); vk and telegram are single-rail.
const (
RailDirectWeb = "direct:web"
RailDirectAndroid = "direct:android"
RailVK = "vk"
RailTelegram = "telegram"
)
// KnownRails is the fixed set of rail keys, for the admin editor and for validation.
var KnownRails = []string{RailDirectWeb, RailDirectAndroid, RailVK, RailTelegram}
// RailKey maps a payment context to its operational rail key: the direct rail by channel subtype
// (android, else web), or the store rail's own name (vk / telegram).
func RailKey(kind Source, subtype string) string {
if kind == SourceDirect {
if subtype == "android" {
return RailDirectAndroid
}
return RailDirectWeb
}
return string(kind)
}
// isKnownRail reports whether rail is one of the fixed rail keys.
func isKnownRail(rail string) bool {
return slices.Contains(KnownRails, rail)
}
@@ -0,0 +1,40 @@
package payments
import "testing"
func TestPurchaseGate(t *testing.T) {
on := RailAvailability{Enabled: true}
offMsg := RailAvailability{Enabled: false, MessageRU: "Чиним", MessageEN: "Fixing"}
offNoMsg := RailAvailability{Enabled: false}
// OverrideDefault follows the rail switch.
if ok, _ := PurchaseGate(OverrideDefault, on, "en"); !ok {
t.Error("default + enabled rail should allow")
}
if ok, r := PurchaseGate(OverrideDefault, offMsg, "ru"); ok || r != "Чиним" {
t.Errorf("default + off rail (ru) = %v/%q, want false/Чиним", ok, r)
}
if ok, r := PurchaseGate(OverrideDefault, offMsg, "en"); ok || r != "Fixing" {
t.Errorf("default + off rail (en) = %v/%q, want false/Fixing", ok, r)
}
// Off rail with no custom message → the built-in default, localized (not the English default in ru).
if ok, r := PurchaseGate(OverrideDefault, offNoMsg, "ru"); ok || r != defaultUnavailable("ru") {
t.Errorf("default + off no-msg (ru) = %v/%q, want false + the ru default", ok, r)
}
// OverrideAllow bypasses the ops switch even when the rail is off.
if ok, r := PurchaseGate(OverrideAllow, offMsg, "en"); !ok || r != "" {
t.Errorf("allow + off rail = %v/%q, want true/empty (bypasses the ops switch)", ok, r)
}
// OverrideDeny blocks even when the rail is on, with a non-empty reason.
if ok, r := PurchaseGate(OverrideDeny, on, "en"); ok || r == "" {
t.Errorf("deny + on rail = %v/%q, want false + a reason", ok, r)
}
// The message falls back to the other language when only one is set.
if _, r := PurchaseGate(OverrideDefault, RailAvailability{MessageEN: "OnlyEN"}, "ru"); r != "OnlyEN" {
t.Errorf("off rail ru with only EN msg = %q, want OnlyEN (fallback)", r)
}
}
+5
View File
@@ -38,6 +38,11 @@ const atomChips = "chips"
// method are omitted (misconfigured or unavailable there). An untrusted context (empty Kind) has
// no method, so only values show; buying is gated server-side regardless.
func projectCatalog(entries []catalogEntry, cxt Context) CatalogView {
// Present products in the canonical listing order shared with the offer and the admin console
// (packs first by rouble price, then values grouped hints → no-ads → combo and by chip price); the
// client renders them as received. Ordered in place — the caller passes a fresh loadCatalog result
// it does not reuse.
sortCatalogEntries(entries)
var out CatalogView
for _, e := range entries {
isPack := false
+7 -17
View File
@@ -1,7 +1,6 @@
package payments
import (
"cmp"
"context"
"errors"
"fmt"
@@ -144,10 +143,12 @@ func validateProduct(in ProductInput, sellable bool) error {
return nil
}
// AdminCatalog lists every product (active and archived) with its composition, prices and whether it
// has been transacted, for the admin editor. Read uncached, straight from the catalog tables.
func (s *Service) AdminCatalog(ctx context.Context) ([]AdminProduct, error) {
return s.store.adminCatalog(ctx)
// AdminCatalog lists products with their composition, prices and whether each has been transacted,
// for the admin editor. includeInactive adds the archived products to the active ones (the console's
// active/all toggle); with it false only active products are returned. Read uncached, straight from
// the catalog tables.
func (s *Service) AdminCatalog(ctx context.Context, includeInactive bool) ([]AdminProduct, error) {
return s.store.adminCatalog(ctx, includeInactive)
}
// SortAdminCatalog orders an admin catalog list in place the same way the public offer lists products
@@ -157,18 +158,7 @@ func (s *Service) AdminCatalog(ctx context.Context) ([]AdminProduct, error) {
// products the same as active ones (the admin list shows both).
func SortAdminCatalog(products []AdminProduct) {
slices.SortStableFunc(products, func(a, b AdminProduct) int {
if pa, pb := adminIsPack(a), adminIsPack(b); pa != pb {
if pa {
return -1 // packs (sales) before values (chip exchange)
}
return 1
} else if pa {
return cmp.Compare(adminPriceAmount(a, string(SourceDirect), CurrencyRUB), adminPriceAmount(b, string(SourceDirect), CurrencyRUB))
}
if d := cmp.Compare(adminValueGroup(a), adminValueGroup(b)); d != 0 {
return d
}
return cmp.Compare(adminPriceAmount(a, "", CurrencyChip), adminPriceAmount(b, "", CurrencyChip))
return compareCatalogRank(adminRank(a), adminRank(b))
})
}
@@ -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))
})
}
+50
View File
@@ -135,6 +135,56 @@ func TestProjectCatalog_Empty(t *testing.T) {
}
}
// 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.
+6 -14
View File
@@ -1,7 +1,6 @@
package payments
import (
"cmp"
"context"
"fmt"
"math"
@@ -60,8 +59,13 @@ func (s *Service) buildOfferPricing(ctx context.Context) (string, error) {
// 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 entries {
for _, e := range sorted {
if isPackEntry(e) {
packs = append(packs, e)
} else {
@@ -69,18 +73,6 @@ func projectOfferPricing(entries []catalogEntry) string {
}
}
// Packs: ascending by the rouble price (the offer's base currency); a pack with no rouble price
// sorts last. Values: by group, then ascending chip price. Stable, so the catalog order breaks ties.
slices.SortStableFunc(packs, func(a, b catalogEntry) int {
return cmp.Compare(offerSortAmount(a, string(SourceDirect), CurrencyRUB), offerSortAmount(b, string(SourceDirect), CurrencyRUB))
})
slices.SortStableFunc(values, func(a, b catalogEntry) int {
if d := cmp.Compare(offerValueGroup(a), offerValueGroup(b)); d != 0 {
return d
}
return cmp.Compare(offerSortAmount(a, "", CurrencyChip), offerSortAmount(b, "", CurrencyChip))
})
var b strings.Builder
if len(packs) > 0 {
b.WriteString("Приобретение внутриигровой валюты «Фишка»:\n\n")
@@ -0,0 +1,63 @@
package payments
import (
"context"
"fmt"
"github.com/google/uuid"
)
// CanPurchase reports whether an account may open a purchase order on rail, combining the account's
// per-account override with the rail's operational switch (PurchaseGate). It is the operational
// availability layer only — the caller still enforces the security gates (trusted platform, the D36
// email anchor, the VK-iOS spend freeze, the min client version). On a block, reason is localized to
// lang for the user; err is only a store failure.
func (s *Service) CanPurchase(ctx context.Context, accountID uuid.UUID, rail, lang string) (ok bool, reason string, err error) {
ov, err := s.store.purchaseOverride(ctx, accountID)
if err != nil {
return false, "", err
}
avail, err := s.store.railAvailability(ctx, rail)
if err != nil {
return false, "", err
}
ok, reason = PurchaseGate(ov, avail, lang)
return ok, reason, nil
}
// RailStatuses returns every known rail's operational status for the admin editor, filling a rail
// with no stored row with the fail-open enabled default so the editor always shows one row per rail.
func (s *Service) RailStatuses(ctx context.Context) (map[string]RailAvailability, error) {
stored, err := s.store.allRailStatus(ctx)
if err != nil {
return nil, err
}
out := make(map[string]RailAvailability, len(KnownRails))
for _, rail := range KnownRails {
if a, ok := stored[rail]; ok {
out[rail] = a
} else {
out[rail] = RailAvailability{Enabled: true}
}
}
return out, nil
}
// SetRailStatus upserts a rail's operational status from the admin editor. It rejects an unknown rail
// key so a typo cannot create a dead row.
func (s *Service) SetRailStatus(ctx context.Context, rail string, a RailAvailability) error {
if !isKnownRail(rail) {
return fmt.Errorf("payments: unknown rail %q", rail)
}
return s.store.setRailStatus(ctx, rail, a, s.clock())
}
// PurchaseOverrideFor returns an account's purchase override (OverrideDefault when none is set).
func (s *Service) PurchaseOverrideFor(ctx context.Context, accountID uuid.UUID) (PurchaseOverride, error) {
return s.store.purchaseOverride(ctx, accountID)
}
// SetPurchaseOverride sets or clears an account's purchase override (OverrideDefault deletes the row).
func (s *Service) SetPurchaseOverride(ctx context.Context, accountID uuid.UUID, ov PurchaseOverride) error {
return s.store.setPurchaseOverride(ctx, accountID, ov, s.clock())
}
@@ -46,6 +46,7 @@ func (s *Service) CreateOrder(ctx context.Context, accountID uuid.UUID, cxt Cont
amount: pack.price,
origin: method,
provider: provider,
shop: directShop(cxt),
}
if err := s.store.createOrder(ctx, o, s.clock()); err != nil {
return OrderResult{}, err
@@ -53,6 +54,16 @@ func (s *Service) CreateOrder(ctx context.Context, accountID uuid.UUID, cxt Cont
return OrderResult{OrderID: orderID, Amount: pack.price, Title: pack.title}, nil
}
// directShop returns the merchant shop (channel) a direct order is issued through — the trusted
// platform subtype for the direct rail ("web"/"android"), or "" for any other rail (the per-shop
// split is direct-only; D42/D44). Recorded on the order for the admin per-channel breakdown.
func directShop(cxt Context) string {
if cxt.Kind == SourceDirect {
return cxt.Subtype
}
return ""
}
// OrderItem returns a pending order's human title and the amount it charges, in the order's own
// currency — the details a provider's item-lookup phase needs (VK's get_item). It reads the order
// and the pack title, honouring the pack even if it was later deactivated (mirrors Fund).
@@ -157,6 +168,22 @@ func (s *Service) RewardPayout(ctx context.Context, cxt Context, present []Sourc
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
// 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
+3 -1
View File
@@ -42,7 +42,8 @@ type RiskInfo struct {
}
// LedgerEntry is one append-only ledger row projected for the report. The string ids are empty when
// the column is NULL; Snapshot is the raw purchase/refund JSON (empty when none).
// the column is NULL; Shop is the direct-rail merchant channel the referenced order used (empty for
// other rails or order-less rows); Snapshot is the raw purchase/refund JSON (empty when none).
type LedgerEntry struct {
Kind string
Source string
@@ -52,6 +53,7 @@ type LedgerEntry struct {
OrderID string
Provider string
ProviderPaymentID string
Shop string
Snapshot string
CreatedAt time.Time
}
@@ -0,0 +1,96 @@
package payments
import (
"context"
"database/sql"
"errors"
"fmt"
"time"
"github.com/google/uuid"
)
// railAvailability reads a rail's operational availability. A missing row is fail-open: enabled with
// no message, so payments stay on unless an operator explicitly disabled the rail.
func (s *Store) railAvailability(ctx context.Context, rail string) (RailAvailability, error) {
var a RailAvailability
err := s.db.QueryRowContext(ctx,
`SELECT enabled, message_ru, message_en FROM payments.rail_status WHERE rail = $1`, rail).
Scan(&a.Enabled, &a.MessageRU, &a.MessageEN)
if errors.Is(err, sql.ErrNoRows) {
return RailAvailability{Enabled: true}, nil
}
if err != nil {
return RailAvailability{}, fmt.Errorf("payments: read rail status %q: %w", rail, err)
}
return a, nil
}
// allRailStatus reads every stored rail-status row for the admin editor, keyed by rail. Rails with
// no row are absent (the caller fills them with the fail-open enabled default).
func (s *Store) allRailStatus(ctx context.Context) (map[string]RailAvailability, error) {
rows, err := s.db.QueryContext(ctx,
`SELECT rail, enabled, message_ru, message_en FROM payments.rail_status`)
if err != nil {
return nil, fmt.Errorf("payments: read rail statuses: %w", err)
}
defer rows.Close()
out := make(map[string]RailAvailability)
for rows.Next() {
var rail string
var a RailAvailability
if err := rows.Scan(&rail, &a.Enabled, &a.MessageRU, &a.MessageEN); err != nil {
return nil, fmt.Errorf("payments: scan rail status: %w", err)
}
out[rail] = a
}
return out, rows.Err()
}
// setRailStatus upserts a rail's operational status (admin).
func (s *Store) setRailStatus(ctx context.Context, rail string, a RailAvailability, now time.Time) error {
if _, err := s.db.ExecContext(ctx,
`INSERT INTO payments.rail_status (rail, enabled, message_ru, message_en, updated_at)
VALUES ($1, $2, $3, $4, $5)
ON CONFLICT (rail) DO UPDATE SET enabled = $2, message_ru = $3, message_en = $4, updated_at = $5`,
rail, a.Enabled, a.MessageRU, a.MessageEN, now); err != nil {
return fmt.Errorf("payments: set rail status %q: %w", rail, err)
}
return nil
}
// purchaseOverride reads an account's purchase override; a missing row is OverrideDefault.
func (s *Store) purchaseOverride(ctx context.Context, accountID uuid.UUID) (PurchaseOverride, error) {
var allow bool
err := s.db.QueryRowContext(ctx,
`SELECT allow FROM payments.account_payment_override WHERE account_id = $1`, accountID).Scan(&allow)
if errors.Is(err, sql.ErrNoRows) {
return OverrideDefault, nil
}
if err != nil {
return OverrideDefault, fmt.Errorf("payments: read purchase override %s: %w", accountID, err)
}
if allow {
return OverrideAllow, nil
}
return OverrideDeny, nil
}
// setPurchaseOverride upserts an account's override, or deletes the row for OverrideDefault (the
// default state is the absence of a row).
func (s *Store) setPurchaseOverride(ctx context.Context, accountID uuid.UUID, ov PurchaseOverride, now time.Time) error {
if ov == OverrideDefault {
if _, err := s.db.ExecContext(ctx,
`DELETE FROM payments.account_payment_override WHERE account_id = $1`, accountID); err != nil {
return fmt.Errorf("payments: clear purchase override %s: %w", accountID, err)
}
return nil
}
if _, err := s.db.ExecContext(ctx,
`INSERT INTO payments.account_payment_override (account_id, allow, updated_at) VALUES ($1, $2, $3)
ON CONFLICT (account_id) DO UPDATE SET allow = $2, updated_at = $3`,
accountID, ov == OverrideAllow, now); err != nil {
return fmt.Errorf("payments: set purchase override %s: %w", accountID, err)
}
return nil
}
@@ -19,11 +19,17 @@ import (
// 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")
// adminCatalog lists every product (active and archived) with its atoms, prices and transacted flag.
func (s *Store) adminCatalog(ctx context.Context) ([]AdminProduct, error) {
// adminCatalog lists products with their atoms, prices and transacted flag. includeInactive adds the
// 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
if err := postgres.SELECT(table.Product.AllColumns).
FROM(table.Product).
WHERE(where).
ORDER_BY(table.Product.CreatedAt.ASC()).
QueryContext(ctx, s.db, &prods); err != nil {
return nil, fmt.Errorf("payments: admin list products: %w", err)
+15 -2
View File
@@ -151,6 +151,7 @@ type newOrder struct {
amount Money
origin Source
provider string
shop string
}
// createOrder inserts a pending order.
@@ -159,12 +160,12 @@ func (s *Store) createOrder(ctx context.Context, o newOrder, now time.Time) erro
table.Orders.OrderID, table.Orders.AccountID, table.Orders.Platform,
table.Orders.ProductID, table.Orders.ExpectedAmount, table.Orders.Currency,
table.Orders.Origin, table.Orders.Status, table.Orders.Provider,
table.Orders.CreatedAt, table.Orders.UpdatedAt,
table.Orders.CreatedAt, table.Orders.UpdatedAt, table.Orders.Shop,
).VALUES(
o.orderID, o.accountID, o.platform,
o.productID, o.amount.Minor(), string(o.amount.Currency()),
string(o.origin), "pending", o.provider,
now, now,
now, now, o.shop,
)
if _, err := stmt.ExecContext(ctx, s.db); err != nil {
return fmt.Errorf("payments: create order: %w", err)
@@ -413,6 +414,18 @@ func (s *Store) rewardConfig(ctx context.Context) (payout, dailyCap, hourlyCap i
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
// 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
@@ -74,9 +74,39 @@ func (s *Store) accountStatement(ctx context.Context, accountID uuid.UUID) (Stat
CreatedAt: r.CreatedAt,
})
}
// Annotate direct-rail entries with the merchant shop (channel) their order was issued through
// (E10/D44), keyed by the ledger's order id. Non-direct / order-less entries stay "".
shops, err := s.orderShops(ctx, accountID)
if err != nil {
return Statement{}, err
}
for i := range out.Ledger {
out.Ledger[i].Shop = shops[out.Ledger[i].OrderID]
}
return out, nil
}
// orderShops maps an account's order ids (as strings) to the merchant shop (channel) each direct
// order was issued through, for annotating the ledger report (E10/D44). Orders with an empty shop
// (non-direct or pre-split) are omitted, so a lookup miss yields "".
func (s *Store) orderShops(ctx context.Context, accountID uuid.UUID) (map[string]string, error) {
var rows []model.Orders
if err := postgres.SELECT(table.Orders.OrderID, table.Orders.Shop).
FROM(table.Orders).
WHERE(table.Orders.AccountID.EQ(postgres.UUID(accountID))).
QueryContext(ctx, s.db, &rows); err != nil {
return nil, fmt.Errorf("payments: load order shops %s: %w", accountID, err)
}
m := make(map[string]string, len(rows))
for _, r := range rows {
if r.Shop != "" {
m[r.OrderID.String()] = r.Shop
}
}
return m, 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
@@ -25,4 +25,5 @@ type Orders struct {
ProviderPaymentID *string
CreatedAt time.Time
UpdatedAt time.Time
Shop string
}
@@ -29,6 +29,7 @@ type ordersTable struct {
ProviderPaymentID postgres.ColumnString
CreatedAt postgres.ColumnTimestampz
UpdatedAt postgres.ColumnTimestampz
Shop postgres.ColumnString
AllColumns postgres.ColumnList
MutableColumns postgres.ColumnList
@@ -82,9 +83,10 @@ func newOrdersTableImpl(schemaName, tableName, alias string) ordersTable {
ProviderPaymentIDColumn = postgres.StringColumn("provider_payment_id")
CreatedAtColumn = postgres.TimestampzColumn("created_at")
UpdatedAtColumn = postgres.TimestampzColumn("updated_at")
allColumns = postgres.ColumnList{OrderIDColumn, AccountIDColumn, PlatformColumn, ProductIDColumn, ExpectedAmountColumn, CurrencyColumn, OriginColumn, StatusColumn, ProviderColumn, ProviderPaymentIDColumn, CreatedAtColumn, UpdatedAtColumn}
mutableColumns = postgres.ColumnList{AccountIDColumn, PlatformColumn, ProductIDColumn, ExpectedAmountColumn, CurrencyColumn, OriginColumn, StatusColumn, ProviderColumn, ProviderPaymentIDColumn, CreatedAtColumn, UpdatedAtColumn}
defaultColumns = postgres.ColumnList{StatusColumn, CreatedAtColumn, UpdatedAtColumn}
ShopColumn = postgres.StringColumn("shop")
allColumns = postgres.ColumnList{OrderIDColumn, AccountIDColumn, PlatformColumn, ProductIDColumn, ExpectedAmountColumn, CurrencyColumn, OriginColumn, StatusColumn, ProviderColumn, ProviderPaymentIDColumn, CreatedAtColumn, UpdatedAtColumn, ShopColumn}
mutableColumns = postgres.ColumnList{AccountIDColumn, PlatformColumn, ProductIDColumn, ExpectedAmountColumn, CurrencyColumn, OriginColumn, StatusColumn, ProviderColumn, ProviderPaymentIDColumn, CreatedAtColumn, UpdatedAtColumn, ShopColumn}
defaultColumns = postgres.ColumnList{StatusColumn, CreatedAtColumn, UpdatedAtColumn, ShopColumn}
)
return ordersTable{
@@ -103,6 +105,7 @@ func newOrdersTableImpl(schemaName, tableName, alias string) ordersTable {
ProviderPaymentID: ProviderPaymentIDColumn,
CreatedAt: CreatedAtColumn,
UpdatedAt: UpdatedAtColumn,
Shop: ShopColumn,
AllColumns: allColumns,
MutableColumns: mutableColumns,
@@ -0,0 +1,13 @@
-- Multi-shop direct rail (E10/D44): record which Robokassa merchant shop (channel) issued a direct
-- order — "web" / "android" (ios later) — so the admin financial report can break direct payments
-- down by channel. Only the direct rail is per-channel; other rails leave it "". Additive only (a
-- defaulted column), applies forward via goose with no data rewrite (no contour wipe); an image
-- rollback ignores the column.
-- +goose Up
ALTER TABLE payments.orders
ADD COLUMN shop text NOT NULL DEFAULT '';
-- +goose Down
ALTER TABLE payments.orders DROP COLUMN shop;
@@ -0,0 +1,26 @@
-- Payment availability controls (D45/D46): a per-rail operational kill switch with a localized message,
-- and a per-account purchase override. Both let an operator disable payments live from the admin —
-- a whole rail/channel, or one account — and explain why to the user on a purchase attempt. Additive
-- (new tables) — applies forward via goose with no data rewrite (no contour wipe); an image rollback
-- ignores both tables. Fail-open by design: a rail with no row is enabled; an account with no row
-- follows the rail switch.
-- +goose Up
CREATE TABLE payments.rail_status (
rail text PRIMARY KEY,
enabled boolean NOT NULL DEFAULT true,
message_ru text NOT NULL DEFAULT '',
message_en text NOT NULL DEFAULT '',
updated_at timestamptz NOT NULL DEFAULT now()
);
CREATE TABLE payments.account_payment_override (
account_id uuid PRIMARY KEY,
allow boolean NOT NULL,
updated_at timestamptz NOT NULL DEFAULT now()
);
-- +goose Down
DROP TABLE payments.account_payment_override;
DROP TABLE payments.rail_status;
+54
View File
@@ -0,0 +1,54 @@
package robokassa
// Shops is a set of Robokassa merchant shops keyed by channel — the subtype of the trusted
// X-Platform signal for the direct rail ("web", "android"; "ios" later). The direct rail routes a
// payment to the per-channel shop for separate merchant accounts, accounting and receipts, while
// every shop still credits the one direct wallet (docs/PAYMENTS_DECISIONS_ru.md D42). An empty set
// leaves the direct order and Result-callback endpoints unregistered.
type Shops map[string]Config
// Channel constants name the direct-rail X-Platform subtypes a shop is keyed by. ChannelWeb is the
// default: an unknown or empty channel routes here on the order side, and the legacy single-shop
// configuration seeds it.
const (
ChannelWeb = "web"
ChannelAndroid = "android"
)
// Shop returns the shop that issues an order for channel, falling back to the web shop when channel
// is unknown, empty or not configured. The fallback is safe: routing only chooses the merchant
// account and receipt, never the credited wallet (always direct, D42), so a mis-attributed channel
// costs at most accounting accuracy, not money. The second result is false when neither the channel
// nor the web shop has a merchant login (the rail is unconfigured).
func (s Shops) Shop(channel string) (Config, bool) {
if channel != "" {
if c, ok := s[channel]; ok && c.MerchantLogin != "" {
return c, true
}
}
if c, ok := s[ChannelWeb]; ok && c.MerchantLogin != "" {
return c, true
}
return Config{}, false
}
// Verifier returns the shop whose Password2 verifies a Result callback delivered to channel's
// dedicated Result route. Unlike Shop it does not fall back to web: each shop's callback is verified
// only by that shop's own credentials, so a route with no configured shop reports false (and its
// handler answers as unregistered). The second result is false when channel has no merchant login.
func (s Shops) Verifier(channel string) (Config, bool) {
if c, ok := s[channel]; ok && c.MerchantLogin != "" {
return c, true
}
return Config{}, false
}
// Configured reports whether at least one shop has a merchant login (the direct rail is live).
func (s Shops) Configured() bool {
for _, c := range s {
if c.MerchantLogin != "" {
return true
}
}
return false
}
+52
View File
@@ -0,0 +1,52 @@
package robokassa
import "testing"
func TestShopsShopRouting(t *testing.T) {
shops := Shops{
ChannelWeb: {MerchantLogin: "webshop", Password1: "w1", Password2: "w2"},
ChannelAndroid: {MerchantLogin: "androidshop", Password1: "a1", Password2: "a2"},
}
// Order side: the exact channel wins.
if c, ok := shops.Shop(ChannelAndroid); !ok || c.MerchantLogin != "androidshop" {
t.Errorf("Shop(android) = %q/%v, want androidshop/true", c.MerchantLogin, ok)
}
// Order side: an unknown or empty channel falls back to the web shop (safe — always credits direct).
if c, ok := shops.Shop("ios"); !ok || c.MerchantLogin != "webshop" {
t.Errorf("Shop(ios) = %q/%v, want webshop/true (web fallback)", c.MerchantLogin, ok)
}
if c, ok := shops.Shop(""); !ok || c.MerchantLogin != "webshop" {
t.Errorf("Shop(empty) = %q/%v, want webshop/true (web fallback)", c.MerchantLogin, ok)
}
// No web shop and an unknown channel → unconfigured.
if _, ok := (Shops{ChannelAndroid: {MerchantLogin: "androidshop", Password1: "a1", Password2: "a2"}}).Shop("ios"); ok {
t.Error("Shop(ios) with no web shop returned ok, want false")
}
}
func TestShopsVerifierIsStrict(t *testing.T) {
shops := Shops{
ChannelWeb: {MerchantLogin: "webshop", Password1: "w1", Password2: "w2"},
ChannelAndroid: {MerchantLogin: "androidshop", Password1: "a1", Password2: "a2"},
}
// Callback side: the exact channel's own credentials, no web fallback (each callback is verified
// only by its own shop's Password2).
if c, ok := shops.Verifier(ChannelAndroid); !ok || c.MerchantLogin != "androidshop" {
t.Errorf("Verifier(android) = %q/%v, want androidshop/true", c.MerchantLogin, ok)
}
if _, ok := shops.Verifier("ios"); ok {
t.Error("Verifier(ios) returned ok, want false (no fallback)")
}
}
func TestShopsConfigured(t *testing.T) {
if (Shops{}).Configured() {
t.Error("an empty set reported configured")
}
if (Shops{ChannelWeb: {}}).Configured() {
t.Error("a shop with no merchant login reported configured")
}
if !(Shops{ChannelWeb: {MerchantLogin: "s", Password1: "1", Password2: "2"}}).Configured() {
t.Error("a configured shop reported not configured")
}
}
+1 -1
View File
@@ -89,7 +89,7 @@ func (s *Server) registerRoutes() {
// payment over the reverse bot-link; the gateway proxies both onto these gateway-only routes.
s.internal.POST("/payments/telegram/precheckout", s.handleTelegramPreCheckout)
s.internal.POST("/payments/telegram/payment", s.handleTelegramPayment)
if s.robokassa.MerchantLogin != "" {
if s.robokassa.Configured() {
s.internal.POST("/payments/robokassa/result", s.handleRobokassaResult)
}
}
@@ -33,10 +33,12 @@ var priceFields = []struct {
{"price_chip", "", payments.CurrencyChip},
}
// consoleCatalog lists every product (active and archived) with its composition, prices, transacted
// flag, and the inline create form.
// consoleCatalog lists the catalog products with their composition, prices, transacted flag, and the
// 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) {
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 {
s.consoleError(c, err)
return
@@ -44,13 +46,58 @@ func (s *Server) consoleCatalog(c *gin.Context) {
// Order the list like the public offer: sales (chip packs) first, then the chip-exchange values,
// grouped and price-sorted the same way, so the console mirrors what a buyer sees.
payments.SortAdminCatalog(products)
var view adminconsole.CatalogView
payout, daily, hourly, err := s.payments.RewardConfig(c.Request.Context())
if err != nil {
s.consoleError(c, err)
return
}
rails, err := s.payments.RailStatuses(c.Request.Context())
if err != nil {
s.consoleError(c, err)
return
}
view := adminconsole.CatalogView{ShowAll: showAll, RewardPayout: payout, RewardDailyCap: daily, RewardHourlyCap: hourly}
for _, rail := range payments.KnownRails {
a := rails[rail]
view.Rails = append(view.Rails, adminconsole.RailStatusRow{Rail: rail, Enabled: a.Enabled, MessageRU: a.MessageRU, MessageEN: a.MessageEN})
}
for _, p := range products {
view.Products = append(view.Products, catalogRow(p))
}
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)
}
// consoleSetRailStatus toggles a payment rail's operational kill switch and its user-facing
// off-message (per language) from the catalog page's payment-availability form. An unchecked box
// disables the rail; an unknown rail is refused by the service.
func (s *Server) consoleSetRailStatus(c *gin.Context) {
rail := strings.TrimSpace(c.PostForm("rail"))
a := payments.RailAvailability{
Enabled: c.PostForm("enabled") == "on",
MessageRU: strings.TrimSpace(c.PostForm("message_ru")),
MessageEN: strings.TrimSpace(c.PostForm("message_en")),
}
if err := s.payments.SetRailStatus(c.Request.Context(), rail, a); err != nil {
s.renderConsoleMessage(c, "Update failed", err.Error(), catalogBack)
return
}
s.renderConsoleMessage(c, "Saved", "payment availability was updated", catalogBack)
}
// catalogRow projects a product into its list row.
func catalogRow(p payments.AdminProduct) adminconsole.ProductRow {
row := adminconsole.ProductRow{ID: p.ID.String(), Title: p.Title, Active: p.Active, Transacted: p.Transacted}
@@ -69,7 +116,7 @@ func (s *Server) consoleCatalogDetail(c *gin.Context) {
if !ok {
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 {
s.consoleError(c, err)
return
@@ -242,7 +289,7 @@ func (s *Server) consoleGrantProduct(c *gin.Context) {
// 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)
products, err := s.payments.AdminCatalog(ctx, true)
if err != nil {
return fv
}
@@ -61,6 +61,7 @@ func (s *Server) registerConsole(router *gin.Engine) {
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/purchase-override", s.consoleSetPurchaseOverride)
gm.POST("/users/:id/delete", s.consoleDeleteUser)
gm.GET("/reasons", s.consoleReasons)
gm.POST("/reasons", s.consoleCreateReason)
@@ -112,6 +113,8 @@ func (s *Server) registerConsole(router *gin.Engine) {
if s.payments != nil {
gm.GET("/catalog", s.consoleCatalog)
gm.POST("/catalog", s.consoleCreateProduct)
gm.POST("/catalog/reward", s.consoleSetReward)
gm.POST("/catalog/rail-status", s.consoleSetRailStatus)
gm.GET("/catalog/:id", s.consoleCatalogDetail)
gm.POST("/catalog/:id", s.consoleUpdateProduct)
gm.POST("/catalog/:id/archive", s.consoleArchiveProduct)
@@ -460,6 +463,9 @@ func (s *Server) consoleUserDetail(c *gin.Context) {
s.log.Warn("console: account statement failed", zap.String("account", id.String()), zap.Error(err))
}
view.Grant = s.grantForm(ctx)
if ov, oerr := s.payments.PurchaseOverrideFor(ctx, id); oerr == nil {
view.PurchaseOverride = overrideName(ov)
}
}
s.renderConsole(c, "user_detail", "users", acc.DisplayName, view)
}
@@ -481,13 +487,54 @@ func financeView(stmt payments.Statement) adminconsole.FinanceView {
for _, e := range stmt.Ledger {
fv.Ledger = append(fv.Ledger, adminconsole.LedgerRow{
Kind: e.Kind, Source: e.Source, Origin: e.Origin, ChipsDelta: e.ChipsDelta,
Product: e.ProductID, Order: e.OrderID, Provider: e.Provider, Snapshot: e.Snapshot,
Product: e.ProductID, Order: e.OrderID, Provider: e.Provider, Shop: e.Shop, Snapshot: e.Snapshot,
At: fmtTime(e.CreatedAt),
})
}
return fv
}
// overrideName renders a purchase override as the form/select value ("default"/"allow"/"deny").
func overrideName(ov payments.PurchaseOverride) string {
switch ov {
case payments.OverrideAllow:
return "allow"
case payments.OverrideDeny:
return "deny"
default:
return "default"
}
}
// consoleSetPurchaseOverride sets or clears an account's per-account purchase override (allow / deny
// / default) from the user card. "allow" bypasses only the operational rail switch, never the
// security gates; "default" clears the override (deletes the row).
func (s *Server) consoleSetPurchaseOverride(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 configured", back)
return
}
var ov payments.PurchaseOverride
switch c.PostForm("override") {
case "allow":
ov = payments.OverrideAllow
case "deny":
ov = payments.OverrideDeny
default:
ov = payments.OverrideDefault
}
if err := s.payments.SetPurchaseOverride(c.Request.Context(), id, ov); err != nil {
s.renderConsoleMessage(c, "Update failed", err.Error(), back)
return
}
s.renderConsoleMessage(c, "Saved", "the purchase override was updated", back)
}
// relationRows maps the social graph entries to the cross-linked, date-formatted rows the
// user card renders.
func relationRows(rels []social.AdminRelation) []adminconsole.RelationRow {
+8 -4
View File
@@ -160,16 +160,20 @@ type guestAuthRequest struct {
// Subtype is the client-reported device family (ios/android/web) of this direct
// (web/native) session; there is no external signer, so it is recorded best-effort.
Subtype string `json:"subtype"`
// Language is the client's detected interface locale, seeding the fresh guest's
// interface language (best-effort; an unsupported/absent value keeps the 'en' default).
Language string `json:"language"`
}
// handleGuestAuth provisions a fresh ephemeral guest account and mints a session,
// seeding its time zone from the optional detected browser offset.
// seeding its time zone from the optional detected browser offset and its interface
// language from the optional detected locale.
func (s *Server) handleGuestAuth(c *gin.Context) {
// The body is optional: an absent or malformed one simply yields no time-zone seed
// (the account keeps the UTC default), so a bind error must not fail the bootstrap.
// The body is optional: an absent or malformed one simply yields no time-zone/language seed
// (the account keeps the UTC / 'en' defaults), so a bind error must not fail the bootstrap.
var req guestAuthRequest
_ = c.ShouldBindJSON(&req)
acc, err := s.accounts.ProvisionGuest(c.Request.Context(), req.BrowserTZ)
acc, err := s.accounts.ProvisionGuest(c.Request.Context(), req.BrowserTZ, req.Language)
if err != nil {
s.abortErr(c, err)
return
+29 -3
View File
@@ -12,6 +12,7 @@ import (
"go.uber.org/zap"
"scrabble/backend/internal/payments"
"scrabble/backend/internal/robokassa"
)
// Ledger/order provider tags per rail.
@@ -66,9 +67,25 @@ func (s *Server) handleWalletOrder(c *gin.Context) {
s.abortErr(c, err)
return
}
// Operational availability gate (D45/D46): the per-rail kill switch + the per-account override,
// before any order is opened. Orthogonal to the security gates in the rail branches below — a
// per-account "allow" override bypasses only this switch, never those. The reason is localized to
// the account's language for the user.
lang := ""
if acc, aerr := s.accounts.GetByID(ctx, uid); aerr == nil {
lang = acc.PreferredLanguage
}
if ok, reason, aerr := s.payments.CanPurchase(ctx, uid, payments.RailKey(cxt.Kind, cxt.Subtype), lang); aerr != nil {
s.abortErr(c, aerr)
return
} else if !ok {
c.AbortWithStatusJSON(http.StatusServiceUnavailable, errorResponse{Error: errorBody{Code: "payment_unavailable", Message: reason}})
return
}
switch cxt.Kind {
case payments.SourceDirect:
if s.robokassa.MerchantLogin == "" {
shop, ok := s.robokassa.Shop(cxt.Subtype)
if !ok {
c.AbortWithStatusJSON(http.StatusNotImplemented, errorResponse{Error: errorBody{Code: "rail_unavailable", Message: "this payment method is not available"}})
return
}
@@ -89,7 +106,7 @@ func (s *Server) handleWalletOrder(c *gin.Context) {
}
c.JSON(http.StatusOK, walletOrderResponse{
OrderID: res.OrderID.String(),
RedirectURL: s.robokassa.PaymentURL(res.OrderID, res.Amount.Major(), res.Title),
RedirectURL: shop.PaymentURL(res.OrderID, res.Amount.Major(), res.Title),
Rail: providerRobokassa,
})
case payments.SourceVK:
@@ -136,7 +153,16 @@ func (s *Server) handleRobokassaResult(c *gin.Context) {
for k, val := range params {
v.Set(k, val)
}
orderID, outSum, ok := s.robokassa.VerifyResult(v)
// The per-shop Result route carries the channel as ?channel=; the legacy bare route defaults to
// the web shop. Each channel is verified only by its own shop's Password2 (Verifier is strict).
channel := c.DefaultQuery("channel", robokassa.ChannelWeb)
shop, ok := s.robokassa.Verifier(channel)
if !ok {
s.log.Warn("robokassa result: unknown shop channel", zap.String("channel", channel))
c.String(http.StatusBadRequest, "bad sign")
return
}
orderID, outSum, ok := shop.VerifyResult(v)
if !ok {
s.log.Warn("robokassa result: bad signature")
c.String(http.StatusBadRequest, "bad sign")
+6
View File
@@ -285,6 +285,12 @@ func (s *Server) handleLinkVKMerge(c *gin.Context) {
// or a completed link (the active account's refreshed profile).
func (s *Server) confirmResultResponse(c *gin.Context, activeID uuid.UUID, res link.ConfirmResult) linkResultResponse {
ctx := c.Request.Context()
if res.Merged {
// A guest initiator's merge ran inline (the guest-primary rule; no confirmation step),
// so render the completed merge — the client adopts the switched session and lands on
// the surviving durable account as if it simply logged in.
return s.mergeResultResponse(c, res.Merge)
}
if res.MergeRequired {
out := linkResultResponse{Status: "merge_required", SecondaryUserID: res.SecondaryID.String()}
if acc, err := s.accounts.GetByID(ctx, res.SecondaryID); err == nil {
+4 -4
View File
@@ -115,9 +115,9 @@ type Deps struct {
// Renderer is the image-render sidecar client for the PNG export artifact. A
// nil Renderer makes the PNG download answer 404 (the GCG artifact still works).
Renderer *render.Client
// Robokassa configures the direct-rail (RUB) provider; an empty MerchantLogin leaves the
// order and Result-callback endpoints unregistered.
Robokassa robokassa.Config
// Robokassa configures the direct-rail (RUB) provider — one merchant shop per channel; an empty
// set leaves the order and Result-callback endpoints unregistered.
Robokassa robokassa.Shops
}
// Server owns the gin engine, the underlying HTTP server and the readiness
@@ -146,7 +146,7 @@ type Server struct {
ads *ads.Service
payments *payments.Service
gamelimits *gamelimits.Service
robokassa robokassa.Config
robokassa robokassa.Shops
notifier notify.Publisher
console *adminconsole.Renderer
exportKey []byte
+3
View File
@@ -56,6 +56,9 @@ func Middleware(logger *zap.Logger) gin.HandlerFunc {
zap.String("path", route),
zap.Int("status", status),
zap.Duration("latency", elapsed),
// The gateway forwards the real caller as X-Forwarded-For (and Caddy does for /_gm), which
// gin resolves here — so the access log carries the client IP, not the gateway's connection.
zap.String("client_ip", c.ClientIP()),
}
fields = append(fields, TraceFieldsFromContext(ctx)...)
+13 -1
View File
@@ -86,7 +86,7 @@ GM_BASICAUTH_HASH= # required; `caddy hash-password` bcrypt
# --- UI build args (baked into the gateway image) ---------------------------
VITE_TELEGRAM_BOT_ID=
VITE_TELEGRAM_LINK= # friend-invite Mini App link (full URL, e.g. https://t.me/<bot>/<app>)
VITE_TELEGRAM_LINK= # friend-invite Mini App link (full URL, e.g. https://telegram.me/<bot>/<app>)
VITE_TELEGRAM_GAME_CHANNEL_NAME= # landing "Play in Telegram" link, the bot's game channel
VITE_VK_APP_LINK= # landing "Play on VK" link (full URL, https://vk.com/app<id>)
VITE_VK_APP_ID= # VK ID "Web" app id (client_id) for VK web-login linking; also the gateway's GATEWAY_VK_ID_APP_ID
@@ -142,6 +142,18 @@ ROBOKASSA_MERCHANT_LOGIN=
ROBOKASSA_PASSWORD1=
ROBOKASSA_PASSWORD2=
ROBOKASSA_TEST=
# Multi-shop direct rail (D42): the vars above seed the "web" channel; add per-channel shops here.
# ROBOKASSA_WEB_* overrides the web shop; ROBOKASSA_ANDROID_* adds the android (RuStore) shop. Each
# credits the one direct wallet; an empty login drops that channel (routing falls back to web). The
# Robokassa cabinet points each shop's Result URL at /pay/robokassa/result/web and .../android.
ROBOKASSA_WEB_MERCHANT_LOGIN=
ROBOKASSA_WEB_PASSWORD1=
ROBOKASSA_WEB_PASSWORD2=
ROBOKASSA_WEB_TEST=
ROBOKASSA_ANDROID_MERCHANT_LOGIN=
ROBOKASSA_ANDROID_PASSWORD1=
ROBOKASSA_ANDROID_PASSWORD2=
ROBOKASSA_ANDROID_TEST=
# --- Gateway anti-abuse ------------------------------------------------------
# Planted honeytoken bearer value: any request presenting it is flagged — a 24h IP ban
+15 -10
View File
@@ -79,6 +79,7 @@ compose binds from this directory.
| `BACKEND_ROBOKASSA_MERCHANT_LOGIN` | secret | Robokassa shop login for the direct RUB rail. Empty leaves the rail off. |
| `BACKEND_ROBOKASSA_PASSWORD1` / `…_PASSWORD2` | secret | Robokassa pass phrases: Password1 signs the launch request, Password2 signs/verifies the Result callback. Use the shop's **test** pair while `…_ROBOKASSA_TEST=1`, the **live** pair for real money. (Password3 — Robokassa's JWT-invoice API — is unused.) |
| `BACKEND_ROBOKASSA_TEST` | **variable** | `1` runs test payments against the test pass phrases (no real money); empty/`0` is live. A variable, not a secret, so go-live is a flag flip + redeploy, not a secret rotation. |
| `BACKEND_ROBOKASSA_{WEB,ANDROID}_{MERCHANT_LOGIN,PASSWORD1,PASSWORD2,TEST}` | secret / variable | Multi-shop direct rail (D42): per-channel Robokassa shops. The legacy `BACKEND_ROBOKASSA_*` seeds the **web** shop; `…_WEB_*` overrides it, `…_ANDROID_*` adds the **android** (RuStore) shop. Each credits the one `direct` wallet; the cabinet Result URL per shop is `/pay/robokassa/result/{web,android}`. Empty login ⇒ that channel unconfigured (order routing falls back to the web shop). |
**Plus the bot token**`TELEGRAM_BOT_TOKEN` (secret), shared by the validator (HMAC
secret) and the bot (Bot API). It defaults to empty in compose, but both **fail at
@@ -97,7 +98,7 @@ without it Docker's resolver handles `otelcol`, `gateway` and `api.telegram.org`
| --- | --- | --- | --- |
| `POSTGRES_DB` | variable | `scrabble` | Database name. |
| `POSTGRES_USER` | variable | `scrabble` | Database user. |
| `DICT_VERSION` | variable (shared) | `v1.3.1` | `scrabble-dictionary` release tag baked into the backend image as the **seed for a fresh volume** (build-arg). A live contour changes dictionary through the admin console, not this; on a seeded volume a changed value is ignored (the recorded `.seed_version` marker wins — the seed-drift guard, ARCHITECTURE.md §5). One shared unprefixed Gitea variable seeds both contours and pins the CI test suite. |
| `DICT_VERSION` | variable (shared) | `v1.3.1` | `scrabble-dictionary` release tag baked into the backend image (build-arg). **Deploy-authoritative**: a redeploy that bumped it **delivers** the version onto the volume (add-only) and **activates** it for new games — old games keep the version they pin, and a newer out-of-band console upload is never downgraded (ARCHITECTURE.md §5). The `.seed_version` marker still guards the flat seed's label (a bump is delivered as a new subdirectory, never a relabel). One shared unprefixed Gitea variable seeds both contours and pins the CI test suite. |
| `LOG_LEVEL` | variable | `info` | Shared log level for backend / gateway / validator / bot (`debug\|info\|warn\|error`). |
| `CADDY_SITE_ADDRESS` | variable | `:80` | Caddy site address. Test: `:80` (host caddy terminates TLS). Prod: a domain, so caddy does its own ACME. |
| `GM_BASICAUTH_USER` | variable | `gm` | Username for the `/_gm` Basic-Auth. |
@@ -107,8 +108,8 @@ without it Docker's resolver handles `otelcol`, `gateway` and `api.telegram.org`
| `TELEGRAM_TEST_ENV` | _pinned_ | `false` | `true` routes the bot through Telegram's test environment (`.../bot<token>/test/METHOD`). **The CI test contour pins this to `true` in `ci.yaml`** (the contour is the test environment) — it is not a Gitea variable. Set it in `.env` for a local run; prod leaves it `false`. |
| `TELEGRAM_API_BASE_URL` | variable | _(empty)_ | Override the Bot API host (a mock/self-hosted server); empty = `https://api.telegram.org`. |
| `VITE_TELEGRAM_BOT_ID` | variable | _(empty)_ | UI build-arg: numeric bot id for the web Login Widget. |
| `VITE_TELEGRAM_LINK` | variable | _(empty)_ | UI build-arg: friend-invite Mini App link (full URL, `https://t.me/<bot>/<app>``<app>` is the Mini App short name from BotFather). |
| `VITE_TELEGRAM_GAME_CHANNEL_NAME` | variable | _(empty)_ | UI build-arg: the landing "Play in Telegram" link, the bot's game channel (e.g. `https://t.me/Erudit_Game`). |
| `VITE_TELEGRAM_LINK` | variable | _(empty)_ | UI build-arg: friend-invite Mini App link (full URL, `https://telegram.me/<bot>/<app>``<app>` is the Mini App short name from BotFather). |
| `VITE_TELEGRAM_GAME_CHANNEL_NAME` | variable | _(empty)_ | UI build-arg: the landing "Play in Telegram" link, the bot's game channel (e.g. `https://telegram.me/Erudit_Game`). |
| `VITE_VK_APP_LINK` | variable (shared) | _(empty)_ | UI build-arg: the landing "Play on VK" link, the VK Mini App (full URL, `https://vk.com/app<id>`). One VK Mini App for all contours. |
| `VITE_VK_APP_ID` | variable (shared) | _(empty)_ | VK ID "Web" app id (`client_id`) for VK web-login linking. Baked into the SPA authorize URL and reused as the gateway's `GATEWAY_VK_ID_APP_ID`. Empty disables the `link.vk.*` ops. One VK ID "Web" app for all contours. |
| `VITE_VK_ID_REDIRECT_URL` | derived | _(empty)_ | VK ID trusted redirect URL — must match the app's registered redirect exactly. The deploy derives `PUBLIC_BASE_URL + /app/` (used by the SPA and as the gateway's exchange `redirect_uri`); set it only for a local run. |
@@ -175,10 +176,14 @@ For local builds set `DICT_VERSION` in `deploy/.env` (template: `.env.example`);
`docker build` needs `--build-arg DICT_VERSION=vX.Y.Z`. The Dockerfiles and `compose` carry no
default — a missing value fails loudly instead of baking a stale tag.
Bumping the seed is a **no-op on a live volume** (the `.seed_version` marker wins — the
seed-drift guard). A running contour/prod moves to a new release **through the admin console**
`/_gm/dictionary` (upload the tarball, preview the per-variant diff, confirm); in-flight games
keep their pinned version, new games use the new one (ARCHITECTURE.md §5).
Bumping `DICT_VERSION` and **redeploying** takes a live contour/prod to the new dictionary:
on boot the backend **delivers** the build's version onto the volume (add-only, into
`BACKEND_DICT_DIR/<version>/` from the unshadowed `BACKEND_DICT_SEED_DIR`) and **activates** it,
so new games pin it while in-flight games keep the version they started on. The `.seed_version`
marker still guards the flat seed's label (the delivery is a new subdirectory, never a relabel).
The admin console `/_gm/dictionary` (upload the tarball, preview the per-variant diff, confirm)
remains for an **out-of-band** update without a redeploy; a console version newer than the build
survives a later restart on an older image (ARCHITECTURE.md §5).
## Production rollout
@@ -241,7 +246,7 @@ that survives losing the host.
## Android app build & release (RuStore)
The standalone Android app is built by a **manual** workflow, never automatically, and is separate from the
web/prod rollout — a signed APK uploaded to RuStore by hand. Full plan: [`../ANDROID_PLAN.md`](../ANDROID_PLAN.md).
web/prod rollout — a signed APK uploaded to RuStore by hand. The build/release runbook follows.
- **Trigger:** `Actions → android-build → Run workflow` from **`master`**, input `confirm=build` (mirrors
`prod-deploy`). **Tag the release first** (`git tag vX.Y.Z` on `master`) — the workflow refuses anything
@@ -256,8 +261,8 @@ web/prod rollout — a signed APK uploaded to RuStore by hand. Full plan: [`../A
the SDK is missing or unreadable.
- **Release keystore** (create once, **back up off-host** — losing it means the app can never be updated):
`keytool -genkeypair -v -keystore erudit-release.jks -alias erudit -keyalg RSA -keysize 4096 -validity 10000`,
then set the Gitea **secrets** `ANDROID_KEYSTORE_BASE64` (`base64 -w0 erudit-release.jks`),
`ANDROID_KEYSTORE_PASSWORD`, `ANDROID_KEY_ALIAS`, `ANDROID_KEY_PASSWORD`. Set the `ANDROID_RUSTORE_URL`
then set the Gitea **secrets** `ANDROID_RUSTORE_KEYSTORE_BASE64` (`base64 -w0 erudit-release.jks`),
`ANDROID_RUSTORE_KEYSTORE_PASSWORD`, `ANDROID_RUSTORE_KEY_ALIAS`, `ANDROID_RUSTORE_KEY_PASSWORD`. Set the `ANDROID_RUSTORE_URL`
variable to the store listing once published (empty until then — the in-app update button no-ops).
- **Wire-break discipline:** the prod deploy that ships an incompatible wire change **also** bumps
`GATEWAY_MIN_CLIENT_VERSION` to that release (see Optional variables), so an old installed APK is turned
@@ -150,6 +150,13 @@
enabled: true
state: started
# Pin every host to UTC so host-level timestamps (journald, file mtimes, cron) line up
# across the fleet — some VPS images ship a local zone (the tg host came up on MSK). The
# services themselves run in UTC regardless; this is about host-side log correlation.
- name: Set the system timezone to UTC
community.general.timezone:
name: Etc/UTC
# --- Swap file (OOM cushion) ---------------------------------------------------
# The prod main host is tight (1.9 GiB) and the per-container memory caps
# (docker-compose.prod.yml) sum to more than RAM, so a simultaneous spike could hit
+7 -6
View File
@@ -107,12 +107,13 @@
}
}
# The public offer page is rendered by the render sidecar: it splices the live catalog price
# list (backend, internal) into the committed ui/legal/offer_ru.md and returns the HTML. Only
# /offer/ is exposed here — the sidecar's /render stays off this allow-list, internal-only. Kept
# disjoint from the landing/app paths so the catch-all below never shadows it.
@offer path /offer /offer/*
handle @offer {
# The public legal pages are rendered by the render sidecar: the offer (with the live catalog
# price list from the backend spliced into ui/legal/offer_ru.md), the privacy policy and the EULA
# (both static markdown). Only these paths are exposed here — the sidecar's /render stays off this
# allow-list, internal-only. Kept disjoint from the landing/app paths so the catch-all below never
# shadows them.
@legal path /offer /offer/* /privacy /privacy/* /eula /eula/*
handle @legal {
reverse_proxy renderer:8090
}
+12
View File
@@ -171,6 +171,18 @@ services:
BACKEND_ROBOKASSA_PASSWORD1: ${ROBOKASSA_PASSWORD1:-}
BACKEND_ROBOKASSA_PASSWORD2: ${ROBOKASSA_PASSWORD2:-}
BACKEND_ROBOKASSA_TEST: ${ROBOKASSA_TEST:-}
# Per-channel shops for the multi-shop direct rail (D42): the legacy ROBOKASSA_* above seeds
# the "web" channel; ROBOKASSA_WEB_* overrides it and ROBOKASSA_ANDROID_* adds the android
# shop. Each shop credits the one direct wallet; an empty login drops that channel (order
# routing falls back to the web shop). Result URLs: /pay/robokassa/result/{web,android}.
BACKEND_ROBOKASSA_WEB_MERCHANT_LOGIN: ${ROBOKASSA_WEB_MERCHANT_LOGIN:-}
BACKEND_ROBOKASSA_WEB_PASSWORD1: ${ROBOKASSA_WEB_PASSWORD1:-}
BACKEND_ROBOKASSA_WEB_PASSWORD2: ${ROBOKASSA_WEB_PASSWORD2:-}
BACKEND_ROBOKASSA_WEB_TEST: ${ROBOKASSA_WEB_TEST:-}
BACKEND_ROBOKASSA_ANDROID_MERCHANT_LOGIN: ${ROBOKASSA_ANDROID_MERCHANT_LOGIN:-}
BACKEND_ROBOKASSA_ANDROID_PASSWORD1: ${ROBOKASSA_ANDROID_PASSWORD1:-}
BACKEND_ROBOKASSA_ANDROID_PASSWORD2: ${ROBOKASSA_ANDROID_PASSWORD2:-}
BACKEND_ROBOKASSA_ANDROID_TEST: ${ROBOKASSA_ANDROID_TEST:-}
# The dictionary lives on a named volume seeded from the image on first boot
# (the image's /opt/dawg is owned by the nonroot UID, which the fresh volume
# inherits). The admin console writes new version subdirectories here, and the
+59 -34
View File
@@ -107,8 +107,10 @@ dropped). Horizontal scaling is explicit future work.
the header **"Connecting…"** spinner, chrome stays online); `offlineNoNetwork` (sustained loss — blue
chrome, a local-only lobby, the transport **kill switch**); and `offlineVersionLocked` (the gateway is
reachable but the build is below the hard minimum — the *update* notice, the client-version gate below). **Offline is
implicit** — detected from failed calls, a reachability probe and the OS hint (`navigator` /
`@capacitor/network`), **never a user toggle** — with **hysteresis** so a brief blip lives entirely in
implicit** — detected from failed calls, a reachability probe, a **live-stream heartbeat watchdog**
(a silence past ~25 s of the gateway's 10 s keep-alive `heartbeat` means a silently-dead stream — e.g.
the radio was cut with no stream error; `ui/src/lib/streamwatchdog.ts`, background-aware) and the OS hint
(`navigator` / `@capacitor/network`), **never a user toggle** — with **hysteresis** so a brief blip lives entirely in
`connecting` (K consecutive probe failures *or* a debounce window trips `offlineNoNetwork`; the first
probe/call success heals back, with a toast). The transport **auto-retries with capped exponential
backoff** — every op on a rate-limit (the gateway rejected it before processing, so it is safe), but
@@ -116,10 +118,17 @@ dropped). Horizontal scaling is explicit future work.
one whose response was lost — its button is disabled while offline and re-issued on reconnect). A
reachability watcher (a lightweight `profile.get` probe; a session-less native guest reconciles a
server guest instead — that reconcile IS the probe) drives recovery, and the live `Subscribe` stream's
drop/recovery feeds the same machine. **Telegram/VK are exempt** — always online: they are never fed an
offline signal, and `offlineMode.active` is additionally hard-gated on `offlineCapable()` (false in
those mini-apps), so nothing — not even a version lock — puts them in offline mode: no blue chrome, no
local lobby, no transport kill switch and no device-local create paths.
drop/recovery feeds the same machine. **Telegram/VK are exempt from the offline-*play* model**
`offlineMode.active` is hard-gated on `offlineCapable()` (false in those mini-apps), so nothing — not
even a version lock — puts them in offline mode: no blue chrome, no local lobby, no transport kill
switch and no device-local create paths. The machine still tracks reachability there, though: a
connection lost **inside an online game** is surfaced on **every platform**, driven off the machine's
confirmed-offline state (`netState.offline`, not `offlineMode.active`) — the game screen shows a
*connection lost* banner, **freezes** the tray and move controls, and hides the resign, add-friend/block
and chat/dictionary controls (a started move stays a draft, re-committed by the player on reconnect), and
the word-check tool falls back to the game's pinned on-device dictionary (`ui/src/lib/dict/check.ts`,
exact when that dawg is cached, else *unavailable*) with its network-only complaint and external
look-up hidden.
**Edge hardening:** every request body on the public listener is capped at
`GATEWAY_MAX_BODY_BYTES` (default 1 MiB — far above any legitimate payload), both at the HTTP
layer (`http.MaxBytesReader`) and as the Connect per-message read limit, so an oversized
@@ -142,7 +151,10 @@ dropped). Horizontal scaling is explicit future work.
and GCG are unaffected** (they stay decoded concrete characters, §9.1).
- **gateway ↔ backend (sync)**: plain HTTP REST/JSON. The gateway injects
`X-User-ID` (and the session's trusted `X-Platform`, §3) for authenticated
requests; `backend` never re-derives identity or platform from the body. Because every sync call targets the one backend host, the
requests, plus the caller's real IP as **`X-Forwarded-For`** on **every** call
(carried on the request context), so the backend records the real caller — the
account's last-login IP, chat/feedback moderation, the access log — rather than
the gateway's own connection address; `backend` never re-derives identity or platform from the body. Because every sync call targets the one backend host, the
gateway's REST client widens its keep-alive pool well past the stdlib default
of 2 idle connections per host; otherwise the per-request connection churn
exhausts ephemeral ports and burns gateway CPU under load (see
@@ -434,22 +446,27 @@ Key points:
version while new games use the new one. A restart re-loads every resident
version via `engine.OpenWithVersions` (the flat seed plus each subdirectory,
skipping the `.staging/` upload area) and restores the active pointer from
`dictionary_state`. The volume preserves uploaded versions across redeploys;
once seeded it is not re-seeded, so after bootstrap dictionary changes go through
the console rather than a rebuild. Because the flat DAWGs carry no embedded
version, `OpenWithVersions` records the version the flat directory was first seeded
at in a `.seed_version` marker on the volume and treats that marker as
**authoritative** (the **seed-drift guard**): on an already-seeded volume a later
`BACKEND_DICT_VERSION` is ignored, so a bumped build seed cannot relabel the
already-seeded bytes — which would otherwise silently serve the wrong dictionary
and void games pinned to the prior label. A running contour therefore moves to a
new release **through the console** (the prior version stays resident, so its games
keep replaying), and `DICT_VERSION` is the seed for a **fresh** volume only:
bumping it on a live contour is a harmless no-op that takes effect on the next
fresh volume. Set it per contour from the deploy's `TEST_`/`PROD_DICT_VERSION`.
(The dictionaries ship as a versioned **release artifact** from the
`scrabble-dictionary` repo; the build's `DICT_VERSION` selects
only the seed.)
`dictionary_state`. The volume preserves uploaded versions across redeploys, so
in-flight games keep replaying on the version they pin. The build's
`BACKEND_DICT_VERSION` is **deploy-authoritative**: on boot `engine.DeliverVersion`
copies the image's DAWGs — kept unshadowed at `BACKEND_DICT_SEED_DIR`, since the
volume mount hides the image's `BACKEND_DICT_DIR` copy — into
`BACKEND_DICT_DIR/<version>/` when that version is not already resident (**add-only**:
the flat seed and prior uploads are never touched), and `InitActiveVersion` makes it
the active version. So a redeploy that bumped `DICT_VERSION` moves new games to the
new dictionary automatically, while old games stay on theirs — no console step. The
one exception: a version installed out-of-band through the console that is **newer**
than the build version and still resident is kept, so a restart on an older image never
downgrades a hotfix (versions compare numerically). Because the flat DAWGs carry no
embedded version, `OpenWithVersions` records the version the flat directory was first
seeded at in a `.seed_version` marker and treats it as authoritative for **that flat
directory** (the **seed-drift guard**): a later `BACKEND_DICT_VERSION` never relabels
the already-seeded flat bytes — it is delivered as a **new subdirectory** instead — so
the guard still prevents mis-serving a game pinned to the flat label. The console
remains the way to update a dictionary **without** a redeploy (an out-of-band upload).
Set `DICT_VERSION` per contour from the deploy's `TEST_`/`PROD_DICT_VERSION`. (The
dictionaries ship as a versioned **release artifact** from the `scrabble-dictionary`
repo.)
- Move generation/validation/scoring use `Solver.GenerateMoves` (ranked),
`Solver.ValidatePlay` and `Solver.ScorePlay`; board mutation uses
`scrabble.Apply`. The engine adds its own deterministic, seeded tile **bag**
@@ -967,10 +984,14 @@ finished journal on each GET. The only degraded platform is a legacy Telegram cl
predating `downloadFile`, where the GCG falls back to the old clipboard copy and the
image option is not offered.
The same sidecar also serves the **public offer page** at `/offer/` the one edge-exposed
route on it (caddy routes `/offer/` here; its `/render` stays internal). It reuses the shared
`ui/src/lib/offer.ts` renderer (again one renderer, no drift) over the owner-edited
`ui/legal/offer_ru.md`, baked into the image, splicing in the **live price list** (§4.4): it
The same sidecar also serves the **public legal pages** — the offer at `/offer/`, the privacy
policy at `/privacy/` and the EULA at `/eula/` — the edge-exposed routes on it (caddy routes them
here; its `/render` stays internal). All three reuse the shared `ui/src/lib/offer.ts`
`renderLegalHtml` (one renderer, no drift) over their owner-edited `ui/legal/*_{ru,en}.md` — each is
**bilingual (RU + EN)** with a client-side language + theme switcher (default Russian, persisted;
the offer's EN view transliterates the Russian product names) — baked into the image. `/privacy/`
and `/eula/` are static; the offer additionally splices in the **live price list** (§4.4) into both
languages: it
fetches the two catalog tables as markdown from the backend's internal
`/api/v1/internal/offer/pricing` at the `<#pricing_template#>` marker, then renders the page. The
backend projects the tables from the active catalog through `payments.Money` (no float reaches the
@@ -1365,7 +1386,9 @@ Single public origin, path-routed. The Vite build has two entries: a lightweight
`/app/` (web), `/telegram/` (the Telegram Mini App; on that path without sign-in data
— no `initData` — the client renders a compact, shareable launch-diagnostic screen instead
of redirecting away) and `/vk/` (the VK Mini App; the client reads the signed `vk_*` launch
parameters from the URL and the gateway verifies them in-process — §12); a stray hit on the
parameters from the URL and the gateway verifies them in-process — §12; on that path without a
signed launch the client renders the same shareable launch-diagnostic screen rather than starting
a throwaway guest); a stray hit on the
gateway's `/` 308-redirects to `/app/`. The **landing** ships in its own static container: the
`landing` target of `gateway/Dockerfile` (caddy:2-alpine + the same Vite build,
`deploy/landing/Caddyfile`) serves it at `/`, so stray public traffic is absorbed by
@@ -1446,9 +1469,10 @@ in-process (the distroless image has no `/etc/mime.types`). Hash-named `/assets/
in-compose **caddy** is the contour's edge: it owns a single `/_gm` Basic-Auth and
routes `/_gm/grafana/*` to **Grafana** (anonymous-admin, so the one shared login gates
it with no per-user Grafana accounts) and the rest of `/_gm/*` to the backend-rendered
**admin console**; `/app/`, `/telegram/`, `/vk/` and the Connect path go to the gateway; `/offer/`
(the public offer, rendered by the `renderer` sidecar with the live catalog price list spliced in)
goes to the render sidecar; and the catch-all — notably the landing at `/` — goes to the landing
**admin console**; `/app/`, `/telegram/`, `/vk/` and the Connect path go to the gateway; the public
legal pages `/offer/`, `/privacy/` and `/eula/` (rendered by the `renderer` sidecar the offer with
the live catalog price list spliced in) go to the render sidecar; and the catch-all — notably the
landing at `/` — goes to the landing
container. The
**Telegram validator** runs as a separate container with **no public ingress**,
answering only internal gRPC (HMAC, no Telegram egress). The **Telegram bot** holds
@@ -1480,7 +1504,8 @@ Two contours, two secret/variable prefixes (`TEST_` / `PROD_`):
forwards the domain to `scrabble:80`, so the in-compose caddy serves plain HTTP
(`CADDY_SITE_ADDRESS=:80`). The in-compose caddy **trusts X-Forwarded-For from
private-range upstreams** (`trusted_proxies private_ranges`), so the real client IP —
used for chat-moderation logging and the gateway's per-IP rate limiting — survives the
used for the gateway's per-IP rate limiting and, forwarded on to the backend, the account's
last-login IP, chat/feedback moderation and the access log — survives the
host-caddy hop; in prod (no host caddy) public clients are untrusted and Caddy uses the
real peer, so the single config is correct and spoof-safe in both contours. The
**bot-link mTLS material** (a private CA + gateway/bot leaves, CN=`gateway`) is
@@ -1536,8 +1561,8 @@ hidden in the MVP (`VITE_PAYMENTS_DISABLED`). The APK is built by a **manual** `
the dicts, and assembles a **release APK** artifact — signed when the `ANDROID_KEYSTORE_*` secrets are
present, unsigned otherwise. `versionCode`/`versionName` are deterministic from the release tag `vMA.MI.PA`
(`versionCode = MA*1_000_000 + MI*1_000 + PA`, `versionName = "MA.MI.PA"`). The runner is a host-executor
with a host-provisioned Android SDK; RuStore upload is manual. Full plan + runbook:
[`../ANDROID_PLAN.md`](../ANDROID_PLAN.md), [`../deploy/README.md`](../deploy/README.md).
with a host-provisioned Android SDK; RuStore upload is manual. Runbook:
[`../deploy/README.md`](../deploy/README.md).
## 14. CI & branches
+43 -18
View File
@@ -30,11 +30,13 @@ render with arbitrary languages, so detection would make the indexed content
nondeterministic); a saved 🌐 choice still wins. The page carries a static Russian SEO head
(title/description, the Open Graph card Telegram/VK link previews use, a canonical link
pinned to the production origin, JSON-LD, the favicon set and `robots.txt`), while the SPA
shell is `noindex` — the landing is the only indexable page. The footer links to the **public
offer** (`/offer/`) and, beside it, **feedback** — the Telegram bot the offer names as the seller's
contact. The offer page (the legal document a purchase accepts) is rendered on demand from
`ui/legal/offer_ru.md` with its **price list** (§4.4) generated from the live product catalog, so
the published prices always match what is currently on sale — no redeploy to update them.
shell is `noindex` — the landing is the only indexable page. The footer links to the three **legal
documents** — the **user agreement** (`/eula/`), the **privacy policy** (`/privacy/`) and the
**public offer** (`/offer/`) — and, beside them, **feedback** (the Telegram bot the documents name as
the seller's contact). Each is **bilingual (RU/EN)** with a language + theme switcher and is rendered
from its `ui/legal/*_{ru,en}.md` sources by the render sidecar; the offer additionally splices in its
**price list** (§4.4) generated from the live product catalog, so the published prices always match
what is currently on sale — no redeploy to update them.
On the plain web the client is an **installable PWA**: a logged-out player sees an install
call-to-action under the login form (and at the bottom of Settings) that installs the app to the
@@ -48,7 +50,7 @@ tail — at a real control, and a **tap anywhere** advances to the next; after t
overlay is gone for good. Two independent series run. The **lobby** series points at the
⚙️ settings, ✏️ statistics and 🎲 new-game tabs. The **game** series, on the player's first game
board, points at the scoreboard header (move history / chat / word-check), the 🔄 pass-and-exchange,
🛟 hints and 🔀 shuffle controls, and the rack. A series counts as seen only **after its last hint**,
hints and 🔀 shuffle controls, and the rack. A series counts as seen only **after its last hint**,
so leaving mid-way replays it from the start next time; it is remembered per device. Arriving at the
lobby is the lobby trigger, so a player who deep-links straight into Settings → Friends still gets the
lobby walk-through on their first trip back. Both series work the same in portrait and landscape (the
@@ -70,7 +72,10 @@ A **VK Mini App** launch works the same way: it authenticates from VK's signed l
`vk_language` and its display name from the VK profile (read on the client, since VK does not put
the name in the signed launch). While the theme preference is "auto" the app follows the VK
client's light/dark scheme, and the layout clears the VK mobile home bar. The same quiet-retry
"couldn't load" screen applies inside VK.
"couldn't load" screen applies inside VK. Opening a Mini App link directly in an ordinary browser —
the `/vk/` entry with no signed VK launch, or `/telegram/` with no Telegram sign-in data — shows a
compact, shareable diagnostic screen instead of proceeding (as a throwaway guest on VK, or by
redirecting away on Telegram), so a player who ends up there wrongly can screenshot it for us.
**Email** sign-in (web) mails a branded six-digit confirmation code — localized
(en/ru), no images — to the address; entering it signs the player in. A new address
is provisioned on the first request but only becomes a durable account once the code
@@ -112,12 +117,15 @@ First platform contact auto-provisions a durable account. From the profile a pla
links an email (via a confirm code) or their Telegram (via the web sign-in); a guest
who links their first identity becomes a durable account. The "already taken" status
of an identity is never revealed before the code/sign-in is verified. If the linked
identity already belongs to another account, the player is shown an explicit,
**irreversible** confirmation and the two accounts are merged into the one they are
using (statistics summed, games and friends transferred, duplicates removed) — except
when a guest links an identity that already has a durable account, where the durable
account is kept and the guest's games move into it. A merge is blocked only while the
two accounts share a game still in progress.
identity already belongs to another account, the two accounts are merged into one
(statistics summed, games and friends transferred, wallet folded, duplicates removed).
A **durable** initiator is shown an explicit, **irreversible** confirmation first —
consolidating two real accounts is consequential. A **guest** initiator is not: the
durable account that owns the identity is kept, and the ephemeral guest — with its games,
wallet and stats folded in — is retired **seamlessly**, so from the player's side it is
simply signing into their account (the app switches to it with no prompt). A merge is
blocked only while the two accounts share a game still in progress; a guest is then asked
to finish that shared game before signing in.
The profile lists the account's **sign-in methods**. On the web a player can add
Telegram (a login-widget popup) or VK (VK ID web login — a redirect to VK's sign-in and
@@ -224,7 +232,10 @@ brief warm-up shows on the first open otherwise — and falls back to the server
local dictionary is unavailable. The dictionary check tool is
unlimited and offers a complaint on any result; for a word it finds, it also links out to an
external reference dictionary (gramota.ru for Russian games, scrabblewordfinder.org for
English) to look it up. Hints are governed per game —
English) to look it up. While offline in an online game the check runs against the game's own
dictionary on the device — the same verdict as the server when that dictionary is available,
otherwise it reads *unavailable offline* — and the complaint and the external look-up, which both
need the network, are hidden. Hints are governed per game —
whether they are allowed and how many each player starts with — and draw on a
personal hint wallet once the per-game allowance is spent. Against the robot
(**vs_ai**) hints are instead **unlimited and wallet-free**, but idle-gated as an
@@ -317,6 +328,17 @@ offline on its own, and restoring the network returns it online by itself. There
switch to remember: a brief drop is ridden out quietly and only a sustained loss turns the chrome
offline, so you are never interrupted for a hiccup.
**Losing the connection while inside an online game** is surfaced on **every platform, including the
Telegram/VK mini-apps** — unlike the offline-play mode above, which is web/native only — because an
online game needs the server for every move. A slim *connection lost* banner appears at the top of the
board and the play area **freezes**: the rack and the move controls disable, and the resign, the
add-friend/block and the chat/dictionary controls are hidden, while a move you had started stays on the
board as a draft. Nothing
is sent; when the connection returns the banner clears, the controls come back and you commit the move
yourself. If you were already in the chat or the dictionary when the drop happened you stay there — chat
becomes read-only (send and nudge disable) and the dictionary keeps checking words against the game's
on-device dictionary.
### Staying up to date
When a new client version is required, the app does not lock you out. On the **native** app a
too-old build shows a notice with **Update** (opens the store) and **Play offline** (dismiss and keep
@@ -430,9 +452,11 @@ unanswered reply. Guests cannot send feedback (the entry is hidden). A player th
barred from feedback (a role, not a full account block) sees the send control disabled.
### Telegram support chat
A user can also reach the operators straight from the Telegram bot: anything they send the bot
other than `/start` — text, a photo, a voice message, a file — is forwarded into the operators'
private support group, grouped into a per-user thread. The user sees no automatic reply; an
A user can also reach the operators straight from the Telegram bot. The `/support` command replies
with the support desk's working hours and what to include (a description and, when possible,
screenshots). Anything else they send the bot other than `/start` — text, a photo, a voice message,
a file — is forwarded into the operators' private support group, grouped into a per-user thread. The
user sees no automatic reply to a forwarded message; an
operator answers from that thread and the bot delivers the answer back as an ordinary bot message,
so to the user it is a quiet one-to-one conversation. Operators can block a user (the bot then
silently ignores their messages) or clear a thread's messages. This is independent of the in-app
@@ -535,7 +559,8 @@ at any time (games already lost stay lost).
Where the bot manages a channel's **linked discussion chat**, everyone may write by default and the
bot **mutes** a player who is **not registered** or is **blocked**, un-muting them once they register
or are unblocked. So an unregistered newcomer who comments is muted (the promo bot points them at the
or are unblocked. It also clears the automatic pin Telegram puts on each channel post that is
auto-forwarded into the chat, so only deliberately pinned messages stay pinned. So an unregistered newcomer who comments is muted (the promo bot points them at the
game to register, after which the bot restores their voice), and a registered, unblocked player simply
writes. An operator can also **mute a player in the chat only** — a `chat_muted` role on the user card —
without a full account block; an account block mutes them in the chat regardless. Muting and unmuting
+41 -19
View File
@@ -32,12 +32,13 @@ top-1 подсказку, безлимитную проверку слова с
главнее. Страница несёт статическую русскую SEO-«шапку» (title/description, карточка
Open Graph, которую используют превью ссылок в Telegram/VK, канонический адрес
продакшен-домена, JSON-LD, набор favicon и `robots.txt`), а оболочка SPA помечена
`noindex` — индексируется только посадочная страница. В подвале — ссылки на **публичную
оферту** (`/offer/`) и рядом на **обратную связь** (тот самый Telegram-бот, который оферта
указывает как контакт Продавца). Страница оферты (юридический документ, который принимается при
покупке) рендерится по запросу из `ui/legal/offer_ru.md`, а её **перечень стоимости** (§4.4)
формируется из живого каталога товаров, поэтому опубликованные цены всегда совпадают с тем, что
сейчас в продаже — без передеплоя.
`noindex` — индексируется только посадочная страница. В подвале — ссылки на три **юридических
документа** — **пользовательское соглашение** (`/eula/`), **политику конфиденциальности**
(`/privacy/`) и **публичную оферту** (`/offer/`) — и рядом на **обратную связь** (тот самый
Telegram-бот, который документы указывают как контакт Продавца). Каждый — **двуязычный (RU/EN)** с
переключателем языка и темы, рендерится из исходников `ui/legal/*_{ru,en}.md` сайдкаром-рендерером;
оферта дополнительно подставляет **перечень стоимости** (§4.4), формируемый из живого каталога
товаров, поэтому опубликованные цены всегда совпадают с тем, что сейчас в продаже — без передеплоя.
В обычном вебе клиент — **устанавливаемое PWA**: незалогиненный игрок видит призыв к установке
под формой входа (и внизу «Настроек»), который в один тап ставит приложение на рабочий стол
@@ -51,7 +52,7 @@ Open Graph, которую используют превью ссылок в Tel
следующей; после последней подсказки оверлей убирается навсегда. Серий две. Серия **лобби**
указывает на вкладки ⚙️ настроек, ✏️ статистики и 🎲 новой игры. Серия **игры**, на первой партии
игрока, указывает на шапку со счётом (история ходов / чат / проверка слова), кнопки 🔄 пас-и-обмен,
🛟 подсказок и 🔀 перемешивания, и на стойку с фишками. Серия считается просмотренной только
подсказок и 🔀 перемешивания, и на стойку с фишками. Серия считается просмотренной только
**после последней подсказки**, поэтому выход на середине в следующий раз покажет её с начала;
запоминается она по устройству. Триггер серии лобби — попадание в лобби, так что игрок, пришедший
по deeplink сразу в Настройки → Друзья, всё равно получит проводку по лобби при первом возврате.
@@ -76,7 +77,10 @@ launch-параметрам VK (их проверяет gateway), и при пе
так как VK не кладёт имя в подписанный запуск). Пока тема в режиме «авто», приложение следует
светлой/тёмной схеме VK-клиента, а раскладка обходит нижнюю home-bar VK на мобильных. Тот же экран
тихого повтора «не удалось
загрузить» действует и внутри VK.
загрузить» действует и внутри VK. Если открыть ссылку на мини-приложение прямо в обычном браузере —
вход `/vk/` без подписанного запуска VK или `/telegram/` без данных входа Telegram — показывается
компактный экран диагностики, которым можно поделиться, вместо того чтобы продолжить (гостем-однодневкой
на VK или редиректом прочь на Telegram), — чтобы игрок, случайно попавший туда, прислал нам скриншот.
**Email**-вход (в вебе) отправляет на адрес брендированный шестизначный код подтверждения —
локализованный (ru/en), без картинок; после ввода игрок входит. Новый адрес заводится при первом
запросе, но становится постоянным аккаунтом только после подтверждения кода — так брошенная,
@@ -117,12 +121,14 @@ Telegram держит **единого бота**: все игроки поль
привязывает email (по confirm-коду) или свой Telegram (через веб-вход); гость,
привязавший первую личность, становится постоянным аккаунтом. Факт «личность уже
занята» не раскрывается до проверки кода/входа. Если привязываемая личность уже
принадлежит другому аккаунту, игроку показывают явное **необратимое**
подтверждение, и два аккаунта сливаются в тот, под которым он сейчас работает
(статистика суммируется, игры и друзья переносятся, дубликаты убираются), — кроме
случая, когда гость привязывает личность с уже существующим постоянным аккаунтом:
тогда сохраняется постоянный аккаунт, а игры гостя переходят в него. Слияние
запрещено, только пока у аккаунтов есть общая незавершённая игра.
принадлежит другому аккаунту, два аккаунта сливаются в один (статистика суммируется,
игры и друзья переносятся, кошелёк складывается, дубликаты убираются). **Постоянному**
инициатору сначала показывают явное **необратимое** подтверждение — объединение двух
настоящих аккаунтов важно. **Гостю** — нет: сохраняется постоянный аккаунт-владелец
личности, а эфемерный гость (с его играми, кошельком и статистикой) ретайрится
**бесшовно**, так что со стороны игрока это просто вход в свой аккаунт (приложение
переключается на него без запроса). Слияние запрещено, только пока у аккаунтов есть
общая незавершённая игра; гостя тогда просят сперва доиграть эту общую партию.
В профиле перечислены **способы входа** аккаунта. В вебе игрок может добавить
Telegram (попап логин-виджета) или VK (веб-вход VK ID — редирект на страницу входа VK и
@@ -232,7 +238,9 @@ e-mail) либо ввод фразы. Активные игры форфейтя
короткий прогрев, — и уходит на сервер, если локальный словарь недоступен. Инструмент проверки слова безлимитный и
предлагает пожаловаться на любой результат; для найденного слова он также даёт ссылку на
внешний справочный словарь (gramota.ru для русских игр, scrabblewordfinder.org для английских),
чтобы его посмотреть. Подсказки управляются настройками
чтобы его посмотреть. В офлайне в онлайн-партии проверка идёт по собственному словарю партии на
устройстве — тот же вердикт, что и на сервере, если этот словарь доступен, иначе показывается
*недоступно офлайн*, — а жалоба и внешняя ссылка, которым нужна сеть, скрываются. Подсказки управляются настройками
партии — разрешены ли они и сколько их у каждого игрока на старте — и расходуют
личный кошелёк подсказок после исчерпания внутриигрового лимита. Против робота
(**vs_ai**) подсказки, наоборот, **безлимитны и без кошелька**, но с idle-гейтом как
@@ -323,6 +331,16 @@ e-mail) либо ввод фразы. Активные игры форфейтя
кратковременный сбой пережидается тихо, и только устойчивая потеря переводит интерфейс в офлайн, так что
вас не прерывают из-за короткой икоты.
**Потеря связи внутри онлайн-партии** показывается на **всех платформах, включая мини-приложения
Telegram/VK** — в отличие от офлайн-режима игры выше, который только для веба и нативного приложения, —
потому что онлайн-партии сервер нужен на каждый ход. Сверху доски появляется тонкий баннер *связь
потеряна*, и игровая область **замораживается**: рэк и ходовые кнопки отключаются, а «сдаться»,
«в друзья»/«заблокировать» и вход в чат/словарь скрываются; начатый ход остаётся на доске черновиком.
Ничего не отправляется;
когда связь возвращается, баннер исчезает, кнопки оживают, и ход отправляешь ты сам. Если связь упала,
когда ты уже был в чате или словаре, ты там и остаёшься — чат становится «только для чтения» (отправка
и nudge отключаются), а словарь продолжает проверять слова по словарю партии на устройстве.
### Актуальная версия
Когда требуется новая версия клиента, приложение не блокирует вас наглухо. В **нативном** приложении
слишком старая сборка показывает уведомление с **Обновить** (открывает магазин) и **Играть офлайн**
@@ -441,9 +459,11 @@ Telegram. На сенсорном устройстве в настройках
обратную связь (роль, а не полная блокировка аккаунта), видит кнопку отправки недоступной.
### Чат поддержки в Telegram
С операторами можно поговорить и прямо из Telegram-бота: всё, что пользователь присылает боту,
кроме `/start` — текст, фото, голосовое, файл, — пересылается в закрытую группу поддержки операторов
и собирается в отдельную ветку на пользователя. Пользователь не получает автоответа; оператор
С операторами можно поговорить и прямо из Telegram-бота. Команда `/support` отвечает графиком работы
поддержки и напоминанием, что приложить (подробное описание и, по возможности, скриншоты). Всё
остальное, что пользователь присылает боту, кроме `/start` — текст, фото, голосовое, файл, —
пересылается в закрытую группу поддержки операторов и собирается в отдельную ветку на пользователя.
Пользователь не получает автоответа на пересланное сообщение; оператор
отвечает из этой ветки, и бот доставляет ответ обратно обычным сообщением — для пользователя это
тихий диалог один на один. Операторы могут заблокировать пользователя (тогда бот молча игнорирует
его сообщения) или очистить сообщения ветки. Это независимо от встроенной «Обратной связи» выше —
@@ -549,7 +569,9 @@ high-rate флага. С карточки пользователя операт
Там, где бот ведёт **привязанный к каналу чат-обсуждение**, по умолчанию писать может каждый, а бот
**глушит** игрока, который **не зарегистрирован** или **заблокирован**, и снимает мьют, как только тот
зарегистрируется или будет разблокирован. То есть незарегистрированного новичка, написавшего в чат,
зарегистрируется или будет разблокирован. Ещё бот убирает автоматический пин, который Telegram ставит
на каждый пост канала, авто-форварднутый в чат, — закреплёнными остаются только намеренно закреплённые
сообщения. То есть незарегистрированного новичка, написавшего в чат,
бот глушит (промо-бот направляет его в игру зарегистрироваться, после чего бот возвращает голос), а
зарегистрированный незаблокированный игрок просто пишет. Оператор также может **замьютить игрока только
в чате** — роль `chat_muted` на карточке пользователя — без полной блокировки аккаунта; блокировка

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