Compare commits

..

406 Commits

Author SHA1 Message Date
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 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 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 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 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 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
Ilia Denisov 2d1fadb50c feat(offline): implicit net-state model, two-tier version gate, unified lobby
CI / changes (pull_request) Successful in 2s
CI / unit (pull_request) Successful in 10s
CI / integration (pull_request) Successful in 20s
CI / ui (pull_request) Successful in 1m12s
CI / conformance (pull_request) Successful in 10s
CI / gate (pull_request) Successful in 0s
CI / deploy (pull_request) Successful in 1m54s
Land the offline-model redesign (ANDROID_PLAN.md O1-O7): replace the explicit offline toggle with a single detected net-state machine, unify the lobby, and add a two-tier client-version gate. Contour-safe: both version vars empty => dormant, the wire change is additive, so web/pwa/vk/tg behaviour is unchanged unless the gate is deliberately configured.

O1 net-state reducer (test-first). O2 store + wiring (connection/offline shims; +@capacitor/network). O3 remove the offline toggle + migrate the pref. O4 two-tier gate: hard update_required degrades to an offline Update/Play-offline notice (not terminal); soft GATEWAY_RECOMMENDED_CLIENT_VERSION -> X-Update-Recommended nudge. O5 unified lobby (device-local + greyed-from-cache server games; self-set identity; closes G-step-0). O6 create flows (with-friends online/offline segment + offline dict guard). O7 docs.

Telegram/VK stay online-only (contour review): the offline model is channel-gated via offlineCapable() (native + plain web; false in the mini-apps). offlineMode.active is hard-gated on it, so the whole model (blue chrome, unified/greyed lobby, transport kill switch, device-local vs_ai/hotseat create) stays inert in Telegram/VK regardless of the detected net state (closing a version-lock path that could have flipped them offline); and New Game's with-friends hides the online/offline segment there, leaving the remote invite alone. ARCHITECTURE already declared "Telegram/VK are exempt" - this makes the code match.

Deploy/CI: wire the version gate through the deploy (GATEWAY_MIN_CLIENT_VERSION + GATEWAY_RECOMMENDED_CLIENT_VERSION as plain unprefixed vars via compose + ci.yaml + prod-deploy.yaml + write-prod-env.sh + .env.example) so the gate is live when set (test-contour commit-hash version fails open; empty => dormant). Disable the manual android-build CI workflow for now (rename .disabled). Bump the app bundle budget 127->130 KB for the added always-loaded wiring. Fix the UI Docker stage (gateway/Dockerfile): install --ignore-scripts so the Alpine/musl SPA build no longer tries to native-build sharp (a local android:assets tool, unused in the image); esbuild's binary is an optional dep so Vite still builds.

Tests: gateway go green (two-tier gate over a real HTTP handler); docker build of the landing + gateway targets green locally; compose --no-interpolate confirms the gateway env; two new telegram.spec.ts e2e (offline signal keeps the chrome online + quick-match opponent choice visible => server enqueue; with-friends shows no pass-and-play), RED-verified; svelte-check 0/0, vitest 617, e2e 248 (chromium + webkit), build + bundle-size 127.8/130.
2026-07-13 02:50:41 +02:00
Ilia Denisov 09e05eef18 docs(offline): stage the offline-model redesign (O1-O7 implementation plan)
Append the O1-O7 implementation stages to ANDROID_PLAN.md's "Offline-model
redesign" section (exact files, produced/consumed interfaces, acceptance
criteria, targeted tests; executed via stage-implementation, TDD from the pure
netState reducer in O1). Flag it in Progress as the next actionable work
(gated G-step-0), self-contained for a fresh session to resume from.
2026-07-12 22:07:49 +02:00
Ilia Denisov 73baf58002 docs(offline): design the offline-model redesign (net-state machine + two-tier gate)
Owner-approved brainstormed design in ANDROID_PLAN.md, resolving G-step-0:
remove the explicit offline toggle -> a single net-state machine
(online / connecting / offlineNoNetwork / offlineVersionLocked) with explicit
hysteresis + 12 enumerated edge cases; unify the lobby (server games greyed
from cache offline; identity recognises both ids); two-tier version gate (hard
degrades to forced offline with an "Update / Play offline" notice; new soft
recommended tier via an additive X-Update-Recommended header); keep server
vs_ai (offline->local, online->server, app decides by state); TG/VK
always-online. Exhaustive test matrix. Client + a small additive backend/wire
bit; contour-safe.
2026-07-12 21:57:40 +02:00
Ilia Denisov eec225c4ee docs(android): bake the version gate, identity model + native build into the docs
ARCHITECTURE §2 (client-version gate + frozen wire contract + gate x offline),
§3 (local-guest / server-guest / reconciliation identity), §13 (native Capacitor
build). FUNCTIONAL (+_ru) a new "Native app (Android)" domain. TESTING (the
clientver/gate Go tests, the native/update e2e + retry mapping, and the manual
on-device Android smoke checklist). deploy/README (GATEWAY_MIN_CLIENT_VERSION +
the wire-break bump discipline + the Android build/release runbook). ui/README
native VITE_* vars. Mark F done in ANDROID_PLAN.md.
2026-07-12 20:38:58 +02:00
Ilia Denisov ca2c6487cf feat(android): manual signed-APK CI workflow
Add .gitea/workflows/android-build.yaml (workflow_dispatch + confirm=build,
master-only; mirrors prod-deploy): builds the native SPA, bundles the offline
dicts, assembles a release APK as a run artifact. JDK 21 via setup-java; the
Android SDK is host-provisioned (host-executor runner) with a fail-fast verify
that also checks the runner user's access. Signing degrades gracefully — no
keystore => unsigned release APK, not a failure.

Wire ui/android/app/build.gradle: versionCode/versionName from the release tag
(-P props; '=' assignment, not the command form that binds .toInteger() to the
DSL setter's null return), plus a guarded signingConfigs.release from env.

Rename VITE_STORE_URL -> VITE_RUSTORE_URL (empty until publish).

Bake the E as-built into ANDROID_PLAN.md.
2026-07-12 20:27:07 +02:00
Ilia Denisov e7cb60c996 docs(android): record D on-device smoke + edge-to-edge fix as-built
Reconcile the Progress/§D status after this session: D is code-complete,
e2e-verified AND on-device-smoke-verified (Pixel_10 / API 37, airplane mode);
the smoke surfaced + fixed the edge-to-edge safe-area bug (top + bottom).
Mark D.5 done (verified by the e2e + the on-device vs_ai turn). Remaining for
D: the reconcile-online leg on-device (hits prod) + the deferred local-game-
visibility decision.
2026-07-12 19:31:22 +02:00
Ilia Denisov 49c53794f4 fix(ui): native header top safe-area under edge-to-edge
The earlier safe-area fix reached the bottom bars (they apply
--tg-safe-bottom) but not the header: its top inset lived only in a
Telegram-fullscreen-scoped rule, so on the native build the header .bar had
just 5px top padding and its content sat under the status bar (measured: the
title at y=11px behind the 54px bar; the back button untappable). Give .bar a
top inset from the SystemBars plugin's --safe-area-inset-top (native-only via
the plugin var; unset -> 0 on web/PWA/Telegram/VK, so no regression;
tg-fullscreen still overrides via specificity). Verified on-device (Pixel_10 /
API 37): the title moved from y=11 to y=65, clear of the status bar.
2026-07-12 19:22:12 +02:00
Ilia Denisov 4a0689a4ac fix(ui): native safe-area under Android 15+ edge-to-edge (WebView < 140)
targetSdk 36 forces edge-to-edge, so the WebView draws behind the system
bars. On Android WebView < 140, env(safe-area-inset-*) wrongly reports 0, so
the app chrome (which only read env()) drew under the status bar (top nav
untappable) and the gesture-nav home indicator (the game's centre Hint button
intercepted; side buttons fine). Capacitor 8's SystemBars core plugin (built
into @capacitor/core, insetsHandling:'css' by default) injects the correct
--safe-area-inset-* values on every WebView; consume them ahead of env():

  --tg-safe-*: var(--safe-area-inset-*, env(safe-area-inset-*, 0px))

Web / PWA / Telegram / VK are unchanged (--safe-area-inset-* is unset there,
so it falls back to env()). Verified on-device (Pixel_10 / API 37) via the
injected var — the emulator's auto-updated WebView 149 hides the env() bug,
so the visual alone won't reproduce it.
2026-07-12 18:58:07 +02:00
Ilia Denisov 0eb72ba955 feat(ui): hide Telegram/VK link buttons on the native build
The native (Capacitor) sign-in surface is guest + email only: VK ID
web-login is a full-page redirect to id.vk.com that cannot return into the
WebView, and the Telegram Login Widget is unreliable there. Profile now
gates telegramLinkable/vkLinkable on !nativeShell (clientChannel android/
ios), hiding both LINK buttons on native; email and account management —
including an existing link's redirect-free UNLINK — stay. Native tg/vk
login (native SDKs / deep-link OAuth) is a separate later stage.

Covered by e2e/native.spec.ts (the reconcile test opens Profile and asserts
no Link Telegram / Link VK buttons, email present).
2026-07-12 18:09:11 +02:00
Ilia Denisov e077258567 feat(ui): offline-first native boot + lazy server-guest reconciliation
Native (Capacitor) cold boot with no cached session now enters as a
device-local guest in auto-offline mode and lands straight in the lobby
(never /login), so the app opens and plays local vs_ai / hotseat with the
APK's bundled dictionaries and zero network. When the gateway becomes
reachable, reconcileServerGuest silently mints + adopts a server guest and
clears the auto-offline, lighting up online features. Web / PWA / Telegram /
VK are byte-for-byte unchanged.

- transport: exec gains { silent, allowOffline } so background reconciliation
  bypasses the offline kill switch (like the reachability probe) and never
  raises the terminal update overlay (a too-old client stays a local guest);
  new authGuestSilent on the client interface, the real transport and the mock.
- app: native no-session boot branch; reconcileServerGuest fired at boot, by
  the recovery poll and the online event; the poll routes the session-less
  guest through reconciliation, since checkReachable needs a token it lacks.
- native: initNativeShell tolerates a missing Capacitor bridge.
- e2e: new native.spec.ts (inject window.androidBridge; boot -> offline lobby,
  local vs_ai move, reconcile -> online, hotseat start) + playwright.config
  bundles the dawgs into dist-e2e/dict for the loader's bundled tier.
2026-07-12 18:03:52 +02:00
Ilia Denisov a035edfb54 docs(android): detail Stage D progress + remaining path + session decisions
Make ANDROID_PLAN.md self-contained so a fresh session resumes Stage D from the repo
alone. Records:
- the done foundations (D.1 bundled dicts + loader tier, D.2 local-guest identity) with
  their exact modules and the committed checkpoint (bcd5a1d);
- the session decisions (owner-approved): the blocking Login is bypassed on native only,
  soft registration reuses the Profile screen, the Telegram/VK link buttons are hidden on
  native (VK's web redirect strands the Capacitor app; native tg/vk is a later stage),
  local-guest name = localized common.guest;
- the native-gated loader-tier correction (a web `./dict/` fetch would hit the gateway's
  own session-gated /dict/ route, so the bundled tier is only tried on native);
- the detailed remaining path — D.3 boot rewrite with the exact blocking-login site
  (app.svelte.ts ~989-991), D.4 reconciliation + the silent seam wiring, D.6 Profile
  tg/vk gating — plus the offline-first e2e strategy (simulate native by injecting
  window.Capacitor) and its initNativeShell/@capacitor/app gotcha.
2026-07-12 17:01:23 +02:00
Ilia Denisov bcd5a1d02d feat(ui): offline-first foundations — bundled dicts + local-guest identity
The additive groundwork for the native offline-first experience (ANDROID_PLAN.md §D).
Inert until the native cold-boot lands: on the web these paths never fire, so web / VK /
Telegram behaviour is unchanged.

- ui/scripts/bundle-dicts.mjs copies the scrabble-dictionary release DAWGs into
  dist/dict/<variant>@<version>.dawg for the native pipeline (run after `pnpm build`,
  before `cap sync`).
- The dict loader gains a bundled tier between the IndexedDB and network tiers, attempted
  only on a native channel (a packaged app serves ./dict/<key>.dawg from its assets).
  Native-gated rather than the plan's "404 on the web" because the relative path would
  otherwise hit the gateway's own session-gated /dict/ route.
- __DICT_VERSION__ Vite define (from VITE_DICT_VERSION, default "dev") + its ambient
  declaration; the offline vs_ai / hotseat creates in NewGame fall back to it when a
  device-local guest has no profile-advertised version.
- New lib/localguest.ts: a persisted device-local guest id (no DB row); the offline vs_ai
  human seat uses it (and the localized common.guest name) when there is no server session.

svelte-check clean, vitest green, native + web builds clean. The cold-boot rewrite,
reconciliation, the Profile soft-sign-in gating and the offline-first e2e follow.
2026-07-12 16:41:19 +02:00
Ilia Denisov a57fd355ba feat(gateway,ui): client-version gate — turn away too-old builds
Introduce a minimum-supported-client gate so a future incompatible wire change
can turn away installed builds too old to speak it, cleanly, instead of letting
them crash on decode. It rides the outermost stable layer (an HTTP header), never
the FlatBuffers payload.

Gateway:
- New internal/clientver: dependency-free parse + compare of the leading
  MAJOR.MINOR.PATCH (a git-describe suffix is tolerated).
- GATEWAY_MIN_CLIENT_VERSION config (empty => gate dormant; validated at load).
- connectsrv checks the X-Client-Version header before decoding the payload:
  Execute returns result_code="update_required" (before the registry lookup),
  Subscribe returns FailedPrecondition. It fails open on an absent or garbled
  header — the header is a client-controlled compatibility signal, not an access
  control.

Client:
- Attach X-Client-Version on every call.
- A terminal update.svelte.ts store + a non-dismissable UpdateOverlay (native
  opens VITE_STORE_URL, web reloads); retry.ts maps FailedPrecondition to the
  update_required sentinel; a mock __update hook drives the e2e.

Wire-additive and contour-safe: no FBS/proto regen, no schema migration; the gate
stays dormant until GATEWAY_MIN_CLIENT_VERSION is deliberately set, so web / VK /
Telegram behaviour is unchanged. The silent reconciliation seam is deferred to the
offline-first work (its only caller). Tests: Go clientver/config/connectsrv gate
tests, retry.test.ts, Playwright update.spec.ts.
2026-07-12 15:47:41 +02:00
Ilia Denisov 2ea91a8354 fix(e2e): pin Web Share off in the desktop export tests (macOS WebKit)
The three "plain desktop browser" export tests (the PNG and GCG downloads plus the
legacy-Telegram clipboard copy) assumed navigator.share is absent — true for Chromium
and for WebKit on the Linux CI host, but desktop WebKit on macOS exposes a working Web
Share API. There shareUrlAsFile / pickGcgDelivery take the share branch, so no download
event fires and the "GCG copied to the clipboard" toast never shows, and the tests
failed only on macOS WebKit.

Pin navigator.share/canShare off in those three tests (a withoutWebShare helper that
mirrors the inverse stub the share-sheet test already uses), so the delivery path under
test is deterministic and identical across engines and OSes. Test-only; no production
change. Full e2e suite: 228 passed on Chromium + WebKit.
2026-07-12 15:14:31 +02:00
Ilia Denisov de003e862a feat(android): resolve gateway origin on native, skip SW, hide MVP purchases
Make the web SPA behave correctly inside the Capacitor native shell, where the
bundle loads from a local origin:

- New lib/origin.ts gatewayOrigin(): absolute URLs resolve to VITE_GATEWAY_URL on
  native (the finished-game export/share URL in Game.svelte and the Wallet
  site-root link), falling back to the page origin on web. transport.ts already
  resolved via VITE_GATEWAY_URL and is left as-is.
- Skip the PWA service worker on the native channel (the assets are already local;
  a worker would risk serving stale content across store updates).
- Hide the money-purchase UI in the MVP: new distribution.purchasesHidden() folds
  VITE_PAYMENTS_DISABLED and the Google Play flag. The Wallet "buy" tab shows a
  neutral, pointer-free note (wallet.purchasesSoon) for the RuStore MVP and keeps
  the RuStore stub Google-Play-only. A ?nopay mock force mirrors ?gp for the e2e.
- Native env types in vite-env.d.ts.

Client-only, contour-safe: no wire/proto/schema change; web/VK/Telegram unchanged.
Tests: origin + purchasesHidden unit tests, a ?nopay wallet e2e. svelte-check
clean, vitest green, web + native vite build clean.
2026-07-12 14:58:35 +02:00
Ilia Denisov aaf2825260 feat(android): add temporary «Э» launcher icon (pending full rebrand)
Generate the Android launcher/adaptive icons + splashes with capacitor-assets from ui/assets/icon.png (the existing maskable brand mark upscaled 512→1024) as a placeholder until the mandatory single-vector icon rebrand recorded in ANDROID_PLAN.md. The adaptive icon insets the foreground 16.7% into the safe zone; the AndroidManifest is untouched (its icon refs already point at @mipmap).

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-12 02:05:46 +02:00
Ilia Denisov 0f5db0ee91 feat(android): scaffold Capacitor 8 native project + hardware Back button
Add @capacitor/{core,android,app,cli,assets} to ui/, a capacitor.config.ts (appId ru.eruditgame.app, appName Эрудит, webDir dist — bundle model, no server.url), and the generated ui/android/ Gradle project (tracked; build outputs and the machine-specific local.properties gitignored, keystore patterns un-commented so signing material can never be committed). Wire the Android hardware Back button in ui/src/lib/native.ts behind a dynamic @capacitor/app import (web/mock bundles never load it), called from App.svelte onMount and reusing routeDepth for the navigation-root check. Whitelist sharp in pnpm-workspace.yaml for @capacitor/assets icon generation.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-12 01:34:25 +02:00
Ilia Denisov 53b33073ac docs(android): fix plan (dict source, Capacitor 8 / SDK 36 / JDK 21) and record scaffolding progress + native-build notes
Correct ANDROID_PLAN.md: bundled offline dictionaries come from the scrabble-dictionary release (DICT_VERSION), not scrabble-solver; pin the toolchain to Capacitor 8 (compileSdk/targetSdk 36, minSdk 24, JDK 21 — @capacitor/android compiles at Java 21). Add a Progress section marking the scaffolding milestone done, and capture the native-Android build recipe + toolchain gotchas in .claude/CLAUDE.md so a fresh session resumes without re-deriving them.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-12 01:34:25 +02:00
developer e3c5cff7b7 Merge pull request 'docs: Android native plan + agent field notes' (#248) from feature/android-native into development
CI / changes (push) Successful in 2s
CI / unit (push) Has been skipped
CI / integration (push) Has been skipped
CI / ui (push) Has been skipped
CI / conformance (push) Has been skipped
CI / gate (push) Successful in 0s
CI / deploy (push) Has been skipped
2026-07-11 21:02:05 +00:00
Ilia Denisov 0c5e40c509 docs: capture agent field notes; drop RuStore-steering for the GP variant
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) Has been skipped
CI / conformance (pull_request) Has been skipped
CI / gate (pull_request) Successful in 0s
CI / deploy (pull_request) Has been skipped
2026-07-11 22:53:26 +02:00
Ilia Denisov edaf2dfd9e docs: add ANDROID_PLAN.md — native Android (Capacitor) RuStore MVP plan 2026-07-11 22:42:41 +02:00
developer 3ce460f72a Merge pull request 'feat(edge): community IP blocklist (Spamhaus DROP) at the gateway' (#246) from feature/edge-blocklist into development
CI / changes (push) Successful in 2s
CI / unit (push) Successful in 12s
CI / integration (push) Successful in 24s
CI / ui (push) Successful in 1m11s
CI / conformance (push) Successful in 10s
CI / gate (push) Successful in 0s
CI / deploy (push) Successful in 1m53s
CI / changes (pull_request) Successful in 2s
CI / unit (pull_request) Successful in 11s
CI / integration (pull_request) Successful in 25s
CI / ui (pull_request) Successful in 1m11s
CI / deploy (pull_request) Has been skipped
CI / conformance (pull_request) Successful in 10s
CI / gate (pull_request) Successful in 0s
2026-07-11 11:39:47 +00:00
Ilia Denisov 4ba9da6721 feat(edge): community IP blocklist (Spamhaus DROP) at the gateway
CI / changes (pull_request) Successful in 2s
CI / unit (pull_request) Successful in 10s
CI / integration (pull_request) Successful in 24s
CI / ui (pull_request) Successful in 1m11s
CI / conformance (pull_request) Successful in 10s
CI / gate (pull_request) Successful in 0s
CI / deploy (pull_request) Successful in 1m49s
Refuse a client whose IP is in a curated CIDR feed with 403 in the same
abuseGuard, before the fail2ban ban. Prod-only (keys by real client IP), off by
default.

- ratelimit.Blocklist: a sorted-range IPv4 matcher (binary search) with an
  allowlist checked first; ParseDROP reads the feed; ApplyRefresh keeps the
  last-good feed on a transient fetch failure and drops it fail-open once stale
  (better to under-block than block a legitimate client on a frozen feed). A
  separate static CIDR set, not the per-IP fail2ban store.
- gateway: a refresher goroutine re-fetches every few hours (bounded fetch + size
  cap); config GATEWAY_BLOCKLIST_{ENABLED,URL,ALLOW,REFRESH,MAX_STALENESS}.
  IPv6 is not matched (a v6 client is still covered by fail2ban / the honeypot).
- observability: gateway_blocklist_blocked_total + entries/age gauges; a Grafana
  alert warns before the feed is dropped; service-overview panels.
- deploy: compose env + write-prod-env.sh + prod-deploy/rollback wiring (opt-in
  via PROD_ vars).
- docs (ARCHITECTURE, deploy/README); unit tests (match, allowlist, IPv6 skip,
  parser, fault-tolerance) + a 403 abuse-guard integration test.
2026-07-11 13:33:28 +02:00
developer ad1cc361e9 Merge pull request 'feat(bot): Telegram bot Bot API health over the bot-link (metrics + alerts)' (#245) from feature/bot-health-telemetry into development
CI / changes (push) Successful in 1s
CI / unit (push) Successful in 11s
CI / integration (push) Successful in 23s
CI / ui (push) Successful in 1m10s
CI / conformance (push) Successful in 10s
CI / gate (push) Successful in 0s
CI / deploy (push) Successful in 1m55s
2026-07-11 10:58:01 +00:00
Ilia Denisov 2c2316fb5e chore(catalog): order the admin catalog list like the public offer
CI / changes (pull_request) Successful in 2s
CI / unit (pull_request) Successful in 11s
CI / integration (pull_request) Successful in 26s
CI / ui (pull_request) Successful in 1m11s
CI / conformance (pull_request) Successful in 9s
CI / gate (pull_request) Successful in 0s
CI / deploy (pull_request) Successful in 1m50s
Sales (chip packs) first, ascending by rouble price; then the chip-exchange
values, grouped and price-sorted the same way projectOfferPricing lists them, so
the /catalog console mirrors what a buyer sees. Internal cosmetics only — no
product behaviour change. The value-group order moves to a shared helper
(valueGroup) so the offer and the admin list cannot drift.
2026-07-11 12:53:37 +02:00
Ilia Denisov bb71e7b1c7 feat(bot): report Telegram bot Bot API health to the gateway over the bot-link
CI / changes (pull_request) Successful in 2s
CI / unit (pull_request) Successful in 12s
CI / integration (pull_request) Successful in 26s
CI / ui (pull_request) Successful in 1m11s
CI / conformance (pull_request) Successful in 10s
CI / gate (pull_request) Successful in 0s
CI / deploy (pull_request) Successful in 1m56s
The bot runs on its own host and exports no telemetry (otelcol is unreachable
from there), so it was a monitoring blind spot. Observe its Bot API health
centrally by wrapping the HTTP client — one place, no per-call-site
instrumentation — and relay it up the existing bot-link as a periodic Health
message the gateway turns into its own metrics.

- proto: add Health (delta connect / api / 429 counters + a last-ok stamp) to
  the FromBot oneof (additive, backward-compatible).
- bot: platform/telegram/internal/health wraps the Bot API HTTP client — it
  stamps liveness on any 2xx (so the getUpdates long-poll keeps it fresh even
  when idle), classifies transport/5xx failures (getUpdates vs other) and 429s,
  and honours a 429's Retry-After (bounded) so the bot backs off; a 4xx other
  than 429 is a normal per-request outcome and is not counted.
- bot-link client: flush the reporter as a Health message every 30s over a
  single-sender loop (a gRPC stream forbids concurrent Send).
- gateway: fold each report into bot_tg_errors_total{kind} and the
  bot_tg_last_ok_unix liveness gauge.
- grafana: alerts (bot disconnected, bot not reaching the Bot API, sustained
  429s) routed to the operator email — which does not go through the bot — plus
  a Telegram-bot dashboard.
- docs (ARCHITECTURE, compose comments); unit tests (observer classification,
  Retry-After, snapshot/commit, hub last-ok monotonicity).
2026-07-11 12:48:06 +02:00
developer 5c1f64c7d1 Merge pull request 'feat(offer): live catalog price list in the public offer (render sidecar)' (#244) from feature/offer-pricing-live into development
CI / changes (push) Successful in 2s
CI / unit (push) Successful in 12s
CI / integration (push) Successful in 19s
CI / ui (push) Successful in 1m11s
CI / conformance (push) Successful in 10s
CI / gate (push) Successful in 0s
CI / deploy (push) Successful in 1m48s
2026-07-11 09:51:48 +00:00
Ilia Denisov b3cf024e9e chore(offer): fix typo
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 1m11s
CI / conformance (pull_request) Successful in 10s
CI / gate (pull_request) Successful in 0s
CI / deploy (pull_request) Successful in 1m54s
2026-07-11 11:45:45 +02:00
Ilia Denisov 6e120bdaa7 docs(offer): update phrasing
CI / changes (pull_request) Successful in 3s
CI / unit (pull_request) Successful in 10s
CI / integration (pull_request) Successful in 20s
CI / ui (pull_request) Successful in 1m10s
CI / conformance (pull_request) Successful in 9s
CI / gate (pull_request) Successful in 0s
CI / deploy (pull_request) Successful in 2m24s
2026-07-11 11:39:43 +02:00
Ilia Denisov 40acbcccdd feat(offer): sort and align the §4.4 price tables, style them for both themes
CI / changes (pull_request) Successful in 2s
CI / unit (pull_request) Successful in 12s
CI / integration (pull_request) Successful in 20s
CI / ui (pull_request) Successful in 1m10s
CI / conformance (pull_request) Successful in 9s
CI / gate (pull_request) Successful in 0s
CI / deploy (pull_request) Successful in 1m47s
- packs sorted by ascending rouble price;
- values grouped hints-only -> no-ads-only -> no-ads+hints -> tournament
  (the tournament group is empty until such products become sellable),
  ascending chip price within each group;
- price columns right-aligned (GFM "---:" separators);
- tables span the full content width; the name column shrinks to its content
  and never wraps;
- muted-but-visible cell borders on the dark theme, where the section rule
  colour blends into the background.
2026-07-11 11:29:41 +02:00
Ilia Denisov b54371845f harden(offer): escape admin product titles against HTML/markdown injection
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 1m11s
CI / conformance (pull_request) Successful in 11s
CI / gate (pull_request) Successful in 0s
CI / deploy (pull_request) Successful in 1m21s
The offer price list projects the admin-entered product title into a markdown
table cell that is rendered, unsanitised, into the public /offer/ page. Escape
the title at the projection boundary so it renders as literal text: HTML
metacharacters become entities and the markdown table pipe and link brackets are
escaped, so a title can neither inject markup nor form a javascript: link. The
committed offer prose keeps its trusted-content treatment (code-reviewed, not
runtime input).
2026-07-10 20:30:36 +02:00
Ilia Denisov b6c2598710 feat(offer): live catalog price list in the public offer, served by the render sidecar
CI / changes (pull_request) Successful in 2s
CI / unit (pull_request) Successful in 11s
CI / integration (pull_request) Successful in 26s
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) Failing after 16m19s
Robokassa moderation requires the public offer to list every digital good with
its price. Move /offer/ off the static landing container to the render sidecar:
it splices the live catalog price list (§4.4) into the owner-edited
ui/legal/offer_ru.md and renders it with the shared ui/src/lib/offer.ts — one
renderer, no drift, always matching the current catalog with no redeploy.

- backend: /api/v1/internal/offer/pricing (internal, off the edge allow-list)
  projects the active catalog into two markdown tables — chip packs priced per
  rail (roubles / VK votes / Telegram Stars) and chip-priced values — through
  payments.Money so no float reaches the page. Cached in memory: warmed at boot,
  marked stale on every catalog mutation, so a served render issues no query.
- renderer: GET /offer/ fetches the tables and substitutes them at the
  <#pricing_template#> marker, then renders; offer_ru.md is baked into the image
  and marked is bundled from ui. GET /offer -> 301. Only /offer/ is edge-exposed.
- caddy: route /offer/ to the sidecar; drop the now-dead landing /offer/
  handlers and the vite emit-offer plugin.
- offer: fill §4.3 (the chip-payment wording) and drop the in-page back link.
- landing footer: a feedback link (the offer's Telegram contact) beside the
  offer link.
- docs (ARCHITECTURE, FUNCTIONAL +_ru, renderer README), CI /offer/ probe,
  unit + integration + node tests.
2026-07-10 20:25:41 +02:00
developer a241e43d79 Merge pull request 'Wallet redesign: split buy/spend, compact balance, exchange confirm + insufficient-chips fix' (#242) from feature/wallet-redesign into development
CI / changes (push) Successful in 2s
CI / deploy (push) Successful in 1m56s
CI / conformance (pull_request) Successful in 10s
CI / gate (pull_request) Successful in 0s
CI / unit (push) Has been skipped
CI / integration (push) Has been skipped
CI / ui (push) Successful in 1m10s
CI / conformance (push) Successful in 10s
CI / gate (push) Successful in 0s
CI / 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 1m12s
CI / deploy (pull_request) Has been skipped
2026-07-10 16:00:54 +00:00
Ilia Denisov 8ce986922a feat(ui): wallet storefront redesign — split buy/spend, compact balance, exchange confirm
CI / changes (pull_request) Successful in 2s
CI / unit (pull_request) Has been skipped
CI / integration (pull_request) Has been skipped
CI / ui (pull_request) Successful in 1m11s
CI / conformance (pull_request) Successful in 10s
CI / gate (pull_request) Successful in 0s
CI / deploy (pull_request) Successful in 1m55s
Reworks the Wallet screen to be more compact and to separate the two flows, and fixes a
reported bug.

Bug: buying a value with too few chips showed the generic "Something went wrong" toast — the
backend's `insufficient_chips` (409) code was propagated correctly but had no i18n mapping, so
`errorKey` fell back to `error.generic`. Add `error.insufficient_chips` / `error.product_not_found`.
Also disable a value's Exchange button up front when the spendable balance cannot cover it (the
server stays authoritative).

Redesign:
- a compact **balance** row leads the screen — the context's own chips (🪙 N) then any linked
  other-platform chips behind that platform's logo (monospaced digits); inside a VK/TG store only
  that store's own segment shows;
- the benefits section is renamed **Active**, moved above the store, shows one inline line
  (hints + ad-free) and hides when nothing is active;
- the **store** splits by a two-way toggle into **Buy chips** (money packs + the watch-an-ad row at
  the top) and **Spend chips** (values, exchanged for chips);
- a value's action reads **Exchange** and opens a single confirmation dialog (chip spends are
  instant, with no provider window to confirm them); the existing cross-platform store-compliance
  warning folds into that same dialog when the spend would draw VK/TG chips.

No payments-model or wire change; the money→chips→values model is unchanged (verified: money buys
only chip packs, values are chips-only — "no-ads for money" is structurally impossible).

Tests: wallet e2e updated for the new layout (chromium + webkit green). Docs: FUNCTIONAL (+_ru)
wallet story rewritten. App bundle budget 126 → 127 KB (always-loaded settings hub).
2026-07-10 17:46:22 +02:00
developer 3469278260 Merge pull request 'fix(monitoring): don't false-fire the WAL-stalled alert on an idle database' (#241) from fix/pg-archive-stalled-idle-falsepositive into development
CI / changes (push) Successful in 2s
CI / unit (push) Successful in 11s
CI / integration (push) Successful in 25s
CI / ui (push) Successful in 1m12s
CI / conformance (push) Successful in 10s
CI / gate (push) Successful in 0s
CI / deploy (push) Successful in 2m38s
2026-07-10 14:31:57 +00:00
Ilia Denisov c66bf1eceb fix(monitoring): pg_archive_stalled must not false-fire on an idle database
CI / changes (pull_request) Successful in 2s
CI / unit (pull_request) Successful in 11s
CI / integration (pull_request) Successful in 24s
CI / ui (pull_request) Successful in 1m11s
CI / conformance (pull_request) Successful in 9s
CI / gate (pull_request) Successful in 0s
CI / deploy (pull_request) Successful in 1m55s
The "WAL archiving stalled" alert fired on prod during a quiet period. An idle Postgres
archives nothing — it never force-switches an empty WAL segment on archive_timeout — so
pg_stat_archiver_last_archive_age grows past 30 min even though archiving is perfectly
healthy (failed_count 0, no .ready segments, pg_wal flat, backups current). A false
positive: no data and no disk at risk (nothing was written, so the frozen recovery point
equals the live state).

Gate the age condition on pg_wal actually growing:

  (pg_stat_archiver_last_archive_age > 1800) and on() (delta(pg_wal_size_bytes[35m]) > 16MB)

so it fires only when WAL is being produced but not archived (the real "pg_wal fills the
disk" danger; genuine archive_command failures are already caught by pg_archive_failing).
`and on()` bridges the two metrics' differing label sets (last_archive_age carries a server
label, the pg_wal gauge does not); delta() (not increase) suits the gauge. pg_stat_wal is
not exported by this postgres_exporter, so pg_wal size growth is the available signal.
Validated on prod with promtool: the expression is empty while idle.

Also fix the runbook command in both archive alerts' annotations: `pgbackrest check` needs
`--pg1-user=scrabble`, or it connects as role "root" (which does not exist) and aborts with
"no database found".
2026-07-10 16:25:01 +02:00
developer 7b4d2421e2 Merge pull request 'In-game UX: board highlight + score badge, full-width rack, bag badge, zoom setting' (#240) from feature/ingame-ux-board-highlight 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 1m11s
CI / conformance (push) Successful in 10s
CI / gate (push) Successful in 0s
CI / deploy (push) Successful in 2m49s
2026-07-10 14:06:20 +00:00
Ilia Denisov bf46b9492d fix(ui): one-word games must not highlight phantom cross words; review polish
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 1m25s
CI / conformance (pull_request) Successful in 11s
CI / gate (pull_request) Successful in 0s
CI / deploy (pull_request) Successful in 2m2s
Board-highlight bug (reported on the contour): formedGeometry walked cross words
unconditionally, so in a single-word (one-word-per-turn) game a staged tile sitting
next to a committed tile lit up a green "cross word" the engine ignores — and which
need not even be a real word (the reported "БО lights up green in a one-word ПОПА
play"). Gate the cross-word walk on the game's multipleWordsPerTurn flag. The score
(8) was already correct — a premium square under the main word.

Also, from review:
- the turn strip reads the staged play's "WORD+WORD = N" while composing a legal move,
  reverting to the turn / result text otherwise;
- the Exchange/Pass dialog shows the bag count ("In the bag: N" / "Bag is empty")
  right-aligned in the title row, via a new optional Modal `titleAside`;
- cosmetics: half the turn strip's bottom padding (the plaques below carry their own
  top pad); a top gap above the landscape rack (it sat flush under the docked history);
  more horizontal padding on tab count badges so a 2-3 digit bag count clears the pill
  ends;
- admin console: the game Summary now shows the single-word / multiple-words rule.

Tests: formed single-word case added; full unit (584) + e2e (chromium + webkit, 113
each) green; backend build + adminconsole templates parse. Docs (FUNCTIONAL +_ru,
UI_DESIGN) updated.
2026-07-10 15:58:05 +02:00
Ilia Denisov 1e1117c28e chore(ui): raise the app bundle budget to 126 KB for the in-game board UX
CI / changes (pull_request) Successful in 2s
CI / unit (pull_request) Has been skipped
CI / integration (pull_request) Has been skipped
CI / ui (pull_request) Successful in 1m10s
CI / conformance (pull_request) Successful in 10s
CI / gate (pull_request) Successful in 0s
CI / deploy (pull_request) Successful in 1m59s
2026-07-10 15:03:30 +02:00
Ilia Denisov 77a690fcf6 feat(ui): move in-game status to the board — highlight, score badge, full-width rack
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 13s
CI / conformance (pull_request) Successful in 9s
CI / gate (pull_request) Failing after 0s
CI / deploy (pull_request) Has been skipped
Remove the under-board status strip and relocate its signals:
- bag count -> a badge on the exchange/pass control + the foot of the move table
- whose-turn / win-lose -> a thin strip above the score plaques
- the tentative-move caption -> the board itself: staged tiles tint green (legal) or
  pink (illegal), the board tiles a formed word runs through go a shade darker, and an
  orange score badge sits on the main word (digit sized like a tile value, clamped on-board)

The word geometry (covered cells + badge anchor) is a new pure client-side helper
(ui/src/lib/formed.ts), independent of the move evaluator, so it works on the local or
network preview path alike; the badge's number still comes from the preview score.

Rack: a seven-column grid filling the tray width in both layouts — square, full-width
tiles — with the confirm control in the fixed 7th slot.

Settings: a touch-only "Zoom the board" toggle (default on, device-local) gates the
tile-placement auto-zoom; taking a hint while zoomed in now zooms out so the highlighted
hint word is never left off-screen.

Docs (FUNCTIONAL +_ru, UI_DESIGN, ARCHITECTURE) and e2e/unit tests updated.
2026-07-10 14:58:32 +02:00
developer 2683103fc1 Merge pull request 'fix(deploy): provision the certs dir traversable by the nonroot gateway' (#238) from fix/ansible-certs-dir-traversable 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 1m10s
CI / conformance (push) Successful in 11s
CI / gate (push) Successful in 0s
CI / deploy (push) Successful in 1m57s
CI / changes (pull_request) Successful in 2s
CI / unit (pull_request) Successful in 10s
CI / integration (pull_request) Successful in 19s
CI / ui (pull_request) Successful in 1m11s
CI / conformance (pull_request) Successful in 10s
CI / gate (pull_request) Successful in 0s
CI / deploy (pull_request) Has been skipped
2026-07-10 10:39:06 +00:00
Ilia Denisov 2d2dd2bc47 fix(deploy): provision the certs dir traversable by the nonroot gateway
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 1m11s
CI / conformance (pull_request) Successful in 9s
CI / gate (pull_request) Successful in 0s
CI / deploy (pull_request) Successful in 1m54s
The Ansible base-directory loop created /opt/scrabble/certs at mode 0750
(deploy:deploy), like config/dumps/images. The gateway (and backend) run as
the distroless nonroot UID 65532 — not the deploy user — so the container
cannot traverse a 0750 certs dir and fails at startup with
"mtls: load server keypair: ... permission denied", crash-looping.

This is latent: a long-running container holds the keypair in memory and
never re-reads the file, so the misconfig only bites when a container
restarts (a host reboot / redeploy). A hoster maintenance reboot exposed it
on prod — the gateway came back crash-looping while the deploy could not SSH
in mid-reboot.

Split certs out of the 0750 loop and create it 0755 (traversable). The keys
stay 0644 by design (the gateway compose relies on it); the host is
single-tenant + SSH-access-controlled, so a traversable certs dir adds no
meaningful exposure. The live prod host was already chmod-fixed by hand; this
keeps the next provisioning run from re-tightening it.
2026-07-10 12:34:21 +02:00
developer df9eace09f Merge pull request 'fix(deploy): wire the Robokassa direct rail into the prod deploy + rollback' (#236) from fix/prod-robokassa-wiring into development
CI / changes (push) Successful in 2s
CI / integration (push) Successful in 20s
CI / conformance (push) Successful in 10s
CI / deploy (push) Successful in 1m53s
CI / changes (pull_request) Successful in 2s
CI / unit (push) Successful in 11s
CI / ui (push) Successful in 1m10s
CI / gate (push) Successful in 0s
CI / ui (pull_request) Successful in 1m10s
CI / unit (pull_request) Successful in 11s
CI / integration (pull_request) Successful in 21s
CI / conformance (pull_request) Successful in 10s
CI / gate (pull_request) Successful in 0s
CI / deploy (pull_request) Has been skipped
2026-07-10 10:04:03 +00:00
Ilia Denisov 0a0a9e5a8d fix(deploy): wire the Robokassa direct rail into the prod deploy + rollback
CI / changes (pull_request) Successful in 2s
CI / unit (pull_request) Successful in 11s
CI / integration (pull_request) Successful in 27s
CI / ui (pull_request) Successful in 1m11s
CI / conformance (pull_request) Successful in 10s
CI / gate (pull_request) Successful in 0s
CI / deploy (pull_request) Successful in 1m54s
The Robokassa credentials reached only the test contour (ci.yaml → TEST_
secrets). The prod-deploy / prod-rollback workflows and write-prod-env.sh
never rendered BACKEND_ROBOKASSA_*, so on prod the shop login was empty and
the direct RUB rail stayed disabled — the PROD_BACKEND_ROBOKASSA_* secrets
went nowhere.

Export the shop login + Password1/Password2 (secrets) and a
BACKEND_ROBOKASSA_TEST flag (a variable, so go-live is a flag flip not a
secret rotation) from both prod workflows, and emit the four ROBOKASSA_*
vars from write-prod-env.sh (the shared deploy/rollback env renderer) so a
rollback keeps the rail up. The compose already maps ROBOKASSA_* →
BACKEND_ROBOKASSA_*. Document the new secrets/variable in the deploy README
and .env.example. Password3 (Robokassa's JWT-invoice API) is unused.
2026-07-10 11:59:20 +02:00
developer 0c9678c42b Merge pull request 'feat(ui): per-kind active-game limit lock on the New Game screen' (#235) from feature/e8-guest-limits-client into development
CI / changes (push) Successful in 2s
CI / unit (push) Successful in 12s
CI / integration (push) Successful in 28s
CI / ui (push) Successful in 1m11s
CI / conformance (push) Successful in 10s
CI / gate (push) Successful in 0s
CI / deploy (push) Successful in 1m53s
2026-07-10 09:11:40 +00:00
Ilia Denisov e40adfb0c7 fix(games): carry game kind on live events + fix the New Game lock funnel
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 1m10s
CI / conformance (pull_request) Successful in 10s
CI / gate (pull_request) Successful in 0s
CI / deploy (pull_request) Successful in 1m56s
Address review of the active-game limit lock:

- Live-event GameViews (opponent_moved, match_found, game_started, ...) did
  not carry game_kind, so a lobby patch from an event zeroed a game's kind
  and the client's per-kind count under-counted — a capped kind read as
  free (a disabled start instead of the lock, and a wrong per-kind result).
  Thread Kind through notify.GameSummary → the event GameView.

- New Game screen refreshes the lobby games on mount so the per-kind count
  reflects the current set, not a stale cached snapshot.

- The guest funnel's login button routes to the profile screen (the account
  controls), not settings.

- Copy: the guest prompt is "sign in to use all the game's features"; the
  durable notice is "finish your active games to start a new one".

Regression tests: the notify opponent-moved payload carries kind; the client
lock locks only the reached kind (vs_ai at cap leaves random open).
2026-07-10 10:55:48 +02:00
Ilia Denisov 3306a016a0 feat(ui): per-kind active-game limit lock on the New Game screen
CI / changes (pull_request) Successful in 2s
CI / unit (pull_request) Successful in 12s
CI / integration (pull_request) Successful in 28s
CI / ui (pull_request) Successful in 1m11s
CI / conformance (pull_request) Successful in 10s
CI / gate (pull_request) Successful in 0s
CI / deploy (pull_request) Successful in 1m46s
Carry the caller's per-tier active-game caps and each game's kind on the
wire (Profile.game_limits + GameView.kind, additive FBS + gateway transcode
+ client codec, committed regen). The New Game screen counts the player's
active games per kind from the lobby and locks a capped start: an outline
button with a lock that opens a funnel modal instead of a game -- a sign-in
prompt for a guest, a "finish a current game first" notice for a signed-in
account (native Telegram popup, in-app modal elsewhere). The lock lifts via
the existing profile refetch after a guest->durable upgrade.

Remove the lobby's old at_game_limit New-Game tab disable + notice: the flag
(now the random-kind cap) conflicted with the per-kind lock -- it hid the
screen where the lock lives and wrongly blocked an unfulfilled kind. The New
Game tab is always enabled; the per-kind start lock is the only gate. The
at_game_limit wire field stays (unused by the client) for a later cleanup.

Tests: client lock logic + codec kind/game_limits roundtrip + gateway
transcode encode + native popup builders (unit); a mock e2e for the lock
badge and the modal.
2026-07-10 10:09:45 +02:00
developer e45167041f Merge pull request 'feat(backend): per-tier, per-kind active-game limits with a guest funnel' (#234) from feature/e8-guest-limits into development
CI / changes (push) Successful in 2s
CI / unit (push) Successful in 12s
CI / integration (push) Successful in 22s
CI / ui (push) Has been skipped
CI / conformance (push) Successful in 10s
CI / gate (push) Successful in 1s
CI / deploy (push) Successful in 1m56s
2026-07-10 07:17:54 +00:00
Ilia Denisov ed53e25e57 feat(backend): per-tier, per-kind active-game limits with a guest funnel
CI / changes (pull_request) Successful in 5s
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 11s
CI / gate (pull_request) Successful in 0s
CI / deploy (pull_request) Successful in 2m41s
Cap a player's simultaneous unfinished games per kind (vs_ai, random,
friends) with independent guest and durable-account tiers, held in a new
single-row backend.config table (-1 = unlimited) behind an in-memory cache
and editable live in the admin console (/_gm/limits). Each game is tagged
with games.game_kind on creation.

This replaces the earlier flat MaxActiveQuickGames=10 combined cap: the
per-tier/kind config is the single mechanism, enforced at the same handler
gate (ensureUnderGameLimit by kind on lobby/enqueue) plus the durable
friends cap in CreateInvitation. game.Service.AtGameLimit only resolves the
tier and counts; the limit policy stays at the request edge.

Guests are now refused friend requests, friend-code redemption,
befriend-in-game and invitation creation outright (403 guest_forbidden) --
previously only the UI hid these.

Admin: a kind column in both game lists and the config editor.

Defaults: guest 1 vs_ai / 1 random / 0 friends; durable 10 / 10 / 10.
2026-07-10 09:03:57 +02:00
developer 2e5136b22a Merge pull request 'feat(admin): manual full-order refund + ledger CSV export' (#233) from feature/e7-refund-export into development
CI / changes (push) Successful in 2s
CI / unit (push) Successful in 11s
CI / integration (push) Successful in 19s
CI / ui (push) Has been skipped
CI / conformance (push) Successful in 10s
CI / gate (push) Successful in 0s
CI / deploy (push) Successful in 2m5s
2026-07-10 04:52:49 +00:00
Ilia Denisov 1bf612a087 feat(admin): manual full-order refund + ledger CSV export
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) Has been skipped
CI / conformance (pull_request) Successful in 10s
CI / gate (pull_request) Successful in 0s
CI / deploy (pull_request) Successful in 1m53s
Closes the admin / reports / catalog work. Each fund row on the /_gm finance
panel gains a Refund action (payments.RefundOrderFull): a full-order refund the
operator records after refunding on the rail — a refund ledger row + a floor-0
chip revoke (never negative, D27), idempotent (a second refund reports
already-refunded). A ledger CSV export (/_gm/ledger.csv, payments.LedgerExport)
streams the whole append-only ledger for tax + reconciliation.

Tests: refund an order in full (chips revoked, a refund row), an idempotent
second refund, the CSV export shape; CSRF-guarded.
2026-07-10 06:46:18 +02:00
developer ec5c6afa23 Merge pull request 'feat(admin): admin grant — raw benefits and by-product reward bundles' (#232) from feature/e7-admin-grant into development
CI / changes (push) Successful in 3s
CI / unit (push) Successful in 12s
CI / integration (push) Successful in 19s
CI / ui (push) Successful in 1m10s
CI / conformance (push) Successful in 10s
CI / gate (push) Successful in 0s
CI / deploy (push) Successful in 1m55s
2026-07-10 04:37:43 +00:00
Ilia Denisov 82648a4398 fix(game): show granted/bought hints in-game — finish the D31 wire removal
CI / changes (pull_request) Successful in 2s
CI / unit (pull_request) Successful in 10s
CI / integration (pull_request) Successful in 19s
CI / ui (pull_request) Successful in 1m11s
CI / conformance (pull_request) Successful in 10s
CI / gate (pull_request) Successful in 0s
CI / deploy (pull_request) Successful in 1m58s
The in-game hint badge ignored the payments hint wallet: loading a game clobbered
app.profile.hintBalance with the deprecated StateView.wallet_balance (zeroed in the
D31 domain removal but left in the protocol as a dead 0, then synced over the real
balance at game load).

Finish the removal honestly. StateView carries the per-game allowance alone
(hints_remaining); the purchasable wallet lives solely on the profile and the client
adds it (lib/hints). Removed StateView.wallet_balance across every layer — backend
StateView/DTO, notify.PlayerState (live events), gateway StateResp + wire.StateView,
the client model/codec/mock/localgame — and dropped the now-unused wallet arg from
hintsRemaining. The FBS field is tombstoned `(deprecated)` (not deleted) so the vtable
slots after it stay stable across a rolling deploy; no accessor is generated. HintResult
keeps wallet_balance (the real post-spend payments balance the client adopts into the
profile). The StateView type no longer has walletBalance, so the clobber cannot return
without a compile error.

Tests: hintsRemaining (2-arg); hints.hintsLeft (allowance + live wallet, no strip); the
game state/hint integration (allowance-only HintsRemaining); gateway transcode; FBS regen.
2026-07-10 06:26:49 +02:00
Ilia Denisov d2d6955cbf feat(admin): admin grant — raw benefits and by-product reward bundles
CI / changes (pull_request) Successful in 2s
CI / unit (pull_request) Successful in 11s
CI / integration (pull_request) Successful in 26s
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 1m48s
The /_gm user card gains a Grant panel: grant raw benefit atoms (hints /
no-ads days / forever) or a defined value product (a reward bundle, including
an archived one), origin-picked. Both write an admin_grant ledger row via
payments.Grant / GrantProduct; the by-product grant records the source
product_id + snapshot. Both refuse a chips atom (never grant currency) or a
tournament atom (no credit target yet); chips/tournament products are also
kept out of the by-product picker.

Tests: the console grant end to end (raw, by-product, refuse a chips pack,
CSRF-guarded).
2026-07-10 05:35:55 +02:00
developer c3eecf16b3 Merge pull request 'feat(admin): product catalog editor' (#231) from feature/e7-catalog-editor into development
CI / changes (push) Successful in 2s
CI / unit (push) Successful in 11s
CI / integration (push) Successful in 22s
CI / ui (push) Has been skipped
CI / conformance (push) Successful in 10s
CI / gate (push) Successful in 0s
CI / deploy (push) Successful in 1m55s
2026-07-10 03:26:26 +00:00
Ilia Denisov 0036b55618 feat(admin): product catalog editor
CI / changes (pull_request) Successful in 2s
CI / unit (pull_request) Successful in 11s
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 0s
CI / deploy (pull_request) Successful in 1m53s
The /_gm console gains a Catalog editor — the source of truth for products.
Create / edit / archive-unarchive products, their atom composition and per-rail
prices (RUB via direct / VOTE via vk / XTR via telegram / CHIP value), and
hard-delete only a never-transacted product (an order or ledger reference forces
archive-only, backed by the FK RESTRICT). The archived flag reuses the existing
product.active. Activation revalidates the sellable shape — a pack (the chips
atom ⇒ a money price per rail, chips-only) or a value (no chips ⇒ a CHIP price);
a tournament-bearing product is composable but never sellable yet.

Backed by payments AdminCatalog / CreateProduct / UpdateProduct /
SetProductActive / DeleteProduct + a pure validateProduct.

Tests: validateProduct (pack / value / tournament / duplicate / shape); the
console editor end to end (create, edit, archive, delete-if-clean, refuse a
transacted delete).
2026-07-10 05:18:54 +02:00
developer aabb32081e Merge pull request 'feat(admin): per-user finance panel — balances, benefits, risk, ledger' (#230) from feature/e7-financial-panel into development
CI / changes (push) Successful in 2s
CI / unit (push) Successful in 11s
CI / integration (push) Successful in 23s
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-10 03:03:45 +00:00
Ilia Denisov e089a0a997 feat(admin): per-user finance panel — balances, benefits, risk, ledger
CI / changes (pull_request) Successful in 4s
CI / unit (pull_request) Successful in 11s
CI / integration (pull_request) Successful in 28s
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 1m46s
The /_gm user card gains a Finance panel: an account's chip balances per
funding segment, benefits per origin (hints, no-ads until/forever), the
recorded refund risk (abuse flag + floor-0 loss), and the append-only ledger
history newest-first. Backed by a new payments.AccountStatement read straight
from the materialized tables + the ledger (uncached — an admin, rare view).

Folds the agreed admin / reports / catalog plan into PLAN.md (the PR stack:
this panel, then the catalog editor, admin grant, refund + ledger export) and
moves the tournament-entry storage design to the tournament stage.
2026-07-10 04:55:55 +02:00
developer 00d1bd33e3 Merge pull request 'feat(ads): VK post-move interstitial + retire deprecated hint_balance/paid_account' (#229) from feature/ads-interstitial 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 1m10s
CI / conformance (push) Successful in 10s
CI / gate (push) Successful in 0s
CI / deploy (push) Successful in 1m53s
2026-07-10 01:48:10 +00:00
Ilia Denisov 7ce8101cfa test(ads): lock the hint-pushed ad restarting the vs_ai gap (no over-serving)
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 1m11s
CI / conformance (pull_request) Successful in 9s
CI / gate (pull_request) Successful in 0s
CI / deploy (pull_request) Successful in 1m57s
2026-07-10 03:43:57 +02:00
Ilia Denisov b5c8a04f0b fix(ads): share one interstitial cooldown timer across kinds
CI / changes (pull_request) Successful in 1s
CI / unit (pull_request) Successful in 10s
CI / integration (pull_request) Successful in 25s
CI / ui (pull_request) Successful in 1m10s
CI / conformance (pull_request) Successful in 10s
CI / gate (pull_request) Successful in 0s
CI / deploy (pull_request) Successful in 1m53s
Cooldowns were tracked per kind ({move,hint}), so a hint ad and a move ad
did not see each other: after a hinted move's ad the move timer was still
zero, and the next plain move fired an ad immediately — two ads within a
minute, under the cooldown. Track a single shared last-shown time; the kind
only selects the required gap (hint 1m, move 5m, vs_ai 30m) measured from the
last interstitial of any kind. A hint can still fire on its shorter gap
(D30's "independent" hint cooldown), but never stacks a second ad within a
cooldown.

Tests: a hint uses the shorter shared gap; a move does not stack onto a
just-shown hint ad.
2026-07-10 03:36:18 +02:00
Ilia Denisov 2ce80c241d fix(ads): fire the hint interstitial on the confirmed move, not on the hint
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 1m10s
CI / conformance (pull_request) Successful in 10s
CI / gate (pull_request) Successful in 0s
CI / deploy (pull_request) Successful in 1m48s
Taking a hint fired the post-move interstitial immediately, when the hint's
preview tiles landed — interrupting the turn and reverting the board to the
rack on the ad's close while the hint stayed spent. Move the trigger to the
move confirmation: a hint applied this turn marks it (hintUsedThisTurn), and
the confirmed play fires the hint-kind interstitial (its own cooldown)
instead of the plain move one; the marker clears on any turn boundary
(applyMoveResult) so a hint-then-pass does not leak into the next move.

e2e: taking a hint fires no ad; confirming a move does.
2026-07-10 03:12:30 +02:00
Ilia Denisov 13be7c3d9a feat(ads): VK post-move interstitial + retire deprecated hint_balance/paid_account
CI / changes (pull_request) Successful in 2s
CI / unit (pull_request) Successful in 10s
CI / integration (pull_request) Successful in 23s
CI / ui (pull_request) Successful in 1m12s
CI / conformance (pull_request) Successful in 9s
CI / gate (pull_request) Successful in 0s
CI / deploy (pull_request) Successful in 1m53s
Interstitial video after a confirmed play or a hint, VK-only, offline
banner-only. The gate is client-mirrored: the backend puts the config
cooldowns (global 5m / vs_ai 30m / hint 1m) and a suppressed flag (the
no-ads / no_banner gate, same as the banner) on Profile.ads via adsFor;
the client self-gates on a per-kind last-shown time in localStorage, with
the VITE_ADS_STUB contour "ad fired" toast. Never fires after a pass,
exchange or resign. maybeShowInterstitial + vkShowInterstitial; the codec
and gateway transcode carry the ads block.

Also retires the deprecated accounts.hint_balance / paid_account domain
usage (expand-contract, code only — the columns stay for a later DROP so
image rollback stays DB-safe): drop the Account fields and their scan, the
dead account.SpendHint, account.GrantHints and the admin grant-hints
action; the in-game hint display now comes wholly from the payments hint
benefit. Banner eligibility and account merge no longer read the legacy
flags.

Tests: ads.test.ts (the client-mirrored gate), the codec ads round-trip,
the gateway ads transcode test, and a profile ads-config integration test
(cooldowns + suppressed under no-ads / no_banner). Docs: PAYMENTS §10 (+ru)
interstitial, the decision amends, backend README, the plan.
2026-07-10 02:48:10 +02:00
developer e08d3301bd Merge pull request 'feat(ads): rewarded video (VK) — client-attested credit + daily/hourly caps' (#228) from feature/ads-rewarded 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 1m9s
CI / conformance (push) Successful in 10s
CI / gate (push) Successful in 0s
CI / deploy (push) Successful in 1m56s
2026-07-09 23:33:10 +00:00
Ilia Denisov 9acf6ab3b4 fix(payments): VK-iOS freeze is purchase-only; remove the rewarded diagnostic
CI / changes (pull_request) Successful in 2s
CI / unit (pull_request) Successful in 12s
CI / integration (pull_request) Successful in 20s
CI / ui (pull_request) Successful in 1m9s
CI / conformance (pull_request) Successful in 11s
CI / gate (pull_request) Successful in 0s
CI / deploy (pull_request) Successful in 2m5s
Testing the rewarded slice surfaced a contradiction: rewarded ads let a VK-iOS
user earn vk chips, but the blanket VK-iOS "spend freeze" then blocked spending
them (the wallet showed "5 (view-only)"). Apple's ToS forbids only BUYING in-app
values on VK-iOS (money -> chips), not spending or earning them — so the freeze is
corrected to purchase-only: vkFrozen() now gates only CreateOrder (the money-in
step), not spendableSources (spending). VK-wallet chips — earned via rewarded ads
or bought on the same account elsewhere (VK Android) — now spend on VK-iOS too.
(Owner's ToS finding.)

Also: the temporary contour diagnostic confirmed VK returns only {result:true} for
a rewarded view (no token/signature) — client-attested is final, no hardening
possible — so the diagnostic (the log and the diag wire field) is removed.

Docs: PAYMENTS(+ru) VK-iOS freeze + D17 amend + PLAN E6. Tests updated (gate /
wallet VK-iOS now spendable).
2026-07-10 01:27:26 +02:00
Ilia Denisov dbd76d53e8 feat(ads): rewarded video (VK) — client-attested credit + daily/hourly caps
CI / changes (pull_request) Successful in 4s
CI / unit (pull_request) Successful in 10s
CI / integration (pull_request) Successful in 26s
CI / ui (pull_request) Successful in 1m11s
CI / conformance (pull_request) Successful in 10s
CI / gate (pull_request) Successful in 0s
CI / deploy (pull_request) Successful in 1m53s
The first ads slice: a voluntary rewarded video credits chips. VK Mini App ads
(VKWebAppShowNativeAds) expose only a client-side watch result — no
server-to-server verify — so the credit is client-attested, guarded by a server
daily + hourly cap (config reward_daily_cap / reward_hourly_cap, default 50 / 10).
The caps are both anti-abuse (bounding a forger who skips the ad and calls the
endpoint directly) and an economic conversion lever (limiting free chips so a
player who wants more buys). D29 amended to VK's reality.

Backend: CreditReward (VK-only, order-less, idempotent on a client nonce, floored
by the caps; payout from config rewarded_payout_chips, default 0 = off) + the
wallet.reward edge op returning the updated wallet (reward_chips gates the
"watch for chips" CTA). Additive migration (two config columns).

Client: the ads-network abstraction (lib/ads.ts, VK impl) + the VK bridge
(vkRewardedReady / vkShowRewarded) + the Wallet CTA + i18n. A contour test stub
(VITE_ADS_STUB -> a toast instead of a real ad; prod always real) and a temporary
diagnostic that logs the raw VK data, so we confirm on the contour exactly what
VK returns (harden to signature-verify if it carries one).

Tests: backend integration (credit, nonce idempotency, hourly cap, disabled,
non-VK refusal) + codec unit (reward wire). Docs: PAYMENTS(+ru) §10, D29 amend,
PLAN E6. Bundle: shared budget 30->31 (reward i18n strings).
2026-07-10 00:41:42 +02:00
developer 68c937f3b6 Merge pull request 'feat(payments): refund engine (best-effort revoke, never negative)' (#227) from feature/payment-intake-refunds 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 10s
CI / gate (push) Successful in 0s
CI / deploy (push) Successful in 1m54s
2026-07-09 21:26:33 +00:00
Ilia Denisov 21fb14facf feat(payments): refund engine (best-effort revoke, never negative)
CI / changes (pull_request) Successful in 2s
CI / unit (pull_request) Successful in 11s
CI / integration (pull_request) Successful in 18s
CI / ui (pull_request) Has been skipped
CI / conformance (pull_request) Successful in 10s
CI / gate (pull_request) Successful in 0s
CI / deploy (pull_request) Successful in 1m45s
The last money-intake slice: reverse a paid order best-effort, exactly once. All
refunds are admin-triggered (E7) — no rail pushes an unsolicited refund (Robokassa
via its refund API / cabinet, VK via support, Telegram via refundStarPayment), so
this ships the engine they all converge on, not a webhook.

The Refund method matches the paid order, appends a refund ledger row (idempotent on
(provider, provider_refund_id) — distinct from the fund's payment id, so both
coexist), and revokes the funded chips floored at 0 (never negative — D27,
balances_chips_chk). When the chips were already spent, the unrecoverable remainder
is recorded as a per-account loss + abuse flag in the new additive
payments.account_risk table (read by the E7 report). The refund ledger row's chip
delta is what was actually reclaimed (the ledger stays reconcilable); the full
reversal rides in the snapshot; the order stays paid.

Additive migration (a new table only) -> rollback-safe, no contour wipe. Robokassa
refund-status polling is deferred (a worker not worth it at low chargeback volume);
failed events are not wired (no rail signals a hard post-charge server decline).

Tests: integration (full revoke; revoke-after-spend = floor-0 + loss + abuse;
duplicate idempotent; unpaid-order guard). Docs: PAYMENTS(+ru) §9, PLAN (E5 -> DONE).
2026-07-09 23:21:10 +02:00
developer ee9924c313 Merge pull request 'feat(payments): Telegram Stars payment rail' (#226) from feature/payment-intake-tg-stars 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 1m10s
CI / conformance (push) Successful in 9s
CI / gate (push) Successful in 0s
CI / deploy (push) Successful in 1m53s
2026-07-09 20:44:06 +00:00
Ilia Denisov e10f3beed5 chore(ui): raise the app bundle budget 123->125 for the payment intake rails
CI / changes (pull_request) Successful in 2s
CI / unit (pull_request) Successful in 11s
CI / integration (pull_request) Successful in 24s
CI / ui (pull_request) Successful in 1m9s
CI / conformance (pull_request) Successful in 11s
CI / gate (pull_request) Successful in 0s
CI / deploy (pull_request) Successful in 1m50s
The wallet order flow plus the Telegram Stars openInvoice / VK order-box launch
and the per-button in-flight state ride the always-loaded Wallet screen, which
crossed the 123 KB gzip ceiling. Bump to 125 (matching the established
feature-driven budget history in the comment) with ~2 KB of headroom.
2026-07-09 22:37:30 +02:00
Ilia Denisov 55dbb1005e fix(ui): scope the wallet buy-button busy dim to the tapped button
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) Failing after 11s
CI / conformance (pull_request) Successful in 10s
CI / gate (pull_request) Failing after 0s
CI / deploy (pull_request) Has been skipped
Tapping one pack's Buy dimmed every buy button at once (the shared busy
flag drove disabled + the :disabled opacity on all of them), so it looked
like both packs reacted to one tap. Track the in-flight product id
(busyId): the concurrency guard stays global, but the pressed/dimmed look
is now scoped to the tapped button. Payments were never affected.
2026-07-09 22:26:28 +02:00
Ilia Denisov 219fc7c827 chore(deploy): re-roll the test contour to pick up the TEST_AWG_CONF DNS fix
CI / changes (pull_request) Successful in 1s
CI / unit (pull_request) Successful in 10s
CI / integration (pull_request) Successful in 19s
CI / ui (pull_request) Successful in 1m9s
CI / conformance (pull_request) Successful in 10s
CI / gate (pull_request) Successful in 0s
CI / deploy (pull_request) Successful in 1m55s
The contour VPN sidecar's netns resolv.conf was pinned to 1.1.1.1 by a DNS=
directive in TEST_AWG_CONF, so internal names (gateway, otelcol) did not resolve
and the bot-link had been down since ~2026-06-29. The directive is removed; this
empty commit re-rolls the contour so the vpn recreates with Docker's 127.0.0.11
resolver and the bot-link (and the Stars rail over it) comes up.
2026-07-09 21:55:53 +02:00
Ilia Denisov 6e03ce0131 feat(payments): Telegram Stars payment rail
CI / changes (pull_request) Successful in 2s
CI / unit (pull_request) Successful in 22s
CI / integration (pull_request) Successful in 20s
CI / ui (pull_request) Successful in 1m10s
CI / conformance (pull_request) Successful in 10s
CI / gate (pull_request) Successful in 0s
CI / deploy (pull_request) Successful in 1m57s
Accept real money via Telegram Stars (XTR) — the third intake rail
alongside Robokassa (direct) and VK Votes.

Only the bot reaches Telegram, so the rail funnels through the reverse
mTLS bot-link:
- the gateway mints the invoice on a CreateInvoice command (the bot
  calls createInvoiceLink, XTR; the link goes to WebApp.openInvoice);
- the bot gates each pre_checkout_query via a ValidatePreCheckout unary
  (the order must exist, be still creditable and not already paid — the
  reusable-invoice double-pay guard; the decline reason is localised to
  the order account's language);
- a completed successful_payment is queued in a durable pure-Go SQLite
  outbox and forwarded via a ForwardPayment unary, credited once
  (idempotent on telegram_payment_charge_id, honours an expired order),
  re-driven on restart and every 30s.

The rail is wired by TELEGRAM_STARS_OUTBOX_DIR (default /data) but stays
inert until a chip pack carries an XTR price, so seeding a Stars price in
the admin is the go-live.

Tests: backend integration (order->forward->credit once, duplicate,
pre_checkout gate) + bot outbox unit (idempotent, restart re-drive) +
executor createInvoice. Docs: PAYMENTS(+ru) §9, ARCHITECTURE, the
platform/telegram README, PLAN.
2026-07-09 21:35:29 +02:00
developer 5612bb624d Merge pull request 'fix(ui): VK-iOS store link from the deployment origin' (#225) from fix/vk-ios-link-origin into development
CI / changes (push) Successful in 2s
CI / unit (push) Has been skipped
CI / integration (push) Has been skipped
CI / ui (push) Successful in 1m9s
CI / conformance (push) Successful in 10s
CI / gate (push) Successful in 0s
CI / deploy (push) Successful in 1m47s
2026-07-09 18:30:54 +00:00
Ilia Denisov 8230253142 fix(ui): derive the VK-iOS store link from the deployment origin
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 1m8s
CI / conformance (pull_request) Successful in 11s
CI / gate (pull_request) Successful in 0s
CI / deploy (pull_request) Successful in 1m57s
Replace the hardcoded erudit-game.ru host with location.origin, so the iOS
store note's "other version" link points at this deployment's own site root
(prod or the contour) without a hardcoded host or build-arg wiring.
2026-07-09 20:26:25 +02:00
developer 890a887257 Merge pull request 'feat(payments): VK Votes payment rail' (#224) from feature/payment-intake-vk into development
CI / changes (push) Successful in 2s
CI / unit (push) Successful in 10s
CI / integration (push) Successful in 20s
CI / ui (push) Successful in 1m9s
CI / conformance (push) Successful in 10s
CI / gate (push) Successful in 0s
CI / deploy (push) Successful in 1m56s
2026-07-09 18:23:24 +00:00
Ilia Denisov e33b9864c8 fix(ui): point the VK-iOS "other version" link at the site root
CI / changes (pull_request) Successful in 2s
CI / unit (pull_request) Successful in 10s
CI / integration (pull_request) Successful in 19s
CI / ui (pull_request) Successful in 1m23s
CI / conformance (pull_request) Successful in 9s
CI / gate (pull_request) Successful in 0s
CI / deploy (pull_request) Successful in 1m55s
The landing at / lists every version's entry; /app/ dropped the player straight
into the web game. Link the iOS store note to the root instead.
2026-07-09 20:18:42 +02:00
Ilia Denisov f6aa0ba4b0 fix(ui): correct the VK-iOS purchase block + add an iOS store note
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 1m9s
CI / conformance (pull_request) Successful in 10s
CI / gate (pull_request) Successful in 0s
CI / deploy (pull_request) Successful in 1m59s
The VK-iOS detection used platformSubtype(), which is server-derived for VK and
so never reported iOS on the client — the pack CTA stayed active and a tap hit a
403 with a generic error. Read the device family from the signed vk_platform
launch param (normalizeSubtype(vkPlatform())) instead. Under the store header on
VK-iOS, add a note that purchases are prohibited by Apple policy, linking to the
web version (opened through VK's external-link route).
2026-07-09 20:11:35 +02:00
Ilia Denisov f48ebf2151 fix(ui): VK-iOS shows the pack CTA disabled with a toast, not a failed buy
CI / changes (pull_request) Successful in 2s
CI / unit (pull_request) Successful in 10s
CI / integration (pull_request) Successful in 22s
CI / ui (pull_request) Successful in 1m9s
CI / conformance (pull_request) Successful in 10s
CI / gate (pull_request) Successful in 0s
CI / deploy (pull_request) Successful in 1m55s
Money purchases are not permitted in the VK iOS app (Apple ToS), and the backend
already refuses them (the CreateOrder VK-iOS freeze). Show the pack "Buy" muted
there and explain on tap ("purchases are not available on this platform"),
instead of letting the tap hit a 403 and a generic error toast. The storefront
still lists the packs.
2026-07-09 19:57:10 +02:00
Ilia Denisov 3bb9f10fad feat(payments): VK Votes payment rail
CI / changes (pull_request) Successful in 2s
CI / unit (pull_request) Successful in 11s
CI / integration (pull_request) Successful in 18s
CI / ui (pull_request) Successful in 1m10s
CI / conformance (pull_request) Successful in 9s
CI / gate (pull_request) Successful in 0s
CI / deploy (pull_request) Successful in 1m45s
Wire the VK Mini Apps ("голоса") rail end to end, reusing the intake engine. The
wallet.order endpoint branches by rail: a VK context opens a pending order
(provider vk) and returns its id, which the client passes to VKWebAppShowOrderBox.
VK's two-phase payment callback is verified at the gateway with the app protected
key (GATEWAY_VK_APP_SECRET) and proxied to a backend intake handler: get_item
returns the ordered pack's title and vote price; a chargeable order_status_change
credits the vk segment exactly once (the same Fund, idempotent on VK's own order
id) and records a succeeded event, so the dispatcher push refreshes the wallet.
Integration test for the VK order->credit path.
2026-07-09 19:27:57 +02:00
Ilia Denisov 3e0763463f feat(gateway): VK payment callback signature verifier
Add the vkpay package: verify a VK Mini Apps payment ("голоса") callback
signature — MD5 (mandated by VK's payment protocol) of the sig-excluded
parameters, sorted by name and concatenated key=value, with the app secret
appended; case-insensitive over the hex digest. Unit-tested (valid / tampered /
wrong-secret / missing). First piece of the VK rail; the two-phase callback
handler, the VK order branch and the client bridge follow.
2026-07-09 18:56:00 +02:00
developer 96adf98e1d Merge pull request 'feat(payments): Robokassa direct-rail payment intake' (#223) from feature/payment-intake-robokassa 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 1m8s
CI / conformance (push) Successful in 10s
CI / gate (push) Successful in 0s
CI / deploy (push) Successful in 1m50s
2026-07-09 16:33:12 +00:00
Ilia Denisov 3367cc2bf1 feat(payments): payment-event dispatcher + the provider-return UX
CI / changes (pull_request) Successful in 3s
CI / unit (pull_request) Successful in 10s
CI / integration (pull_request) Successful in 22s
CI / ui (pull_request) Successful in 1m9s
CI / conformance (pull_request) Successful in 10s
CI / gate (pull_request) Successful in 0s
CI / deploy (pull_request) Successful in 1m45s
Deliver payment_events to connected clients as an in-app wallet-refresh push: a
background dispatcher drains undispatched events and publishes a KindNotification
"payment" signal, marking each delivered; the client bumps a wallet-refresh
counter the open Wallet screen watches, re-fetching in place. A return-focus
refetch is the fallback. The Robokassa Success/Fail return now serves a
self-closing page (the payment opens in a separate window) so the customer drops
back into the live app instead of a cold start. Integration test for the event
drain/mark queue.
2026-07-09 18:26:06 +02:00
Ilia Denisov 04435a3283 docs(payments): bake the E5 direct-rail decisions + a /pay/ CI probe
CI / changes (pull_request) Successful in 3s
CI / unit (pull_request) Successful in 10s
CI / integration (pull_request) Successful in 25s
CI / ui (pull_request) Successful in 1m10s
CI / conformance (pull_request) Successful in 9s
CI / gate (pull_request) Successful in 0s
CI / deploy (pull_request) Successful in 2m40s
Record the E5 delivery and resolved decisions in PLAN.md (Shp_order matching,
order-id idempotency, cabinet-side receipt, never-negative → schema-free) and
mark the stage WIP. Add a CI probe asserting /pay/robokassa/result reaches the
gateway rather than the landing catch-all.
2026-07-09 17:48:29 +02:00
Ilia Denisov be0e4995f3 feat(deploy): wire the Robokassa direct rail into the contour
Map the Robokassa merchant login + Password1/Password2 into the backend
container env from the deploy secrets (TEST_BACKEND_ROBOKASSA_*), and force
IsTest on the test contour so it can never take real money. An empty login
leaves the direct order + callback endpoints unregistered.
2026-07-09 17:45:54 +02:00
Ilia Denisov 936a70ab94 feat(ui): wire the chip-pack purchase to Robokassa
Replace the disabled "Soon" pack action with a real purchase: the Wallet opens a
money order (wallet.order) and sends the player to the provider's hosted-payment
page (window.open via openExternalUrl); the chips are credited later by the
verified server callback. Add a public-offer link under the packs (paying
accepts the offer). Codec order round-trip unit test + a mock-e2e purchase test;
the Google Play stub and the chip-spend paths are unchanged.
2026-07-09 17:43:02 +02:00
Ilia Denisov 4f6c22d669 feat(gateway): the Robokassa /pay/ edge routes
Add the public /pay/robokassa/result callback proxy (rate-limited; forwards the
provider's form parameters to the backend intake, the single writer, and echoes
its "OK<InvId>" back to Robokassa) and the /pay/robokassa/{success,fail}
browser-return redirects into the app. Route /pay/* to the gateway in the
contour Caddyfile so the callback reaches the edge, not the landing catch-all.
The backend intake now returns the echo body as JSON for the gateway to relay.
2026-07-09 17:33:22 +02:00
Ilia Denisov 2a6dc5a304 feat(gateway): wire the wallet.order edge call for the direct rail
Add the WalletOrderRequest / WalletOrderResponse FlatBuffers messages and the
wallet.order Connect op: the gateway decodes the order request, forwards it to
the backend POST /wallet/order and returns the created order id plus the
provider launch URL the client opens. Regenerate the committed Go and TS
FlatBuffers code.
2026-07-09 17:30:39 +02:00
Ilia Denisov a66a5bfa08 test(payments): integration coverage for the intake credit path
Cover the order→callback→credit path over Postgres: a funded order credits its
segment exactly once; a replayed callback for the same order credits nothing
(the ledger idempotency index holds); a mismatched paid amount is refused with
no write; an expired pending order is still honoured by a late valid callback.
2026-07-09 17:04:14 +02:00
Ilia Denisov 36a5730e52 feat(payments): direct-rail order + intake handlers, config and reaper
Wire the Robokassa direct rail into the backend transport. POST
/api/v1/user/wallet/order (walletGate + a D36 confirmed-email gate for the
direct rail) opens a pending order and returns the signed Robokassa payment
URL. The internal, gateway-only /payments/robokassa/result endpoint verifies
the Result signature, credits the matched order exactly once via Fund (honoured
even if expired), records a succeeded payment event, and answers Robokassa's
"OK<InvId>". Add the Robokassa env config, an account HasConfirmedEmail check
(D36), the payment_events writer, and a periodic pending-order reaper. The
routes register only when a Robokassa merchant login is configured.
2026-07-09 16:59:34 +02:00
Ilia Denisov 7860efce48 feat(payments): order-flow and fund credit engine + the Robokassa adapter
Add the payment-intake write path (provider-agnostic) and the Robokassa
direct-rail glue, both unit-tested; transport, wire and UI follow.

- payments: extend the ledger insert to thread order_id/provider/
  provider_payment_id (spend/grant pass nil); add the order store
  (create/read/expire + a pack-price loader) and the fund credit — a
  fund ledger row + a guarded balance upsert + mark-paid in one tx,
  idempotent on the (provider, provider_payment_id) unique index, cache
  invalidated after commit. A valid callback is honoured even on an
  expired order. Service CreateOrder/Fund/ExpireOrders; Money.Major for
  the provider amount field.
- robokassa: build the signed hosted-payment URL (SHA-256, order id via
  Shp_order, InvId unused) and verify the Result callback signature
  (Password2), extracting the order and amount. Receipt/fiscalisation is
  configured shop-side, so no Receipt parameter is sent.
2026-07-09 16:48:48 +02:00
developer ca60025fbe Merge pull request 'feat(ui): public offer page at /offer/ with a landing footer link' (#222) from feature/public-offer-page into development
CI / changes (push) Successful in 2s
CI / unit (push) Successful in 11s
CI / integration (push) Successful in 18s
CI / ui (push) Successful in 1m9s
CI / conformance (push) Successful in 9s
CI / gate (push) Successful in 0s
CI / deploy (push) Successful in 1m38s
2026-07-09 14:12:28 +00:00
Ilia Denisov 0bdc034f7a chore: offer formatting
CI / changes (pull_request) Successful in 2s
CI / unit (pull_request) Successful in 10s
CI / integration (pull_request) Successful in 19s
CI / ui (pull_request) Successful in 1m8s
CI / conformance (pull_request) Successful in 10s
CI / gate (pull_request) Successful in 0s
CI / deploy (pull_request) Successful in 1m41s
2026-07-09 16:04:56 +02:00
Ilia Denisov 625fc3135a feat(ui): serve the public offer at /offer/ with a landing footer link
CI / changes (pull_request) Successful in 2s
CI / unit (pull_request) Successful in 10s
CI / integration (pull_request) Successful in 19s
CI / ui (pull_request) Successful in 1m9s
CI / conformance (pull_request) Successful in 9s
CI / gate (pull_request) Successful in 0s
CI / deploy (pull_request) Successful in 1m41s
The offer is the legal document a purchase accepts, needed before real-money
intake goes live. Convert the source PDF to an editable ui/legal/offer_ru.md and
render it to a standalone static dist/offer/index.html at build (a vite
emit-offer plugin using marked); the landing container serves it at /offer/,
with a bare /offer redirecting in. Add a small centered "Публичная оферта"
footer link on the landing (ru/en) and a CI probe asserting /offer/ serves the
page rather than silently falling through to the landing shell.
2026-07-09 16:03:50 +02:00
developer a118c63411 Merge pull request 'fix(deploy): run the base-backup timer's pgBackRest as the postgres role' (#221) from feature/pitr-timer-fix 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 1m8s
CI / conformance (push) Successful in 10s
CI / gate (push) Successful in 0s
CI / deploy (push) Successful in 1m41s
2026-07-09 00:07:52 +00:00
Ilia Denisov 7123ba439d fix(deploy): run the base-backup timer's pgBackRest as the postgres role
CI / changes (pull_request) Successful in 2s
CI / unit (pull_request) Successful in 10s
CI / integration (pull_request) Successful in 18s
CI / ui (pull_request) Successful in 1m9s
CI / conformance (pull_request) Successful in 9s
CI / gate (pull_request) Successful in 0s
CI / deploy (pull_request) Successful in 1m46s
docker exec defaults to root, so the systemd base-backup timer connected to the
database as role "root" (then "postgres") — neither exists; the superuser role is
POSTGRES_USER (scrabble). Run the timer's pgBackRest as the postgres OS user (-u postgres,
for lock-dir/PGDATA consistency with archive-push) and connect with --pg1-user=scrabble.
archive_command (run by the postgres server process) was already correct.

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

Local games created before this fix keep the old display (no migration).
2026-07-06 15:36:51 +02:00
Ilia Denisov 19e7ea5da0 fix(offline): persist a plain profile snapshot; keep the sticky offline flag
CI / changes (pull_request) Successful in 2s
CI / unit (pull_request) Has been skipped
CI / integration (pull_request) Has been skipped
CI / ui (pull_request) Successful in 1m10s
CI / conformance (pull_request) Successful in 10s
CI / gate (pull_request) Successful in 0s
CI / deploy (pull_request) Successful in 1m40s
The offline cold-boot never engaged: it persisted app.profile — a Svelte
$state proxy — which is not structured-cloneable, so the IndexedDB write threw
and fell back to a localStorage entry that loadProfile (IDB-only on a
successful-empty read) never reads. loadProfile returned null, the boot
short-circuit missed, and it even cleared the sticky offline flag — so offline
mode stopped persisting across relaunches (owner-observed on the contour).

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

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

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

Cold offline launch hanging on the splash (boot still fetches the profile over
the network) is the separate C2 offline-boot follow-up (needs a persisted
profile + a boot short-circuit).
2026-07-06 14:04:23 +02:00
Ilia Denisov ef832b823d feat(offline): offline lobby lists + creates local vs_ai games
CI / changes (pull_request) Successful in 2s
CI / unit (pull_request) Has been skipped
CI / integration (pull_request) Has been skipped
CI / ui (pull_request) Successful in 1m10s
CI / conformance (pull_request) Successful in 10s
CI / gate (pull_request) Successful in 0s
CI / deploy (pull_request) Successful in 1m39s
In offline mode the lobby now shows only the device-local games and its
New-vs-AI entry creates one through the in-browser engine — the visible
payoff of the offline mode.

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

Pure additive library code; no runtime behavior change.
2026-07-06 01:35:11 +02:00
developer 3a85f64726 Merge pull request 'fix(metrics): second-scale buckets for edge_request_duration' (#186) from fix/edge-latency-histogram-buckets into development
CI / gate (push) Successful in 0s
CI / changes (pull_request) Successful in 1s
CI / integration (pull_request) Successful in 15s
CI / ui (pull_request) Successful in 1m8s
CI / conformance (pull_request) Successful in 9s
CI / changes (push) Successful in 2s
CI / unit (push) Successful in 11s
CI / integration (push) Successful in 14s
CI / ui (push) Has been skipped
CI / conformance (push) Successful in 10s
CI / deploy (push) Successful in 1m46s
CI / unit (pull_request) Successful in 10s
CI / deploy (pull_request) Has been skipped
CI / gate (pull_request) Successful in 0s
2026-07-05 20:55:31 +00:00
Ilia Denisov 00bb66ba0f fix(metrics): second-scale buckets for edge_request_duration
CI / changes (pull_request) Successful in 2s
CI / unit (pull_request) Successful in 10s
CI / integration (pull_request) Successful in 14s
CI / ui (pull_request) Has been skipped
CI / conformance (pull_request) Successful in 8s
CI / gate (pull_request) Successful in 0s
CI / deploy (pull_request) Successful in 1m44s
The edge request-latency histogram records seconds but used the OTel SDK's
default millisecond-calibrated bucket boundaries (first boundary 5), so every
sub-5s request fell into one bucket and histogram_quantile(0.99) interpolated
to ~4.95s for every message type — regardless of the real (millisecond)
latency. That tripped the >1s "Gateway request latency p99 high" alert on
essentially every request, flapping fire/resolve on each app open (seen on the
test and prod contours).

Set explicit second-scale bucket boundaries (0.005 … 10s) straddling the 1s SLO
so the p99 reflects real latency and the alert fires only on genuine slowness.
Regression test asserts the histogram carries a sub-second boundary.
2026-07-05 22:49:04 +02:00
developer a2c79c149a Merge pull request 'feat(email): PWA login sends code only; drop admin link from alert emails' (#185) from feature/pwa-login-code-only into development
CI / changes (push) Successful in 1s
CI / unit (push) Successful in 10s
CI / integration (push) Successful in 18s
CI / ui (push) Successful in 1m9s
CI / conformance (push) Successful in 9s
CI / gate (push) Successful in 0s
CI / deploy (push) Successful in 1m50s
2026-07-05 20:26:04 +00:00
Ilia Denisov 061366da5a feat(email): PWA login sends code only; drop admin link from alert emails
CI / changes (pull_request) Successful in 2s
CI / unit (pull_request) Successful in 10s
CI / integration (pull_request) Successful in 15s
CI / ui (pull_request) Successful in 1m8s
CI / conformance (pull_request) Successful in 9s
CI / gate (pull_request) Successful in 0s
CI / deploy (pull_request) Successful in 1m40s
Two email changes, per the owner:

1. Code-only login from an installed PWA. A login requested while running as a
   standalone PWA now omits the one-tap confirm link from the email — the link
   would open in a separate browser whose minted session cannot reach the PWA,
   stranding the login. The code is typed in the same window instead. The client
   sends a `pwa` flag (isStandalone) on the email-code request (a new FBS field,
   threaded through the gateway); the backend omits the deeplink when it is set,
   reusing the existing deletion-code link-omission path. Non-PWA browser logins
   keep the one-tap link. No polling, no migration.

2. Security: the operator alert digest no longer embeds the admin-console (/_gm)
   URL. An admin link must never travel in an email, where a mail provider could
   cache or index it; the operator opens the console directly.

Tests: an inttest asserting the PWA login email omits the link (and a browser one
keeps it); a codec round-trip of the new pwa field; the alert-digest test flipped
to guard the admin link is absent. Docs: ARCHITECTURE + FUNCTIONAL (+ru).
2026-07-05 22:13:21 +02:00
developer d19609f87d Merge pull request 'feat(pwa): installable web app + landing web entry' (#184) from feature/pwa-install-cta into development
CI / changes (push) Successful in 3s
CI / unit (push) Successful in 10s
CI / integration (push) Successful in 15s
CI / ui (push) Successful in 1m8s
CI / conformance (push) Successful in 9s
CI / gate (push) Successful in 0s
CI / deploy (push) Successful in 2m40s
2026-07-05 19:36:37 +00:00
Ilia Denisov 51147a1429 feat(pwa): installable web app + landing web entry
CI / changes (pull_request) Successful in 2s
CI / unit (pull_request) Successful in 10s
CI / integration (pull_request) Successful in 19s
CI / ui (pull_request) Successful in 1m8s
CI / conformance (pull_request) Successful in 9s
CI / gate (pull_request) Successful in 0s
CI / deploy (pull_request) Successful in 1m43s
Make the web SPA an installable PWA and surface it to users:

- manifest.webmanifest + an install-only service worker + 192/512/maskable
  icons (ui/public); PWA head tags in index.html; the .webmanifest MIME type
  registered in the gateway (the distroless image has no /etc/mime.types).
- Platform-adaptive install CTA (components/InstallApp.svelte + lib/pwa):
  one-tap on Chromium, manual Add-to-Home-Screen instructions on iOS Safari,
  hidden elsewhere / once installed / inside a Mini App. Shown under the
  logged-out login card and at the bottom of Settings.
- The landing gains a third entry linking /app/ (the brand tile), with a
  caption under all three (Telegram / VK / Веб-версия).

The service worker is navigation-only (network-first, cached-shell fallback);
hashed assets and the Connect stream are untouched. It exists to satisfy
Chromium's installability requirement and is the single growth point for a
future opt-in offline mode.

Tests: pwa.ts unit tests; a webui probe (manifest/sw.js content-type + the
SPA-fallback-to-HTML trap); e2e for the one-tap CTA, the iOS instructions
modal and the landing web entry. Docs: ARCHITECTURE §13, FUNCTIONAL (+ru),
gateway README.
2026-07-05 21:11:41 +02:00
developer d61ba68e9b Merge pull request 'feat(banner): per-campaign colour overrides and urgent alerts' (#182) from feature/banner-colors-urgent into development
CI / integration (push) Successful in 15s
CI / deploy (push) Successful in 1m57s
CI / unit (pull_request) Successful in 10s
CI / integration (pull_request) Successful in 15s
CI / conformance (pull_request) Successful in 8s
CI / gate (pull_request) Successful in 0s
CI / deploy (pull_request) Has been skipped
CI / changes (push) Successful in 2s
CI / unit (push) Successful in 10s
CI / ui (push) Successful in 1m8s
CI / conformance (push) Successful in 9s
CI / gate (push) Successful in 0s
CI / changes (pull_request) Successful in 2s
CI / ui (pull_request) Successful in 1m8s
2026-07-05 13:57:32 +00:00
Ilia Denisov 6db9178449 feat(banner): per-campaign colour overrides and urgent alerts
CI / changes (pull_request) Successful in 2s
CI / unit (pull_request) Successful in 10s
CI / integration (pull_request) Successful in 15s
CI / ui (pull_request) Successful in 1m8s
CI / conformance (pull_request) Successful in 9s
CI / gate (pull_request) Successful in 0s
CI / deploy (pull_request) Successful in 1m50s
Non-default campaigns gain an optional colour override (background / text /
link) in two sets — one for every theme, one for the dark theme only — and an
"urgent" flag.

- Colours ride profile.get as six trailing FlatBuffers strings on
  BannerCampaign (backward-compatible). The client resolves the cascade
  (dark <- dark ?? all, light <- all) per rendered theme and derives the strip
  border from the background in JS (no CSS color-mix, for the old Android
  WebView floor); AdBanner applies them as inline vars scoped to the strip.
- Urgent is resolved entirely server-side: while any enabled, in-window urgent
  campaign exists, computeActiveSet returns only the urgent campaigns and
  bannerFor skips the eligibility gate — so a system notice reaches every viewer
  (paid / hint-holding / no_banner included) and preempts the ordinary feed. No
  wire field; it appears on each viewer's next profile.get.
- Admin console (/_gm/banners): native colour pickers + a live light/dark
  preview of the strip, and an urgent toggle. The default campaign stays plain,
  enforced by the service and a DB CHECK.

Migration 00009 is additive (nullable colour columns + a bool default +
all-or-nothing / hex / default-plain CHECKs) — expand-contract, rollback-safe.

Docs: ARCHITECTURE §10, UI_DESIGN, FUNCTIONAL (+ru). Tests: ads unit (urgent
preempt + colour validation), codec + resolver unit, gateway transcode, and
integration (colour round-trip + urgent bypass against real Postgres).
2026-07-05 15:36:35 +02:00
developer ac383880b7 Merge pull request 'fix(ui): hold info toast ~1s before it rises and fades' (#181) from feature/toast-hold-before-fade 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 1m7s
CI / conformance (push) Successful in 9s
CI / gate (push) Successful in 0s
CI / deploy (push) Successful in 1m44s
2026-07-05 12:24:08 +00:00
developer 16349f5ad9 Merge pull request 'fix(ui): poll for out-of-band email confirmation' (#180) from feature/email-confirm-poll-fallback into development
CI / changes (push) Successful in 1s
CI / unit (push) Has been skipped
CI / integration (push) Has been skipped
CI / ui (push) Successful in 1m8s
CI / conformance (push) Successful in 9s
CI / gate (push) Successful in 0s
CI / deploy (push) Successful in 1m45s
2026-07-05 12:20:38 +00:00
developer 101a3f118c Merge pull request 'feat(ui): hide current-host sign-in row in profile' (#179) from feature/hide-current-host-signin-row 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 1m4s
CI / conformance (push) Successful in 9s
CI / gate (push) Successful in 0s
CI / deploy (push) Successful in 2m33s
2026-07-05 12:16:22 +00:00
Ilia Denisov 400b6ac2a5 fix(ui): hold info toast ~1s before it rises and fades
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 1m8s
CI / conformance (pull_request) Successful in 8s
CI / gate (pull_request) Successful in 0s
CI / deploy (pull_request) Successful in 1m39s
The info toast began drifting up and fading the instant it finished
appearing (the CSS keyframes went straight from the 12% appeared-stop to
the 100% risen-and-faded stop), so a glanced message was already leaving.

Insert a ~1s hold at full opacity/rest before the rise-and-fade: extend the
animation 2s → 3s with keyframe stops at 8% (appeared, ~240ms) and 41% (end
of the ~1s hold), keeping the original rise-and-fade pace for the tail. The
reduced-motion variant gets the same appear/hold/fade timing (fade only, no
travel). The showToast dismissal timer is bumped 2000 → 3000ms to stay in
lockstep with the animation (the error toast's 4s dwell is unchanged).
2026-07-05 14:10:34 +02:00
Ilia Denisov fb7490f1df fix(ui): poll for out-of-band email confirmation
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 1m8s
CI / conformance (pull_request) Successful in 9s
CI / gate (pull_request) Successful in 0s
CI / deploy (pull_request) Successful in 1m39s
An email link/code can be confirmed out of band: the recipient taps the
one-tap link in the email, which confirms in another browser/session. The
backend already publishes a `notify` `profile` re-fetch signal for this
(handlers_auth.go handleEmailConfirmLink), and the client re-fetches on it.

But the live stream is single-shot with no replay: a Mini App backgrounded
while the user is in their mail app tapping the link drops the stream and
misses the event (the gateway hub has no subscriber to deliver to), and the
reconnect on foreground does not re-sync — so the open code form stayed
until a manual reload. On Telegram Desktop the app is never backgrounded,
so the push works and there was no bug.

Add a client-side fallback: while an add-email confirmation is pending
(a code was sent, no email yet), poll `profile.get` on a 4s interval and on
foreground regain until the address lands; the effect stops as soon as the
email appears. The live push still updates instantly when foregrounded —
this only covers the backgrounded-miss gap.

Tests: a mock e2e attaches the email WITHOUT emitting a live event (new
window.__mock.clearEmail / confirmEmailOutOfBand seams), so it exercises the
poll, not the push, and asserts the code form collapses into the email row.
Docs: ARCHITECTURE.md §10 notes the single-shot gap + the poll fallback.
2026-07-05 13:58:39 +02:00
Ilia Denisov c1805e5b7c feat(ui): hide current-host sign-in row in profile
CI / changes (pull_request) Successful in 2s
CI / unit (pull_request) Has been skipped
CI / integration (pull_request) Has been skipped
CI / ui (pull_request) Successful in 1m6s
CI / conformance (pull_request) Successful in 9s
CI / gate (pull_request) Successful in 0s
CI / deploy (pull_request) Successful in 1m40s
Inside a Telegram/VK Mini App the host provider is auto-linked. Once the
player also linked an email, `canUnlink` turned true and the "Unlink"
control appeared on the host platform's own row — letting them unlink the
very platform they are signed in through, which is meaningless.

Gate the Telegram row on `!insideTelegram()` and the VK row on
`!insideVK()`, reusing the runtime host detectors that already gate the
"link" buttons. Symmetric: inside TG only the TG row is hidden (the VK row
still shows, since VK is not the current host), and vice versa. The web and
native builds are unchanged (both detectors are false there); the backend
is untouched — this is a UI display gate, and `linkUnlink` still refuses to
remove the last identity.

Docs: FUNCTIONAL.md (+_ru mirror).
2026-07-05 13:10:40 +02:00
developer fcedadcb5b Merge pull request 'feat(gateway): unsupported-engine telemetry beacon + Grafana counter' (#177) from feature/unsupported-engine-telemetry into development
CI / changes (push) Successful in 2s
CI / unit (push) Successful in 10s
CI / integration (push) Successful in 19s
CI / ui (push) Successful in 1m6s
CI / conformance (push) Successful in 9s
CI / gate (push) Successful in 0s
CI / deploy (push) Successful in 1m44s
CI / changes (pull_request) Successful in 2s
CI / unit (pull_request) Successful in 9s
CI / integration (pull_request) Successful in 15s
CI / ui (pull_request) Successful in 1m5s
CI / conformance (pull_request) Successful in 9s
CI / deploy (pull_request) Has been skipped
CI / gate (pull_request) Successful in 0s
2026-07-04 21:16:23 +00:00
Ilia Denisov 2feb638329 feat(gateway): unsupported-engine telemetry beacon + Grafana counter
CI / changes (pull_request) Successful in 2s
CI / unit (pull_request) Successful in 10s
CI / integration (pull_request) Successful in 19s
CI / ui (pull_request) Successful in 1m5s
CI / conformance (pull_request) Successful in 9s
CI / gate (pull_request) Successful in 0s
CI / deploy (pull_request) Successful in 1m40s
The index.html boot guard now fires a fire-and-forget beacon (POST /telemetry/unsupported)
when it turns a client away on the unsupported-engine screen, so the owner can see — on the
Users dashboard beside "app opens" — how many real clients hit it and on which engines.

- Client: navigator.sendBeacon (fetch fallback) with a localStorage dedup keyed by app version
  + reason + Chromium, so a user reopening the app is one report, not ten.
- Gateway: a new unauthenticated POST /telemetry/unsupported handler (the client never booted,
  so it carries no session), per-IP public-limited and body-capped, mirroring the export-download
  route. It folds the beacon into the OTel counter unsupported_engine_total {reason =
  no_bigint/no_proxy/boot_error/other, chromium}, with reason allow-listed and the Chromium major
  reduced to a bounded range (normalizeUnsupported) so a spoofed beacon cannot inflate the metric
  cardinality; the full user agent is logged, not labelled.
- Caddy: /telemetry/* added to the @gateway matcher (else it falls to the landing catch-all).
- Grafana: two panels on the Scrabble — Users dashboard (by reason, by Chromium major).
- Docs: ARCHITECTURE.md §11.

Tests: recordUnsupportedEngine (metric split), normalizeUnsupported (cardinality bounding), and
the handler route end to end (204 / 405 / counter). go build+vet+test and gofmt clean; ui
check/build/e2e green; the client beacon + dedup verified against a forced hard-gate (BigInt
removed) — one POST, deduped on reopen, correct reason/chromium/version labels.
2026-07-04 23:03:47 +02:00
developer 4dfedd02a3 feat(ui): support old Android in-app WebViews (es2019 + core-js + engine screen)
CI / changes (push) Successful in 2s
CI / unit (push) Has been skipped
CI / integration (push) Has been skipped
CI / ui (push) Successful in 1m5s
CI / conformance (push) Successful in 9s
CI / gate (push) Successful in 0s
CI / deploy (push) Successful in 1m54s
Old Android System WebViews (the Telegram/VK in-app browser; a real device with Google
Play auto-updates the WebView, but emulators / no-Play / restricted devices stay frozen)
showed a white screen. Two causes, fixed in order:

- build.target es2022 shipped ?./?? verbatim, which the old engine cannot parse. Lowered
  to es2019 so esbuild down-levels the syntax.
- The es2020+ runtime globals the bundle and its deps call (globalThis, structuredClone,
  Array.at, ...) are missing on those engines. A conditional core-js loader (emitPolyfills
  writes dist/polyfills.js, document.write'd by an index.html gate only when needed) covers
  them; modern engines download nothing, so the bundle-size budget is untouched.

BigInt (the 64-bit FlatBuffers timestamp decode) and Proxy (Svelte 5 runes) cannot be
polyfilled, so the effective floor is Chrome 67. Below it, a permanent ES5 boot guard in
index.html shows a friendly "this device's OS or browser can't run the app" screen (with
the web-version link inside a Mini App) and a "Diagnostic information" view with a Copy
button, instead of a white screen. A reactive net raises the same screen on an uncaught
boot error that never signals window.__booted.

Board tile glyphs used container-query units (cqw, Chrome 105+) under .cell font-size:0,
so they collapsed to 0px on Chrome 74 (invisible letters); added vmin fallbacks.

The unsupported-engine telemetry beacon is a separate follow-up PR.
2026-07-04 20:36:05 +00:00
developer 44117e906c Merge pull request 'fix(ci): prod build needs EXPORT_SIGN_KEY for compose interpolation' (#174) from fix/prod-build-export-sign-key into development
CI / changes (push) Successful in 2s
CI / changes (pull_request) Successful in 1s
CI / unit (push) Successful in 10s
CI / integration (push) Successful in 18s
CI / ui (push) Successful in 1m5s
CI / conformance (push) Successful in 9s
CI / unit (pull_request) Successful in 10s
CI / integration (pull_request) Successful in 15s
CI / ui (pull_request) Successful in 1m5s
CI / conformance (pull_request) Successful in 9s
CI / gate (push) Successful in 0s
CI / deploy (push) Successful in 1m40s
CI / gate (pull_request) Successful in 0s
CI / deploy (pull_request) Has been skipped
2026-07-03 21:42:05 +00:00
Ilia Denisov c90331b189 fix(ci): prod build needs EXPORT_SIGN_KEY for compose interpolation
CI / changes (pull_request) Successful in 2s
CI / unit (pull_request) Successful in 10s
CI / integration (pull_request) Successful in 16s
CI / ui (pull_request) Successful in 1m5s
CI / conformance (pull_request) Successful in 9s
CI / gate (pull_request) Successful in 0s
CI / deploy (pull_request) Successful in 1m44s
The first v1.8.0 prod-deploy build job failed: `docker compose build` interpolates the
WHOLE compose file, and the backend's :?-guarded EXPORT_SIGN_KEY — added with the
finished-game export after the last prod release (v1.7.0), so the prod build never
exercised it — was absent from the build job's env, tripping the guard before any image
built. Prod was untouched (build-only failure: no image pushed, no deploy, no migration).

Add EXPORT_SIGN_KEY (audited every :?-guarded compose var against the build job env — it
was the only genuinely-missing one; TELEGRAM_MINIAPP_URL is derived in the run step).
Verified by reproducing the build-job env + `docker compose config`.
2026-07-03 23:37:48 +02:00
developer 0dfecc2af7 Merge pull request 'fix(ci): arm the maintenance flag on the test-contour deploy' (#172) from fix/maintenance-flag-test-contour into development
CI / deploy (push) Successful in 1m44s
CI / changes (pull_request) Successful in 1s
CI / gate (pull_request) Successful in 0s
CI / deploy (pull_request) Has been skipped
CI / changes (push) Successful in 2s
CI / unit (push) Successful in 10s
CI / integration (push) Successful in 16s
CI / ui (push) Successful in 1m6s
CI / conformance (push) Successful in 9s
CI / gate (push) Successful in 0s
CI / unit (pull_request) Successful in 9s
CI / integration (pull_request) Successful in 16s
CI / ui (pull_request) Successful in 1m6s
CI / conformance (pull_request) Successful in 8s
2026-07-03 21:10:14 +00:00
Ilia Denisov 446ea2ac45 fix(ci): arm the maintenance flag on the test-contour deploy
CI / changes (pull_request) Successful in 2s
CI / unit (pull_request) Successful in 10s
CI / integration (pull_request) Successful in 15s
CI / ui (pull_request) Successful in 1m5s
CI / conformance (pull_request) Successful in 9s
CI / gate (pull_request) Successful in 0s
CI / deploy (pull_request) Successful in 1m53s
The maintenance overlay never showed on the test contour: only prod-deploy.sh raised
the caddy maintenance flag, so a test redeploy was a bare gateway/backend recreate — the
SPA saw a transient 502 ("Reconnecting…"), never the 503 + X-Scrabble-Maintenance marker
the overlay keys on. So the feature (PR #171) was unverifiable off prod.

Raise the flag around the recreate in ci.yaml's deploy job too, mirroring prod. Ordering
matters because of the config reseed: ci does `rm -rf $conf` + recreate, so the running
caddy sits on the stale pre-reseed bind mount and cannot see a flag written to the new
dir until it is recreated. So: raise the flag, force-recreate caddy FIRST (onto the fresh
mount, where it carries the flag and 503s), then recreate gateway/backend (the window),
then the other config services, then lower it. A trap clears the flag if the step fails,
and the next deploy's reseed wipes a stale one — so the contour can't stick in maintenance.
The caddy-routed probes run in the next step, after the flag is lowered.

Prod was already correct (prod-deploy.sh); this only makes the test contour faithful so the
overlay + reload can be seen there. An open SPA must already be on the PR-#171 bundle (which
carries the overlay code) — reload once to bootstrap onto it.
2026-07-03 23:04:19 +02:00
developer 01a9249002 Merge pull request 'feat(ui): in-session maintenance overlay on the edge 503 marker' (#171) from feature/maintenance-overlay 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 1m5s
CI / conformance (push) Successful in 9s
CI / gate (push) Successful in 0s
CI / deploy (push) Successful in 2m6s
2026-07-03 20:52:05 +00:00
Ilia Denisov 7dcd62fdd7 feat(ui): reload the SPA on maintenance recovery
CI / changes (pull_request) Successful in 2s
CI / unit (pull_request) Has been skipped
CI / integration (pull_request) Has been skipped
CI / ui (pull_request) Successful in 1m6s
CI / conformance (pull_request) Successful in 9s
CI / gate (pull_request) Successful in 0s
CI / deploy (pull_request) Successful in 1m47s
The deploy that ends a maintenance window may ship a client-incompatible change
(wire/schema bump), and the in-session bundle is the old one. On recovery, reload to
pick up the fresh client instead of just hiding the overlay. Ordering is safe: the edge
maintenance flag (deploy/prod-deploy.sh) spans the WHOLE roll and clears only at script
exit — after the gateway (which serves the embedded SPA) has rolled — so the edge 503s
everything until the entire deploy is live; recovery therefore always serves the new SPA.
A window.__maint.recover() hook + e2e cover the reload.
2026-07-03 22:46:55 +02:00
Ilia Denisov d67e582c03 feat(ui): in-session maintenance overlay on the edge 503 marker
CI / changes (pull_request) Successful in 2s
CI / unit (pull_request) Has been skipped
CI / integration (pull_request) Has been skipped
CI / ui (pull_request) Successful in 1m4s
CI / conformance (pull_request) Successful in 11s
CI / gate (pull_request) Successful in 0s
CI / deploy (pull_request) Successful in 1m39s
The edge caddy 503 carries X-Scrabble-Maintenance during a deploy window, but a user
already in the running SPA only sees their calls start failing — the static caddy page
catches only a fresh load. Detect that marker (strictly — not any transient
'unavailable', which the Connecting indicator already covers) on the raw ConnectError at
the two transport catch sites (unary exec + the live subscribe stream), before
toGatewayError discards the response headers, and raise a non-dismissable dimmed overlay
that mirrors the static caddy page. It self-clears: a capped-backoff poll (a cheap read,
mirroring connection.svelte.ts) lifts it on the first success, and a manual "retry"
button forces an immediate re-check — so it can never get stuck. On detection the read
retry loop fails fast, so a window doesn't burn the retry budget on every call.

- pure detector maintenance.ts (maintenanceRetryMs / parseRetryAfterMs) + unit tests;
  the store + self-clearing poll in maintenance.svelte.ts (mirrors connection.svelte.ts)
- MaintenanceOverlay.svelte (clones Splash's fixed/inset/dimmed shell, non-dismissable),
  mounted app-global in App.svelte after Coachmark
- transport.ts detects + reports at both catch sites, clears on any successful read
- i18n RU "Технические работы" / EN "Under maintenance"; a window.__maint mock hook
  (the mock can't emit a real 503) + a Playwright spec

Prod is same-origin (VITE_GATEWAY_URL empty) so the marker header is readable without a
CORS expose-header. Verified: pnpm check (0), unit (402), build, e2e (186, Chromium+WebKit).
2026-07-03 22:40:49 +02:00
developer 75fe07865a Merge pull request 'feat(deploy): prod OOM swap cushion + edge maintenance page' (#170) from feature/prod-hardening into development
CI / changes (push) Successful in 1s
CI / unit (push) Successful in 9s
CI / integration (push) Successful in 15s
CI / ui (push) Successful in 1m6s
CI / conformance (push) Successful in 9s
CI / gate (push) Successful in 0s
CI / deploy (push) Successful in 1m43s
2026-07-03 20:28:29 +00:00
Ilia Denisov 597e200f37 Merge remote-tracking branch 'origin/development' into feature/prod-hardening
CI / changes (pull_request) Successful in 2s
CI / unit (pull_request) Successful in 10s
CI / integration (pull_request) Successful in 15s
CI / ui (pull_request) Successful in 1m4s
CI / conformance (pull_request) Successful in 9s
CI / gate (pull_request) Successful in 1s
CI / deploy (pull_request) Successful in 1m40s
2026-07-03 22:17:05 +02:00
developer 3ef18d33b6 Merge pull request 'feat(gateway): enable GATEWAY_HONEYTOKEN + GF_SMTP_ENABLED on both contours' (#169) from feature/enable-honeytoken into development
CI / changes (push) Successful in 1s
CI / unit (push) Successful in 10s
CI / integration (push) Successful in 17s
CI / ui (push) Successful in 1m6s
CI / conformance (push) Successful in 9s
CI / gate (push) Successful in 0s
CI / deploy (push) Successful in 2m35s
2026-07-03 20:16:44 +00:00
Ilia Denisov c8601c0115 feat(deploy): prod OOM swap cushion + edge maintenance page
CI / changes (pull_request) Successful in 2s
CI / unit (pull_request) Successful in 10s
CI / integration (pull_request) Successful in 18s
CI / ui (pull_request) Successful in 1m5s
CI / conformance (pull_request) Successful in 9s
CI / gate (pull_request) Successful in 0s
CI / deploy (pull_request) Successful in 1m39s
Two prod-deploy hardening measures (owner-requested):

- Swap file (Ansible common role, swap_size=1G, vm.swappiness=10). The per-container
  memory caps enforce that one service can't eat all RAM, but they overcommit the
  1.9 GiB main host (~2.8 GiB of caps), so a simultaneous spike could hit the kernel
  OOM-killer (and it might pick postgres). A small swap absorbs the overshoot.
  Idempotent, builtin-only (no ansible.posix).

- Edge maintenance page. prod-deploy.sh raises a flag around the rolling swap /
  migration window that the caddy edge serves a static 503 "технические работы" page
  from (deploy/caddy/maintenance.html), for the user-facing routes only — /_gm
  (Grafana) stays reachable. Cleared on any exit (success, health failure + rollback,
  or error) by a shell trap so it can never stick on. The 503 carries Retry-After +
  an X-Scrabble-Maintenance marker so a follow-up SPA overlay can tell a planned
  window apart from a transient error (the static page only catches a fresh load; an
  in-session user needs the app-side overlay). Not zero-downtime — the single
  stateful backend still blips — but the window is graceful instead of raw 502s.

Verified: caddy validate; the gate 503s every non-/_gm path incl. the Connect/gRPC
edge and serves the page + markers; /_gm bypasses; toggling needs no reload
(per-request stat). Ansible --syntax-check + compose config (base+prod) pass.
2026-07-03 22:09:37 +02:00
Ilia Denisov a0021d1994 feat(gateway): wire GATEWAY_HONEYTOKEN through the deploy
CI / changes (pull_request) Successful in 2s
CI / unit (pull_request) Successful in 10s
CI / integration (pull_request) Successful in 20s
CI / ui (pull_request) Successful in 1m6s
CI / conformance (pull_request) Successful in 10s
CI / gate (pull_request) Successful in 0s
CI / deploy (pull_request) Successful in 1m44s
The planted honeytoken bearer trap already existed in the gateway + compose, but
no workflow fed GATEWAY_HONEYTOKEN, so it was always empty (inert). Wire the
per-contour TEST_/PROD_GATEWAY_HONEYTOKEN secret into the ci deploy, prod-deploy
and prod-rollback env (the prod path via the shared deploy/write-prod-env.sh),
document it (deploy/README + .env.example), and fix the stale compose comment.

Empty secret = trap off (no ":?" guard), so a deploy is safe before the operator
sets the value + plants the bait. On prod (IP ban on) presenting it earns a 24h
ban + alarm; on test (ban off) it logs + a ban metric.

GF_SMTP_ENABLED is enabled separately via the TEST_/PROD_GF_SMTP_ENABLED Gitea
variables (=true) — no code change.
2026-07-03 21:36:23 +02:00
developer 4b4dcab9b6 Merge pull request 'chore(deploy): dedupe & regroup Gitea CI variables/secrets' (#168) from feature/ci-vars-dedupe into development
CI / changes (push) Successful in 2s
CI / unit (push) Successful in 10s
CI / integration (push) Successful in 15s
CI / ui (push) Successful in 1m4s
CI / conformance (push) Successful in 8s
CI / gate (push) Successful in 0s
CI / deploy (push) Successful in 1m41s
2026-07-03 19:16:33 +00:00
Ilia Denisov 7f85362288 chore(deploy): dedupe & regroup Gitea CI variables/secrets
CI / changes (pull_request) Successful in 2s
CI / unit (pull_request) Successful in 9s
CI / integration (pull_request) Successful in 16s
CI / ui (pull_request) Successful in 1m4s
CI / conformance (pull_request) Successful in 9s
CI / gate (pull_request) Successful in 0s
CI / deploy (pull_request) Successful in 1m44s
Collapse identical TEST_/PROD_ pairs to single unprefixed Gitea entries, derive
the public URLs from PUBLIC_BASE_URL at deploy time, and share the prod env.sh
renderer between deploy and rollback.

- Collapse to one unprefixed variable: DICT_VERSION, SMTP_RELAY_HOST/PORT/TLS,
  GRAFANA_SMTP_PORT, VITE_VK_APP_LINK, VITE_VK_APP_ID (one Selectel relay + one
  pair of VK apps serve every contour). Secrets collapsed by the owner:
  SMTP_RELAY_USER/PASS, GATEWAY_VK_APP_SECRET, GATEWAY_VK_ID_CLIENT_SECRET.
- Derive at deploy from PUBLIC_BASE_URL (no longer stored): TELEGRAM_MINIAPP_URL,
  GRAFANA_ROOT_URL, VITE_VK_ID_REDIRECT_URL. Removes the prod/test asymmetry and
  fills the missing test VITE_VK_APP_ID (VK web login was half-configured on test).
- Extract deploy/write-prod-env.sh + write-prod-bot-env.sh, shared by prod-deploy
  and prod-rollback so the two cannot drift: a rollback now re-renders the FULL
  runtime env (email / VK login / Grafana alerts previously went dark after a
  rollback) and passes TELEGRAM_SUPPORT_CHAT_ID.
- Single-source DICT_VERSION (CI env + both deploys), fix the v1.3.0/v1.3.1 drift in
  .env.example/README, correct the misleading honeytoken/abuse-ban compose comment,
  and rewrite the deploy/README variable list (+ the previously undocumented
  GATEWAY_VK_APP_SECRET and TELEGRAM_SUPPORT_CHAT_ID).

The Gitea variables are reworked via the API; the stale TEST_/PROD_ entries are
deleted after the test contour goes green.
2026-07-03 21:07:07 +02:00
developer 72e9b600b0 Merge pull request 'fix(account): dedupe colliding identities on merge, journaling to the dossier' (#167) from feature/merge-email-dedupe into development
CI / changes (push) Successful in 2s
CI / unit (push) Successful in 10s
CI / integration (push) Successful in 15s
CI / ui (push) Has been skipped
CI / conformance (push) Successful in 9s
CI / gate (push) Successful in 0s
CI / deploy (push) Successful in 1m53s
2026-07-03 17:48:49 +00:00
Ilia Denisov 0eefbfd6a4 fix(account): dedupe colliding identities on merge, journaling to the dossier
CI / changes (pull_request) Successful in 2s
CI / unit (pull_request) Successful in 10s
CI / integration (pull_request) Successful in 16s
CI / ui (pull_request) Has been skipped
CI / conformance (pull_request) Successful in 9s
CI / gate (pull_request) Successful in 0s
CI / deploy (pull_request) Successful in 2m2s
An account merge blanket-reassigned all of the secondary's identities to the primary,
so merging two accounts that each had a confirmed email (or telegram/vk) left the
survivor with two identities of one kind — which the profile and the retention dossier
both treat as singular (the profile showed an arbitrary one; a later change-email
journaled only one). The merge now keeps the primary's identity and journals the
secondary's colliding one to retained_identities (reason=merge) before dropping it, so
the survivor has one identity per kind and the absorbed credential still lands in the
legal dossier.

- migration 00008: widen retained_identities.reason CHECK to admit 'merge'
  (expand-contract — Up only widens the set, so an image rollback stays DB-safe).
- accountmerge: dedupeIdentities + retainMergedIdentity before the identity reassign.
- inttest TestAccountMergeDedupesEmail; docs ARCHITECTURE §4 + retention.go reason note.
2026-07-03 19:32:52 +02:00
developer c680e695d3 Merge pull request 'feat(account): VK ID web login to link a VK identity from a browser' (#166) from feature/vk-web-link into development
CI / changes (push) Successful in 2s
CI / unit (push) Successful in 10s
CI / integration (push) Successful in 15s
CI / ui (push) Successful in 1m4s
CI / conformance (push) Successful in 9s
CI / gate (push) Successful in 0s
CI / deploy (push) Successful in 2m5s
2026-07-03 16:09:57 +00:00
Ilia Denisov 2c465c01d2 feat(account): VK ID web login to link a VK identity from a browser
CI / changes (pull_request) Successful in 2s
CI / unit (pull_request) Successful in 10s
CI / integration (pull_request) Successful in 18s
CI / ui (pull_request) Successful in 1m4s
CI / conformance (pull_request) Successful in 9s
CI / gate (pull_request) Successful in 0s
CI / deploy (pull_request) Successful in 1m39s
A browser has no signed VK Mini App launch params, so linking VK on the web uses
VK ID's raw OAuth 2.1 flow (PKCE, no @vkid/sdk): the SPA redirects to VK's hosted
login and returns with an authorization code, which the gateway exchanges
server-side (confidential, under the VK "Web" app's protected key) for the trusted
vk user id — then the existing link/merge machinery attaches or merges it.

- fbs LinkVKRequest{code, device_id, code_verifier}; codec + TS bindings.
- backend link.Service ConfirmVK/MergeVK/attachVK (KindVK, mirror Telegram),
  handleLinkVK[Merge], routes /user/link/vk[/merge], backendclient LinkVK[Merge].
- gateway internal/vkid confidential code exchange (id.vk.com/oauth2/auth);
  transcode link.vk.confirm/merge (registered only when configured) + config
  GATEWAY_VK_ID_{APP_ID,CLIENT_SECRET,REDIRECT_URL} + main wiring.
- UI lib/vkid (PKCE authorize redirect + callback), Profile "Link VK" control,
  boot callback handling; a merge re-authorizes for a fresh code (VK codes are
  single-use). Web-only (a redirect strands a Mini App webview).
- Deploy: VITE_VK_APP_ID + VITE_VK_ID_REDIRECT_URL build args + gateway env,
  ci.yaml/prod-deploy TEST_/PROD_ vars, compose/Dockerfile/.env.example/README.
- Tests: vkid exchange unit (string/number user_id, id_token fallback, errors),
  transcode link.vk, backend ConfirmVK/MergeVK inttest, codec encodeLinkVK.
- Docs: ARCHITECTURE §4, FUNCTIONAL(+ru), gateway README.
2026-07-03 17:59:33 +02:00
developer 60faa4f064 Merge pull request 'feat(observability): Grafana infra alerts + admin email notifications (PR4)' (#165) from feature/email-relay-pr4 into development
CI / changes (push) Successful in 1s
CI / unit (push) Successful in 10s
CI / integration (push) Successful in 20s
CI / ui (push) Successful in 1m4s
CI / conformance (push) Successful in 9s
CI / gate (push) Successful in 0s
CI / deploy (push) Successful in 1m39s
2026-07-03 14:05:45 +00:00
Ilia Denisov 854c4b3005 fix(deploy): stage blackbox config + derive bare Grafana SMTP from-address
CI / changes (pull_request) Successful in 2s
CI / unit (pull_request) Successful in 10s
CI / integration (pull_request) Successful in 15s
CI / ui (pull_request) Successful in 1m5s
CI / conformance (pull_request) Successful in 9s
CI / gate (pull_request) Successful in 0s
CI / deploy (pull_request) Successful in 1m28s
Two contour-deploy failures from PR4: (1) the deploy did not stage deploy/blackbox/, so
blackbox_exporter crash-looped on a missing config mount — add blackbox to the ci.yaml cp
and the prod-deploy tar; (2) Grafana rejects the 'Name <addr>' From form the backend go-mail
accepts and validates it even when SMTP is disabled, crash-looping Grafana — the deploy now
splits SMTP_RELAY_SERVICE_FROM into a bare GF_SMTP_FROM_ADDRESS + GRAFANA_SMTP_FROM_NAME
(handles both display and bare forms). Verified the sed split + compose render.
2026-07-03 15:30:39 +02:00
Ilia Denisov ec1bdfca00 fix(deploy): Grafana SMTP reuses relay host + explicit GRAFANA_SMTP_PORT
CI / changes (pull_request) Successful in 1s
CI / unit (pull_request) Successful in 10s
CI / integration (pull_request) Successful in 17s
CI / ui (pull_request) Successful in 1m5s
CI / conformance (pull_request) Successful in 8s
CI / gate (pull_request) Successful in 0s
CI / deploy (pull_request) Failing after 1s
Drop the separate GRAFANA_SMTP_HOST (it duplicated SMTP_RELAY_HOST): Grafana differs from
the backend only in needing the STARTTLS port, so GF_SMTP_HOST is composed from
SMTP_RELAY_HOST + GRAFANA_SMTP_PORT (an explicit per-contour var, no magic default), reusing
SMTP_RELAY_USER/PASS. Wired through ci/prod/.env.example/README.
2026-07-03 15:17:43 +02:00
Ilia Denisov 70f0f9e36a docs(architecture): observability alerting + admin-alert worker
CI / changes (pull_request) Successful in 2s
CI / unit (pull_request) Successful in 11s
CI / integration (pull_request) Successful in 18s
CI / ui (pull_request) Successful in 1m6s
CI / conformance (pull_request) Successful in 9s
CI / gate (pull_request) Successful in 0s
CI / deploy (pull_request) Failing after 1m8s
§11 gains the alerting layer: Grafana infra rules (scrape-down, edge error-rate/p99, host
mem/disk/cpu, postgres connections, TLS cert < 20d via blackbox), noDataState=OK, and the
backend admin-alert worker (coalesced email on new feedback / complaints).
2026-07-03 15:08:57 +02:00
Ilia Denisov 55f6176538 feat(deploy): Grafana infra alerts + blackbox cert probe + admin-alert wiring
Grafana: GF_SMTP from the shared relay (STARTTLS host:port) + alerting provisioning
(contact point → SERVICE_EMAIL, route-all policy, and rules for scrape-target down,
gateway internal-error rate + p99 latency, host mem/disk/cpu, postgres connections, and
TLS cert < 20 days). All rules noDataState=OK so an absent metric never false-alerts.
blackbox_exporter probes the edge caddy's TLS (probe_ssl_earliest_cert_expiry) — effective
on prod (caddy terminates TLS; contour caddy is HTTP-only so the metric is absent).
Wires the new env through compose (backend admin From/To, Grafana SMTP), ci.yaml (TEST_),
prod-deploy (PROD_ + env.sh), .env.example and the README var table.
2026-07-03 15:08:09 +02:00
Ilia Denisov 8d2cd97e17 feat(adminalert): operator email on new feedback / word complaints
New adminalert worker polls for feedback + word complaints arriving since the last check
and coalesces a burst into one digest email per interval (5 min), inert unless a distinct
admin sender (BACKEND_SMTP_ADMIN_FROM) and recipient (BACKEND_ADMIN_EMAIL, comma-separated
allowed) are configured. The mailer gains a per-message From override and splits a
comma-separated To into separate recipients (go-mail needs them as a list). Feedback/game
stores gain CountSince/CountComplaintsSince. Unit tests cover the digest, the skip-when-
empty, and the recipient split.
2026-07-03 14:53:51 +02:00
developer f1a12c2f44 Merge pull request 'feat(account): deletion = legal retention, not erasure (PR3)' (#164) from feature/email-relay-pr3 into development
CI / changes (push) Successful in 1s
CI / unit (push) Successful in 10s
CI / integration (push) Successful in 15s
CI / ui (push) Successful in 1m4s
CI / conformance (push) Successful in 10s
CI / gate (push) Successful in 0s
CI / deploy (push) Successful in 1m41s
2026-07-03 12:19:13 +00:00
Ilia Denisov 4cc37e5760 fix(admin): unified user search across live + deleted, incl. the retention journal
CI / changes (pull_request) Successful in 2s
CI / unit (pull_request) Successful in 10s
CI / integration (pull_request) Successful in 14s
CI / ui (pull_request) Successful in 1m6s
CI / conformance (pull_request) Successful in 9s
CI / gate (pull_request) Successful in 0s
CI / deploy (pull_request) Successful in 2m2s
The email/name/external-id search was scoped to one tab and only looked in the live
identities table, so a deleted account (whose credentials moved to retained_identities on
deletion) could not be found by the email/id it held. Now a people search spans live and
deleted accounts in one query (robots only on the Robots tab) and also matches the
retention journal and the retained real name; results carry a 'deleted' badge. Integration
test: a deleted account is found by its held email and external id.
2026-07-03 14:15:02 +02:00
Ilia Denisov 3faca690dd fix(delete): review follow-ups — admin Deleted filter, guest gate, dialog spacing
CI / changes (pull_request) Successful in 2s
CI / unit (pull_request) Successful in 9s
CI / integration (pull_request) Successful in 16s
CI / ui (pull_request) Successful in 1m5s
CI / conformance (pull_request) Successful in 9s
CI / gate (pull_request) Successful in 0s
CI / deploy (pull_request) Successful in 1m46s
- Admin /users gains a Deleted scope (between People and Robots); every other scope now
  hides tombstoned accounts (deleted_at filter in UserFilter).
- Admin delete-user is offered for any non-deleted account (drop the not-guest gate, so a
  stale is_guest account can still be tombstoned).
- Harden EmailService.ConfirmCode to ClearGuest — defence-in-depth so no confirmed-email
  path leaves is_guest set (the currently-live paths already do).
- Space the delete/change dialog's action row from its input field.
Integration tests: the Deleted filter scoping + ConfirmCode guest promotion.
2026-07-03 13:59:43 +02:00
Ilia Denisov c2a8426b74 docs: account deletion = legal retention, not erasure
CI / changes (pull_request) Successful in 2s
CI / unit (pull_request) Successful in 9s
CI / integration (pull_request) Successful in 18s
CI / ui (pull_request) Successful in 1m5s
CI / conformance (pull_request) Successful in 8s
CI / gate (pull_request) Successful in 0s
CI / deploy (pull_request) Successful in 1m43s
FUNCTIONAL (+ru): the deletion user story — anonymised live surfaces ([Deleted]),
freed sign-in methods, a two-year-retained dossier, the code/phrase step-up, game
forfeit + all-robot drop, fresh account on reopen. ARCHITECTURE: the retention model
(retained_identities journal on every detach, tombstone + [Deleted] sentinel, drop
all-robot games, purpose=delete step-up, cold-load last-login, two-year TTL reaper).
2026-07-03 13:24:01 +02:00
Ilia Denisov 251c7af3f6 feat(admin): deleted-account dossier + operator delete-user action
The user-detail console gains a Deletion & retention panel: last login (time + IP),
the tombstone (deleted-at + retained real name), and the retention journal (the legal
dossier of detached credentials). A Delete-user action runs the same deletion
orchestration as the in-app flow (mirrors the email-erase pattern). Store readers
RetainedIdentities + DeletionInfo back the view; integration test covers them.
2026-07-03 13:22:33 +02:00
Ilia Denisov cabcd94d92 feat(profile): account-deletion flow + terminal deleted screen
Profile gains a Delete-account control (durable accounts) opening a step-up dialog: a
mailed code for an email account, or the typed DELETE phrase for a platform-only one.
On success the app swaps to a terminal AccountDeleted screen ('Учётная запись удалена')
with a Close that closes the host Mini App (telegramClose / vkClose; web = no close).
Wires deleteRequest/deleteConfirm through client/transport/mock/codec; ru/en i18n;
codec wire test + Chromium/WebKit e2e.
2026-07-03 13:18:37 +02:00
Ilia Denisov aa2290b7b4 feat(account): deletion orchestration + step-up + gateway edge
Step-up: email accounts confirm with a mailed code (purpose=delete, no deeplink —
ConfirmByToken refuses a delete token so a stray click can't delete); platform-only
accounts type a fixed phrase (anti-impulse). Endpoints /user/delete/{request,confirm};
the confirm orchestration resigns active games, drops all-robot games, tombstones +
anonymizes the account (freeing its creds), and revokes its sessions — the tombstone is
the point of no return, the rest best-effort. Gateway account.delete.{request,confirm}
ops + fbs AccountDeleteConfirm/AccountDeleteRequestResult + branded ru/en delete email.
Integration tests cover the step-up (code + no-email) and the orchestration pieces.
2026-07-03 13:10:34 +02:00
Ilia Denisov fcde7d3db6 feat(accountdelete): drop all-robot games + unspoofable [Deleted] label
Q2=B: DropAllRobotGames deletes the deletee's games with no human opponent (vs-AI or
auto-match-robot; children cascade), keeping games with any human seat (anonymized
instead). Q1=A: the anon label is the sentinel [Deleted] — the editable-name rule forbids
brackets, so no live player can impersonate a deleted account. Integration test covers
drop-vs-keep.
2026-07-03 13:02:14 +02:00
Ilia Denisov d889edfdb9 feat(account): retention TTL reaper (2-year legal hold)
Daily background reaper purges the deletion dossier past its two-year TTL: every
retained_identities row by detached_at (covering unlink/change on live accounts too),
and — for accounts tombstoned before the cutoff — the retained feedback thread plus the
dossier PII (deleted_display_name, last_login_ip). Chat is kept (a shared game artifact)
and the tombstone row stays. Started from main next to the guest reaper. Integration
test covers the cutoff boundary and the deleted-account feedback/PII purge.
2026-07-03 12:08:55 +02:00
Ilia Denisov 52e6378f40 feat(accountdelete): anonymize-and-tombstone deletion core
New accountdelete package: AnonymizeAndTombstone journals every credential to the
retention log (reason=delete) then removes them (freeing email/vk/tg for reuse),
snapshots the real display name into deleted_display_name and scrubs the live one to
'Удалённый игрок', sets deleted_at, anonymizes the account's game-seat snapshots, and
drops its friendships/blocks/invitations/friend-codes/drafts/pending-codes — all in one
transaction. Chat, feedback and complaints are kept (the tombstone keeps their
no-cascade FKs valid). Session revocation + game forfeit are orchestrated a layer up.
Integration test covers journalling, tombstone/scrub and credential reuse.
2026-07-03 12:01:43 +02:00
Ilia Denisov 12af378b18 feat(account): stamp last-login time + IP on cold app-load
The profile GET (fetched once per cold app-load by the SPA) stamps accounts
.last_login_at/.last_login_ip, throttled to at most once per hour per account
(best-effort, never blocks the read). IP from the gateway-forwarded X-Forwarded-For.
Feeds the account-deletion dossier. Integration test covers the throttle.
2026-07-03 11:54:37 +02:00
Ilia Denisov 1598646021 feat(account): journal detached credentials to the retention log
On unlink (RemoveIdentity, reason=unlink) and email change (replaceEmailIdentity,
reason=change) write the outgoing credential to retained_identities before removing
the live identities row — so the legal dossier survives while the (kind, external_id)
frees for reuse. Same transaction, so the dossier and live state cannot diverge.
Integration tests cover both reasons.
2026-07-03 11:52:48 +02:00
Ilia Denisov 710ab06333 feat(db): retention schema for account deletion (migration 00007)
Additive/expand-contract: new append-only retained_identities table (the legal
dossier of every detached credential, keyed by account_id, TTL'd by detached_at)
plus nullable accounts columns last_login_at/last_login_ip (cold-load stamp) and
deleted_at/deleted_display_name (tombstone + retained real name). Regenerates the
go-jet models for accounts + retained_identities only.
2026-07-03 11:50:14 +02:00
developer 61c7da271c Merge pull request 'feat(account): provider linking, unlink & email change (PR2)' (#163) from feature/email-relay-pr2 into development
CI / changes (push) Successful in 1s
CI / unit (push) Successful in 10s
CI / integration (push) Successful in 18s
CI / ui (push) Successful in 1m5s
CI / conformance (push) Successful in 8s
CI / gate (push) Successful in 0s
CI / deploy (push) Successful in 1m51s
2026-07-03 09:16:03 +00:00
Ilia Denisov 029aa2d4cc fix(profile): emoji-presentation envelope icon for the email row
CI / changes (pull_request) Successful in 2s
CI / unit (pull_request) Successful in 10s
CI / integration (pull_request) Successful in 14s
CI / ui (pull_request) Successful in 1m4s
CI / conformance (pull_request) Successful in 9s
CI / gate (pull_request) Successful in 0s
CI / deploy (pull_request) Successful in 1m58s
The bare U+2709 rendered as a mono pseudo-glyph; add the U+FE0F variation selector
so it shows as ✉️.
2026-07-03 11:05:36 +02:00
Ilia Denisov 3f4792a39b test(e2e): sign-in methods matrix — change-email + link/unlink Telegram
CI / changes (pull_request) Successful in 2s
CI / unit (pull_request) Successful in 10s
CI / integration (pull_request) Successful in 19s
CI / ui (pull_request) Successful in 1m4s
CI / conformance (pull_request) Successful in 9s
CI / gate (pull_request) Successful in 0s
CI / deploy (pull_request) Successful in 1m37s
Replace the stale skipped linking specs: change-email refuses a taken address with
the non-disclosing message (never revealing the other account) and replaces a free
one; the web Telegram link control links then unlinks through the confirm dialog.
Chromium + WebKit.
2026-07-03 10:04:54 +02:00
Ilia Denisov 150660819a docs: sign-in methods matrix — unlink + change-email
FUNCTIONAL (+ru): drop the stale 'linking UI hidden' note; document the profile
sign-in-methods matrix, the unlink last-identity guard, and the non-disclosing
atomic email change. ARCHITECTURE: unlink + change-email behaviour and the
purpose=change deeplink branch.
2026-07-03 10:01:54 +02:00
Ilia Denisov 912096a0f1 test(account): unlink guard + change-email replace/refuse/deeplink
Integration: unlinking a provider keeps the other identities and refuses removing
the last one; change-email replaces the address (freeing the old), refuses a taken
address without merging, and works through the one-tap deeplink token. Unit: the
change-email template renders localised ru/en copy.
2026-07-03 09:59:42 +02:00
Ilia Denisov e4fc7f033d feat(profile): sign-in methods matrix — email add/change, provider link/unlink
Profile shows the account's sign-in methods for guests and durable accounts alike:
add or change email, link Telegram on the web (login widget), and unlink a linked
provider. Email is never unlinked (it is changed); unlink is offered only when
another identity remains, and a change to an address owned by another account shows
the non-disclosing 'check the address or contact support'. Add-VK-on-web stays
deferred (no VK OAuth).

Wires linkUnlink / changeEmailRequest / changeEmailConfirm through the client,
transport, mock and codec (encodeLinkUnlink + LinkResult 'unlinked'/'changed'
statuses); codec wire tests; ru/en i18n.
2026-07-03 09:57:05 +02:00
Ilia Denisov b918217497 feat(account): unlink provider + change-email edges (backend + gateway)
Unlink: POST /user/link/unlink (telegram|vk) via account.RemoveIdentity, refusing
the last identity; email is never unlinked. New fbs LinkUnlinkRequest + gateway
link.unlink op, returning the refreshed profile.

Change-email: purposeChange confirm-codes (RequestChangeCode/ConfirmChange) that
atomically replace the account's confirmed email (account.replaceEmailIdentity);
a new address owned by another account is refused without disclosure, never merged.
The one-tap deeplink handles purposeChange too. Reuses the LinkEmail* fbs tables;
gateway link.email.change.{request,confirm} ops + backendclient methods; branded
ru/en change-email copy.
2026-07-03 09:47:42 +02:00
Ilia Denisov a3eb4719de refactor(account): generalize RemoveEmailIdentity to RemoveIdentity(kind)
Generalize the email-erase store op to any identity kind (with the same
last-identity guard and, for email, the pending-confirmation cleanup) so the
profile Unlink control can reuse it for Telegram/VK. RemoveEmailIdentity stays as a
thin wrapper for the admin console.
2026-07-03 09:20:59 +02:00
Ilia Denisov 3a823ca7ef feat(profile): carry linked identities in the profile (email, telegram, vk)
Add email / telegram_linked / vk_linked to the Profile (fbs table + regenerated
Go/TS bindings, gateway ProfileResp + encodeProfile, backend DTO, UI model +
decode). They are filled outside the pure projection — Server.profileResponse now
reads the account's identities (like the banner seam) — and will drive the profile's
Add / Unlink / change-email controls.
2026-07-03 09:17:17 +02:00
developer 76e7916ff6 Merge pull request 'feat(email): one-tap confirm deeplink + client language (PR1b)' (#162) from feature/email-relay-pr1b into development
CI / changes (push) Successful in 2s
CI / unit (push) Successful in 9s
CI / integration (push) Successful in 15s
CI / ui (push) Successful in 1m3s
CI / conformance (push) Successful in 9s
CI / gate (push) Successful in 0s
CI / deploy (push) Successful in 1m40s
2026-07-03 06:58:12 +00:00
Ilia Denisov 54af644429 fix(ui): localise the confirm screen, drop the brand on the error state
CI / changes (pull_request) Successful in 2s
CI / unit (pull_request) Successful in 10s
CI / integration (pull_request) Successful in 15s
CI / ui (pull_request) Successful in 1m3s
CI / conformance (pull_request) Successful in 10s
CI / gate (pull_request) Successful in 0s
CI / deploy (pull_request) Successful in 1m42s
The session-less /confirm page defaulted to English, so it showed the English
app title. Carry the recipient's language on the deeplink (?lang) and setLocale on
load so the page matches the email. Show a localised brand wordmark (Эрудит / Erudit,
matching the email) on the success state only; the invalid/expired state now shows
just the message, no header.
2026-07-03 05:54:28 +02:00
Ilia Denisov 356bf1a5ba feat(admin): search users by email + erase a bound email
CI / changes (pull_request) Successful in 2s
CI / unit (pull_request) Successful in 10s
CI / integration (pull_request) Successful in 14s
CI / ui (pull_request) Successful in 1m3s
CI / conformance (pull_request) Successful in 9s
CI / gate (pull_request) Successful in 0s
CI / deploy (pull_request) Successful in 1m40s
Add an exact (strict) email filter to the /users list (UserFilter.EmailExact →
a kind='email' identity match) with a search input, and an 'Erase email' action on
the user card that deletes the bound email identity and its pending confirmations,
freeing the address. It refuses to remove the account's only identity
(ErrLastIdentity), which would leave it unreachable. Integration tests for both.
2026-07-03 05:30:30 +02:00
Ilia Denisov 1dca6741f1 feat(ui): auto-confirm the email deeplink on load
CI / changes (pull_request) Successful in 1s
CI / unit (pull_request) Successful in 10s
CI / integration (pull_request) Successful in 14s
CI / ui (pull_request) Successful in 1m3s
CI / conformance (pull_request) Successful in 9s
CI / gate (pull_request) Successful in 0s
CI / deploy (pull_request) Successful in 1m44s
Drop the confirm button: the /confirm screen confirms the token as soon as it
loads. The token rides the URL fragment (never sent to the server), so a plain link
prefetch cannot reach it, and the manual six-digit code is the fallback if an
aggressive scanner runs the page. Update the ARCHITECTURE/FUNCTIONAL wording
accordingly and swap the confirm.prompt/action strings for confirm.busy (en+ru).
2026-07-03 05:18:24 +02:00
Ilia Denisov 77a18a3cc1 fix(account): clear the guest flag on a deeplink email link
ConfirmByToken attached the confirmed email to the account but, on the link path,
skipped ClearGuest — so a guest who bound an email via the one-tap deeplink stayed a
guest (the code-based flow clears it in the link service). Clear the flag on a free
link too, promoting the guest to a durable account; the profile live event then
refreshes the open session. Integration test added.
2026-07-03 05:18:24 +02:00
Ilia Denisov 5804f7266e fix(account): seed the email account display name from the local part
CI / changes (pull_request) Successful in 1s
CI / unit (pull_request) Successful in 9s
CI / integration (pull_request) Successful in 17s
CI / ui (pull_request) Successful in 1m3s
CI / conformance (pull_request) Successful in 9s
CI / gate (pull_request) Successful in 0s
CI / deploy (pull_request) Successful in 1m53s
An email account was provisioned with no display name (unlike Telegram/VK, which
seed one), so an email login showed an empty name. Seed it from the email's local
part (before '@', trimmed and capped to the column width) on first contact; the
user can rename it later. Only new accounts are seeded — an existing account's name
is never overwritten.
2026-07-03 05:10:22 +02:00
Ilia Denisov 65f2c87a74 docs+test(email): document the confirm deeplink + wire-contract tests
CI / changes (pull_request) Successful in 2s
CI / unit (pull_request) Successful in 9s
CI / integration (pull_request) Successful in 16s
CI / ui (pull_request) Successful in 1m3s
CI / conformance (pull_request) Successful in 9s
CI / gate (pull_request) Successful in 0s
CI / deploy (pull_request) Successful in 1m52s
Document the one-tap confirm deeplink in ARCHITECTURE (§4 the login magic-link /
link confirm+profile-refresh, prefetch-safe token) and the new notify 'profile'
sub-kind (§10), and add the one-tap link to the FUNCTIONAL email story (+ru). Add
codec round-trip assertions for the EmailRequest language field and
encodeEmailConfirmLink (the mock e2e bypasses the codec).
2026-07-03 04:33:27 +02:00
Ilia Denisov 409462fc09 feat(ui): one-tap confirm deeplink screen + client language
Add the /confirm/<token> SPA route and Confirm screen: a prefetch-safe button
POSTs the token via a new confirmEmailLink RPC (client/transport/mock/codec +
ConfirmLinkResult model). A login adopts the minted session and enters the app; a
link shows confirmed / merge-in-the-app; an invalid or expired token asks for a new
code. Exempt /confirm from the no-session /login redirect. Forward the client
locale on the email request (authEmailRequest gains language → app.locale) so a
fresh web login email is localised. Handle the new 'profile' live-event sub-kind by
re-fetching the profile, so a link confirmed in another browser reflects in-app at
once. i18n en+ru.
2026-07-03 04:30:16 +02:00
Ilia Denisov 762155a55e feat(gateway): confirmEmailLink RPC + language on the email request + profile event
Add the confirm-link edge method (auth.email.confirm_link): a new
EmailConfirmLinkRequest/Result fbs table, the transcode const + handler + encoder,
and the backend-client call to the existing /sessions/email/confirm-link endpoint —
it rides Execute under the existing service prefix, so no proto/Caddy change. Add a
language field to EmailRequestRequest and forward it (the backend already seeds it).
Add the NotifyProfile sub-kind + notify.ProfileChanged, published by the confirm-link
handler on a successful link so an in-app session re-fetches its profile when the
email was confirmed in another browser. Regenerated fbs bindings (Go + TS).
2026-07-03 04:22:09 +02:00
Ilia Denisov 25d80bc31d feat(server): confirm-link endpoint for the one-tap deeplink
Add POST /internal/sessions/email/confirm-link: it verifies a deeplink token via
ConfirmByToken and, for a login, mints a session (the deeplink page signs in with
it); for a link, attaches the confirmed email and reports "confirmed" or
"merge_required" (the app drives the interactive merge). The token, not a request
session, is the authorization. Add LinkConfirmation.IsLogin() and integration tests
for the login, link and merge branches (the token is read from the mailed link).
The gateway RPC, live event and SPA route follow.
2026-07-03 04:13:48 +02:00
Ilia Denisov d5b4bba018 feat(account): restore the confirm deeplink token (domain layer)
Re-apply the deeplink backend deferred out of PR1a: migration 00006 adds
email_confirmations.purpose + link_token_hash (hand-edited jet), each issued code
now carries an opaque 256-bit token (only its SHA-256 stored), and ConfirmByToken
resolves a token to a login (confirm + clear guest) or a link (attach when free,
signal merge when owned elsewhere). issueCode now embeds the /app/#/confirm/<token>
link in the email. The confirm endpoint, gateway RPC and SPA route follow.
2026-07-03 04:07:10 +02:00
developer 638c147cc0 Merge pull request 'feat(email): make transactional email live — branded relay, rate-limit, squat fix (PR1a)' (#161) from feature/email-relay-pr1 into development
CI / changes (push) Successful in 2s
CI / unit (push) Successful in 10s
CI / integration (push) Successful in 15s
CI / ui (push) Successful in 1m3s
CI / conformance (push) Successful in 9s
CI / gate (push) Successful in 0s
CI / deploy (push) Successful in 1m47s
2026-07-03 01:56:55 +00:00
Ilia Denisov c702f1bdac fix(email): explicit TLS mode for non-standard relay ports
CI / changes (pull_request) Successful in 1s
CI / unit (pull_request) Successful in 10s
CI / integration (pull_request) Successful in 17s
CI / ui (pull_request) Successful in 1m4s
CI / conformance (pull_request) Successful in 9s
CI / gate (pull_request) Successful in 0s
CI / deploy (pull_request) Successful in 1m54s
The 465-only implicit-TLS heuristic mis-classified Selectel's SSL port (1127),
which the mailer would have dialled with STARTTLS and failed. Add BACKEND_SMTP_TLS
(ssl|starttls) — empty still derives the mode from the port (implicit on 465, else
STARTTLS) — and dial implicit TLS with WithSSL()+WithPort so any port works, not
just 465. Wire SMTP_RELAY_TLS through compose/ci/prod/.env.example and document it
(Selectel: 1127 = SSL, 1126 = STARTTLS). Unit-tested.
2026-07-03 03:44:55 +02:00
Ilia Denisov 01d02fcef6 feat(ui): show the email upgrade box to guests
CI / changes (pull_request) Successful in 2s
CI / unit (pull_request) Successful in 11s
CI / integration (pull_request) Successful in 15s
CI / ui (pull_request) Successful in 1m2s
CI / conformance (pull_request) Successful in 9s
CI / gate (pull_request) Successful in 0s
CI / deploy (pull_request) Successful in 1m41s
Un-hide the Profile email box for guest accounts (hidden={!p.isGuest}): a guest
binds an email to register / sign in, and a returning address opens the existing
merge dialog. Provider linking stays hidden — the Telegram control keeps its
wiring behind a hidden attribute — until the non-guest linking matrix (PR2). The
two linking e2e specs remain skipped (they assume a non-guest login and the
visible Telegram control); update their stale comments.
2026-07-03 03:18:23 +02:00
Ilia Denisov 9e0f929e40 test+docs(email): squat-fix coverage and the email-pipeline docs
Add an integration test asserting the guest-until-confirmed squat fix (an
email-login account is a reapable guest until the code is confirmed, then
durable). Document the branded relay pipeline in ARCHITECTURE (go-mail, TLS by
port, no client cert, PUBLIC_BASE_URL anti-injection, per-recipient send throttle,
guest-until-confirmed), FUNCTIONAL (+ru), TESTING and the backend README config
table (BACKEND_SMTP_* + BACKEND_PUBLIC_BASE_URL).
2026-07-03 03:13:15 +02:00
Ilia Denisov a29e00ee13 feat(deploy): wire the SMTP relay through every contour
Add the backend confirm-code relay env to compose (BACKEND_SMTP_HOST/PORT/
USERNAME/PASSWORD/FROM + BACKEND_PUBLIC_BASE_URL), sourced from SMTP_RELAY_* /
SMTP_RELAY_FROM / PUBLIC_BASE_URL. An empty host keeps the backend on the log
mailer so a contour without relay credentials still boots. Port defaults to 465
(implicit TLS). Map the TEST_ set in ci.yaml and the PROD_ set in prod-deploy.yaml
(both the deploy-main env and the env.sh heredoc). Document the six variables in
.env.example and the deploy README (secrets: user/pass; variables: host/port/from
+ the per-contour PUBLIC_BASE_URL, required whenever the relay host is set).
2026-07-03 03:09:19 +02:00
Ilia Denisov 2207ac6132 feat(account): throttle confirm-code sends per recipient
Add an in-memory SendLimiter enforcing a one-per-minute cooldown and a
five-per-rolling-hour cap per recipient address, checked before provisioning or
sending in RequestCode, RequestLoginCode and RequestLinkCode. It guards against
email bombing and protects the relay quota. The limiter is injected in main
(nil in tests, so the domain suite is unaffected); ErrTooManyRequests maps to
HTTP 429.
2026-07-03 03:02:49 +02:00
Ilia Denisov 3877b23894 feat(account): brand and harden the email pipeline
Swap the net/smtp mailer for go-mail behind the existing Mailer seam: the
Message struct now carries a text + HTML body, TLS mode is chosen from the port
(implicit TLS on 465, else mandatory STARTTLS), a dial timeout bounds the
synchronous send, and no client certificate is needed. Add a branded, image-free,
mobile-friendly ru/en HTML template (with a plain-text alternative) rendering a
large readable code and an ignore-notice footer with a landing link.

Add BACKEND_PUBLIC_BASE_URL config (the canonical origin for the email footer
link, never the request Host — anti-injection), required when a relay is
configured.

Fix the email-login address squat: ProvisionEmail creates the account flagged
is_guest until the code is confirmed, so an abandoned login is reaped like any
guest and its address freed; confirming (login or link) clears the flag. Seed the
new account's language from the client, plumbed through the email-login request.

The confirm deeplink, its transport surface and the send rate-limit land in
follow-up work.
2026-07-03 02:38:43 +02:00
developer d5fbaa3034 feat(export): server-rendered artifacts behind one signed download URL (#160)
CI / changes (push) Successful in 1s
CI / unit (push) Successful in 9s
CI / integration (push) Successful in 15s
CI / ui (push) Successful in 1m2s
CI / conformance (push) Successful in 9s
CI / gate (push) Successful in 0s
CI / deploy (push) Successful in 1m42s
The finished-game export (GCG + a new PNG of the final position) is one
signed, short-lived relative URL (game.export_url; HMAC-SHA256, 10-min
TTL, BACKEND_EXPORT_SIGN_KEY) resolved against the client's own origin
and delivered by the best affordance each platform has (five on-device
review rounds):

- TG Android/desktop: native showPopup chooser -> native downloadFile
  dialog (bridge-only chain, activation-safe).
- TG iOS: app-modal chooser -> OS share sheet with the fetched file
  (a popup callback cannot supply the activation the sheet needs).
- VK iOS: VKWebAppDownloadFile for both formats.
- VK Android: the PNG opens in VK's native image viewer, the GCG copies
  to the clipboard (the VK Android downloader hangs on any download,
  Content-Length/Range notwithstanding).
- VK desktop iframe / desktop browsers: plain anchor downloads.
- Mobile browsers: the OS share sheet (fetch-then-share).
- Legacy TG (< Bot API 8.0): app modal + GCG clipboard, no image option.

The PNG is rasterized on demand by the new internal `renderer` sidecar
(node:22-slim + skia-canvas + baked Liberation/Noto Color Emoji fonts)
executing the SAME ui/src/lib/gameimage.ts the ui project unit-tests;
the backend rebuilds the render payload from the journal +
engine.AlphabetTable, and the device date locale, IANA time zone and
localized non-play labels ride the signed URL. Nothing is stored — the
artifact re-derives from the immutable journal on each GET. The gateway
forwards /dl/* (caddy @gateway matcher extended) behind the per-IP
public rate limiter and serves bytes via http.ServeContent.

Deploy: renderer service in compose + prod overlay + rolling order +
prod push list; TEST_/PROD_EXPORT_SIGN_KEY secrets; the sidecar smoke
runs in the ui CI job. Docs: ARCHITECTURE, FUNCTIONAL(+_ru), UI_DESIGN,
TESTING, deploy/README, renderer/README.
2026-07-02 21:58:07 +00:00
developer 16a4431158 Merge pull request 'feat(game): finished-game export as a PNG image behind a format chooser' (#159) from feature/game-export-image 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 1m0s
CI / conformance (push) Successful in 9s
CI / gate (push) Successful in 0s
CI / deploy (push) Successful in 1m21s
2026-07-02 15:29:40 +00:00
developer 422e3ccc2a Merge pull request 'fix(game): no placement auto-zoom in landscape' (#158) from feature/landscape-no-autozoom 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 59s
CI / conformance (push) Successful in 9s
CI / gate (push) Successful in 0s
CI / deploy (push) Successful in 1m20s
2026-07-02 15:19:24 +00:00
Ilia Denisov 946420db93 fix(game): own export chooser modal; PNG option outside in-app webviews only
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 59s
CI / conformance (pull_request) Successful in 9s
CI / gate (pull_request) Successful in 0s
CI / deploy (pull_request) Successful in 1m26s
Telegram's native showPopup delivers its callback with no user
activation, so navigator.share (TG iOS) and clipboard writes (TG
Android GCG copy) silently fail from it — the chooser is now always the
app's own modal, keeping the button click's gesture alive for the
delivery APIs.

The data:URL preview modal is dropped: the Android TG/VK long-press
menu mangles data: URLs (dead download, black-screen open, base64
clipboard garbage), so a binary PNG has no working client-side route in
those webviews at all. The image option is withheld there until the
server-rendered signed-URL delivery (Telegram downloadFile /
VKWebAppDownloadFile) lands; the plain web and mobile browsers keep it.

On-device findings by the owner on the test contour (TG iOS, TG
Android, VK Android).
2026-07-02 17:16:05 +02:00
Ilia Denisov a0eacf3011 feat(game): finished-game export as a PNG image behind a format chooser
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 59s
CI / conformance (pull_request) Successful in 9s
CI / gate (pull_request) Successful in 0s
CI / deploy (pull_request) Successful in 1m16s
The history header's export button now opens a chooser — Telegram's
native popup inside Telegram, the app's own modal elsewhere — offering
the GCG file and a new client-rendered PNG of the final position
(lib/gameimage, Canvas 2D, lazy dynamic import, zero dependencies):
light theme, classic A..O/1..15 axes, label-free premium fills, and a
fixed-typography per-seat scoresheet with GCG-style move coordinates,
multi-word sub-lines, endgame rack-settlement row, winner trophy and a
hostname + device-locale finish date footer; a long game stretches the
board, never the typography.

Delivery mirrors the GCG rules (Web Share with no blob fallback, else
download) except on Android Telegram/VK WebViews and the desktop VK
iframe, where a binary PNG has no clipboard-text fallback: those get a
preview modal with a long-press/right-click save hint and a copy-image
button where ClipboardItem exists.
2026-07-02 16:20:35 +02:00
Ilia Denisov 622d3965a7 fix(game): no placement auto-zoom in landscape
CI / changes (pull_request) Successful in 3s
CI / unit (pull_request) Has been skipped
CI / integration (pull_request) Has been skipped
CI / ui (pull_request) Successful in 59s
CI / conformance (pull_request) Successful in 9s
CI / gate (pull_request) Successful in 0s
CI / deploy (pull_request) Successful in 2m12s
Landscape fits the whole board, so the coarse-pointer auto-zoom on tile
placement, drag hover-hold and hint only hid the rest of the position.
Gate all three on portrait; manual double-tap/pinch zoom is unchanged.
New e2e lock both sides: landscape placement stays unzoomed, portrait
placement still auto-zooms (touch-emulated, both engines).
2026-07-02 15:08:30 +02:00
developer 2ab01ed8f7 Merge pull request 'feat(landing): Russian default + SEO head, icons and OG card' (#157) from feature/landing-seo 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 57s
CI / conformance (push) Successful in 9s
CI / gate (push) Successful in 0s
CI / deploy (push) Successful in 1m15s
2026-07-01 23:29:28 +00:00
Ilia Denisov 1ed624eaf1 feat(landing): Russian default + SEO head, icons and OG card
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 57s
CI / conformance (pull_request) Successful in 8s
CI / gate (pull_request) Successful in 0s
CI / deploy (pull_request) Successful in 1m20s
The landing now always opens in Russian (saved 🌐 choice still wins) —
browser-language detection made the indexed content nondeterministic
(Googlebot renders with en-US). landing.html gains the static Russian
SEO head: title/description, canonical pinned to the production origin,
Open Graph card (Telegram/VK link previews), twitter:card, JSON-LD,
theme-color and the favicon set; the SPA shell turns noindex and its
tab title becomes «Эрудит (Скрэббл)». New assets/icons generator
(same tile design as the VK loader) produces favicon.svg/ico,
apple-touch-icon.png and og-image.png into ui/public/, plus robots.txt.
2026-07-02 01:25:19 +02:00
developer f3e2a6822b Merge pull request 'fix(ui): VK Android external links + landing VK entry + drop 1️⃣ badge' (#156) from feature/vk-links-landing into development
CI / changes (push) Successful in 2s
CI / unit (push) Successful in 10s
CI / integration (push) Successful in 14s
CI / ui (push) Successful in 57s
CI / conformance (push) Successful in 9s
CI / gate (push) Successful in 0s
CI / deploy (push) Successful in 1m18s
2026-07-01 22:39:26 +00:00
Ilia Denisov 65063621a9 fix: landing smoke test
CI / changes (pull_request) Successful in 3s
CI / unit (pull_request) Successful in 9s
CI / integration (pull_request) Successful in 14s
CI / ui (pull_request) Successful in 57s
CI / conformance (pull_request) Successful in 10s
CI / gate (pull_request) Successful in 0s
CI / deploy (pull_request) Successful in 1m18s
2026-07-02 00:29:38 +02:00
Ilia Denisov 4e169c368d chore: i18n game descriptions
CI / changes (pull_request) Successful in 3s
CI / unit (pull_request) Successful in 9s
CI / integration (pull_request) Successful in 16s
CI / ui (pull_request) Failing after 1m0s
CI / conformance (pull_request) Successful in 10s
CI / gate (pull_request) Failing after 0s
CI / deploy (pull_request) Has been skipped
2026-07-02 00:24:16 +02:00
Ilia Denisov aec915d5c1 feat(ui): drop the one-word status-bar badge
CI / changes (pull_request) Successful in 2s
CI / unit (pull_request) Successful in 10s
CI / integration (pull_request) Successful in 17s
CI / ui (pull_request) Successful in 57s
CI / conformance (pull_request) Successful in 9s
CI / gate (pull_request) Successful in 0s
CI / deploy (pull_request) Successful in 1m19s
The small 1️⃣ in the status bar's score-preview slot read as noise; the
single-word rule keeps its spelled-out label in the history header and
the lobby invitation card.
2026-07-02 00:10:08 +02:00
Ilia Denisov 5f574a765d feat(landing): VK entry logo + restore the Telegram channel build-arg
Add the VK Mini App logo next to the Telegram one on the landing hero,
linked via the new VITE_VK_APP_LINK build-arg (full URL, wired through
compose, CI and prod-deploy from TEST_/PROD_VITE_VK_APP_LINK).

Also restore the landing's Telegram link itself: commit 57c778f
collapsed the per-language vars to VITE_TELEGRAM_GAME_CHANNEL_NAME in
compose/CI but left gateway/Dockerfile with the stale _EN/_RU ARGs and
without the plain one, so the built bundle saw the var as undefined and
dead-code-eliminated the whole channel-link branch — deployed landings
(prod included) have shown no Telegram logo since.
2026-07-02 00:10:02 +02:00
Ilia Denisov db17287113 fix(ui): route external links out of the Android VK WebView
The Android VK client's WebView ignores target=_blank and navigates the
Mini App's own window to the target, stranding the player outside the
game with no way back (the dictionary lookup, About/Feedback links, the
ad banner and the bot-link modal fallbacks). vk-bridge 3.x has no method
to open an external URL, so external links are routed through VK's own
leave-VK redirect (vk.com/away.php), which the client intercepts
natively and hands to the system browser.

onExternalLinkClick moves from lib/telegram to a new lib/links that
composes the Telegram and VK routers; iOS and desktop VK open _blank
correctly and are left alone.
2026-07-02 00:09:52 +02:00
developer c864147982 Merge pull request 'feat(telemetry): local move-preview adoption metrics (Phase 4)' (#155) from feature/local-eval-telemetry into development
CI / changes (push) Successful in 3s
CI / unit (push) Successful in 10s
CI / integration (push) Successful in 17s
CI / ui (push) Successful in 58s
CI / conformance (push) Successful in 10s
CI / gate (push) Successful in 0s
CI / deploy (push) Successful in 1m18s
2026-07-01 21:39:59 +00:00
Ilia Denisov 2e8fa83814 feat(telemetry): local move-preview adoption metrics (Phase 4)
CI / changes (pull_request) Successful in 3s
CI / unit (pull_request) Successful in 10s
CI / integration (pull_request) Successful in 15s
CI / ui (pull_request) Successful in 57s
CI / conformance (pull_request) Successful in 9s
CI / gate (pull_request) Successful in 0s
CI / deploy (pull_request) Successful in 1m17s
Measure uptake of the client-side local move-preview accelerator (§5) so
adoption can be watched before defaulting it on: app cold starts, dictionary
loads by result (fetched / cache_hit / miss) and move previews by path
(local / network — the backend load shed).

A small best-effort client beacon (POST /metrics/local-eval, session-gated)
batches counter deltas and posts them on a 60s timer and when the app is
backgrounded — never on the gameplay path: the in-app counters are plain
in-memory increments, only the periodic flush touches the network and it is
fire-and-forget. The gateway folds each batch into three OTel counters
(local_eval_cold_start_total, local_eval_dict_load_total,
local_eval_preview_total), clamped against a spoofed inflation.

- gateway: counters + recordLocalEval + session-gated /metrics/local-eval handler
- ui: localeval-metrics accumulator/beacon; hooks in the dict loader, in
  Game.recompute and in bootstrap (skipped under the mock harness)
- caddy: route /metrics/* to the gateway
- docs: ARCHITECTURE §11; Grafana "Scrabble — Users" dashboard panels
2026-07-01 23:31:49 +02:00
developer 5f87b3fa43 Merge pull request 'feat: on-device move preview (local eval) with network fallback' (#154) from feature/local-eval into development
CI / changes (push) Successful in 2s
CI / unit (push) Successful in 10s
CI / integration (push) Successful in 17s
CI / ui (push) Successful in 58s
CI / conformance (push) Successful in 9s
CI / gate (push) Successful in 0s
CI / deploy (push) Successful in 1m18s
2026-07-01 21:02:09 +00:00
Ilia Denisov 5689f7f6a3 feat: on-device move preview (local eval) with network fallback
CI / changes (pull_request) Successful in 3s
CI / unit (pull_request) Successful in 10s
CI / integration (pull_request) Successful in 15s
CI / ui (pull_request) Successful in 58s
CI / conformance (pull_request) Successful in 9s
CI / gate (pull_request) Successful in 0s
CI / deploy (pull_request) Successful in 1m17s
Score and validate a tentative move on-device instead of a per-arrangement network
round trip. The dawg reader and the validate/score/direction slice of the
scrabble-solver engine are ported to TypeScript (ui/src/lib/dict), pinned
byte-for-byte to the Go engine by a `conformance` CI job (full-dictionary reader
parity plus a battery of plays across every variant and both cross-word rules,
including the inferred orientation). The server stays authoritative — submit_play
re-validates — so the local result is an advisory accelerator only.

- backend: Registry.DictBytes + an authed GET /api/v1/user/dict/{variant}/{version}
  (immutable) streaming the pinned per-game dawg; caddy routes /dict to the gateway.
- gateway: a session-gated /dict edge route proxying it; fetchDict on the transport.
- client: the dictionary loads on game open (low priority so it never starves the
  game on a slow link; aborted at a 5s cap or when leaving the game), is cached in
  IndexedDB (best-effort, self-healing on a rejected blob) and reused across
  sessions; a warm-up overlay covers a cold load, then the network preview is the
  fallback; a bad-connection breaker stops warming after repeated misses; the move
  preview cancels its in-flight request when the tiles change.
- parity generators backend/cmd/{dictgen,validategen} + gated Vitest suites, run in
  CI against the release dictionaries. A hidden debug readout lists the cached
  dictionaries + breaker state, and its reset clears the cache.
- docs: ARCHITECTURE §5, TESTING, UI_DESIGN, FUNCTIONAL (+ru).
2026-07-01 22:58:40 +02:00
developer f0399e1bbc Merge pull request 'build(ui): drop sourcemaps from prod bundle' (#153) from feature/ui-drop-prod-sourcemaps 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 57s
CI / gate (push) Successful in 0s
CI / deploy (push) Successful in 1m16s
2026-07-01 15:00:44 +00:00
Ilia Denisov e6c5198caa build(ui): drop sourcemaps from prod 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 57s
CI / gate (pull_request) Successful in 0s
CI / deploy (pull_request) Successful in 1m17s
The production UI build shipped `.map` files with full `sourcesContent`, and
the gateway/landing images serve `dist/` verbatim, so anyone could fetch
`/app/assets/main-*.js.map` (same assets under `/vk/`, `/telegram/`) and
reconstruct the entire TypeScript/Svelte source at the edge.

Gate `build.sourcemap` off for `mode === 'production'` (the Docker image build).
Dev and the `mock` e2e build (`vite build --mode mock`) keep maps for debugging.
Document the posture in ARCHITECTURE.md §12.
2026-07-01 16:57:05 +02:00
developer 84cc56198e Merge pull request 'feat(ui): paint VK status bar to the app theme (VKWebAppSetViewSettings)' (#152) from feature/vk-status-bar-chrome 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 57s
CI / gate (push) Successful in 0s
CI / deploy (push) Successful in 1m17s
2026-07-01 12:39:11 +00:00
Ilia Denisov 5f9b4a7a38 feat(ui): paint VK status bar to the app theme (VKWebAppSetViewSettings)
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 58s
CI / gate (pull_request) Successful in 0s
CI / deploy (pull_request) Successful in 2m13s
Parity with the Telegram chrome painting: on a VK Mini App launch and on
every theme change, set VK's status-bar appearance (and, on Android, the
action/navigation bar colours) from the app's live theme tokens, so the VK
chrome matches the UI instead of clashing.

syncVKChrome mirrors syncTelegramChrome; the status-bar appearance is
derived from the --bg token's luminance (appearanceForBg, unit-tested).
Wired into the VK onScheme handler (fires on launch + on theme change) and
setTheme. Docs: UI_DESIGN VK integration.
2026-07-01 13:54:14 +02:00
developer 4458f0e545 Merge pull request 'fix(ui): copy GCG to clipboard on Android in-app WebViews' (#151) from feature/gcg-export-android-fix 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 57s
CI / gate (push) Successful in 0s
CI / deploy (push) Successful in 1m13s
2026-07-01 11:23:59 +00:00
developer 41f26da9a7 Merge pull request 'fix(game): clear nudges on game completion' (#148) from feature/nudge-clear-on-completion into development
CI / changes (push) Successful in 2s
CI / unit (push) Successful in 10s
CI / integration (push) Successful in 17s
CI / ui (push) Has been skipped
CI / gate (push) Successful in 0s
CI / deploy (push) Successful in 1m15s
2026-07-01 11:23:39 +00:00
developer cf1d773c18 Merge pull request 'fix(ui): mobile WebView polish — tap-flash, VK haptics, VK swipe-back' (#149) from feature/mobile-webview-polish 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 57s
CI / gate (push) Successful in 0s
CI / deploy (push) Successful in 1m14s
2026-07-01 11:23:29 +00:00
Ilia Denisov 88b6761e28 fix(ui): copy GCG to clipboard on Android in-app WebViews
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 57s
CI / gate (pull_request) Successful in 0s
CI / deploy (pull_request) Successful in 1m16s
On-device diagnostics from Android Telegram and VK confirmed both expose
no navigator.share AND no navigator.canShare, so the export fell to a Blob
<a download> that those WebViews silently ignore — nothing happened.

pickGcgDelivery is now a 3-way decision: Web Share where available (iOS),
a clipboard copy in an Android in-app WebView (Telegram/VK: no share, dead
download), else a desktop Blob download. shareOrDownloadGcg reports the
outcome so the game shows a "GCG copied" toast; the copy is VKWebAppCopyText
inside VK (which also covers the desktop VK iframe, where navigator.clipboard
is blocked) and navigator.clipboard otherwise.

Unit tests cover the 3-way choice and the copy/failed outcomes; new i18n key
game.gcgCopied (en+ru); docs ARCHITECTURE/FUNCTIONAL(+ru)/UI_DESIGN/TESTING.
2026-07-01 13:17:00 +02:00
Ilia Denisov f4dbb545e0 fix(ui): suppress tap-flash directly on the header title and back
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 57s
CI / gate (pull_request) Successful in 0s
CI / deploy (pull_request) Successful in 1m30s
The global -webkit-tap-highlight-color on #app was not enough on Android
in-app WebViews (Telegram, VK): they only suppress the selection-like tap
flash when -webkit-user-select / -webkit-tap-highlight-color /
-webkit-touch-callout sit DIRECTLY on the tapped element, not inherited.
Set them on the two tappable header controls — the title (a 10-tap debug
target) and the back chevron.
2026-07-01 13:06:11 +02:00
Ilia Denisov 71c3411276 fix(ui): mobile in-app WebView polish (tap-flash, VK haptics, swipe-back)
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 57s
CI / gate (pull_request) Successful in 0s
CI / deploy (pull_request) Successful in 1m13s
- Tap-highlight: add -webkit-tap-highlight-color: transparent on #app.
  Android in-app WebViews (Telegram, VK) flashed a momentary selection-like
  box on tappable nodes on tap — seen on the header title and the back
  chevron; user-select (already none) does not govern it. Inherited, so
  this clears it app-wide.

- VK haptics: mirror the Telegram haptic set on VK via VK Bridge taptic
  (impact / notification / selection), routed through a new shared
  lib/haptics.ts dispatcher. VK users previously got no haptics; the game
  and error call sites now fire haptic() instead of telegramHaptic().

- VK swipe-back: disable VK's horizontal swipe-back at launch
  (VKWebAppSetSwipeSettings history:false) so it does not fight the app's
  own edge-swipe-back and on-board tile drag — parity with Telegram's
  disabled vertical swipes; the app owns navigation via its back chevron.

Docs: UI_DESIGN (no-select / tap-highlight, VK integration).
2026-06-30 23:00:19 +02:00
Ilia Denisov f9acea1d9a fix(game): clear nudges on game completion
CI / changes (pull_request) Successful in 3s
CI / unit (pull_request) Successful in 10s
CI / integration (pull_request) Successful in 14s
CI / ui (pull_request) Has been skipped
CI / gate (pull_request) Successful in 0s
CI / deploy (pull_request) Successful in 1m19s
Nudge badges lingered in the lobby on games that ended by turn-timeout,
resignation or forfeit: those paths commit the finish directly, bypassing
the move path's per-mover NudgeClearer, so the awaited seat's nudge was
never marked read. A finished game's nudges are stale, so clear them all.

Add a wired NudgeExpirer (social.ExpireNudges) called from the shared
commit finish block — covering every completion path — and from the
voidGame recovery path. It clears every seat's nudge bits for the game
and leaves chat messages unread; unlike ClearNudges it records no
publish-to-read latency, since a completion is an expiry, not a read.

An integration test reproduces the timeout case (nudge cleared, chat
kept). Docs: ARCHITECTURE §9.1, FUNCTIONAL (+_ru), backend/README.
2026-06-30 22:51:54 +02:00
developer adf7c55695 Merge pull request 'feat(ui): first-run onboarding coachmarks' (#147) from feature/onboarding-coachmarks 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 57s
CI / gate (push) Successful in 0s
CI / deploy (push) Successful in 1m21s
2026-06-30 20:09:33 +00:00
Ilia Denisov 6636d7c309 feat(ui): first-run onboarding coachmarks
CI / changes (pull_request) Successful in 3s
CI / unit (pull_request) Has been skipped
CI / integration (pull_request) Has been skipped
CI / ui (pull_request) Successful in 57s
CI / gate (pull_request) Successful in 0s
CI / deploy (pull_request) Successful in 1m20s
A one-time coachmark overlay walks a new player through the lobby and their
first game board: a light dimmed layer draws one tail-pointed hint bubble at a
time, advancing on a tap anywhere and removing itself for good after the last
hint. Two independent series (lobby: settings/stats/new game; game:
header/pass-exchange/hints/shuffle/rack), gated by a per-device persisted flag
and marked done only after the last hint, so an interrupted run replays from
the start. A deep-link into Settings -> Friends still triggers the lobby series
on the first trip back to the lobby.

Targets carry a data-coach attribute, so one positioning engine anchors the
bubble in both portrait and landscape, re-measuring each frame until the
geometry settles (route slide, hidden-banner reflow, fonts). The promo banner
hides while the overlay is up (app.coachActive); a hidden DebugPanel "Reset
visited" control replays the walk-through. Off by default in the mock build so
the Playwright smoke is unaffected; ?coach forces it on for the dedicated e2e.

Pure geometry (step lists, nextVisibleStep, placeBubble) in lib/coachmark.ts
(unit-tested); Coachmark.svelte renders. Docs: FUNCTIONAL(+ru) onboarding
story, UI_DESIGN coachmark section.
2026-06-30 21:48:56 +02:00
developer 90f2427fa7 Merge pull request 'feat(assets): VK loading-screen Lottie — bouncing Erudit tile' (#146) from feature/vk-loader-lottie into development
CI / changes (push) Successful in 2s
CI / unit (push) Has been skipped
CI / integration (push) Has been skipped
CI / ui (push) Has been skipped
CI / gate (push) Successful in 0s
CI / deploy (push) Successful in 1m28s
2026-06-30 16:37:26 +00:00
Ilia Denisov d0f860a33e feat(assets): VK loading-screen Lottie — bouncing Erudit tile
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) Has been skipped
CI / gate (pull_request) Successful in 0s
CI / deploy (pull_request) Successful in 1m30s
A 96x96 transparent Lottie for the VK app loading screen: the «Э» tile
(score 8) drops in under gravity, lands with a soft cushion squash
(left/right edges bulge convex, corners follow via a chordal spline),
springs back and loops (~1.1s). A warm glint is caught on the bounce.

Pure 2D shapes (ddd:0) for player compatibility; ~11KB (<24KB limit).
Glyphs are real LiberationSans outlines (Arial-metric, matching the
game's font stack). Includes a reproducible, dependency-light build
pipeline (build/generate.js + extract.js + glyphs.json) and a README.
The preview GIF's green-baize background is preview-only; the asset
itself is transparent.
2026-06-30 18:29:51 +02:00
developer d1ceccf033 Merge pull request 'docs: sync FUNCTIONAL/UI_DESIGN/.claude with the shipped VK + landscape behaviour (#140-#142 follow-up)' (#145) from fix/vk-docs-deeplink-and-pan into development
CI / changes (push) Successful in 2s
CI / unit (push) Has been skipped
CI / integration (push) Has been skipped
CI / ui (push) Has been skipped
CI / gate (push) Successful in 0s
CI / deploy (push) Successful in 1m19s
2026-06-30 06:07:37 +00:00
Ilia Denisov 373aa2aa6d docs: sync VK theme-follow + home-bar safe-area into FUNCTIONAL/UI_DESIGN/.claude
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 / gate (pull_request) Successful in 0s
CI / deploy (pull_request) Successful in 1m19s
The #142 polish shipped three VK behaviours the docs never picked up (they were
written for the #140 auth MVP):

- the "auto" theme follows the VK client light/dark (VKWebAppUpdateConfig), not
  the VK webview's prefers-color-scheme;
- the layout clears the VK mobile home bar via CSS env() max'd with the VK bridge
  insets (VKWebAppUpdateInsets) — the bridge value is needed on Android, where the
  VK webview exposes no env() inset (.claude said env() handled it alone);
- share/copy route through the bridge (VKWebAppShare/VKWebAppCopyText).

FUNCTIONAL (+_ru) VK paragraph gains theme+home-bar parity with the Telegram one;
UI_DESIGN gets a VK-integration note beside the Telegram one.
2026-06-30 08:05:38 +02:00
Ilia Denisov 4a6fe318be docs: correct VK invite-link reality + note desktop board drag-to-pan
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 / gate (pull_request) Successful in 0s
CI / deploy (pull_request) Successful in 1m17s
Two doc edits that did not land alongside their code:

- FUNCTIONAL (+_ru) claimed a friend invitation is shareable as a Telegram
  *or VK* deep link "that opens it directly". On VK that is false: vkShareLink
  emits a plain vk.com/app<id> link (VK forwards no payload to the Mini App),
  so the recipient enters the copied code by hand (#142, commit d76f1f4 pivoted
  off the earlier #f<code> plan; the doc kept the old wording).
- UI_DESIGN omitted the desktop / landscape-iframe mouse drag-to-pan of the
  zoomed board added in #141 (touch scrolls the viewport natively; a mouse
  cannot, so an explicit drag-to-pan handler moves it).
2026-06-30 07:51:11 +02:00
developer 00441e3657 Merge pull request 'chore(ci): bump dictionary test-suite seed to v1.3.1' (#143) from chore/dict-seed-v1.3.1 into development
CI / changes (push) Successful in 2s
CI / integration (push) Successful in 14s
CI / deploy (push) Successful in 1m16s
CI / changes (pull_request) Successful in 2s
CI / unit (pull_request) Successful in 9s
CI / integration (pull_request) Successful in 16s
CI / unit (push) Successful in 9s
CI / ui (push) Successful in 55s
CI / gate (push) Successful in 0s
CI / ui (pull_request) Successful in 56s
CI / gate (pull_request) Successful in 0s
CI / deploy (pull_request) Has been skipped
2026-06-30 05:29:44 +00:00
developer 3b7c7c077e chore(ci): bump dictionary test-suite seed to v1.3.1
CI / changes (pull_request) Successful in 2s
CI / unit (pull_request) Successful in 9s
CI / integration (pull_request) Successful in 15s
CI / ui (pull_request) Successful in 56s
CI / gate (pull_request) Successful in 0s
CI / deploy (pull_request) Successful in 1m18s
The scrabble-dictionary release v1.3.1 drops the obscene lexicon (mat).
Bump the centralised CI seed so unit/integration jobs validate against the
same dictionary the prod deploy now pins (vars.PROD_DICT_VERSION=v1.3.1).
2026-06-30 05:27:42 +00:00
developer e900d592f8 Merge pull request 'feat(vk): native share/copy, auto theme, friend-code deep link, home-bar safe area' (#142) from feature/vk-bridge-integration 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 56s
CI / gate (push) Successful in 0s
CI / deploy (push) Successful in 1m25s
2026-06-29 21:04:53 +00:00
Ilia Denisov d76f1f4026 fix(vk): friend-code link is the plain vk.com/app link (VK strips iframe query payload)
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 55s
CI / gate (pull_request) Successful in 0s
CI / deploy (pull_request) Successful in 1m33s
The contour diagnostic confirmed VK forwards only the signed vk_* params to the iframe — a
custom payload on the app link is dropped (the '#' eaten by the vk.com SPA, a '?' query
stripped), so the friend-code cannot ride the link. The VK share link is now just
vk.com/app<id>; the recipient enters the copied code by hand (VKWebAppCopyText works). The
vkStartParam reader + bootVK routing stay as a no-op, ready for a post-moderation channel.
Removes the temporary deep-link diagnostic.
2026-06-29 22:58:43 +02:00
Ilia Denisov 0ea9764a0d chore(vk): temporary deep-link diagnostic (remove after contour catch)
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 56s
CI / gate (pull_request) Successful in 0s
CI / deploy (pull_request) Successful in 1m26s
2026-06-29 22:41:08 +02:00
Ilia Denisov 6a5ce12fab fix(vk): friend-code link as ?hash, Android safe-area via bridge insets, landscape home-bar colour
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 55s
CI / gate (pull_request) Successful in 0s
CI / deploy (pull_request) Successful in 1m22s
Contour review of the VK Bridge group:

- #6 invite link: VK's documented '#' direct-link payload is eaten by the vk.com SPA before it
  reaches the app, so the friend-code link now carries the payload as a query param
  (vk.com/app<id>?hash=f<code>); the recipient already reads the `hash` query param (vkStartParam).
  (Whether VK forwards the '?' through to the iframe is being confirmed on the contour.)
- #8 Android: the VK mobile webview does not surface the home-bar inset via CSS env() (config
  insets are iOS-only), so subscribe to the bridge insets (VKWebAppUpdateConfig + VKWebAppUpdateInsets)
  and set --tg-safe-* to max(env(), the VK value).
- #8 landscape colour: the home-indicator strip was the (grey) page background because the two-pane
  landscape game has no bottom bar. The left-panel controls bar now paints its own chrome into the
  inset (Screen gains a selfInset flag that drops the shell's detached padding strip), and the
  game-land runs flush to the edge.

Verified: svelte-check, 347 unit, build, bundle-gate; the landscape safe-area painting reproduced in
the mock (controls bar + board reach the edge, strip takes the bar colour). The VK-Bridge / VK launch
behaviours (Android insets, the ?hash forward) need the live contour.
2026-06-29 22:15:43 +02:00
Ilia Denisov da17c18895 feat(vk): native share/copy, auto theme, friend-code deep link, home-bar safe area
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 55s
CI / gate (pull_request) Successful in 0s
CI / deploy (pull_request) Successful in 1m25s
Group B of the VK integration — the contour-verified follow-up to the launch+auth MVP:

- Share/copy inside the VK iframe go through VK Bridge: the friend-code invite shares via
  VKWebAppShare and copies via VKWebAppCopyText, since navigator.share is absent in the desktop
  iframe and navigator.clipboard is blocked there.
- The invite link is a VK Mini App direct link (vk.com/app<id>#f<code>) on VK instead of the
  Telegram link; the app id comes from vk_app_id in the launch params (no build arg needed). The
  recipient's launch routes the deep link from VK's `hash` launch query parameter.
- The app's "auto" theme follows the VK client's light/dark appearance (VKWebAppUpdateConfig),
  which the VK mobile webview's prefers-color-scheme does not track.
- The safe-area CSS vars default to env(safe-area-inset-*), so the VK mobile layout clears the
  home bar (and Capacitor/PWA too); Telegram still overrides them from its SDK.

vk.ts adds vkAppId/vkStartParam/vkShare/vkCopyText/vkOnScheme. Verified: svelte-check, 347 unit
(+ vkAppId/vkStartParam/vkShareLink), build, bundle-gate. The VK-Bridge behaviours need the live
contour (not reproducible headless).
2026-06-29 21:27:36 +02:00
developer 303348ed39 Merge pull request 'fix(ui): landscape board zoom/pan + rack tile rendering + mobile block label' (#141) from fix/landscape-board-ui 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 56s
CI / gate (push) Successful in 0s
CI / deploy (push) Successful in 1m31s
2026-06-29 19:11:05 +00:00
Ilia Denisov a06dfe00fc fix(ui): pin the landscape confirm button into the freed tile slot
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 55s
CI / gate (pull_request) Successful in 0s
CI / deploy (pull_request) Successful in 1m24s
Follow-up to the contour review: with the rack now exactly seven fixed-size slots in the
narrow landscape panel, the 56px confirm button (sized for the roomy portrait rack) was
wider than the single slot a staged tile frees, so it overlapped the now-rightmost tile.
Match the button width to one landscape tile slot, so it sits inside the freed slot with
its right edge level with the rack's right edge — the mirror of the first tile's left edge.
2026-06-29 20:54:44 +02:00
Ilia Denisov a88678c5c6 fix(ui): landscape zoom two-step (sync both axes) + fixed rack tile size
CI / changes (pull_request) Successful in 3s
CI / unit (pull_request) Has been skipped
CI / integration (pull_request) Has been skipped
CI / ui (pull_request) Successful in 56s
CI / gate (pull_request) Successful in 0s
CI / deploy (pull_request) Failing after 2m35s
Follow-up to the contour review of the landscape fixes:

- Zoom still positioned "in two steps": a wide viewport overflows vertically as soon as
  the square board grows past its height, but horizontally only once it grows past the
  (much wider) width — so the browser pins scrollLeft to 0 while the vertical axis
  already pans (no time/ease tween fixes it; the horizontal scroll range physically is
  not there yet). Found by instrumenting the scroll trajectory. Now drive both axes by
  one progress = how far the board has grown past the viewport width (when a horizontal
  pan first becomes possible): until then the board just zooms centred, past it both
  axes pan together in one diagonal motion. Also disable scroll-anchoring so the browser
  stops fighting the programmatic scroll mid-transition. Re-verified: both axes now
  start and move together.
- Rack tiles resized when a tile was placed: landscape used flex-grow, so removing a
  tile regrew the rest. Give them a fixed size (1/7 of the fixed-width rack), like the
  portrait rack, so placing a tile leaves the rest put.
2026-06-29 20:26:01 +02:00
Ilia Denisov 500a01cf97 fix(ui): landscape board zoom/pan + rack tile rendering, shorter mobile block label
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 57s
CI / gate (pull_request) Successful in 0s
CI / deploy (pull_request) Successful in 1m28s
Bugs surfaced while testing in VK but present on every platform (verified across
browsers, not VK-specific):

- Rack tiles: the letter used a fixed rem size, so in landscape — where tiles shrink
  below 46px in the narrow left panel — it overflowed and dropped into the bottom-left
  corner. Size it relative to the rack (cqw, like the board's labels; the tile's own
  container-query size resolves unreliably under flex + aspect-ratio).
- Landscape board zoom positioned "in two steps": the per-frame clamp-to-max reached
  the wide axis before the tall one. Interpolate both scroll axes together by time over
  the grow/shrink transition instead (settles on zoom-out too).
- The zoomed board could not be panned with a mouse (touch scrolls the overflow:auto
  viewport natively; a mouse cannot drag-scroll a div). Add a drag-to-pan handler, active
  only while zoomed, off pending tiles, past a small movement threshold, swallowing the
  trailing click so it does not also act on a cell.
- Shorten the in-game block-confirm label ru "Блокируем?" -> "В бан?" (the long form
  overflowed the seat score chip on mobile).
2026-06-27 13:46:15 +02:00
developer d9ede77e2f Merge pull request 'feat(vk): embed the game as a VK Mini App' (#140) from feature/vk-embedding into development
CI / changes (push) Successful in 3s
CI / unit (push) Successful in 10s
CI / integration (push) Successful in 15s
CI / ui (push) Successful in 57s
CI / gate (push) Successful in 0s
CI / deploy (push) Successful in 1m30s
2026-06-27 11:23:44 +00:00
Ilia Denisov 65c194264c feat(vk): embed the game as a VK Mini App
CI / changes (pull_request) Successful in 2s
CI / unit (pull_request) Successful in 10s
CI / integration (pull_request) Successful in 14s
CI / ui (pull_request) Successful in 1m0s
CI / gate (pull_request) Successful in 0s
CI / deploy (pull_request) Successful in 1m19s
Mirror the Telegram Mini App wrapper for VK: the SPA loads at a new /vk/
entry, authenticates from VK's signed launch parameters, and provisions a
'vk' platform identity — the minimum to run the game in VK test mode.

- Gateway verifies the launch signature in-process (internal/vkauth:
  HMAC-SHA256 over the sorted vk_* params under GATEWAY_VK_APP_SECRET,
  base64url) — a pure offline check, no side-service. New auth.vk op
  (gated on the secret), backendclient.VKAuth, /vk/ SPA mount.
- Backend: KindVK + ProvisionVK/vkSeed, /sessions/vk handler, identity
  kind widened to include 'vk' (migration 00005, expand-contract).
- UI: src/lib/vk.ts (VK Bridge, lazy-imported), bootVK + the /vk/ boot
  dispatch, encodeVKLogin + authVK across transport/client/mock. VK omits
  the name from the signed params, so the client reads it via
  VKWebAppGetUserInfo as an unsigned display seed.
- Deploy: /vk in the edge Caddyfile, GATEWAY_VK_APP_SECRET wired through
  compose + .env.example + CI (TEST_) + prod-deploy (PROD_).
- Admin console: surface the VK user id (link to the VK profile) next to
  the Telegram id on the user card.
- Docs: ARCHITECTURE §12/§13, FUNCTIONAL (+ _ru), gateway README; VK
  integration reference under .claude/.

Signature algorithm verified against dev.vk.com plus independent Node/Python
references and a %2C edge-case vector.
2026-06-27 11:37:31 +02:00
developer 13c22734ee Merge pull request 'chore: track the deploy-check skill in the repo' (#139) from chore/deploy-check-skill into development
CI / changes (push) Successful in 2s
CI / unit (push) Has been skipped
CI / integration (push) Has been skipped
CI / ui (push) Has been skipped
CI / gate (push) Successful in 0s
CI / deploy (push) Successful in 1m26s
2026-06-24 13:07:22 +00:00
Ilia Denisov 7dabcd1317 chore: track the deploy-check skill in the repo
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) Has been skipped
CI / gate (pull_request) Successful in 0s
CI / deploy (pull_request) Successful in 1m23s
The deploy-check skill is repo-specific: it encodes this project's hard-won
pre-deploy runtime constraints (distroless nonroot, edge Alt-Svc/HTTP3, caddy
recreate, DICT_VERSION boot, expand-contract migrations, the Telegram permission
model) and points at deploy/README.md, docs/ARCHITECTURE.md, docs/EDGE_HTTP3.md and
the agent memory files. It belongs with the deploy logic it guards, not in the global
config, so it travels with the repo and stays versioned alongside it.
2026-06-24 14:39:00 +02:00
developer b03e012011 Merge pull request 'feat(telegram): native dialogs (experimental, stacked on #136)' (#137) from feature/telegram-native-dialogs into development
CI / unit (push) Has been skipped
CI / integration (push) Has been skipped
CI / changes (pull_request) Successful in 2s
CI / integration (pull_request) Successful in 17s
CI / ui (pull_request) Successful in 56s
CI / gate (pull_request) Successful in 0s
CI / deploy (pull_request) Has been skipped
CI / changes (push) Successful in 2s
CI / ui (push) Successful in 56s
CI / gate (push) Successful in 0s
CI / deploy (push) Successful in 1m16s
CI / unit (pull_request) Successful in 10s
2026-06-24 11:41:28 +00:00
developer 2495446a47 Merge pull request 'feat(telegram): Mini App embedding enhancements' (#136) from feature/telegram-embedding into development
CI / changes (push) Successful in 2s
CI / unit (push) Successful in 10s
CI / integration (push) Successful in 15s
CI / ui (push) Successful in 56s
CI / gate (push) Successful in 0s
CI / deploy (push) Successful in 1m19s
2026-06-24 11:41:15 +00:00
Ilia Denisov 35705f7d1e fix(telegram): defer deep-link notices until the loading cover clears
CI / changes (pull_request) Successful in 2s
CI / unit (pull_request) Successful in 10s
CI / integration (pull_request) Successful in 23s
CI / ui (pull_request) Successful in 56s
CI / gate (pull_request) Successful in 0s
CI / deploy (pull_request) Successful in 1m28s
The stale-invite / welcome-on-redeem notices are raised during boot, so the native
popup fired over the loading splash. Gate both the native popup and the in-app Modal
on the current route's loading cover being gone — the tile splash on the lobby
(splashDone), the plain loading screen elsewhere (app.ready) — so the notice appears
with the settled screen on every build (Telegram and native/web alike).
2026-06-24 13:25:14 +02:00
Ilia Denisov 4f0cc81dfb fix(telegram): evaluate native-dialog availability at fire time
CI / changes (pull_request) Successful in 2s
CI / unit (pull_request) Successful in 10s
CI / integration (pull_request) Successful in 17s
CI / ui (pull_request) Successful in 56s
CI / gate (pull_request) Successful in 0s
CI / deploy (pull_request) Successful in 1m27s
The deep-link info modals (stale invite, welcome-on-redeem) captured
insideTelegram() && telegramDialogsAvailable() in a const at component init, but they
mount in App before bootstrap loads the SDK, so the value was always false and they
fell back to the in-app Modal even inside Telegram. Evaluate it at fire time (in the
effect and the {#if} guard), as the destructive confirms already do at click time.
2026-06-24 13:16:11 +02:00
Ilia Denisov 2ba7cc3086 feat(telegram): native dialogs for confirms and deep-link notices
CI / changes (pull_request) Successful in 2s
CI / unit (pull_request) Successful in 10s
CI / integration (pull_request) Successful in 15s
CI / ui (pull_request) Successful in 56s
CI / gate (pull_request) Successful in 0s
CI / deploy (pull_request) Successful in 1m19s
Inside the Mini App, route the destructive confirmations (resign, block, unfriend)
through Telegram's native showConfirm, and present the deep-link info modals (stale
invite, welcome-on-redeem) as a native showPopup whose button opens the bot chat.
Outside Telegram, or on a client predating the dialogs, the existing in-app Modal is
used unchanged; offline also keeps the modal so the action retains its disabled state.

Adds showConfirm/showPopup wrappers to telegram.ts and a pure popup-params builder
(nativedialogs.ts) with unit tests; the deep-link modal components choose native vs
in-app via an effect gated on insideTelegram + dialog availability.
2026-06-24 12:36:52 +02:00
Ilia Denisov 29b6c7e4d8 docs(telegram): document the Mini App embedding enhancements
CI / changes (pull_request) Successful in 2s
CI / unit (pull_request) Successful in 9s
CI / integration (pull_request) Successful in 17s
CI / ui (pull_request) Successful in 56s
CI / gate (pull_request) Successful in 0s
CI / deploy (pull_request) Successful in 1m22s
Record the current state in ARCHITECTURE and FUNCTIONAL (+ _ru mirror): inside the
Mini App the client tracks Telegram's live theme switch, fits the full device
safe-area, exposes a native Settings button into the in-app settings, and syncs the
device-independent display prefs (theme, reduce-motion, board labels — not the
interface language) across the user's Telegram devices via CloudStorage; the
validator denies a bot user (is_bot) before provisioning an account.
2026-06-24 12:19:50 +02:00
Ilia Denisov 8de9fb1ecd feat(telegram): deny bot users at initData validation
CI / changes (pull_request) Successful in 2s
CI / unit (pull_request) Successful in 9s
CI / integration (pull_request) Successful in 15s
CI / ui (pull_request) Successful in 55s
CI / gate (pull_request) Successful in 0s
CI / deploy (pull_request) Successful in 1m19s
The validator parsed only id/username/first_name/language_code from the signed
Telegram user, so a WebAppUser flagged is_bot would have been provisioned a normal
account. The HMAC already proves Telegram signed the payload, so is_bot==true is
Telegram itself attesting the launching principal is a bot.

Parse is_bot and reject it (ErrInvalidInitData -> gateway 4xx -> launch error). A
real user opening the Mini App never carries it, so this is a defensive deny. Tests
cover both the denied (is_bot true) and allowed (is_bot false) paths.
2026-06-24 11:55:30 +02:00
Ilia Denisov 8a5a5d6c4d feat(telegram): sync client display prefs via CloudStorage
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 56s
CI / gate (pull_request) Successful in 0s
CI / deploy (pull_request) Successful in 1m20s
Theme, reduce-motion and board labels lived only in local IndexedDB/localStorage,
so they did not follow the user across devices and could be lost when the Telegram
WebView cleared storage.

Mirror these three device-independent prefs to Telegram CloudStorage (Bot API 6.9)
from the single local-persist point (persistPrefs), and reconcile them from
CloudStorage in the background on launch (reconcileCloudPrefs) so a change made on
another device follows the user here. The local store stays the instant-render
cache; the interface language is intentionally excluded (it syncs via the durable
account). Pure encode/decode extracted to cloudprefs.ts with unit tests; the
CloudStorage transport wrappers are added to telegram.ts. No-op outside Telegram or
on a client predating CloudStorage.
2026-06-24 11:39:29 +02:00
Ilia Denisov ea931c6680 feat(telegram): native SettingsButton opens our Settings screen
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 55s
CI / gate (pull_request) Successful in 0s
CI / deploy (pull_request) Successful in 1m16s
Inside the Mini App, reveal Telegram's native Settings button (Bot API 7.0)
and route its taps to the Settings screen — the standard Mini App affordance.
The in-app gear entry stays the primary path (two entry points by design).
No-op outside Telegram or on a client predating the button.
2026-06-24 10:47:05 +02:00
Ilia Denisov d0f60ee41d fix(telegram): paint the home-indicator strip with the bottom bar's colour
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 55s
CI / gate (pull_request) Successful in 0s
CI / deploy (pull_request) Successful in 1m15s
The safe-area bottom inset was reserved on the Screen wrapper, so the strip
that holds space for the home indicator showed the content background and read
as detached from the coloured bottom bar / header above it.

Move the bottom inset onto the bottom bar: the Screen .tabbar wrapper now paints
--bg-elev and pads itself by --tg-safe-bottom, so the strip continues the
TabBar's chrome; a screen with no tab bar pads its bottom-most content
(.content:last-child) instead, so the strip takes that content's own colour.
2026-06-24 10:41:26 +02:00
Ilia Denisov b84bd1297e feat(telegram): clear bottom and side safe-area insets
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 55s
CI / gate (pull_request) Successful in 0s
CI / deploy (pull_request) Successful in 1m17s
Only the device safe-area TOP inset was mirrored, so on phones with a home
indicator the rack / bottom bar sat under it, and in landscape the notch
clipped the screen edges.

Mirror the full device safe-area inset (bottom / left / right) into new
--tg-safe-bottom / --tg-safe-left / --tg-safe-right CSS vars (0 outside
Telegram) and pad the shared Screen wrapper by them, so every screen clears the
home indicator and the landscape notch; the top inset stays owned by the header.
Replace telegramSafeAreaTop with telegramSafeAreaInset (the full inset object),
with a unit test.
2026-06-24 10:24:56 +02:00
Ilia Denisov 0fb6004a8b feat(telegram): re-apply theme live on themeChanged
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 56s
CI / gate (pull_request) Successful in 0s
CI / deploy (pull_request) Successful in 1m21s
The Mini App read Telegram's themeParams/colorScheme only once at launch, so
switching Telegram's light/dark theme (or its auto day/night) while the app was
open left the SPA on stale colours until a relaunch.

Subscribe to the themeChanged WebApp event and re-apply the theme live. Extract
the launch-time token + colour-scheme + chrome application into syncTelegramTheme
(reading live themeParams when no launch snapshot is passed) and call it from both
applyTelegramChrome (launch) and the new event handler. Add a telegramThemeParams
live getter (+ unit test). Drop the stale 'immersive fullscreen' note from
applyTelegramChrome — the app deliberately does not request fullscreen.
2026-06-24 09:20:41 +02:00
developer c1d1c1624b Merge pull request 'feat(telegram): promo deep-link seeds EN variant (+ UI: share label, header padding)' (#134) from feature/promo-deeplink-and-ui-nits into development
CI / unit (push) Successful in 10s
CI / ui (push) Successful in 56s
CI / gate (push) Successful in 0s
CI / changes (push) Successful in 2s
CI / integration (push) Successful in 16s
CI / deploy (push) Successful in 1m18s
CI / changes (pull_request) Successful in 2s
CI / unit (pull_request) Successful in 10s
CI / integration (pull_request) Successful in 15s
CI / ui (pull_request) Successful in 56s
CI / gate (pull_request) Successful in 0s
CI / deploy (pull_request) Has been skipped
2026-06-23 20:50:14 +00:00
Ilia Denisov 9207664fbd docs(telegram): note the promo body @username is a deep link
CI / changes (pull_request) Successful in 3s
CI / unit (pull_request) Successful in 10s
CI / integration (pull_request) Successful in 15s
CI / ui (pull_request) Successful in 55s
CI / gate (pull_request) Successful in 0s
CI / deploy (pull_request) Successful in 1m17s
2026-06-23 22:45:54 +02:00
Ilia Denisov a4581663f4 feat(telegram): link the promo body @username to the Mini App deep link
CI / changes (pull_request) Successful in 2s
CI / unit (pull_request) Successful in 10s
CI / integration (pull_request) Successful in 18s
CI / ui (pull_request) Successful in 56s
CI / gate (pull_request) Successful in 0s
CI / deploy (pull_request) Successful in 1m22s
The promo message body now renders "@<bot>" as an HTML text_link to the same ?startapp deep link the button uses (ParseMode HTML), so tapping the mention opens the seeded Mini App instead of the bot profile. Same payload (campaign start-param, else the forwarded /start payload) backs both the button and the mention.
2026-06-23 22:40:52 +02:00
Ilia Denisov 03dfc29a54 feat(telegram): promo deep-link seeds English Scrabble for new users
CI / changes (pull_request) Successful in 2s
CI / unit (pull_request) Successful in 10s
CI / integration (pull_request) Successful in 18s
CI / ui (pull_request) Successful in 56s
CI / gate (pull_request) Successful in 0s
CI / deploy (pull_request) Successful in 50s
The promo bot button carries a configurable variant-seed start-param (default verudit_ru-scrabble_en). The gateway parses start_param from the validated initData and forwards it; the backend, on first contact only, seeds the new account variant_preferences from it (English Scrabble alongside the default Erudit).

No schema change (the scrabble_en CHECK is already in the baseline) and the gateway<->backend REST field is additive, so the rolling deploy is safe in either order. TELEGRAM_PROMO_START_PARAM configures the payload (empty forwards the user own /start payload). Covered by account unit tests, a gateway transcode test, and an integration test asserting new-only seeding.
2026-06-23 21:51:52 +02:00
Ilia Denisov c02262fcf7 style(ui): halve custom header top/bottom padding
The custom title bar was too tall. Halve the standard bar padding (10->5px) and, in the Telegram path, the notch gap (16->8px) and bottom (6->3px); the notch safe-area inset is unchanged. Title and back chevron stay vertically centred.
2026-06-23 21:51:52 +02:00
Ilia Denisov f8fab4a4c2 fix(ui): rename Friends "Share via Telegram" to "Share"
The share button uses the OS Web Share sheet outside Telegram (the TG share picker only inside it), so "via Telegram" was misleading. Rename to a neutral "Share"/"Поделиться" and drop the now-unused tgshare class.
2026-06-23 21:51:52 +02:00
developer 10264e10c8 Merge pull request 'fix(telegram): support relay — text_mention card + reopen deleted topic' (#132) from feature/telegram-support-card-mention into development
CI / changes (push) Successful in 2s
CI / unit (push) Successful in 10s
CI / integration (push) Successful in 17s
CI / ui (push) Has been skipped
CI / changes (pull_request) Successful in 2s
CI / gate (push) Successful in 0s
CI / deploy (push) Successful in 1m17s
CI / unit (pull_request) Successful in 10s
CI / integration (pull_request) Successful in 17s
CI / ui (pull_request) Has been skipped
CI / gate (pull_request) Successful in 0s
CI / deploy (pull_request) Has been skipped
2026-06-23 16:51:04 +00:00
Ilia Denisov fc1715128e fix(telegram): reopen a deleted support topic instead of losing it to General
CI / changes (pull_request) Successful in 2s
CI / unit (pull_request) Successful in 9s
CI / integration (pull_request) Successful in 16s
CI / ui (pull_request) Has been skipped
CI / gate (pull_request) Successful in 0s
CI / deploy (pull_request) Successful in 1m18s
Telegram silently routes a copyMessage aimed at a deleted forum topic
into the chat's General topic with NO error, so the bot's error-based
recreate never fired — the user's next message landed in General with no
card. Confirmed against the prod bot logs: a relay after a topic deletion
logged neither "relay to topic failed" nor "topic gone, reopening".

Probe topic liveness before reusing it: re-applying the info card's reply
markup is a no-op that errors only when the card (hence the topic) is
gone, so the bot detects the deletion and reopens the topic + card rather
than relying on the (absent) copy error. The post-copy recreate stays as
a race backstop.
2026-06-23 18:45:39 +02:00
Ilia Denisov bb18dc362b fix(telegram): support card — text_mention name, no command/dead link
CI / changes (pull_request) Successful in 3s
CI / unit (pull_request) Successful in 10s
CI / integration (pull_request) Successful in 17s
CI / ui (pull_request) Has been skipped
CI / gate (pull_request) Successful in 0s
CI / deploy (pull_request) Successful in 1m19s
The topic info card built the profile link as `tg://user?id=`, which
clients render as dead text for a user they don't already know (the
test-vs-prod difference an operator saw), and it put the raw display
name through HTML — so a name starting with "/" was auto-detected as a
tappable bot command that fired when tapped.

Render the card with message entities instead: the display name is
covered by a `text_mention` entity (the reliable way to mention a
username-less user the bot has already seen — it messaged the bot),
which links the profile AND, because the name sits inside an entity,
suppresses the "/command" and "@mention" auto-detection on it. Drop the
HTML parse mode and the tg:// link. Entity offsets are UTF-16.
2026-06-23 18:33:14 +02:00
developer d86e022373 Merge pull request 'feat(telegram): bot support relay — per-user forum topics' (#130) from feature/telegram-support-relay into development
CI / unit (push) Successful in 10s
CI / integration (push) Successful in 15s
CI / gate (push) Successful in 0s
CI / deploy (push) Successful in 1m22s
CI / changes (push) Successful in 2s
CI / ui (push) Successful in 56s
CI / changes (pull_request) Successful in 2s
CI / unit (pull_request) Successful in 10s
CI / integration (pull_request) Successful in 15s
CI / ui (pull_request) Successful in 55s
CI / gate (pull_request) Successful in 0s
CI / deploy (pull_request) Has been skipped
2026-06-23 16:08:18 +00:00
Ilia Denisov 6a602aefae feat(telegram): bot support relay — per-user forum topics
CI / changes (pull_request) Successful in 2s
CI / unit (pull_request) Successful in 12s
CI / integration (pull_request) Successful in 16s
CI / ui (pull_request) Successful in 56s
CI / gate (pull_request) Successful in 0s
CI / deploy (pull_request) Successful in 1m20s
Users who DM the bot anything but /start are relayed into a private
forum supergroup, one topic per user. Operators (the chat's admins)
reply in the topic and the bot copies it back to the user; an info card
opening each topic carries a Block/Unblock toggle and a Clear button.
State is a small JSON file on a new /data volume — the bot host has no
database. Off by default (TELEGRAM_SUPPORT_CHAT_ID=0): the prod bot is
unchanged until the operator sets the chat id and adds the bot as a
forum admin.

- internal/support: concurrency-safe JSON store (topic map, block list,
  relayed message ids) with field-targeted mutators and atomic save
- bot/support.go: relay both ways via copyMessage, short-TTL admin
  cache, callback buttons, per-user topic-create lock, loop guard
  (skip the bot's own posts), reopen a deleted topic on the next message
- config + compose + CI/prod-deploy: TELEGRAM_SUPPORT_CHAT_ID per
  contour + TELEGRAM_SUPPORT_STATE_DIR; bot-state named volume; /data
  pre-owned by UID 65532 so a fresh volume is writable under distroless
- docs: ARCHITECTURE §15 + decision record, FUNCTIONAL (+ru), README
2026-06-23 17:47:00 +02:00
developer e6277dcd43 Merge pull request 'fix(ui): Android Telegram nav — windowed mode, no close guard' (#128) from feature/telegram-android-nav-fixes into development
CI / changes (push) Successful in 2s
CI / unit (pull_request) Has been skipped
CI / integration (pull_request) Has been skipped
CI / ui (pull_request) Successful in 56s
CI / gate (pull_request) Successful in 0s
CI / unit (push) Has been skipped
CI / integration (push) Has been skipped
CI / ui (push) Successful in 56s
CI / gate (push) Successful in 0s
CI / deploy (push) Successful in 1m18s
CI / changes (pull_request) Successful in 2s
CI / deploy (pull_request) Has been skipped
2026-06-23 13:14:04 +00:00
Ilia Denisov 37070c3cb7 feat(ui): drop Telegram fullscreen; own back chevron everywhere; hidden debug panel
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 56s
CI / gate (pull_request) Successful in 0s
CI / deploy (pull_request) Successful in 2m13s
Finalises the Telegram Mini App navigation work after on-device testing (Pixel
10 / Android 17 + iOS, fresh beta clients):

- Remove requestFullscreen entirely. Immersive fullscreen hid Telegram's native
  header (and its BackButton) and the Android system swipe-back minimised the
  app; the owner prefers the windowed full-size (expand) presentation, so the
  app never requests fullscreen on any platform now.
- The app's own back chevron (Header, showBack = !!back) drives back-navigation
  on every platform; the native Telegram BackButton is dropped — it does not
  render in the windowed Mini App (backVisible=false on iOS and Android), so
  relying on it lost back navigation (notably none on iOS).
- Replace the temporary always-on diagnostic overlay with a hidden debug panel
  (components/DebugPanel): ten quick taps on the header title open it; it shows a
  privacy-safe client diagnostic snapshot (app version, locale, online, userId,
  Telegram chrome / viewport / SDK state — no secrets, no IP) and shares it via
  the OS share sheet / clipboard; a tap anywhere except Share dismisses it.
- Drop the now-dead telegramRequestFullscreen / telegramBackButton /
  isTelegramAndroid helpers and the iOS-fullscreen unit test.

Telegram has no native non-modal notification API (only modal showPopup /
showAlert), so in-app toasts stay ours. Docs: UI_DESIGN.md.
2026-06-23 15:05:13 +02:00
Ilia Denisov 53d6883ffd fix(ui): own back chevron in Telegram on all platforms; drop the native BackButton
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 55s
CI / gate (pull_request) Successful in 0s
CI / deploy (pull_request) Successful in 1m30s
The Telegram native BackButton does not render in the windowed Mini App (the
owner's emulator + a fresh beta TG report backVisible=false on both iOS and
Android), so relying on it lost back navigation — iOS had no back affordance at
all. Show the app's own back chevron whenever there is a back target, on every
platform (Header showBack = !!back), and drop the now-dead native BackButton
effect (App.svelte). The native close control stays — a windowed Mini App
cannot hide it (no Telegram API).

WIP: the temp lobby diagnostic overlay and the requestFullscreen no-op remain
for the owner's emulator test; finalize after confirmation.
2026-06-23 14:37:43 +02:00
Ilia Denisov 93c57b3558 test(ui): TEMP-skip the iOS fullscreen unit test (requestFullscreen is a no-op for the owner test; restore with the iOS path)
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 56s
CI / gate (pull_request) Successful in 0s
CI / deploy (pull_request) Successful in 1m30s
2026-06-23 14:09:40 +02:00
Ilia Denisov 6f00c2f41d chore(ui): TEMP disable fullscreen for testing + Android back chevron + BackButton diag
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 8s
CI / gate (pull_request) Failing after 0s
CI / deploy (pull_request) Has been skipped
WIP for the Android nav investigation (TEMP bits reverted before merge):
- telegramRequestFullscreen: temporarily a no-op (incl. iOS) so the owner can
  confirm the Android "fullscreen look" is Telegram's own Mini App presentation,
  not our requestFullscreen (isFullscreen is already false on Android).
- Header: show the app's own back chevron in Telegram on Android, where the
  native BackButton does not render — a reliable tap-back. [keep]
- Diagnostic overlay moved app-wide (pointer-events:none) and now reports
  BackButton state (req/present/visible) + viewport geometry, to see whether the
  native BackButton can capture the Android system swipe-back.
2026-06-23 14:04:21 +02:00
Ilia Denisov 79766438a2 chore(ui): TEMP lobby diagnostic for the Android fullscreen issue
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 56s
CI / gate (pull_request) Successful in 0s
CI / deploy (pull_request) Successful in 1m19s
Renders Telegram viewport/fullscreen state (isFullscreen, isExpanded, viewport
heights, innerH vs screenH, safe-area insets) in the lobby, inside Telegram
only, to diagnose why the app still opens fullscreen on Android with
requestFullscreen now iOS-only and no persisted state (fresh TG + test account).
REVERT before merge.
2026-06-23 13:36:54 +02:00
Ilia Denisov 6aa5023b24 fix(ui): Android Telegram nav — windowed mode, no close guard
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 56s
CI / gate (pull_request) Successful in 0s
CI / deploy (pull_request) Successful in 1m18s
Three Android Mini App issues, all in the Telegram chrome:

- Entering a game showed no native back button, and the Android system
  swipe-back minimised the app instead of navigating. Root cause: immersive
  fullscreen (requestFullscreen). On Android, fullscreen replaces the native
  header — and its BackButton, which also captures the system back — with a bare
  close/menu pill, so back navigation has no control to land on. Request
  fullscreen on iOS only; Android stays windowed, keeping the native header +
  BackButton (and the system swipe-back that routes to it). iOS is unchanged.

- Closing the game board always prompted "changes that you made may not be
  saved", even on a board just opened and untouched. The close-confirmation was
  armed unconditionally on game mount. Remove it entirely: move drafts auto-save
  (debounced during play + flushed on destroy), so nothing is lost on close.

Drops the now-unused telegramClosingConfirmation + isMobilePlatform helpers and
their SDK interface fields. Docs: UI_DESIGN.md.
2026-06-23 11:48:38 +02:00
developer 508dc870ec Merge pull request 'feat(ui): diagnostic screen on /telegram/ instead of the landing bounce' (#126) from feature/telegram-launch-diagnostic into development
CI / ui (push) Successful in 56s
CI / gate (push) Successful in 0s
CI / deploy (push) Successful in 1m16s
CI / ui (pull_request) Successful in 56s
CI / gate (pull_request) Successful in 0s
CI / changes (push) Successful in 2s
CI / unit (push) Has been skipped
CI / integration (push) Has been skipped
CI / changes (pull_request) Successful in 2s
CI / unit (pull_request) Has been skipped
CI / integration (pull_request) Has been skipped
CI / deploy (pull_request) Has been skipped
2026-06-23 08:32:18 +00:00
Ilia Denisov e3899d4755 feat(ui): record the SDK load outcome in the launch diagnostic
CI / changes (pull_request) Successful in 2s
CI / unit (pull_request) Has been skipped
CI / integration (pull_request) Has been skipped
CI / ui (pull_request) Successful in 55s
CI / gate (pull_request) Successful in 0s
CI / deploy (pull_request) Successful in 1m16s
The diagnostic showed only sdk: yes/no (window.Telegram presence), not why the
SDK was absent. Capture how the dynamic telegram-web-app.js load resolved —
present / loaded / no-webapp / error / timeout — and surface it as
"sdk-load: <outcome>". error/timeout pinpoint a blocked or hanging telegram.org
(the prime suspect for an empty launch); no-webapp a loaded-but-broken script.

loadTelegramSDK records the outcome (telegramSdkOutcome); collectTelegramDiag
carries it into the screen. Unit tests cover each outcome; the blocked-script
e2e now asserts sdk-load: error.
2026-06-23 10:18:02 +02:00
Ilia Denisov ae5090b851 fix(ui): load telegram-web-app.js dynamically with a timeout
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 55s
CI / gate (pull_request) Successful in 0s
CI / deploy (pull_request) Successful in 1m18s
The Telegram SDK was a render-blocking <script src="telegram.org/..."> in the
shared index.html shell, so it ran on every entry (/telegram/, /app/, native).
On a network that blocks telegram.org — common where Telegram itself reaches
users only over a proxy — the script hangs forever, stranding the whole page,
including the launch-diagnostic screen meant to surface exactly this failure.
This is the likely root cause of the Android "won't open" reports (all launch
methods fail identically; iOS on a different network works).

Remove the head <script> and load the SDK dynamically (lib/telegram.ts
loadTelegramSDK) with a 10s timeout, only on a Telegram entry (the /telegram/
path or a tgWebApp launch fragment). The SPA — served from our own reachable
origin — boots first and controls the load: on a block/error it falls through
to the diagnostic screen (reporting sdk: no) instead of hanging, and Retry
re-attempts. /app/ and the native build no longer touch telegram.org.

Pin the SDK to the version the official page recommends (?62) for the newer
client features the app already uses (fullscreen, safe areas, swipe guard).

Tests: loadTelegramSDK unit tests (present / error / timeout); an e2e that
aborts the script fetch and asserts the diagnostic still renders.
2026-06-23 09:59:24 +02:00
Ilia Denisov 0c5d3808d7 feat(ui): diagnostic screen on /telegram/ instead of the landing bounce
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 55s
CI / gate (pull_request) Successful in 0s
CI / deploy (pull_request) Successful in 1m22s
A Mini App launch on /telegram/ without sign-in data (empty initData) used to
location.replace('/') — bouncing the visitor to the marketing landing. That
destroyed all diagnosability and, on some Android clients that reach the entry
with no initData, simply looked like the app refusing to open.

Replace the bounce with a compact, privacy-safe launch-error screen
(screens/TelegramLaunchError.svelte) that renders a one-screenshot diagnostic
snapshot captured at the moment of failure: SDK/WebApp presence, Telegram
platform/version, whether initData is empty, whether the URL fragment carried
tgWebAppData, the initData field NAMES present/missing (never the signed
values, never an IP), and OS/mobile/browser via User-Agent Client Hints plus
the full User-Agent. A Share button delivers it through the OS share sheet
(clipboard copy on desktop, reusing the GCG no-webview-strand guard); Retry
re-checks in place to recover a late initData without a reload (which would
discard the launch fragment).

This is the instrument to root-cause the Android empty-initData failure, which
is not reproducible in Playwright.

Tests: collectTelegramDiag + shareText/pickTextShare unit tests; the /telegram/
e2e now asserts the diagnostic screen, not a redirect. Docs: ARCHITECTURE.md +
UI_DESIGN.md updated.
2026-06-23 09:37:36 +02:00
developer 18db62e19d Merge pull request 'feat(ui): friends list as lobby-style rows with kebab + confirm modals' (#123) from feature/friends-list-kebab-confirm into development
CI / ui (push) Successful in 56s
CI / gate (push) Successful in 0s
CI / changes (pull_request) Successful in 3s
CI / unit (pull_request) Successful in 10s
CI / changes (push) Successful in 2s
CI / unit (push) Has been skipped
CI / integration (push) Has been skipped
CI / deploy (push) Successful in 1m18s
CI / integration (pull_request) Successful in 14s
CI / ui (pull_request) Successful in 56s
CI / gate (pull_request) Successful in 0s
CI / deploy (pull_request) Has been skipped
2026-06-22 22:28:25 +00:00
Ilia Denisov d4e34efa80 test(ui): e2e for the friends-list kebab, confirm modals and outside-tap
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 56s
CI / gate (pull_request) Successful in 0s
CI / deploy (pull_request) Successful in 1m20s
Cover the reworked Settings -> Friends interactions in the mock e2e
(Chromium + WebKit): the row kebab slides open the block/remove icons and an
outside tap collapses it; blocking confirms (naming the friend) and moves them
to Blocked; removing confirms and drops the friendship.
2026-06-23 00:24:33 +02:00
Ilia Denisov 12ff6dad86 feat(ui): close the friends kebab on an outside tap
CI / changes (pull_request) Successful in 3s
CI / unit (pull_request) Has been skipped
CI / integration (pull_request) Has been skipped
CI / ui (pull_request) Successful in 54s
CI / gate (pull_request) Successful in 0s
CI / deploy (pull_request) Successful in 1m30s
A slid-open friend row now collapses when the user taps anywhere outside its
action buttons (taps on a kebab are skipped so its own toggle still drives the
open/close). Uses the same capture-phase window pointerdown idiom as Screen,
active only while a row is revealed.
2026-06-23 00:20:09 +02:00
Ilia Denisov 5a80696fe8 feat(ui): friends list as lobby-style rows with kebab + confirm modals
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 53s
CI / gate (pull_request) Successful in 0s
CI / deploy (pull_request) Successful in 1m31s
Settings -> Friends previously rendered each friend as a bordered card with
two always-visible text buttons (Remove / Block) that fired immediately. Rework
the whole screen to the lobby's visual language: one-line rows split by
hairline separators across all three sections (friends, incoming requests,
blocked).

Each friend row gains a right-hand kebab that slides the row open to reveal two
icon actions split by a vertical divider -- block (no-entry) and remove (cross)
-- mirroring the lobby's slide-to-reveal. Both actions now require a
confirmation modal; since the slide moves a short name off-screen, the modal
keeps a generic title and shows the friend's name in the body, above the
buttons, so a long name cannot stretch the sheet. Incoming keeps its
accept/decline buttons and blocked keeps unblock, inline on their rows.

Add the friends.actions / friends.blockConfirm / friends.unfriendConfirm keys
to both i18n catalogs and document the flow in FUNCTIONAL (+_ru).
2026-06-23 00:09:39 +02:00
developer d6401bb76c Merge pull request 'fix(deploy): force-recreate caddy on its roll so config-only changes apply' (#122) from feature/prod-deploy-force-recreate-caddy into development
CI / changes (push) Successful in 2s
CI / unit (push) Successful in 10s
CI / integration (push) Successful in 15s
CI / ui (push) Successful in 53s
CI / gate (push) Successful in 0s
CI / deploy (push) Successful in 1m17s
2026-06-22 20:09:43 +00:00
Ilia Denisov e0a5753f1a fix(deploy): force-recreate caddy on its roll so config-only changes apply
CI / changes (pull_request) Successful in 2s
CI / unit (pull_request) Successful in 10s
CI / integration (pull_request) Successful in 15s
CI / ui (pull_request) Successful in 54s
CI / gate (pull_request) Successful in 0s
CI / deploy (pull_request) Successful in 1m20s
The prod rolling deploy rolls each service with `compose up -d --no-deps <svc>`.
For caddy that is a no-op on a config-only release: its image is pinned
(caddy:2-alpine, no $TAG), so the compose definition is unchanged between
releases, compose treats the container as current and does not recreate it, and
admin is off so there is no hot reload. The new bind-mounted Caddyfile is seeded
to the host but never loaded -- the v1.2.2 `Alt-Svc: clear` edge fix deployed
green yet did not take effect until caddy was restarted by hand.

Force a recreate for caddy on its roll (every other service already recreates on
its new $TAG image), so a bind-mounted Caddyfile change always applies. Costs a
~1-2s caddy blip per deploy, acceptable for the infrequent manual prod rollout.
2026-06-22 22:03:35 +02:00
developer ba57687430 Merge pull request 'fix(grafana): real byte thresholds for the Database size stat' (#120) from feature/grafana-db-size-thresholds into development
CI / changes (push) Successful in 2s
CI / changes (pull_request) Successful in 2s
CI / unit (push) Successful in 10s
CI / integration (push) Successful in 16s
CI / ui (push) Successful in 54s
CI / unit (pull_request) Successful in 10s
CI / integration (pull_request) Successful in 15s
CI / ui (pull_request) Successful in 54s
CI / gate (push) Successful in 0s
CI / deploy (push) Successful in 1m20s
CI / gate (pull_request) Successful in 0s
CI / deploy (pull_request) Has been skipped
2026-06-22 19:41:42 +00:00
developer 6cb88b28c4 Merge pull request 'fix(edge): suppress dead HTTP/3 advert with Alt-Svc: clear' (#119) from feature/edge-suppress-http3-altsvc into development
CI / changes (push) Successful in 3s
CI / unit (push) Successful in 10s
CI / integration (push) Successful in 20s
CI / ui (push) Successful in 55s
CI / gate (push) Successful in 0s
CI / deploy (push) Successful in 1m18s
2026-06-22 19:41:16 +00:00
Ilia Denisov 46d569720c fix(grafana): give "Database size" stat real byte thresholds
CI / changes (pull_request) Successful in 2s
CI / unit (pull_request) Successful in 10s
CI / integration (pull_request) Successful in 17s
CI / ui (pull_request) Successful in 54s
CI / gate (pull_request) Successful in 0s
CI / deploy (pull_request) Successful in 1m18s
The "Database size" stat had no thresholds, so Grafana applied its stat
default (green base, red at >=80). The query is pg_database_size_bytes, so a
healthy ~9 MiB database (9.4M >> 80) rendered permanently RED on the
Scrabble - Resources dashboard (test + prod), reading as a false alert; the
neighbouring percentunit cache-hit stat stayed green only because its 0..1
values fall under 80.

Add absolute byte thresholds sized to the 40 GiB prod disk (4.6 GiB used,
observability bounded -- Tempo <=1 GiB, Prometheus 7d -- so the DB is the
only unbounded grower): green up to 8 GiB, yellow at 8 GiB (~20% of disk),
red at 16 GiB (~40%), an early warning with ample runway before the disk
tightens, not a panic line. Cosmetic panel coloring only; there are no
Grafana alert rules provisioned.
2026-06-22 21:35:48 +02:00
Ilia Denisov 9253b1bdca fix(edge): suppress dead HTTP/3 advert with Alt-Svc: clear
CI / changes (pull_request) Successful in 2s
CI / unit (pull_request) Successful in 10s
CI / integration (pull_request) Successful in 17s
CI / ui (pull_request) Successful in 54s
CI / gate (pull_request) Successful in 0s
CI / deploy (pull_request) Successful in 1m21s
Caddy enables HTTP/3 by default on any TLS listener and emits
Alt-Svc: h3=":443"; ma=2592000, but UDP/443 is never reachable: the prod
compose maps only "443:443" (TCP) and ufw opens 443/tcp (test contour: the
host caddy publishes only :443/tcp). A client that cached the 30-day advert
tries QUIC first on later opens, gets no response, and waits for the QUIC
attempt to time out before falling back to h2 -- which surfaced as the
Telegram Mini App intermittently hanging on load (a barely-noticeable pause
up to a blank window). The h2/TCP serving path itself is healthy (~10ms TTFB).

Emit Alt-Svc: clear site-wide at the contour caddy so clients actively drop
any cached alternative and stay on h2/h1. This caddy terminates TLS in prod
(the fix target); in the test contour it serves plain :80 and the host caddy
re-stamps its own Alt-Svc, so the live test fix lives in the host caddy. Add
docs/EDGE_HTTP3.md (symptom, diagnosis method, verify, and option B -- serving
h3 for real -- if it recurs) and link it from ARCHITECTURE.md.
2026-06-22 21:20:31 +02:00
787 changed files with 82798 additions and 2730 deletions
+242
View File
@@ -0,0 +1,242 @@
# Agent field notes — scrabble-game
Non-obvious, hard-won project knowledge that is **not** in the main docs (`CLAUDE.md`, `docs/*`,
`deploy/README.md`). Kept in the repo so it travels with a clone to any host. These are working
memory, not a spec — **verify any named file / flag / function against the current code before acting
on it**, and prefer the authoritative docs where they overlap. Add to this file as new gotchas turn up.
## Codegen & build
- **jetgen churns everything.** `backend/cmd/jetgen` regenerates go-jet code for *all* tables and may
reorder output. After running it, revert the churn on tables you did not touch and commit only your
table's change.
- **flatc is version-pinned** (`pkg/Makefile`, `REQUIRED_FLATC = 23.5.26`, hard-checked). A different
flatc silently churns the generated wire code and can flip wire defaults. Never regenerate FBS with
another flatc version.
- **gopls lags codegen.** Right after an FBS/jet regen, gopls shows phantom "undefined" errors. Trust
`go build` / `go test`, not the editor squiggles.
- **go-jet type mapping:** SQL `numeric``float64`, `interval``string`. Never store money as
`numeric` (float precision loss) — use **bigint minor units + a `Money` type**; store durations as
**int seconds**, not `interval`.
- **`go mod tidy` chokes on dot-free local module paths** (`scrabble/...`). Hand-edit `go.mod` when a
bump is needed. The solver (`../scrabble-solver`) is consumed via `go.work` replace locally, but a
prod bump goes through a solver **PR → master + a published tag**, not a local replace.
- **Run the whole CI suite locally before pushing** — unit + integration (`//go:build integration`,
Postgres) + the UI job + codegen check. Do not lean on CI to catch what a local run would.
- **CI runner shares this host's `/tmp` as a different user.** In workflow steps, write artifacts to
`${GITHUB_WORKSPACE}`, never a fixed `/tmp/...` path (cross-user permission failures otherwise).
- **pnpm corepack pre-flight flakes.** `pnpm exec` / `pnpm check` occasionally abort on a corepack
pre-flight. Dodge it by invoking the tool directly: `node_modules/.bin/<tool>`.
## Native Android build (Capacitor)
The native Android app is a Capacitor 8 wrapper of the `ui` SPA, scaffolded under
`ui/` (a Node project outside `go.work`). Hard-won bring-up facts — verify against current code:
- **Capacitor 8 needs JDK 21, not 17.** `@capacitor/android` compiles at `VERSION_21`; a JDK 17 Gradle
run dies with `error: invalid source release: 21`. Install sudo-free via the Homebrew **formula**
`brew install openjdk@21` (the `temurin@21` **cask** wants sudo for a system `.pkg`, unusable
non-interactively) + a user symlink into `~/Library/Java/JavaVirtualMachines/` so
`/usr/libexec/java_home -v 21` finds it. JDK 17 stays fine for `sdkmanager`/`avdmanager`.
- **Cap 8 SDK pins:** compileSdk/targetSdk **36**, minSdk **24**, Gradle **8.14.3**, AGP **8.13.0**
(`ui/android/variables.gradle`). Install `platforms;android-36` — Android Studio's newer
`android-36.1` does NOT satisfy compileSdk 36.
- **SDK is Android Studio's** at `~/Library/Android/sdk` (no `cmdline-tools` by default → `brew install
--cask android-commandlinetools`, then `sdkmanager`/`avdmanager --sdk_root=$HOME/Library/Android/sdk`).
Gradle finds it via **`ANDROID_HOME`** (`local.properties` is gitignored, machine-specific).
- **Build recipe:** `cd ui && pnpm build && node_modules/.bin/cap sync android`; then `cd ui/android &&
ANDROID_HOME=~/Library/Android/sdk JAVA_HOME=$(/usr/libexec/java_home -v 21) ./gradlew assembleDebug`
→ `ui/android/app/build/outputs/apk/debug/app-debug.apk`. Prefer the local `ui/node_modules/.bin/cap`
(dodges the corepack flake).
- **Emulator smoke:** existing AVDs `Pixel_10` / `Pixel_4_Android_10_API_29` / `Pixel_Android_9`;
`emulator -avd <name>`, `adb install -r <apk>`, `adb shell monkey -p ru.eruditgame.app -c
android.intent.category.LAUNCHER 1`, `adb exec-out screencap -p > x.png`. The boot wait needs `sleep`
→ run it as a **background** Bash task (foreground `sleep` is blocked). Browsers/WebView are cached, so a
full offline vs_ai turn is drivable via `adb shell input tap` (tap through the first-run coachmark tour —
each tap advances one step — then Hint places a suggested word; commit → the robot replies).
- **Android 15+ edge-to-edge safe-area (WebView-version-dependent, bit me on API 37).** targetSdk 36 forces
edge-to-edge — the WebView draws behind the status bar (top) and the gesture-nav home indicator (bottom).
On Android **WebView < 140**, `env(safe-area-inset-*)` wrongly reports **0**, so chrome relying on it draws
under the bars and is untappable: the top nav under the clock, and the game's bottom action bar's **centre**
button (Hint) under the home-indicator pill (side buttons still work; `navigation_mode`=2 is gesture nav).
Fix is CSS-only, in TWO parts: (1) Capacitor 8's **SystemBars** plugin (built into `@capacitor/core`,
`insetsHandling:'css'` default — no dep, no config) injects correct `--safe-area-inset-*`; consume them as
`--tg-safe-*: var(--safe-area-inset-*, env(safe-area-inset-*, 0px))` (`ui/src/app.css`) — fixes any consumer
that ALREADY applies the token (the bottom bars: `Game.svelte`/`Screen.svelte` `--tg-safe-bottom`).
(2) But a consumer that never applied the top inset on the native path is NOT fixed by the token alone — the
**header's** top inset was Telegram-fullscreen-scoped only, so the native header sat under the status bar on
EVERY WebView; it needed its own `.bar { padding-top: calc(var(--safe-area-inset-top, 0px) + 5px) }`
(`Header.svelte`, native-only via the plugin var; tg-fullscreen still overrides via specificity). **Measure
per element, don't eyeball** — a centred title at y=11 under a 54px bar reads as "fine" in a screenshot but
is overlapping; an emulator WebView auto-updated to ≥140 (Chrome 149) also hides the `env()`=0 half (so the
BOTTOM looks fine there while the user's < 140 device overlaps). **Inspect a live debug WebView** over CDP:
`adb forward tcp:9222 localabstract:$(adb shell cat /proc/net/unix | grep -o 'webview_devtools_remote_[0-9]*'
| head -1)`, then Playwright `chromium.connectOverCDP('http://localhost:9222')` → `page.evaluate` (read each
element's `getBoundingClientRect().top`, and `--safe-area-inset-*` vs `env(...)`).
- **`sharp` is whitelisted** in `ui/pnpm-workspace.yaml` (`allowBuilds: sharp: true`) — `@capacitor/assets`
uses it for `pnpm android:assets` (launcher icon/splash); else pnpm 11 raises `ERR_PNPM_IGNORED_BUILDS`.
- **Bundled offline dicts come from the `scrabble-dictionary` release** (`scrabble-dawg-<DICT_VERSION>.tar.gz`,
keyed on the `DICT_VERSION` Gitea var — the same source the backend image + CI `curl`), NOT
`scrabble-solver/dawg` (those are the solver's pinned test fixtures).
## Wire / schema evolution (client ↔ gateway ↔ backend)
- **FBS is additive-only.** Add **trailing** fields ("added trailing — backward-compatible"). Never
delete or reorder a mid-table field — **deprecate** it (`(deprecated)`); deleting shifts field IDs
and breaks older readers.
- **Retiring a domain field must also retire the WIRE field it fed** — by deprecation, not deletion,
and do not just zero it: a dead `0` the client dutifully syncs will clobber the real value.
- **The gateway transcodes FBS ↔ backend JSON DTOs in lockstep.** A wire change usually means editing
both the FBS schema and the backend DTO.
- **Seat display name is built in two places** — `game.Service` live events **and** the server REST
DTOs. Change both or they drift.
- **A per-viewer flag is computed only in the per-viewer REST DTO**; the client seeds it from REST,
then bumps it from the live event. Don't expect it on the shared broadcast.
## UI / Svelte 5
- **Never name a `$state` variable `state`.** `svelte-check` then misreads `$state` as a store
subscription. Rename (e.g. `view`).
- **Svelte trims literal edge whitespace** in markup. To keep a separator space, emit an expression:
`{' : '}`.
- **No global `.btn` / `.ghost` button classes.** Style buttons per-component with scoped CSS + design
tokens (mirror `NewGame`'s `.invite`).
- **A `$state` proxy fails structured-clone** when persisted to IndexedDB. Snapshot at the call site
with `$state.snapshot(...)` before storing.
## Platform / WebView quirks (Telegram, VK, iOS, native, old Android)
- **Telegram `showPopup` eats the user-activation.** Share / clipboard called from inside a
`showPopup` callback fail (no gesture). Use your own `Modal` for gesture-gated Web APIs.
- **iOS Telegram `<a download blob:>` navigates away** and strands the SPA. Deliver files by **Web
Share on mobile**, and only use a `<a download>` Blob path on **desktop**.
- **Android TG/VK WebViews** lack `navigator.share` and ignore `<a download>`. Deliver a
client-generated file by **copying it to the clipboard**.
- **VK Android WebView ignores `target=_blank`.** Open external links through `lib/links.ts`
(routes to `vk.com/away.php`). Verify on-device.
- **Per-platform file-delivery last hop differs** (TG / VK / plain browser) — there is a delivery
matrix; do not re-litigate it without new on-device facts.
- **Old Android System WebView floor ≈ Chrome 67.** The bundle targets **es2019** (esbuild lowers
syntax), a conditional `core-js` polyfill loads only on old engines, and `index.html` has a boot
gate (BigInt / Proxy are the hard block → the unsupported-engine screen). There's also a `vmin`
glyph fallback for old rendering.
- **iOS WKWebView overscroll and Telegram swipe-to-close are not reproducible in Playwright.** Verify
those live on the deployed contour, not in the e2e.
- **Telegram Desktop Mini App shows a persistent bottom-right loader** — that's the long-lived
Subscribe stream, cosmetic, deliberately left as-is.
## Testing
- **UI test layers:** vitest (node env, pure logic, **no jsdom**) + Playwright **mock** e2e. The mock
e2e **bypasses the codec**, so wire/codec bugs need **codec unit tests**, not e2e coverage.
- **Mock overlay blocks e2e.** The cold-load overlay must be instant under the mock build or it
intercepts Playwright taps.
- **Mock tile pools lack a blank `'?'`.** The seeded game `G1` hard-codes one; flip `G1`'s variant to
eyeball per-variant tiles.
- **Durable Playwright MCP servers** (chromium + webkit) exist for UI inspection (there's a
plugin-config gotcha in wiring them).
- **The `playwright test` runner can't *fetch* browsers in this sandbox** — `playwright install` dies
with `EBADF` against the Playwright CDN (blocked network), even with the sandbox off. Run the e2e in
**CI** (the `ui` job installs chromium+webkit), or drive a state live through the **Playwright MCP**
browser against a local `vite --mode mock` server for visual verification. **But if chromium/webkit are
already cached** in `~/Library/Caches/ms-playwright/`, `playwright test` runs locally fine (only the CDN
fetch is blocked) — the native offline-first e2e was run green locally this way (chromium + webkit),
its dawgs from the sibling `../../scrabble-solver/dawg` via the webServer's `bundle-dicts.mjs` fallback.
- **A native (Capacitor) e2e must inject `window.androidBridge`, NOT `window.Capacitor.getPlatform`.**
`@capacitor/core` (pulled in during boot by `initNativeShell`'s dynamic `@capacitor/app` import)
**replaces** any pre-set `window.Capacitor` with its own shim and derives the platform from
`window.androidBridge` (android) / `window.webkit.messageHandlers` (ios) — so a bare injected
`Capacitor.getPlatform: () => 'android'` is clobbered to `web` and the boot falls to `/login`. Inject
`window.androidBridge = { postMessage(){} }` in an `addInitScript` (see `e2e/native.spec.ts` `simulateNative`);
`initNativeShell` is written to tolerate the stub bridge (`try/catch` round the `@capacitor/app` addListener).
- **`docker run -p ...` boot tests fail from the shell** (published ports unreachable in this env).
Use **testcontainers** for container-backed tests.
- **Distroless images run as UID 65532 (nonroot).** Bind-mounted TLS keys must be **0644** (not 0600)
or the service crash-loops on start.
## Deploy / test contour (operational)
- **The TEST contour runs on THIS dev host.** Inspect it via `docker` / Prometheus. A host-side
`curl` to caddy **hangs** (NAT hairpin) — don't debug the edge that way.
- **The contour's client IP is the home-router SNAT address**, not the real external IP. Correct in
prod, not a bug — which is why the IP ban / blocklist are **prod-only**.
- **The contour is one shared env, last-deploy-wins.** Keep a multi-PR batch a **linear stack** (one
PR, one deploy) so deploys don't clobber each other.
- **A schema/wire PR breaks the contour** until a `DROP SCHEMA` + backend restart. Note such a
prerelease step in `PRERELEASE.md`.
- **A contour DB wipe resets the account but not the client's stored locale**; the reconciler then
syncs the stale locale, masking a fresh TG `language_code` seed. Clear client prefs too when testing
locale.
- **`DNS=` in `TEST_AWG_CONF` pins the VPN netns to 1.1.1.1** → internal names go NXDOMAIN → the
bot-link silently dies. Diagnose from inside the netns.
- **Swap one contour service to a local image** without deploy secrets via a busybox-in-the-netns
socket-inspect trick (single-service recreate).
- **A rolling deploy did NOT recreate caddy on a config-only change.** Force `--force-recreate` for
caddy; don't trust "deploy green" for an edge-config change.
- **A new gateway edge route MUST be added to the Caddyfile `@gateway` matcher** or it falls through
to the landing catch-all. Add a CI probe for the route.
- **Prod caddy logs warnings only, no access log.** Trace a request via the backend "http request"
telemetry log or Tempo, not caddy.
- **Confirm a release is live without SSH** by grepping the served SPA: `__APP_VERSION__` inside
`/assets/main-*.js`.
- **"App hangs on load" was a dead HTTP/3 advert:** caddy sent `Alt-Svc: h3` with no UDP/443 open;
clients cache it ~30 days. Fix with `Alt-Svc: clear`.
- **Config-poison deploy loop:** a crash-looping config-mounted service + a missing bind source
produces a root-owned directory that then fails deploys. Break it by removing the root-owned dir.
- **Maintenance-window contract** (planned-deploy 503): a marker header, `/_gm` exempt, the flag spans
the whole roll, the SPA overlay reloads on recovery. Don't break these invariants.
- **A new dictionary goes live via a `/_gm/dictionary` upload, NOT a redeploy.** In-flight games keep
their pinned version. The **owner** does the upload.
- **Renderer deploy job flakes** on the skia-canvas GitHub binary download when its lockfile changes —
re-run, it's not your code.
## Repo workflow
- **PR-based, zero issues.** Work is tracked via PRs + `PRERELEASE.md`. "заведи задачу" means *do it*
/ add a plan line — not file a tracker issue.
- **`tea` CLI for all Gitea ops** (PRs, secrets, variables, dispatch). `gh` does **not** work here. The
agent **cannot self-approve** a PR but **can merge** it after the owner approves. Watch the
stale-mergeable trap (re-check mergeability right before merging).
- **Watch every push/merge/deploy to green** with `python3 ~/.claude/bin/gitea-ci-watch.py`, launched
**bare** under a background task. It polls run-level conclusions; its `ALL GREEN` already covers the
gated deploy job. Pass `--no-runs 600` when the runner is busy. A **merge** is the most-forgotten
case — watch the post-merge runs too.
- **After a merge, switch to the merged-into branch** (`development`/`master`), pull, and prune the
local feature branch.
- **The contour deploy probe checks the backend `/readyz`**; a PR deploy builds the PR's own code; a
wedged contour can be recovered by recreating the host container set.
## Domain semantics (not obvious from the code)
- **Account deletion must NOT delete any user messages** — including feedback / support. Interview the
owner on every deletion point before wiring it.
- **`account.time_zone` is `NOT NULL DEFAULT 'UTC'`, seeded from a detected ±HH:MM offset.** An email
account row is created at the **code-request** step, not at confirmation.
- **The robot has two distinct time windows:** a **sleep window** (~00:0007:00, gates its moves and
nudges) vs the **player away window** (turn-timeout only). "окно отсутствия" in dialogue = the
**sleep** window.
## Production topology
- **Two prod hosts.** `main` — full stack + ACME/edge (Selectel); `tg` — Telegram bot only (vdsina).
SSH aliases `scrabble-main-ops` / `scrabble-tg-ops`. Deploy is **manual dispatch only**, rolling +
health-gated + auto-rollback. Hosts are provisioned by `deploy/ansible/` (inventory + vars there —
the source of truth for IPs/roles).
- **The agent has targeted root SSH** to the hosts (and may run the prod Ansible). Surface every
owner-side step **before** a deploy, not after.
## Feature-area pointers (state lives in the linked docs/plans, not here)
- **Monetization** — «Фишка» currency, per-platform wallets, ads. Agreements in
`docs/PAYMENTS_DECISIONS_ru.md`; a phased plan (E0E9). go-jet money rule above applies.
- **PITR** — pgBackRest → Selectel S3 (encrypted, 30-day), currently **gated off**; runbook in
`deploy/README.md`. Gotcha: run pgBackRest via docker-exec **as the `postgres` user** with
`--pg1-user=scrabble`.
- **Offline mode** — the robot brain, move generator/validator/scorer and DAWG reader are **JS ports**
of the Go engine, bundled client-side and **parity-pinned** by golden tests. Multi-phase; native
offline-first bundles the dictionaries.
- **VK ID web login** — raw OAuth 2.1 against `id.vk.com`, a **separate VK "Web" app** from the Mini
App, server-side confidential code exchange.
- **Email relay** — Selectel SMTP + confirm / link / unlink / deletion codes + alerts.
- **Native Android** — Capacitor bundle model, client-version gate, offline-first, RuStore; the
design lives in `docs/ARCHITECTURE.md` (§2 gate, §3 identity, §13 native build).
+89
View File
@@ -0,0 +1,89 @@
---
name: deploy-check
description: "Use before any deploy-touching change to this repo — phrases like '/deploy-check', 'is this prod-safe', 'before we deploy', 'deploy safety review', 'проверь перед деплоем', 'это безопасно для прода'. Runs a pre-deploy checklist of this project's hard-won runtime constraints against the current diff, so the crash classes that have bitten live environments get caught before shipping instead of after."
---
# Pre-deploy runtime-constraint check
Triggered before shipping anything that touches the deploy contour (Dockerfiles,
`deploy/`, Caddyfile, compose, migrations, boot guards, the Telegram side-service,
edge config). The worst frictions in this repo were never logic bugs — they were
**environment mismatches that crashed a live env and forced a redesign**. Run this
list against the diff first; turn crash-and-redesign into a single pass.
This checklist is a prompt, **not** the source of truth. The canonical detail
lives in `deploy/README.md`, `docs/ARCHITECTURE.md`, `docs/EDGE_HTTP3.md`, and the
agent memory files referenced below — read them when an item is in play, and add a
new class here when a new incident teaches one.
## How to run it
1. `git diff <base>...HEAD --stat` to see what the change actually touches.
2. For every risk class below that the diff touches, perform the **Check** and
report PASS / FAIL with the exact file:line to fix. Skip classes the diff does
not touch — say which you skipped and why.
3. Remember: **deploy-job green ≠ healthy**. CI's deploy probe has historically
passed with a dead backend (it only checked static landing+gateway). Verify the
real feature live (`/readyz`, the actual flow) after deploy, not just the green.
## Risk classes
### 1. Container user — distroless nonroot UID 65532
- **Bit us:** TLS keys `chmod 600` for the host owner crash-looped gateway + bot at
boot with "permission denied" — service images run UID 65532.
- **Check:** any new/changed mounted secret, key, or config file must be readable by
UID 65532 (`0644`, not `0600`). Scan the diff for file modes, `chmod`, and new
volume mounts. (memory: `distroless-nonroot-mounted-secrets`)
### 2. Caddy header pipeline ordering
- **Bit us:** `header_up delete` after `set` nulled the value (the honeypot tag went
empty); it passed CI and only showed up live.
- **Check:** in any Caddyfile change, verify the `set` / `delete` / `header_up`
ordering for every affected route, and test the tripwire/route on the live
contour, not just CI.
### 3. Edge Alt-Svc / HTTP3
- **Bit us:** edge advertised `Alt-Svc: h3` while UDP/443 was never exposed
(docker tcp-only + ufw tcp-only); clients cached it 30 days and stalled on dead
QUIC before falling back to h2 — Mini App "hangs on load".
- **Check:** any edge/caddy change keeps `Alt-Svc: clear` (or only advertises h3 if
UDP/443 is genuinely exposed). (memory: `tg-app-load-stall-dead-http3-altsvc`,
`docs/EDGE_HTTP3.md`)
### 4. Prod caddy config recreate
- **Bit us:** prod rolling deploy did **not** recreate caddy on a config-only change
(pinned `caddy:2-alpine` + `admin off`), so a new Caddyfile deployed GREEN but
stayed inert until a manual `docker restart`.
- **Check:** a config-only edge change must `--force-recreate` caddy in
`prod-deploy.sh` `roll()`; never trust deploy-green for edge config.
(memory: `prod-deploy-caddy-config-recreate`)
### 5. DICT_VERSION / dictionary boot
- **Bit us:** an early `DICT_VERSION` refuse-boot guard was wrong and crashed the
live env when bumped on a seeded volume; it had to be redesigned to "marker-wins".
- **Check:** any change touching `DICT_VERSION`, dict load, or the boot guard must
keep marker-wins semantics and survive a seeded volume **and** an image rollback.
`DICT_VERSION` is a required build-arg (no default), single-sourced. A new dict
goes live via the admin console upload, not a redeploy. (memory:
`dict-version-deploy-verify`, `contour-schema-change-wipe`)
### 6. Migrations — expand-contract + rollback safety
- **Bit us / risk:** a non-backward-compatible migration breaks image rollback (DB
ahead of rolled-back code).
- **Check:** migrations must be **expand-contract** (backward-compatible). A schema
change adds the maintenance window + a consistent `pg_dump` in prod-deploy. On the
**test contour**, a schema/wire-label change needs `DROP SCHEMA backend CASCADE` +
backend restart (new code vs old persisted DB), else the contour breaks. (memory:
`contour-schema-change-wipe`)
### 7. Telegram permission model
- **Bit us:** permissions are an **AND-intersection** — default-allow with explicit
denies, not default-deny; inverting it broke access.
- **Check:** any change to the Telegram permission / relay logic preserves the
AND-intersection default-allow shape. (memory: `telegram-forum-relay-gotchas`)
## Output
A short PASS/FAIL table over the classes the diff touches, each FAIL with the exact
file:line and the fix. If every touched class passes, say so plainly and name the
post-deploy live check to run (not just "CI green").
+175
View File
@@ -0,0 +1,175 @@
# VK Mini App / VK Games — integration reference
Captured research + our implementation map, so a future session does not need to re-fetch
the VK docs. Authoritative external source: <https://dev.vk.com/> (the `dev.vk.com` portal
does not render via plain HTTP fetch; the facts below were cross-checked against the VKCOM
reference repos cited at the end and verified against our own Go implementation).
A VK **game** is technically a **VK Mini App**: an HTML5 SPA VK loads in an **iframe inside
vk.com** (desktop + mobile web) and in a **WebView** inside the VK mobile apps (iOS/Android).
We serve our existing SPA under a dedicated `/vk/` path, mirroring the Telegram `/telegram/`
entry — the single-origin, path-routed model.
## 1. Embedding model
- VK loads the app at the **Web iframe URL** configured in the app settings (HTTPS + valid
cert required), appending the signed launch parameters as the **URL query string**.
- An optional separate **Mobile iframe URL** is used by the VK mobile apps (we use the same).
- No special `X-Frame-Options` / CSP `frame-ancestors` is required from us — VK frames the
configured origin. (Our edge sets **no** framing headers today, so VK works as-is; see the
clickjacking note in §Security.)
- URL must match the settings exactly (scheme, host, no stray `www`/whitespace).
## 2. Launch parameters (URL query)
VK appends these to the iframe `src`. The `vk_*` set is what the signature covers.
| Param | Meaning |
| --- | --- |
| `vk_user_id` | signed-in VK user numeric id — **the identity** |
| `vk_app_id` | our registered app id |
| `vk_is_app_user` | 0/1 — user authorized/installed the app |
| `vk_are_notifications_enabled` | 0/1 |
| `vk_language` | 2-letter UI language (`ru`, `en`, …) |
| `vk_platform` | `mobile_iphone` \| `mobile_android` \| `mobile_web` \| `desktop_web` \| … |
| `vk_ts` | unix seconds when VK generated the params |
| `vk_ref` | where the app was opened from (`catalog`, `feed`, …) |
| `vk_access_token_settings` | comma-separated granted scopes (often empty) |
| `vk_group_id`, `vk_viewer_group_role`, `vk_is_favorite`, `vk_client` | optional/contextual |
| `sign` | **the signature** (see §3) — NOT part of the signed set |
Always present: `vk_user_id`, `vk_app_id`, `vk_platform`, `vk_ts`, `sign`.
The user's **name is NOT in the launch params** (only `vk_user_id`). Read it client-side via
`VKWebAppGetUserInfo` (see §4) — unsigned, so treat it as a cosmetic display seed only.
## 3. Signature verification (`sign`) — CONFIRMED base64url, not hex
Algorithm (verified against our `gateway/internal/vkauth` + an independent Python reference):
1. Collect the query params whose key starts with `vk_` (exclude `sign`).
2. Sort by key (alphabetical).
3. Serialize as a URL-encoded query string `k=v&k=v…` (Go `url.Values.Encode()` matches VK's
reference serialization for the constrained launch-param charset).
4. `HMAC-SHA256(serialized, secret)` where `secret` = the app's **«Защищённый ключ»**
(protected / secure key, a.k.a. client_secret) from the app settings.
5. **base64url, no padding** (`+``-`, `/``_`, strip `=`).
6. Constant-time compare against `sign`.
VK launch params have **no built-in expiry** (unlike Telegram's `auth_date`). We do NOT enforce
freshness — the minted server session is the short-lived credential; a replay only
re-authenticates the same `vk_user_id`.
Verified against the official doc <https://dev.vk.com/ru/mini-apps/development/launch-params-sign>
(prose + PHP example: base64url = `strtr('+/','-_')` + `rtrim('=')`) and reproduced identically by
independent Node `crypto` + Python references. **Doc-example caveat**: that page shows secret
`wvl68m4dR1UpLrVRli` → sign `exTIBP…`, but the secret is a **placeholder** — recomputing with it does
NOT yield the shown sign (it was made with the real, unshown key). Don't chase the mismatch; our
`vkauth.Verify` is correct (`gateway/internal/vkauth/vkauth_test.go` carries cross-checked vectors,
incl. the `%2C` comma case for `vk_access_token_settings`).
## 4. VK Bridge (client SDK)
`@vkontakte/vk-bridge` (npm, v3.x; bundled — `default` export `bridge`). Methods we use / may use:
- `VKWebAppInit`**required**: tells VK the Mini App loaded (dismisses VK's loading cover).
- `VKWebAppGetUserInfo``{ id, first_name, last_name, photo_200, … }`; no extra scope needed.
- `VKWebAppGetLaunchParams` — parsed `vk_*` **without** `sign` (so NOT usable for our server
verification — read `window.location.search` instead, which carries `sign`).
- `VKWebAppGetAuthToken` — OAuth access token for VK API calls (only if we ever call VK API).
- `VKWebAppShare` — native share dialog (the friend-code invite uses it; `navigator.share` is absent
in the desktop VK iframe). **Used.**
- `VKWebAppCopyText` — clipboard copy that works inside the VK iframe, where `navigator.clipboard` is
blocked. **Used** as the copy-code / copy-link path.
- `VKWebAppUpdateConfig` (subscribe) — light/dark scheme; the app follows it while the theme pref is
"auto" (the VK webview's prefers-color-scheme does not track it). **Used.**
- `VKWebAppSetViewSettings` / `VKWebAppSetSwipeSettings` — viewport / swipe-back (mobile); not used.
- `VKWebAppUpdateInsets` (+ `VKWebAppUpdateConfig`) — device safe-area insets; the app **max'es** them
with CSS `env(safe-area-inset-*)` (viewport-fit=cover) so the bottom home bar is cleared. The bridge
value is needed on Android, where the VK webview exposes no `env()` inset. **Used.**
The bridge talks to the embedding VK client over postMessage; it is NOT an external fetch, so
it has no telegram.org-style load-hang risk. The SDK reads browser globals at import — we import
it **lazily** so the pure URL helpers stay node-test-importable.
**Deep links — NOT possible on VK (confirmed on the contour).** The VK iframe receives ONLY the signed
`vk_*` launch params (+ `sign`); VK strips any custom data from the app link. The documented
`vk.com/app<id>#<payload>` form is eaten by the vk.com SPA (which owns the URL hash), and a
`vk.com/app<id>?hash=<payload>` query is dropped (the diagnostic showed `rawSearch` with only `vk_*`
and an empty `hash`). So the friend-code invite link is just `vk.com/app<id>` (`vkShareLink`, app id
from `vk_app_id`); the recipient enters the **copied code by hand** (`VKWebAppCopyText` works). The
`vkStartParam` reader + the `bootVK` routing stay as a no-op today, ready if a post-moderation VK
channel (e.g. an invite API) ever delivers a payload.
## 5. Test mode (to verify before moderation)
1. App already registered (we have the App ID).
2. In the app settings (dev.vk.com / `vk.com/editapp?act=settings&app_id=<id>`):
- Category = **Игра** (Game).
- **Web iframe URL** = our public HTTPS `/vk/` (the test-contour origin for contour testing,
prod `https://erudit-game.ru/vk/` later). Mobile iframe URL = same.
- Copy the **«Защищённый ключ»** → set as `GATEWAY_VK_APP_SECRET` (Gitea `TEST_`/`PROD_` secret).
- Add own VK id to **testers**; open in test mode.
3. Test mode = visible only to admins/testers, no payments processed.
## 6. Auth / identity (our model)
- `vk_user_id` (from verified params) → backend identity `kind='vk'`, `external_id=vk_user_id`,
auto-confirmed (a platform identity). First contact seeds language from `vk_language` and the
display name from the client-supplied `VKWebAppGetUserInfo` name (placeholder if empty).
- No VK access token / VK API call needed for the launch+login MVP.
## 7. Payments / monetization
VK Pay / «голоса» (votes) are **optional**, not required to publish a free game. Not planned.
## 8. ToS / moderation (pre-publish, analyzed — no blocker for a free «Эрудит»)
- **Trademark**: "Scrabble" is trademarked. Our public brand is **«Эрудит»** (erudit-game.ru),
a generic Russian word-game name → fine. Ensure the VK-registered app name is «Эрудит»/word-game,
NOT "Scrabble". The repo name is internal and irrelevant to moderation.
- **Pre-publish requirements**: public **Privacy Policy** + **ToS** URLs (disclose collected data:
`vk_user_id`, language; mention VK), **age rating** (likely 6+/12+), icon, description.
- **Dictionary**: standard word lists; VK may expect offensive-word filtering — likely fine for a
dictionary game, flag if moderation asks.
- **In-game chat (UGC)**: we already have a moderated chat + support relay → covered.
- Moderation reviews after submission (commonly ~2472h); rejects on violence/hate/sexual/illegal
content or IP infringement — none apply.
## 9. Platforms
Desktop web (iframe), mobile web (iframe), VK iOS app (WKWebView), VK Android app (WebView). Bridge
methods behave per-platform; the app's own back chevron + app-shell document-pin cover navigation
without VK-specific code. Theme/viewport fine-tuning is best verified live in the real VK client
(not reproducible in Playwright — like the iOS gesture caveats).
## 10. Our implementation map (what to touch for VK)
- **Wire**: `pkg/fbs/scrabble.fbs``VKLoginRequest{ params, browser_tz, display_name }`
(regen: `make -C pkg fbs` + `pnpm -C ui codegen`).
- **Gateway**: `internal/vkauth/` (the §3 verify), `internal/transcode` op `auth.vk`
(registered via `WithVKAuth(secret)` option; `DomainCode``invalid_vk_params`),
`internal/backendclient` `VKAuth``POST /api/v1/internal/sessions/vk`,
config `GATEWAY_VK_APP_SECRET`, SPA mount `/vk/` in `internal/connectsrv/server.go`.
- **Backend**: `internal/account` `KindVK` + `ProvisionVK`/`vkSeed` + `confirmed` for platform
kinds; `internal/server/handlers_auth.go` `handleVKAuth` + route; migration
`00005_vk_identity.sql` (widen `identities_kind_chk` to include `'vk'`, expand-contract).
- **UI**: `src/lib/vk.ts` (`onVKPath`/`vkLaunchParams`/`insideVK`/`vkInit`/`vkUserName` plus `vkAppId`/
`vkStartParam`/`vkShare`/`vkCopyText`/`vkOnScheme`), `app.svelte.ts` `bootVK` (+ deep-link routing
and VK scheme→theme) + the `/vk/` dispatch branch + shared `retryMiniAppBoot`, `codec.ts`
`encodeVKLogin`, `transport.ts`/`client.ts`/`mock/client.ts` `authVK`, `deeplink.ts` `vkShareLink`,
`Friends.svelte` (VK share/copy), `app.css` `--tg-safe-*` defaulting to `env(safe-area-inset-*)`.
- **Edge/deploy**: `deploy/caddy/Caddyfile` `/vk` path; `GATEWAY_VK_APP_SECRET` in
`docker-compose.yml` + `.env.example` + `ci.yaml` (`TEST_…` secret) + `prod-deploy.yaml`
(`PROD_…` secret, deploy-main).
- **Deferred**: payments (VK Pay / votes), native (Capacitor) VK, account-linking a vk identity to an
existing account, VK push. (Done after the launch+auth MVP — Group B: native share + clipboard via
the bridge, the friend-code deep link, the auto-theme follow, and the home-bar safe area.)
## Sources
- VKCOM/vk-bridge — <https://github.com/VKCOM/vk-bridge>
- VKCOM/vk-apps-launch-params (canonical signature examples) — <https://github.com/VKCOM/vk-apps-launch-params>
- kravetsone/vk-launch-params — <https://github.com/kravetsone/vk-launch-params>
- SevereCloud/vksdk `vkapps.ParamsVerify` (Go reference) — <https://pkg.go.dev/github.com/SevereCloud/vksdk/v2/vkapps>
- VK Mini Apps API — <https://github.com/VKCOM/vk-mini-apps-api>
+172
View File
@@ -0,0 +1,172 @@
# Manual signed-APK build for the standalone Android app (RuStore). Runs ONLY from master, ONLY on
# workflow_dispatch with confirm=build — the same deliberate-manual shape as prod-deploy.yaml, never on
# a PR. It builds the native-flavoured SPA, bundles the offline dictionaries into the APK assets, and
# assembles a release APK, uploaded as a run artifact (RuStore upload stays manual for the MVP).
#
# Signing degrades gracefully: with the ANDROID_KEYSTORE_* secrets present the APK is signed; without
# them build.gradle produces an UNSIGNED release APK (so a dry run still proves the whole pipeline).
# The keystore is a publication prerequisite — see deploy/README.md (Android build/release runbook).
# The toolchain is self-provisioned here (JDK 21 + a cached Android SDK), so the runner host needs
# nothing pre-installed.
name: android-build
run-name: "android build ${{ github.sha }}"
on:
workflow_dispatch:
inputs:
confirm:
description: 'Type "build" to confirm an APK build from master.'
required: true
default: ""
permissions:
contents: read
env:
NO_COLOR: "1"
# The dictionary release, one source of truth (same Gitea variable the backend image + CI use). It
# both fetches the DAWGs and labels the bundled files (VITE_DICT_VERSION must equal __DICT_VERSION__).
DICT_VERSION: ${{ vars.DICT_VERSION }}
VITE_DICT_VERSION: ${{ vars.DICT_VERSION }}
# Hide in-app purchases in the RuStore MVP (RuStore, not Google Play — VITE_GP_BUILD stays unset).
VITE_PAYMENTS_DISABLED: "1"
# The update overlay's store target; empty until publication (the button no-ops, and the version gate
# is dormant in the MVP so it never fires). Set the ANDROID_RUSTORE_URL variable when the app is live.
VITE_RUSTORE_URL: ${{ vars.ANDROID_RUSTORE_URL }}
# Host-executor runner: the Android SDK is pre-installed on the host. Override ANDROID_SDK_DIR if it
# lives elsewhere (the default matches deploy/README.md's install path).
ANDROID_HOME: ${{ vars.ANDROID_SDK_DIR || '/opt/android-sdk' }}
ANDROID_SDK_ROOT: ${{ vars.ANDROID_SDK_DIR || '/opt/android-sdk' }}
jobs:
build:
if: ${{ github.ref == 'refs/heads/master' && inputs.confirm == 'build' }}
runs-on: ubuntu-latest
defaults:
run:
shell: bash
steps:
- uses: actions/checkout@v4
with:
fetch-depth: 0
# Host-executor runner: the Android SDK is pre-installed on the host (JDK 21 comes from setup-java
# below). Fail fast + legibly if the runner user cannot read/execute it or a needed package is
# missing — this doubles as the runner-access check (deploy/README.md).
- name: Verify the host Android SDK
run: |
sm="$ANDROID_HOME/cmdline-tools/latest/bin/sdkmanager"
if [ ! -x "$sm" ]; then
echo "::error::sdkmanager not found/executable at $sm — set the ANDROID_SDK_DIR variable if the SDK lives elsewhere, or grant the runner user read+exec: sudo chmod -R a+rX \"$ANDROID_HOME\""; exit 1
fi
for pkg in "platforms/android-36" "build-tools"; do
if [ ! -d "$ANDROID_HOME/$pkg" ]; then
echo "::error::missing $ANDROID_HOME/$pkg — run: \"$sm\" 'platforms;android-36' 'build-tools;36.0.0'"; exit 1
fi
done
echo "Android SDK OK at $ANDROID_HOME"; "$sm" --version
- name: Compute version + native build env
id: prep
env:
PUBLIC_BASE_URL: ${{ vars.PROD_PUBLIC_BASE_URL }}
run: |
# A store release must sit on an exact vMAJOR.MINOR.PATCH tag (G tags before dispatch) so the
# versionCode is deterministic and strictly increasing across uploads. Refuse anything else
# rather than derive a versionCode from a "-N-gSHA" describe.
desc="$(git describe --tags --exact-match 2>/dev/null || true)"
case "$desc" in
v[0-9]*.[0-9]*.[0-9]*) ;;
*) echo "::error::HEAD is not on a clean vX.Y.Z tag (git describe --exact-match = '${desc:-none}'); tag the release first"; exit 1 ;;
esac
v="${desc#v}"
IFS=. read -r MA MI PA <<< "$v"
# 10# forces base-10 so a zero-padded part is never read as octal.
code=$(( 10#$MA * 1000000 + 10#$MI * 1000 + 10#$PA ))
# The native SPA talks to the production origin (reuse the prod public base URL); strip any
# trailing slash so the Connect endpoint never doubles it.
gateway="${PUBLIC_BASE_URL%/}"
{
echo "tag=$desc"
echo "name=$v"
echo "code=$code"
echo "gateway=$gateway"
} >> "$GITHUB_OUTPUT"
echo "release $desc -> versionName $v versionCode $code, gateway $gateway"
# Same release + fetch as the Go/UI jobs in ci.yaml — the bundled dicts come from this tarball,
# NOT the scrabble-solver sibling (ui is a Node project outside go.work).
- name: Fetch dictionary DAWGs
run: |
mkdir -p "${GITHUB_WORKSPACE}/dawg"
curl -fsSL -o /tmp/dawg.tar.gz "https://gitea.iliadenisov.ru/developer/scrabble-dictionary/releases/download/${DICT_VERSION}/scrabble-dawg-${DICT_VERSION}.tar.gz"
tar xzf /tmp/dawg.tar.gz -C "${GITHUB_WORKSPACE}/dawg"
ls -la "${GITHUB_WORKSPACE}/dawg"
- name: Set up Node
uses: actions/setup-node@v4
with:
node-version: 22
- name: Install pnpm
run: npm install -g pnpm@11.0.9
- name: Install deps
working-directory: ui
run: pnpm install --frozen-lockfile
- name: Build the SPA (native flavour)
working-directory: ui
env:
VITE_GATEWAY_URL: ${{ steps.prep.outputs.gateway }}
VITE_APP_VERSION: ${{ steps.prep.outputs.tag }}
run: pnpm run build
# Copy the release DAWGs into dist/dict/<variant>@<version>.dawg for the offline-first bundled tier
# (after the build, before cap sync copies dist/ into the native assets).
- name: Bundle the dictionaries
working-directory: ui
env:
DICT_DIR: ${{ github.workspace }}/dawg
run: node scripts/bundle-dicts.mjs
- name: Set up JDK 21
uses: actions/setup-java@v4
with:
distribution: temurin
java-version: "21"
# Local cap binary (dodges the corepack pre-flight flake); syncs dist/ (incl. dict/) + native deps.
- name: Sync the native project
working-directory: ui
run: node_modules/.bin/cap sync android
- name: Decode the release keystore
id: keystore
env:
ANDROID_KEYSTORE_BASE64: ${{ secrets.ANDROID_KEYSTORE_BASE64 }}
run: |
if [ -n "$ANDROID_KEYSTORE_BASE64" ]; then
printf '%s' "$ANDROID_KEYSTORE_BASE64" | base64 -d > "${GITHUB_WORKSPACE}/release.jks"
echo "file=${GITHUB_WORKSPACE}/release.jks" >> "$GITHUB_OUTPUT"
echo "keystore decoded -> signed release build"
else
echo "file=" >> "$GITHUB_OUTPUT"
echo "::warning::ANDROID_KEYSTORE_BASE64 is not set — building an UNSIGNED release APK (not installable/publishable)"
fi
- name: Assemble the release APK
working-directory: ui/android
env:
ANDROID_KEYSTORE_FILE: ${{ steps.keystore.outputs.file }}
ANDROID_KEYSTORE_PASSWORD: ${{ secrets.ANDROID_KEYSTORE_PASSWORD }}
ANDROID_KEY_ALIAS: ${{ secrets.ANDROID_KEY_ALIAS }}
ANDROID_KEY_PASSWORD: ${{ secrets.ANDROID_KEY_PASSWORD }}
run: ./gradlew assembleRelease -PversionCode=${{ steps.prep.outputs.code }} -PversionName=${{ steps.prep.outputs.name }} --console=plain
- name: Upload the APK artifact
uses: actions/upload-artifact@v4
with:
name: erudit-${{ steps.prep.outputs.name }}-apk
path: ui/android/app/build/outputs/apk/release/*.apk
if-no-files-found: error
+289 -19
View File
@@ -26,12 +26,12 @@ on:
push:
branches: [development]
# The dictionary release the test suite validates against — the current
# scrabble-dictionary release. Centralised here so a release bump is one edit; the
# unit/integration jobs inherit it. The deploy job overrides it per contour with
# vars.TEST_DICT_VERSION (the seed for a fresh volume), see deploy/README.md.
# The dictionary release. One Gitea variable is the single source of truth: the
# test suite validates against it here (inherited by the unit/integration jobs) and
# both contours' deploy jobs seed a fresh volume with the same value. A release bump
# is one edit (the variable). See deploy/README.md.
env:
DICT_VERSION: v1.3.0
DICT_VERSION: ${{ vars.DICT_VERSION }}
jobs:
# changes detects which areas a PR/push touched, so the test jobs can skip when
@@ -73,6 +73,9 @@ jobs:
go=false; ui=false
if echo "$files" | grep -qE '^(backend/|pkg/|gateway/|platform/|loadtest/|go\.work)'; then go=true; fi
if echo "$files" | grep -qE '^ui/'; then ui=true; fi
# The render sidecar bundles ui/src/lib, so its dir rides the ui lane (the
# deploy's compose build picks it up either way).
if echo "$files" | grep -qE '^renderer/'; then ui=true; fi
# A workflow or deploy change re-runs everything as a safety net.
if echo "$files" | grep -qE '^(\.gitea/workflows/|deploy/)'; then go=true; ui=true; fi
else
@@ -199,19 +202,96 @@ jobs:
- name: Bundle-size budget
run: node scripts/bundle-size.mjs
# The render sidecar executes the shared ui/src/lib/gameimage.ts on skia-canvas;
# its smoke test guards the bundling + skia seam (docs/TESTING.md).
- name: Render sidecar test
working-directory: renderer
run: |
pnpm install --frozen-lockfile
pnpm test
- name: Install Playwright browsers
run: pnpm exec playwright install chromium webkit
timeout-minutes: 5
# The offline e2e plays a real local vs_ai game, so it needs the per-variant dawgs; fetch the
# release the same way the Go jobs do and point the mock preview's copy step (scripts/
# e2e-dict.mjs, via E2E_DICT_DIR below) at it.
- name: Fetch dictionary DAWGs
run: |
mkdir -p "${GITHUB_WORKSPACE}/dawg"
curl -fsSL -o /tmp/dawg.tar.gz "https://gitea.iliadenisov.ru/developer/scrabble-dictionary/releases/download/${DICT_VERSION}/scrabble-dawg-${DICT_VERSION}.tar.gz"
tar xzf /tmp/dawg.tar.gz -C "${GITHUB_WORKSPACE}/dawg"
ls -la "${GITHUB_WORKSPACE}/dawg"
- name: E2E smoke (mock)
run: pnpm run test:e2e
timeout-minutes: 5
env:
E2E_DICT_DIR: ${{ github.workspace }}/dawg
# conformance proves the client's local move preview (the ported dawg reader +
# validator, ui/src/lib/dict) byte-for-byte against the authoritative Go engine:
# a Go step generates golden parity vectors from the release dictionaries, then the
# gated Vitest suite replays them. It spans both toolchains, so it runs whenever the
# Go engine side or the UI side changed.
conformance:
needs: changes
if: ${{ needs.changes.outputs.go == 'true' || needs.changes.outputs.ui == 'true' }}
runs-on: ubuntu-latest
defaults:
run:
shell: bash
env:
GOPRIVATE: gitea.iliadenisov.ru/*
steps:
- name: Checkout
uses: actions/checkout@v4
- name: Fetch dictionary DAWGs
run: |
mkdir -p "${GITHUB_WORKSPACE}/dawg"
curl -fsSL -o /tmp/dawg.tar.gz "https://gitea.iliadenisov.ru/developer/scrabble-dictionary/releases/download/${DICT_VERSION}/scrabble-dawg-${DICT_VERSION}.tar.gz"
tar xzf /tmp/dawg.tar.gz -C "${GITHUB_WORKSPACE}/dawg"
- name: Set up Go
uses: actions/setup-go@v5
with:
go-version-file: go.work
cache: true
- name: Generate golden parity vectors
run: |
go run ./backend/cmd/dictgen -dawg-dir "${GITHUB_WORKSPACE}/dawg" -out /tmp/dictgold
go run ./backend/cmd/validategen -dawg-dir "${GITHUB_WORKSPACE}/dawg" -out /tmp/validgold
go run ./backend/cmd/movegen -dawg-dir "${GITHUB_WORKSPACE}/dawg" -out "${GITHUB_WORKSPACE}/movegold"
- name: Set up Node
uses: actions/setup-node@v4
with:
node-version: 22
- name: Install pnpm
run: npm install -g pnpm@11.0.9
- name: Install deps
working-directory: ui
run: pnpm install --frozen-lockfile
- name: Local-eval conformance (reader + validator vs the Go engine)
working-directory: ui
env:
DICT_DAWG_DIR: ${{ github.workspace }}/dawg
DICT_GOLD_DIR: /tmp/dictgold
DICT_VALID_DIR: /tmp/validgold
DICT_MOVEGEN_DIR: ${{ github.workspace }}/movegold
run: pnpm exec vitest run src/lib/dict/
# gate is the single branch-protection required check. It always runs and passes
# only when each upstream job succeeded or was skipped (a path-filtered no-op),
# failing the merge if any actually failed or was cancelled.
gate:
needs: [unit, integration, ui]
needs: [unit, integration, ui, conformance]
if: always()
runs-on: ubuntu-latest
defaults:
@@ -221,7 +301,7 @@ jobs:
- name: Aggregate required checks
run: |
fail=
for r in "unit:${{ needs.unit.result }}" "integration:${{ needs.integration.result }}" "ui:${{ needs.ui.result }}"; do
for r in "unit:${{ needs.unit.result }}" "integration:${{ needs.integration.result }}" "ui:${{ needs.ui.result }}" "conformance:${{ needs.conformance.result }}"; do
name="${r%%:*}"; res="${r#*:}"
echo "$name = $res"
case "$res" in
@@ -236,9 +316,13 @@ jobs:
# Auto test-deploy on a PR into development and on the push that merges it.
# A PR into master is test-only (this job is skipped); prod deploy is manual.
# Gates on `gate` (so a real test failure blocks the deploy) but runs even when
# some test jobs were path-skipped.
needs: [gate]
if: ${{ (github.event_name == 'push' && github.ref == 'refs/heads/development') || (github.event_name == 'pull_request' && github.base_ref == 'development') }}
# some test jobs were path-skipped. Skipped entirely when neither the Go nor the
# UI side changed (e.g. a docs-only change): the contour image is unchanged, so
# there is nothing to redeploy. `changes` still defaults both to true when the
# diff is uncomputable, and a workflow/deploy edit forces both true, so an
# ambiguous or infra change still deploys as a safety net.
needs: [changes, gate]
if: ${{ (needs.changes.outputs.go == 'true' || needs.changes.outputs.ui == 'true') && ((github.event_name == 'push' && github.ref == 'refs/heads/development') || (github.event_name == 'pull_request' && github.base_ref == 'development')) }}
runs-on: ubuntu-latest
defaults:
run:
@@ -261,12 +345,59 @@ jobs:
GRAFANA_ADMIN_PASSWORD: ${{ secrets.TEST_GRAFANA_ADMIN_PASSWORD }}
TELEGRAM_BOT_TOKEN: ${{ secrets.TEST_TELEGRAM_BOT_TOKEN }}
TELEGRAM_PROMO_BOT_TOKEN: ${{ secrets.TEST_TELEGRAM_PROMO_BOT_TOKEN }}
# VK Mini App protected key (offline HMAC for the launch-param signature); empty
# leaves the VK auth path (auth.vk) disabled until the operator sets the secret.
# One VK Mini App serves every contour -> unprefixed secret.
GATEWAY_VK_APP_SECRET: ${{ secrets.GATEWAY_VK_APP_SECRET }}
# VK ID web login (browser VK-identity linking): the VK ID "Web" app's protected key
# for the server-side confidential code exchange — a SEPARATE VK app from the Mini
# App above. One VK ID "Web" app serves every contour -> unprefixed secret.
GATEWAY_VK_ID_CLIENT_SECRET: ${{ secrets.GATEWAY_VK_ID_CLIENT_SECRET }}
# Client-version gate (ARCHITECTURE.md §2): one plain (unprefixed) variable serves every
# contour. In the test contour the stamped client version is a commit hash (unparseable ⇒
# fail-open), so setting these only enforces on real semver prod builds. Empty ⇒ dormant.
GATEWAY_MIN_CLIENT_VERSION: ${{ vars.GATEWAY_MIN_CLIENT_VERSION }}
GATEWAY_RECOMMENDED_CLIENT_VERSION: ${{ vars.GATEWAY_RECOMMENDED_CLIENT_VERSION }}
# Planted honeytoken bearer: presenting it flags the caller (logs + a ban metric on
# test where the IP ban is off; a 24h IP ban on prod). Per-contour secret; empty = trap off.
GATEWAY_HONEYTOKEN: ${{ secrets.TEST_GATEWAY_HONEYTOKEN }}
# Signs the finished-game export download URLs (backend + compose interpolation).
EXPORT_SIGN_KEY: ${{ secrets.TEST_EXPORT_SIGN_KEY }}
# Transactional email via the shared Selectel relay: one account for every
# contour -> unprefixed host/port/tls/user/pass. Empty host leaves the backend
# on the log mailer (email disabled) but the contour still boots.
SMTP_RELAY_USER: ${{ secrets.SMTP_RELAY_USER }}
SMTP_RELAY_PASS: ${{ secrets.SMTP_RELAY_PASS }}
SMTP_RELAY_HOST: ${{ vars.SMTP_RELAY_HOST }}
SMTP_RELAY_PORT: ${{ vars.SMTP_RELAY_PORT }}
SMTP_RELAY_TLS: ${{ vars.SMTP_RELAY_TLS }}
# Direct-rail (Robokassa) sandbox intake on the contour: the test shop's merchant login +
# Password1/Password2. IsTest is forced to 1 below so the contour can never take real money
# (independent of the shop's own mode). Empty login leaves the direct rail off.
ROBOKASSA_MERCHANT_LOGIN: ${{ secrets.TEST_BACKEND_ROBOKASSA_MERCHANT_LOGIN }}
ROBOKASSA_PASSWORD1: ${{ secrets.TEST_BACKEND_ROBOKASSA_PASSWORD1 }}
ROBOKASSA_PASSWORD2: ${{ secrets.TEST_BACKEND_ROBOKASSA_PASSWORD2 }}
ROBOKASSA_TEST: "1"
SMTP_RELAY_FROM: ${{ vars.TEST_SMTP_RELAY_FROM }}
# Operator alerts: backend admin emails (new feedback / complaints) + Grafana
# infra alerts. Distinct senders + recipients; Grafana uses the relay's STARTTLS
# host:port. Empty leaves the alert worker off and Grafana SMTP disabled.
SMTP_RELAY_ADMIN_FROM: ${{ vars.TEST_SMTP_RELAY_ADMIN_FROM }}
ADMIN_EMAIL: ${{ vars.TEST_ADMIN_EMAIL }}
SMTP_RELAY_SERVICE_FROM: ${{ vars.TEST_SMTP_RELAY_SERVICE_FROM }}
SERVICE_EMAIL: ${{ vars.TEST_SERVICE_EMAIL }}
GRAFANA_SMTP_PORT: ${{ vars.GRAFANA_SMTP_PORT }}
GF_SMTP_ENABLED: ${{ vars.TEST_GF_SMTP_ENABLED }}
# Canonical public origin for links in the email (this contour's URL);
# required by the backend whenever SMTP_RELAY_HOST is set.
PUBLIC_BASE_URL: ${{ vars.TEST_PUBLIC_BASE_URL }}
GM_BASICAUTH_USER: ${{ vars.TEST_GM_BASICAUTH_USER }}
GRAFANA_ROOT_URL: ${{ vars.TEST_GRAFANA_ROOT_URL }}
CADDY_SITE_ADDRESS: ${{ vars.TEST_CADDY_SITE_ADDRESS }}
TELEGRAM_MINIAPP_URL: ${{ vars.TEST_TELEGRAM_MINIAPP_URL }}
# TELEGRAM_MINIAPP_URL, GRAFANA_ROOT_URL and VITE_VK_ID_REDIRECT_URL are derived
# from PUBLIC_BASE_URL in the run step below, not stored as their own variables.
TELEGRAM_GAME_CHANNEL_ID: ${{ vars.TEST_TELEGRAM_GAME_CHANNEL_ID }}
TELEGRAM_CHAT_ID: ${{ vars.TEST_TELEGRAM_CHAT_ID }}
TELEGRAM_SUPPORT_CHAT_ID: ${{ vars.TEST_TELEGRAM_SUPPORT_CHAT_ID }}
TELEGRAM_BOT_USERNAME: ${{ vars.TEST_TELEGRAM_BOT_USERNAME }}
# The promo button reuses the UI's Mini App link variable.
TELEGRAM_BOT_LINK: ${{ vars.TEST_VITE_TELEGRAM_LINK }}
@@ -276,11 +407,19 @@ jobs:
VITE_TELEGRAM_BOT_ID: ${{ vars.TEST_VITE_TELEGRAM_BOT_ID }}
VITE_TELEGRAM_LINK: ${{ vars.TEST_VITE_TELEGRAM_LINK }}
VITE_TELEGRAM_GAME_CHANNEL_NAME: ${{ vars.TEST_VITE_TELEGRAM_GAME_CHANNEL_NAME }}
VITE_GATEWAY_URL: ${{ vars.TEST_VITE_GATEWAY_URL }}
# Unset vars render empty -> the compose ":-" defaults apply.
# VK Mini App landing link + VK ID "Web" app id: one value each serves every
# contour -> unprefixed. VITE_VK_APP_ID also feeds the gateway (GATEWAY_VK_ID_APP_ID);
# the VK ID redirect URL is derived from PUBLIC_BASE_URL in the run step below.
VITE_VK_APP_LINK: ${{ vars.VITE_VK_APP_LINK }}
VITE_VK_APP_ID: ${{ vars.VITE_VK_APP_ID }}
# Rewarded-ad test stub: set TEST_VITE_ADS_STUB=1 to swap real ads for a toast on the
# contour (empty = real ads, for capturing the real VK ad result). Prod never sets it.
VITE_ADS_STUB: ${{ vars.TEST_VITE_ADS_STUB }}
# VITE_GATEWAY_URL omitted: the SPA is served same-origin, so it stays the
# compose ":-" empty default. Other unset vars likewise fall to their defaults.
POSTGRES_DB: ${{ vars.TEST_POSTGRES_DB }}
POSTGRES_USER: ${{ vars.TEST_POSTGRES_USER }}
DICT_VERSION: ${{ vars.TEST_DICT_VERSION }}
DICT_VERSION: ${{ vars.DICT_VERSION }}
LOG_LEVEL: ${{ vars.TEST_LOG_LEVEL }}
run: |
# Seed the config files to a stable host path. The runner checks out into
@@ -291,8 +430,34 @@ jobs:
conf="$HOME/.scrabble-deploy"
rm -rf "$conf"
mkdir -p "$conf"
cp -r caddy otelcol prometheus tempo grafana "$conf"/
cp -r caddy otelcol prometheus tempo grafana blackbox "$conf"/
export SCRABBLE_CONFIG_DIR="$conf"
# Maintenance page for the redeploy window, mirroring prod-deploy.sh so the SPA
# overlay is exercised on the test contour too (not only prod). Raised just before
# the recreate and lowered once caddy is back (below); the trap clears it if the
# step fails so the contour never sticks in maintenance (and the reseed above wipes a
# stale flag anyway). The caddy-routed probes run in the NEXT step, after it is lowered.
maint_flag="$conf/caddy/on"
trap 'rm -f "$maint_flag"' EXIT
# Derive the public URLs from the one canonical origin instead of storing each as
# its own variable (paths are structural SPA routes / the Caddy /_gm sub-path).
# Exported before build so the VK ID redirect is baked into the SPA.
base="${PUBLIC_BASE_URL%/}"
export TELEGRAM_MINIAPP_URL="$base/telegram/"
export GRAFANA_ROOT_URL="$base/_gm/grafana/"
export VITE_VK_ID_REDIRECT_URL="$base/app/"
# Grafana's SMTP from_address must be a BARE address (it rejects the "Name" <addr>
# form the backend go-mail accepts) and validates it even when SMTP is disabled — a
# bad value crash-loops Grafana. Split the display-format SERVICE From into a bare
# address + name for Grafana; the backend keeps the full form.
svc_from="${SMTP_RELAY_SERVICE_FROM:-}"
case "$svc_from" in
*"<"*">"*)
export GRAFANA_SMTP_FROM_ADDRESS="$(printf '%s' "$svc_from" | sed -E 's/.*<([^>]+)>.*/\1/')"
export GRAFANA_SMTP_FROM_NAME="$(printf '%s' "$svc_from" | sed -E 's/[[:space:]]*<[^>]*>.*$//; s/^"//; s/"$//')" ;;
*)
export GRAFANA_SMTP_FROM_ADDRESS="$svc_from" ;;
esac
# Bot-link mTLS material for the test contour: a private CA + gateway/bot
# leaves (CN=gateway, the service name the bot dials). Prod supplies these
# from PROD_ secrets instead. Regenerated each deploy; both ends redeploy
@@ -305,12 +470,23 @@ jobs:
# bot on its own host instead (deploy/docker-compose.bot.yml), and the prod
# main host omits both. Without the profile they would not start here.
docker compose --ansi never --profile telegram-local build --progress plain
# Raise the maintenance page, THEN bring caddy onto the reseeded config mount so it
# actually carries the flag: the running caddy sits on the stale pre-reseed mount (the
# dir was rm'd + recreated — see the force-recreate note below), so a flag written to
# the new dir is invisible until caddy is recreated. With the fresh caddy up, an open
# SPA sees the 503 marker + overlay for the whole recreate window, not a bare reconnect.
: > "$maint_flag"
docker compose --ansi never up -d --force-recreate --no-deps caddy
docker compose --ansi never --profile telegram-local up -d --remove-orphans
# The config-only services bind-mount the reseeded config dir. A plain `up -d`
# leaves them on the previous bind mount (the dir was rm'd + recreated), so a
# changed Caddyfile or Grafana dashboard is ignored — force-recreate them to
# pick up the fresh config.
docker compose --ansi never up -d --force-recreate --no-deps caddy otelcol prometheus tempo grafana
# changed Grafana dashboard is ignored — force-recreate them to pick up the fresh
# config. (Caddy was already recreated above so it would carry the maintenance flag.)
docker compose --ansi never up -d --force-recreate --no-deps otelcol prometheus tempo grafana
# Lower the maintenance page: services are back. An open SPA's poll now gets through
# (once the gateway finishes booting) and reloads into the fresh client; the caddy
# probes in the next step see 200. The EXIT trap is a backstop if we failed earlier.
rm -f "$maint_flag"
- name: Probe the landing, gateway and backend
run: |
@@ -336,6 +512,100 @@ jobs:
docker logs --tail 50 scrabble-backend || true
exit 1
- name: Probe the /offer/ public offer page is served
run: |
set -u
# /offer/ is rendered by the render sidecar: it splices the live catalog price list
# (fetched from the backend's internal endpoint) into the committed ui/legal/offer_ru.md.
# If the @offer caddy route is missing, the request falls to the landing shell (also 200),
# and if the backend fetch fails the sidecar returns 502 — so assert offer-specific content
# (the seller INN, §11) AND that the pricing marker was substituted (proves the fetch +
# splice ran), never just the status. Data-independent: an empty catalog still substitutes
# the marker with nothing, so "pricing_template" must be absent either way.
# Full body is needed (the INN is in §11 and the marker check spans the page), so this
# cannot be a head-only probe like the legal one. Retry with a timeout instead: the offer is
# now bilingual (RU + EN, larger), and a single-shot fetch can race the renderer's restart in
# the same deploy.
ok=
for i in $(seq 1 20); do
out="$(docker run --rm --network edge alpine:3.20 wget -q -T 10 -O - http://scrabble/offer/ 2>&1 || true)"
if echo "$out" | grep -q "290210610742" && ! echo "$out" | grep -q "pricing_template"; then
echo "ok: /offer/ serves the rendered offer with the price list spliced in"
ok=1
break
fi
sleep 3
done
if [ -z "$ok" ]; then
echo "FAIL: /offer/ did not serve the rendered offer (route fell through, or the price splice failed)"
docker logs --tail 50 scrabble-renderer || true
docker logs --tail 50 scrabble-backend || true
docker logs --tail 50 scrabble-landing || true
exit 1
fi
- name: Probe the /privacy/ and /eula/ legal pages are served
run: |
set -u
# /privacy/ and /eula/ are static legal markdown rendered by the render sidecar. If the
# @legal caddy route is missing they fall to the landing shell (also 200, canonical
# erudit-game.ru/), so assert the page-specific canonical URL — it uniquely identifies the
# rendered legal doc reaching the edge, not the landing catch-all. Read only the <head>
# (head -c 4096; the canonical link sits in the first bytes): the check is then
# size-independent, so the large EULA page does not exceed the timeout while the monitoring
# stack is still booting post-deploy and starving the CPU-capped renderer. Retry like the
# landing/gateway/backend probe to ride out that window.
for route in privacy eula; do
ok=
for i in $(seq 1 20); do
head_out="$(docker run --rm --network edge alpine:3.20 sh -c "wget -q -T 10 -O - http://scrabble/${route}/ 2>/dev/null | head -c 4096" || true)"
if printf '%s' "$head_out" | grep -qF "erudit-game.ru/${route}/"; then
echo "ok: /${route}/ serves the rendered legal page"
ok=1
break
fi
sleep 3
done
if [ -z "$ok" ]; then
echo "FAIL: /${route}/ did not serve the rendered legal page (@legal route missing?)"
docker logs --tail 50 scrabble-renderer || true
exit 1
fi
done
- name: Probe the /pay/ callback route reaches the gateway
run: |
set -u
# /pay/robokassa/result must reach the gateway, not fall to the landing catch-all. An
# unsigned probe is rejected downstream, so the gateway answers a 4xx/5xx (never a 200 or
# a 404 landing.html), which proves the edge route is wired.
out="$(docker run --rm --network edge alpine:3.20 wget -S -q -O /dev/null http://scrabble/pay/robokassa/result 2>&1 || true)"
echo "$out" | grep -E "HTTP/" || true
if echo "$out" | grep -qE "HTTP/1\.1 (4|5)[0-9][0-9]"; then
echo "ok: /pay/ reaches the gateway (non-landing response)"
else
echo "FAIL: /pay/robokassa/result did not reach the gateway (landing catch-all?)"
docker logs --tail 50 scrabble-gateway || true
exit 1
fi
- name: Probe the /dict edge route reaches the gateway
run: |
set -u
# The client fetches each game's dictionary blob at {edge}/dict/{variant}/{version}
# for the local move preview. If caddy does not route /dict to the gateway the request
# falls to the static landing and the client silently gets a non-dawg blob. Probed
# unauthenticated it must be the gateway's 401 (the route reaches the gateway), never a
# 404/200 from the landing catch-all.
out="$(docker run --rm --network edge alpine:3.20 wget -S -q -O /dev/null http://scrabble/dict/scrabble_en/v1 2>&1 || true)"
echo "$out" | grep -E "HTTP/" || true
if echo "$out" | grep -q " 401"; then
echo "ok: /dict reaches the gateway (401 unauthenticated)"
else
echo "FAIL: /dict did not reach the gateway (expected 401) — caddy route missing?"
exit 1
fi
- name: Probe the Telegram validator and bot liveness
run: |
set -u
+91 -41
View File
@@ -40,11 +40,22 @@ jobs:
VITE_TELEGRAM_BOT_ID: ${{ vars.PROD_VITE_TELEGRAM_BOT_ID }}
VITE_TELEGRAM_LINK: ${{ vars.PROD_VITE_TELEGRAM_LINK }}
VITE_TELEGRAM_GAME_CHANNEL_NAME: ${{ vars.PROD_VITE_TELEGRAM_GAME_CHANNEL_NAME }}
VITE_GATEWAY_URL: ${{ vars.PROD_VITE_GATEWAY_URL }}
# VK Mini App link + VK ID "Web" app id: one value each serves every contour.
VITE_VK_APP_LINK: ${{ vars.VITE_VK_APP_LINK }}
VITE_VK_APP_ID: ${{ vars.VITE_VK_APP_ID }}
# VITE_GATEWAY_URL omitted: the SPA is served same-origin (compose ":-" default).
POSTGRES_PASSWORD: ${{ secrets.PROD_POSTGRES_PASSWORD }}
GM_BASICAUTH_HASH: ${{ secrets.PROD_GM_BASICAUTH_HASH }}
TELEGRAM_MINIAPP_URL: ${{ vars.PROD_TELEGRAM_MINIAPP_URL }}
DICT_VERSION: ${{ vars.PROD_DICT_VERSION }}
# `docker compose build` interpolates the WHOLE compose file, so every :?-guarded
# runtime var must be present at build even though it is not a build-arg — incl. the
# backend's EXPORT_SIGN_KEY (added with the finished-game export after v1.7.0, which is
# why the first v1.8.0 build tripped on it). POSTGRES_PASSWORD/GM_BASICAUTH_HASH above
# are here for the same reason; DICT_VERSION + the derived Mini App URL cover the rest.
EXPORT_SIGN_KEY: ${{ secrets.PROD_EXPORT_SIGN_KEY }}
# PUBLIC_BASE_URL drives the derived VK ID redirect (baked into the SPA) and the
# Mini App URL; both are computed in the build step, not stored variables.
PUBLIC_BASE_URL: ${{ vars.PROD_PUBLIC_BASE_URL }}
DICT_VERSION: ${{ vars.DICT_VERSION }}
steps:
- uses: actions/checkout@v4
with:
@@ -58,10 +69,15 @@ jobs:
working-directory: deploy
run: |
export TAG="${{ steps.ver.outputs.tag }}" APP_VERSION="${{ steps.ver.outputs.tag }}" SCRABBLE_CONFIG_DIR=.
# The four main-stack images via compose (reuses the build args, incl. VERSION);
# Derive the public URLs from the one canonical origin: the VK ID redirect is a
# build-arg baked into the SPA, and the Mini App URL satisfies the (profiled-out)
# bot service's compose ":?" guard during parse. See deploy/write-prod-env.sh.
base="${PUBLIC_BASE_URL%/}"
export VITE_VK_ID_REDIRECT_URL="$base/app/" TELEGRAM_MINIAPP_URL="$base/telegram/"
# The main-stack images via compose (reuses the build args, incl. VERSION);
# the bot separately, since it is profiled out of the prod compose.
docker compose -f docker-compose.yml -f docker-compose.prod.yml build
docker compose -f docker-compose.yml -f docker-compose.prod.yml push backend gateway landing validator
docker compose -f docker-compose.yml -f docker-compose.prod.yml push postgres backend gateway landing validator renderer
docker build -f ../platform/telegram/Dockerfile --target bot --build-arg VERSION="$TAG" -t "$REGISTRY/scrabble-telegram-bot:$TAG" ..
docker push "$REGISTRY/scrabble-telegram-bot:$TAG"
@@ -82,17 +98,76 @@ jobs:
GM_BASICAUTH_HASH: ${{ secrets.PROD_GM_BASICAUTH_HASH }}
GRAFANA_ADMIN_PASSWORD: ${{ secrets.PROD_GRAFANA_ADMIN_PASSWORD }}
TELEGRAM_BOT_TOKEN: ${{ secrets.PROD_TELEGRAM_BOT_TOKEN }}
GATEWAY_VK_APP_SECRET: ${{ secrets.GATEWAY_VK_APP_SECRET }}
# Robokassa direct-rail (backend BACKEND_ROBOKASSA_*): the prod shop login + the pass phrases
# that sign the launch request / verify the Result callback, and the test-mode flag — a var so
# go-live is a flag flip, not a secret redeploy ("1" runs test payments against the test
# passwords; empty/"0" is live). An empty login leaves the direct rail disabled.
ROBOKASSA_MERCHANT_LOGIN: ${{ secrets.PROD_BACKEND_ROBOKASSA_MERCHANT_LOGIN }}
ROBOKASSA_PASSWORD1: ${{ secrets.PROD_BACKEND_ROBOKASSA_PASSWORD1 }}
ROBOKASSA_PASSWORD2: ${{ secrets.PROD_BACKEND_ROBOKASSA_PASSWORD2 }}
ROBOKASSA_TEST: ${{ vars.PROD_BACKEND_ROBOKASSA_TEST }}
# VK ID web login: the "Web" app id (the gateway reuses it as GATEWAY_VK_ID_APP_ID at
# runtime) + the app's protected key. Both shared across contours. The redirect URL is
# derived from PUBLIC_BASE_URL in deploy/write-prod-env.sh.
VITE_VK_APP_ID: ${{ vars.VITE_VK_APP_ID }}
GATEWAY_VK_ID_CLIENT_SECRET: ${{ secrets.GATEWAY_VK_ID_CLIENT_SECRET }}
# Client-version gate (ARCHITECTURE.md §2): plain (unprefixed) vars, shared across contours
# (empty ⇒ dormant). On prod the stamped client version is a real semver, so the gate enforces
# here — set GATEWAY_MIN_CLIENT_VERSION to the release in the same rollout that ships a breaking
# wire change; bump GATEWAY_RECOMMENDED_CLIENT_VERSION (≥ min) to nudge upgrades softly.
GATEWAY_MIN_CLIENT_VERSION: ${{ vars.GATEWAY_MIN_CLIENT_VERSION }}
GATEWAY_RECOMMENDED_CLIENT_VERSION: ${{ vars.GATEWAY_RECOMMENDED_CLIENT_VERSION }}
# Planted honeytoken bearer: presenting it earns a 24h IP ban + a high-severity alarm.
# Per-contour secret; empty = trap off. Rendered by deploy/write-prod-env.sh.
GATEWAY_HONEYTOKEN: ${{ secrets.PROD_GATEWAY_HONEYTOKEN }}
# Community IP blocklist (Spamhaus DROP): opt-in. Set _ENABLED=true + _URL (the feed) once
# verified; _ALLOW is a comma-separated never-block set (own infra). Empty ⇒ off.
GATEWAY_BLOCKLIST_ENABLED: ${{ vars.PROD_GATEWAY_BLOCKLIST_ENABLED }}
GATEWAY_BLOCKLIST_URL: ${{ vars.PROD_GATEWAY_BLOCKLIST_URL }}
GATEWAY_BLOCKLIST_ALLOW: ${{ vars.PROD_GATEWAY_BLOCKLIST_ALLOW }}
# Signs the finished-game export download URLs (backend BACKEND_EXPORT_SIGN_KEY).
EXPORT_SIGN_KEY: ${{ secrets.PROD_EXPORT_SIGN_KEY }}
# Transactional email via the shared Selectel relay (confirm-codes): one account for
# every contour -> unprefixed host/port/tls/user/pass.
SMTP_RELAY_USER: ${{ secrets.SMTP_RELAY_USER }}
SMTP_RELAY_PASS: ${{ secrets.SMTP_RELAY_PASS }}
SMTP_RELAY_HOST: ${{ vars.SMTP_RELAY_HOST }}
SMTP_RELAY_PORT: ${{ vars.SMTP_RELAY_PORT }}
SMTP_RELAY_TLS: ${{ vars.SMTP_RELAY_TLS }}
SMTP_RELAY_FROM: ${{ vars.PROD_SMTP_RELAY_FROM }}
# Operator alerts: backend admin emails + Grafana infra alerts (distinct senders +
# recipients; Grafana uses the relay's STARTTLS host:port).
SMTP_RELAY_ADMIN_FROM: ${{ vars.PROD_SMTP_RELAY_ADMIN_FROM }}
ADMIN_EMAIL: ${{ vars.PROD_ADMIN_EMAIL }}
SMTP_RELAY_SERVICE_FROM: ${{ vars.PROD_SMTP_RELAY_SERVICE_FROM }}
SERVICE_EMAIL: ${{ vars.PROD_SERVICE_EMAIL }}
GRAFANA_SMTP_PORT: ${{ vars.GRAFANA_SMTP_PORT }}
GF_SMTP_ENABLED: ${{ vars.PROD_GF_SMTP_ENABLED }}
PUBLIC_BASE_URL: ${{ vars.PROD_PUBLIC_BASE_URL }}
PROD_BOTLINK_CA: ${{ secrets.PROD_BOTLINK_CA }}
PROD_BOTLINK_GATEWAY_CERT: ${{ secrets.PROD_BOTLINK_GATEWAY_CERT }}
PROD_BOTLINK_GATEWAY_KEY: ${{ secrets.PROD_BOTLINK_GATEWAY_KEY }}
GM_BASICAUTH_USER: ${{ vars.PROD_GM_BASICAUTH_USER }}
GRAFANA_ROOT_URL: ${{ vars.PROD_GRAFANA_ROOT_URL }}
CADDY_SITE_ADDRESS: ${{ vars.PROD_CADDY_SITE_ADDRESS }}
LOG_LEVEL: ${{ vars.PROD_LOG_LEVEL }}
DICT_VERSION: ${{ vars.PROD_DICT_VERSION }}
DICT_VERSION: ${{ vars.DICT_VERSION }}
POSTGRES_DB: ${{ vars.PROD_POSTGRES_DB }}
POSTGRES_USER: ${{ vars.PROD_POSTGRES_USER }}
TELEGRAM_MINIAPP_URL: ${{ vars.PROD_TELEGRAM_MINIAPP_URL }}
# Point-in-time recovery (pgBackRest -> S3), prod main host only. Endpoint/bucket/
# region + the archive-mode switch are variables; the S3 keys + the repository cipher
# passphrase are secrets. All empty/off until the operator arms archiving; flipping
# PROD_PGBACKREST_ARCHIVE_MODE=on activates it (deploy/README.md, arming).
PGBACKREST_ARCHIVE_MODE: ${{ vars.PROD_PGBACKREST_ARCHIVE_MODE }}
PGBACKREST_S3_ENDPOINT: ${{ vars.PROD_PGBACKREST_S3_ENDPOINT }}
PGBACKREST_S3_PORT: ${{ vars.PROD_PGBACKREST_S3_PORT }}
PGBACKREST_S3_BUCKET: ${{ vars.PROD_PGBACKREST_S3_BUCKET }}
PGBACKREST_S3_REGION: ${{ vars.PROD_PGBACKREST_S3_REGION }}
PGBACKREST_S3_KEY: ${{ secrets.PROD_PGBACKREST_S3_KEY }}
PGBACKREST_S3_KEY_SECRET: ${{ secrets.PROD_PGBACKREST_S3_KEY_SECRET }}
PGBACKREST_CIPHER_PASS: ${{ secrets.PROD_PGBACKREST_CIPHER_PASS }}
# TELEGRAM_MINIAPP_URL and GRAFANA_ROOT_URL are derived from PUBLIC_BASE_URL in
# deploy/write-prod-env.sh, not stored variables.
steps:
- uses: actions/checkout@v4
with:
@@ -120,24 +195,8 @@ jobs:
run: |
umask 077
mkdir -p stage/certs-main
cat > stage/env.sh <<EOF
export REGISTRY='$REGISTRY'
export SCRABBLE_CONFIG_DIR='/opt/scrabble'
export POSTGRES_DB='${POSTGRES_DB:-scrabble}'
export POSTGRES_USER='${POSTGRES_USER:-scrabble}'
export POSTGRES_PASSWORD='$POSTGRES_PASSWORD'
export GM_BASICAUTH_USER='${GM_BASICAUTH_USER:-gm}'
export GM_BASICAUTH_HASH='$GM_BASICAUTH_HASH'
export GRAFANA_ADMIN_PASSWORD='$GRAFANA_ADMIN_PASSWORD'
export GRAFANA_ROOT_URL='$GRAFANA_ROOT_URL'
export CADDY_SITE_ADDRESS='$CADDY_SITE_ADDRESS'
export LOG_LEVEL='${LOG_LEVEL:-info}'
export DICT_VERSION='$DICT_VERSION'
export APP_VERSION='$TAG'
export TELEGRAM_BOT_TOKEN='$TELEGRAM_BOT_TOKEN'
export TELEGRAM_MINIAPP_URL='$TELEGRAM_MINIAPP_URL'
export GATEWAY_ABUSE_BAN_ENABLED='true'
EOF
# Shared with prod-rollback so the two paths render an identical runtime env.
APP_VERSION="$TAG" bash deploy/write-prod-env.sh stage/env.sh
printf '%s\n' "$PROD_BOTLINK_CA" > stage/certs-main/ca.crt
printf '%s\n' "$PROD_BOTLINK_GATEWAY_CERT" > stage/certs-main/gateway.crt
printf '%s\n' "$PROD_BOTLINK_GATEWAY_KEY" > stage/certs-main/gateway.key
@@ -148,7 +207,7 @@ jobs:
ssh_main 'mkdir -p /opt/scrabble/compose'
tar -C deploy -czf - docker-compose.yml docker-compose.prod.yml prod-deploy.sh \
| ssh_main 'tar -C /opt/scrabble/compose -xzf -'
tar -C deploy -czf - caddy otelcol prometheus tempo grafana \
tar -C deploy -czf - caddy otelcol prometheus tempo grafana blackbox \
| ssh_main 'tar -C /opt/scrabble -xzf -'
tar -C stage -czf - certs-main \
| ssh_main 'rm -rf /opt/scrabble/certs && mkdir -p /opt/scrabble/certs && tar -C /opt/scrabble/certs --strip-components=1 -xzf -'
@@ -176,9 +235,11 @@ jobs:
PROD_BOTLINK_BOT_CERT: ${{ secrets.PROD_BOTLINK_BOT_CERT }}
PROD_BOTLINK_BOT_KEY: ${{ secrets.PROD_BOTLINK_BOT_KEY }}
LOG_LEVEL: ${{ vars.PROD_LOG_LEVEL }}
TELEGRAM_MINIAPP_URL: ${{ vars.PROD_TELEGRAM_MINIAPP_URL }}
# PUBLIC_BASE_URL drives the derived Mini App URL in deploy/write-prod-bot-env.sh.
PUBLIC_BASE_URL: ${{ vars.PROD_PUBLIC_BASE_URL }}
TELEGRAM_GAME_CHANNEL_ID: ${{ vars.PROD_TELEGRAM_GAME_CHANNEL_ID }}
TELEGRAM_CHAT_ID: ${{ vars.PROD_TELEGRAM_CHAT_ID }}
TELEGRAM_SUPPORT_CHAT_ID: ${{ vars.PROD_TELEGRAM_SUPPORT_CHAT_ID }}
TELEGRAM_BOT_USERNAME: ${{ vars.PROD_TELEGRAM_BOT_USERNAME }}
TELEGRAM_BOT_LINK: ${{ vars.PROD_VITE_TELEGRAM_LINK }}
steps:
@@ -192,19 +253,8 @@ jobs:
run: |
umask 077
mkdir -p stage/certs-bot
cat > stage/env.bot.sh <<EOF
export SCRABBLE_CONFIG_DIR='/opt/scrabble'
export BOT_IMAGE='$REGISTRY/scrabble-telegram-bot:$TAG'
export BOTLINK_GATEWAY_ADDR='$MAIN_HOST:9443'
export TELEGRAM_BOT_TOKEN='$TELEGRAM_BOT_TOKEN'
export TELEGRAM_MINIAPP_URL='$TELEGRAM_MINIAPP_URL'
export TELEGRAM_GAME_CHANNEL_ID='$TELEGRAM_GAME_CHANNEL_ID'
export TELEGRAM_CHAT_ID='$TELEGRAM_CHAT_ID'
export TELEGRAM_PROMO_BOT_TOKEN='$TELEGRAM_PROMO_BOT_TOKEN'
export TELEGRAM_BOT_USERNAME='$TELEGRAM_BOT_USERNAME'
export TELEGRAM_BOT_LINK='$TELEGRAM_BOT_LINK'
export LOG_LEVEL='${LOG_LEVEL:-info}'
EOF
# Shared with prod-rollback so the two paths render an identical bot env.
BOT_IMAGE="$REGISTRY/scrabble-telegram-bot:$TAG" bash deploy/write-prod-bot-env.sh stage/env.bot.sh
printf '%s\n' "$PROD_BOTLINK_CA" > stage/certs-bot/ca.crt
printf '%s\n' "$PROD_BOTLINK_BOT_CERT" > stage/certs-bot/bot.crt
printf '%s\n' "$PROD_BOTLINK_BOT_KEY" > stage/certs-bot/bot.key
+50 -35
View File
@@ -51,13 +51,52 @@ jobs:
PROD_BOTLINK_GATEWAY_CERT: ${{ secrets.PROD_BOTLINK_GATEWAY_CERT }}
PROD_BOTLINK_GATEWAY_KEY: ${{ secrets.PROD_BOTLINK_GATEWAY_KEY }}
GM_BASICAUTH_USER: ${{ vars.PROD_GM_BASICAUTH_USER }}
GRAFANA_ROOT_URL: ${{ vars.PROD_GRAFANA_ROOT_URL }}
CADDY_SITE_ADDRESS: ${{ vars.PROD_CADDY_SITE_ADDRESS }}
LOG_LEVEL: ${{ vars.PROD_LOG_LEVEL }}
DICT_VERSION: ${{ vars.PROD_DICT_VERSION }}
DICT_VERSION: ${{ vars.DICT_VERSION }}
POSTGRES_DB: ${{ vars.PROD_POSTGRES_DB }}
POSTGRES_USER: ${{ vars.PROD_POSTGRES_USER }}
TELEGRAM_MINIAPP_URL: ${{ vars.PROD_TELEGRAM_MINIAPP_URL }}
PUBLIC_BASE_URL: ${{ vars.PROD_PUBLIC_BASE_URL }}
# Full runtime env — parity with prod-deploy's deploy-main so a rollback re-renders
# the SAME env.sh (email / VK login / Grafana alerts survive a rollback). TELEGRAM_MINIAPP_URL
# and GRAFANA_ROOT_URL are derived from PUBLIC_BASE_URL in deploy/write-prod-env.sh.
GATEWAY_VK_APP_SECRET: ${{ secrets.GATEWAY_VK_APP_SECRET }}
# Robokassa direct-rail: the rollback re-renders the same runtime env (write-prod-env.sh), so
# it must carry the same credentials or the direct rail goes dark after a rollback.
ROBOKASSA_MERCHANT_LOGIN: ${{ secrets.PROD_BACKEND_ROBOKASSA_MERCHANT_LOGIN }}
ROBOKASSA_PASSWORD1: ${{ secrets.PROD_BACKEND_ROBOKASSA_PASSWORD1 }}
ROBOKASSA_PASSWORD2: ${{ secrets.PROD_BACKEND_ROBOKASSA_PASSWORD2 }}
ROBOKASSA_TEST: ${{ vars.PROD_BACKEND_ROBOKASSA_TEST }}
VITE_VK_APP_ID: ${{ vars.VITE_VK_APP_ID }}
GATEWAY_VK_ID_CLIENT_SECRET: ${{ secrets.GATEWAY_VK_ID_CLIENT_SECRET }}
GATEWAY_HONEYTOKEN: ${{ secrets.PROD_GATEWAY_HONEYTOKEN }}
# Community IP blocklist — rendered on rollback too so a rollback keeps the same edge policy.
GATEWAY_BLOCKLIST_ENABLED: ${{ vars.PROD_GATEWAY_BLOCKLIST_ENABLED }}
GATEWAY_BLOCKLIST_URL: ${{ vars.PROD_GATEWAY_BLOCKLIST_URL }}
GATEWAY_BLOCKLIST_ALLOW: ${{ vars.PROD_GATEWAY_BLOCKLIST_ALLOW }}
EXPORT_SIGN_KEY: ${{ secrets.PROD_EXPORT_SIGN_KEY }}
SMTP_RELAY_USER: ${{ secrets.SMTP_RELAY_USER }}
SMTP_RELAY_PASS: ${{ secrets.SMTP_RELAY_PASS }}
SMTP_RELAY_HOST: ${{ vars.SMTP_RELAY_HOST }}
SMTP_RELAY_PORT: ${{ vars.SMTP_RELAY_PORT }}
SMTP_RELAY_TLS: ${{ vars.SMTP_RELAY_TLS }}
SMTP_RELAY_FROM: ${{ vars.PROD_SMTP_RELAY_FROM }}
SMTP_RELAY_ADMIN_FROM: ${{ vars.PROD_SMTP_RELAY_ADMIN_FROM }}
ADMIN_EMAIL: ${{ vars.PROD_ADMIN_EMAIL }}
SMTP_RELAY_SERVICE_FROM: ${{ vars.PROD_SMTP_RELAY_SERVICE_FROM }}
SERVICE_EMAIL: ${{ vars.PROD_SERVICE_EMAIL }}
GRAFANA_SMTP_PORT: ${{ vars.GRAFANA_SMTP_PORT }}
GF_SMTP_ENABLED: ${{ vars.PROD_GF_SMTP_ENABLED }}
# PITR archiving parity: a rollback must re-render the SAME env.sh, else it would
# silently disarm WAL archiving (PGBACKREST_ARCHIVE_MODE would fall back to off).
PGBACKREST_ARCHIVE_MODE: ${{ vars.PROD_PGBACKREST_ARCHIVE_MODE }}
PGBACKREST_S3_ENDPOINT: ${{ vars.PROD_PGBACKREST_S3_ENDPOINT }}
PGBACKREST_S3_PORT: ${{ vars.PROD_PGBACKREST_S3_PORT }}
PGBACKREST_S3_BUCKET: ${{ vars.PROD_PGBACKREST_S3_BUCKET }}
PGBACKREST_S3_REGION: ${{ vars.PROD_PGBACKREST_S3_REGION }}
PGBACKREST_S3_KEY: ${{ secrets.PROD_PGBACKREST_S3_KEY }}
PGBACKREST_S3_KEY_SECRET: ${{ secrets.PROD_PGBACKREST_S3_KEY_SECRET }}
PGBACKREST_CIPHER_PASS: ${{ secrets.PROD_PGBACKREST_CIPHER_PASS }}
INPUT_TARGET: ${{ inputs.target_version }}
steps:
- uses: actions/checkout@v4
@@ -89,24 +128,9 @@ jobs:
run: |
umask 077
mkdir -p stage/certs-main
cat > stage/env.sh <<EOF
export REGISTRY='$REGISTRY'
export SCRABBLE_CONFIG_DIR='/opt/scrabble'
export POSTGRES_DB='${POSTGRES_DB:-scrabble}'
export POSTGRES_USER='${POSTGRES_USER:-scrabble}'
export POSTGRES_PASSWORD='$POSTGRES_PASSWORD'
export GM_BASICAUTH_USER='${GM_BASICAUTH_USER:-gm}'
export GM_BASICAUTH_HASH='$GM_BASICAUTH_HASH'
export GRAFANA_ADMIN_PASSWORD='$GRAFANA_ADMIN_PASSWORD'
export GRAFANA_ROOT_URL='$GRAFANA_ROOT_URL'
export CADDY_SITE_ADDRESS='$CADDY_SITE_ADDRESS'
export LOG_LEVEL='${LOG_LEVEL:-info}'
export DICT_VERSION='$DICT_VERSION'
export APP_VERSION='$TARGET'
export TELEGRAM_BOT_TOKEN='$TELEGRAM_BOT_TOKEN'
export TELEGRAM_MINIAPP_URL='$TELEGRAM_MINIAPP_URL'
export GATEWAY_ABUSE_BAN_ENABLED='true'
EOF
# Same writer as prod-deploy's deploy-main -> the rollback re-renders the FULL
# runtime env (not a subset), so email / VK login / Grafana alerts survive it.
APP_VERSION="$TARGET" bash deploy/write-prod-env.sh stage/env.sh
printf '%s\n' "$PROD_BOTLINK_CA" > stage/certs-main/ca.crt
printf '%s\n' "$PROD_BOTLINK_GATEWAY_CERT" > stage/certs-main/gateway.crt
printf '%s\n' "$PROD_BOTLINK_GATEWAY_KEY" > stage/certs-main/gateway.key
@@ -147,9 +171,11 @@ jobs:
PROD_BOTLINK_BOT_CERT: ${{ secrets.PROD_BOTLINK_BOT_CERT }}
PROD_BOTLINK_BOT_KEY: ${{ secrets.PROD_BOTLINK_BOT_KEY }}
LOG_LEVEL: ${{ vars.PROD_LOG_LEVEL }}
TELEGRAM_MINIAPP_URL: ${{ vars.PROD_TELEGRAM_MINIAPP_URL }}
# PUBLIC_BASE_URL drives the derived Mini App URL; SUPPORT_CHAT_ID for parity with deploy.
PUBLIC_BASE_URL: ${{ vars.PROD_PUBLIC_BASE_URL }}
TELEGRAM_GAME_CHANNEL_ID: ${{ vars.PROD_TELEGRAM_GAME_CHANNEL_ID }}
TELEGRAM_CHAT_ID: ${{ vars.PROD_TELEGRAM_CHAT_ID }}
TELEGRAM_SUPPORT_CHAT_ID: ${{ vars.PROD_TELEGRAM_SUPPORT_CHAT_ID }}
TELEGRAM_BOT_USERNAME: ${{ vars.PROD_TELEGRAM_BOT_USERNAME }}
TELEGRAM_BOT_LINK: ${{ vars.PROD_VITE_TELEGRAM_LINK }}
steps:
@@ -163,19 +189,8 @@ jobs:
run: |
umask 077
mkdir -p stage/certs-bot
cat > stage/env.bot.sh <<EOF
export SCRABBLE_CONFIG_DIR='/opt/scrabble'
export BOT_IMAGE='$REGISTRY/scrabble-telegram-bot:$TARGET'
export BOTLINK_GATEWAY_ADDR='$MAIN_HOST:9443'
export TELEGRAM_BOT_TOKEN='$TELEGRAM_BOT_TOKEN'
export TELEGRAM_MINIAPP_URL='$TELEGRAM_MINIAPP_URL'
export TELEGRAM_GAME_CHANNEL_ID='$TELEGRAM_GAME_CHANNEL_ID'
export TELEGRAM_CHAT_ID='$TELEGRAM_CHAT_ID'
export TELEGRAM_PROMO_BOT_TOKEN='$TELEGRAM_PROMO_BOT_TOKEN'
export TELEGRAM_BOT_USERNAME='$TELEGRAM_BOT_USERNAME'
export TELEGRAM_BOT_LINK='$TELEGRAM_BOT_LINK'
export LOG_LEVEL='${LOG_LEVEL:-info}'
EOF
# Same writer as prod-deploy's deploy-bot (parity; includes TELEGRAM_SUPPORT_CHAT_ID).
BOT_IMAGE="$REGISTRY/scrabble-telegram-bot:$TARGET" bash deploy/write-prod-bot-env.sh stage/env.bot.sh
printf '%s\n' "$PROD_BOTLINK_CA" > stage/certs-bot/ca.crt
printf '%s\n' "$PROD_BOTLINK_BOT_CERT" > stage/certs-bot/bot.crt
printf '%s\n' "$PROD_BOTLINK_BOT_KEY" > stage/certs-bot/bot.key
+1193
View File
File diff suppressed because it is too large Load Diff
+11 -1
View File
@@ -125,9 +125,10 @@ gateway/ # module scrabble/gateway: Connect-RPC edge, embeds
ui/ # Svelte + Vite SPA + landing (Node project, not in go.work)
pkg/ # shared: telemetry, version, wire/FlatBuffers, proto, mtls
platform/telegram/ # Telegram side-service: cmd/validator (HMAC, no VPN) + cmd/bot (Bot API; dials gateway over reverse mTLS bot-link)
renderer/ # image-render sidecar (Node + skia-canvas): runs ui/src/lib/gameimage.ts server-side for the finished-game PNG export
loadtest/ # module scrabble/loadtest: the load/stress harness
docs/ .gitea/workflows/ CLAUDE.md README.md
backend/Dockerfile gateway/Dockerfile platform/telegram/Dockerfile loadtest/Dockerfile # multi-stage distroless; gateway/Dockerfile has the `landing` target, platform/telegram/Dockerfile has `validator`+`bot` targets
backend/Dockerfile gateway/Dockerfile platform/telegram/Dockerfile loadtest/Dockerfile renderer/Dockerfile # multi-stage distroless (renderer: node:22-slim + skia-canvas + fonts); gateway/Dockerfile has the `landing` target, platform/telegram/Dockerfile has `validator`+`bot` targets
deploy/ # docker-compose (+ prod overlay + bot host) + ansible provisioning + caddy + landing + otelcol (OTLP + docker_stats) + prometheus/tempo/grafana + node_exporter + postgres_exporter; prod-deploy.sh
```
@@ -143,6 +144,7 @@ go run ./backend/cmd/backend # /healthz, /readyz on :8080
cd ui && pnpm install && pnpm check && pnpm test:unit && pnpm build # the UI
pnpm start # UI mock mode: lobby -> game, no backend
cd renderer && pnpm install && pnpm test # image-render sidecar (bundles ui/src/lib/gameimage.ts, skia smoke)
docker build --build-arg DICT_VERSION=v1.3.0 -f backend/Dockerfile -t scrabble-backend . # DICT_VERSION required (no default); gateway embeds the SPA
docker build -f gateway/Dockerfile --target gateway -t scrabble-gateway .
@@ -154,3 +156,11 @@ The `ui` module is a Node project (pnpm), **not** in `go.work`; it is the `ui` j
the single `.gitea/workflows/ci.yaml`. Committed edge codegen under `ui/src/gen/`
(regenerate with `pnpm codegen`); pnpm build-script approval lives in
`ui/pnpm-workspace.yaml` (`allowBuilds: esbuild: true`).
## Agent field notes
Non-obvious, hard-won knowledge the agent has accumulated that is **not** captured in the docs above,
kept in the repo so it travels with a clone. Verify any named file/flag against current code before
acting on it.
@.claude/CLAUDE.md
+1029
View File
File diff suppressed because it is too large Load Diff
+18
View File
@@ -0,0 +1,18 @@
# Icons
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 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: 23 KiB

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

@@ -0,0 +1 @@
<svg xmlns="http://www.w3.org/2000/svg" width="1024" height="1024" viewBox="0 0 1024 1024"><path transform="translate(237.62 774.88)" d="M250.04-34.91Q326.35-34.91 368.57-91.33Q410.78-147.75 410.78-243.55Q410.78-249.23 410.78-254.91L254.10-254.91L208.64-185.91L195.65-185.91L195.65-368.57L208.64-368.57L254.10-299.56L407.53-299.56Q396.98-392.92 351.52-447.72Q306.06-502.52 241.11-502.52Q217.57-502.52 198.90-498.46Q180.22-494.40 162.36-482.22L83.62-354.77L69-354.77L77.93-505.76Q112.84-522.81 154.25-533.37Q195.65-543.92 259.78-543.92Q319.86-543.92 371.41-523.22Q422.96-502.52 460.71-465.58Q498.46-428.64 519.57-378.31Q540.67-327.98 540.67-267.90Q540.67-188.34 504.55-125.83Q468.42-63.32 405.10-27.60Q341.78 8.12 259.78 8.12Q200.52 8.12 150.59-3.25Q100.67-14.61 67.38-34.10L56.02-171.29L69.82-171.29L142.88-63.32Q165.61-50.33 191.59-42.62Q217.57-34.91 250.04-34.91" fill="#f2d9a0"/><path d="M781.00 744.04 L795.25 695.59 A14.85 14.85 0 1 0 766.76 695.59 Z M781.00 744.04 L830.08 732.15 A14.85 14.85 0 1 0 815.84 707.48 Z M781.00 744.04 L815.84 780.60 A14.85 14.85 0 1 0 830.08 755.93 Z M781.00 744.04 L766.76 792.49 A14.85 14.85 0 1 0 795.25 792.49 Z M781.00 744.04 L731.93 755.93 A14.85 14.85 0 1 0 746.17 780.60 Z M781.00 744.04 L746.17 707.48 A14.85 14.85 0 1 0 731.93 732.15 Z M781.0048 744.0384 m-14.845952 0 a14.845952 14.845952 0 1 0 29.691904 0 a14.845952 14.845952 0 1 0 -29.691904 0 Z" fill="#f2d9a0"/></svg>

After

Width:  |  Height:  |  Size: 1.4 KiB

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: 30 KiB

@@ -0,0 +1 @@
<svg xmlns="http://www.w3.org/2000/svg" width="1024" height="1024" viewBox="0 0 1024 1024"><path transform="translate(237.62 774.88)" d="M250.04-34.91Q326.35-34.91 368.57-91.33Q410.78-147.75 410.78-243.55Q410.78-249.23 410.78-254.91L254.10-254.91L208.64-185.91L195.65-185.91L195.65-368.57L208.64-368.57L254.10-299.56L407.53-299.56Q396.98-392.92 351.52-447.72Q306.06-502.52 241.11-502.52Q217.57-502.52 198.90-498.46Q180.22-494.40 162.36-482.22L83.62-354.77L69-354.77L77.93-505.76Q112.84-522.81 154.25-533.37Q195.65-543.92 259.78-543.92Q319.86-543.92 371.41-523.22Q422.96-502.52 460.71-465.58Q498.46-428.64 519.57-378.31Q540.67-327.98 540.67-267.90Q540.67-188.34 504.55-125.83Q468.42-63.32 405.10-27.60Q341.78 8.12 259.78 8.12Q200.52 8.12 150.59-3.25Q100.67-14.61 67.38-34.10L56.02-171.29L69.82-171.29L142.88-63.32Q165.61-50.33 191.59-42.62Q217.57-34.91 250.04-34.91" fill="#5a3a12"/><path d="M781.00 744.04 L795.25 695.59 A14.85 14.85 0 1 0 766.76 695.59 Z M781.00 744.04 L830.08 732.15 A14.85 14.85 0 1 0 815.84 707.48 Z M781.00 744.04 L815.84 780.60 A14.85 14.85 0 1 0 830.08 755.93 Z M781.00 744.04 L766.76 792.49 A14.85 14.85 0 1 0 795.25 792.49 Z M781.00 744.04 L731.93 755.93 A14.85 14.85 0 1 0 746.17 780.60 Z M781.00 744.04 L746.17 707.48 A14.85 14.85 0 1 0 731.93 732.15 Z M781.0048 744.0384 m-14.845952 0 a14.845952 14.845952 0 1 0 29.691904 0 a14.845952 14.845952 0 1 0 -29.691904 0 Z" fill="#5a3a12"/></svg>

After

Width:  |  Height:  |  Size: 1.4 KiB

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

+115
View File
@@ -0,0 +1,115 @@
# Erudit — VK loading-screen logo (Lottie)
Animated logo for the **VK app loading screen** (shown before the SPA assets load).
The Erudit «Э» tile (score `8`) **drops in under gravity, lands with a soft squash —
its left/right edges bulging into a cushion — then springs back up**, looping. A light
"glint" is caught at the moment of the bounce.
| File | Purpose |
|------|---------|
| `erudit-loader.json` | The Lottie to upload to VK. |
| `erudit-loader-preview.gif` | Looping preview. Rendered on a green "baize" background **only in the preview** — the real asset has a **transparent** background. |
| `build/` | The reproducible build pipeline (see *Regenerate*). |
**VK requirements met:** 96×96 px · vector Lottie JSON · ≤ 24 KB (~11 KB) · seamless
~1.1 s loop · transparent background.
Design reference: a photo of the physical wooden Erudit tile.
---
## How it works
A single flat layer holds the tile — a rounded-rect **face path**, the two glyphs, and
a thin border — and everything is driven by that layer's transform plus a few colour /
shape tracks. The anchor sits on the tile's **bottom edge**, so the squash happens
against the floor.
### Bounce (gravity)
`position.y` of the bottom edge goes apex → floor → apex with **no dwell** (the apex is
an instantaneous turn-around). The fall uses a strong **ease-in** (accelerate) and the
rise a strong **ease-out** (decelerate), so it reads as real gravity. While falling fast
the tile slightly **stretches** (tall+thin); that is the squash-and-stretch setup.
### Soft cushion (squash)
On contact the layer **squashes** (scaleY↓, scaleX↑) about the bottom anchor, with a
touch of skew. Crucially the squash/cushion is **decoupled from the fall speed** — it
eases in and out *slowly* (`ES`), so the landing feels soft even though the fall is
fast. The squash is deliberately gentle (≈ 86 % / 112 %).
### The cushion shape (the hard part)
The face is **not** a plain rounded rect — it is a path whose left/right contour bows
out into a convex cushion on impact:
- the rest rounded-rect outline is sampled (fine corner arcs + edge mids), and on impact
every point is pushed outward by a smooth **barrel** profile `f(y) = b·(1 (y/HH)²)`
(max at the middle, fading to zero at the top/bottom);
- so the **whole side — corners included — bows out as one piece**, never just the
straight middle;
- bezier handles are computed as a **chordal spline** (handle length ∝ the adjacent
chord), which stays smooth across the uneven corner/edge point spacing. This is what
keeps the corner↔cushion junction kink-free — both at rest and fully bulged — which a
naïve uniform Catmull-Rom (or moving discrete points with fixed tangents) does not.
### Glint
At the bounce the face flashes a little lighter (`FACE_GLINT`) and the glyphs warm
toward brown-red (`BLACK_LIT`), then settle back. A cheap, warm "catch the light".
### Border & background
The tile carries a thin static **border** a touch darker than the face (`BORDER`) so it
reads on any background. The **green baize background is added only when rendering the
preview GIF** — the Lottie itself is transparent.
### Player compatibility
Only plain 2-D shapes (`ddd:0`) — no 3-D layers, expressions or effects — so every
Lottie player (lottie-web on the web, rlottie on native) renders it identically.
---
## Glyphs
`Э` (U+042D) and `8` are **real outlines from LiberationSans-Regular** — metric-
compatible with Arial, matching the game's `--font: system-ui … Arial` stack
(see `ui/src/app.css`). `build/extract.js` pulls the contours into `build/glyphs.json`
as Lottie cubic paths; a hair of same-colour stroke adds a touch of weight.
---
## Regenerate
Requirements: Node ≥ 18. `extract.js` additionally needs `opentype.js`
(`npm i opentype.js`); **`generate.js` has no dependencies**.
```sh
cd assets/vk
# 1. (optional) re-extract the glyphs — only if you change the font:
# default font: /usr/share/fonts/truetype/liberation/LiberationSans-Regular.ttf
node build/extract.js [/path/to/font.ttf] # -> build/glyphs.json
# 2. build the animation (reads build/glyphs.json):
node build/generate.js erudit-loader.json # -> erudit-loader.json
# 3. (optional) live preview — needs lottie-web (npm i lottie-web):
node build/build-preview.js erudit-loader.json preview.html
# then open preview.html in a browser.
```
The preview GIF is assembled by rendering the Lottie frame-by-frame (lottie-web canvas,
filled with the green baize) and stitching with `ffmpeg`; the live HTML preview is the
quickest way to eyeball changes.
## Tunables (top of `build/generate.js`)
| Symbol | Meaning |
|--------|---------|
| `OP`, `FR` | loop length (frames) / fps — overall speed |
| `T_HIT / T_PEAK / T_LIFT / T_REC` | beats: contact / squash peak / lift-off / cushion recovered |
| `EI / EO / ES` | easings: fast fall / fast rise / slow soft cushion |
| `APEX`, `FLOOR` | bottom-edge screen-y at the top of the bounce / at rest |
| `HW`, `HH`, `CR` | tile half-width / half-height / corner radius |
| `scl` squash values | the squash amount (scaleX↑ / scaleY↓) |
| `bulge` `b` | cushion depth (how far the sides bow out) |
| `FACE / FACE_GLINT` | wood face / glint flash |
| `BORDER` | tile rim colour |
| `BLACK / BLACK_LIT` | glyph / glyph-at-glint (brown-red) |
| preview bg `#3C7858` | the green-baize colour used **only** in the GIF |
+31
View File
@@ -0,0 +1,31 @@
'use strict';
const fs = require('fs');
const data = fs.readFileSync(process.argv[2] || 'erudit.json', 'utf8');
const lottiePath = __dirname + '/node_modules/lottie-web/build/player/lottie.min.js';
const html = `<!doctype html><html><head><meta charset="utf8"><style>
body{margin:0;background:#2b2b2b;font-family:sans-serif;color:#ccc}
.row{display:flex;gap:18px;padding:18px;align-items:flex-end;flex-wrap:wrap}
.cell{text-align:center}
.chk{background-image:linear-gradient(45deg,#8a8a8a 25%,transparent 25%),linear-gradient(-45deg,#8a8a8a 25%,transparent 25%),linear-gradient(45deg,transparent 75%,#8a8a8a 75%),linear-gradient(-45deg,transparent 75%,#8a8a8a 75%);background-size:16px 16px;background-position:0 0,0 8px,8px -8px,-8px 0;background-color:#b5b5b5}
.s96{width:96px;height:96px}.big{width:336px;height:336px}small{font-size:11px}
</style></head><body><div class="row" id="row"></div>
<script src="./lottie.min.js"></script>
<script>
const animationData=${data};window.AD=animationData;
const row=document.getElementById('row');window.anims=[];
function make(cls,frame,label){
const cell=document.createElement('div');cell.className='cell';
const box=document.createElement('div');box.className='chk '+cls;cell.appendChild(box);
cell.appendChild(document.createElement('br'));
const cap=document.createElement('small');cap.textContent=label;cell.appendChild(cap);
row.appendChild(cell);
const a=lottie.loadAnimation({container:box,renderer:'svg',loop:false,autoplay:false,animationData:JSON.parse(JSON.stringify(animationData))});
a.addEventListener('DOMLoaded',()=>a.goToAndStop(frame,true));
window.anims.push(a);
}
[0,22,45,68].forEach(f=>make('s96',f,'f'+f));
make('big',22,'f22 x3.5');
window.__ready=true;
</script></body></html>`;
fs.writeFileSync(process.argv[3] || 'preview.html', html);
console.log('wrote', process.argv[3] || 'preview.html');
+67
View File
@@ -0,0 +1,67 @@
'use strict';
// Extract the Cyrillic "Э" (U+042D) and the digit "8" outlines from a grotesque
// font (LiberationSans = Arial-metric, matching the game's system-ui/Arial stack)
// and emit Lottie cubic-bezier contours, centred at the origin, y-down.
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
function glyphContours(ch) {
const p = font.charToGlyph(ch).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 };
});
return { contours: out, bbox: { x: minx, y: miny, w: maxx - minx, h: maxy - miny } };
}
const E = glyphContours('Э');
const D8 = glyphContours('8');
fs.writeFileSync('glyphs.json', JSON.stringify({ em: FS, E, D8 }));
console.log('Э bbox', E.bbox, 'contours', E.contours.map(c => c.v.length));
console.log('8 bbox', D8.bbox, 'contours', D8.contours.map(c => c.v.length));
+153
View File
@@ -0,0 +1,153 @@
'use strict';
// VK preloader Lottie: a flat wooden Erudit tile ("Э" + score "8") that drops in
// under gravity, squashes on impact (convex cushion bulging out the left/right edges
// + a touch of skew), and springs back up — looping. A light "glint" is caught at the
// moment of the bounce. Pure 2D shapes (ddd:0). Glyphs are real LiberationSans
// outlines (Arial-metric, = the game's font stack); see extract.js -> glyphs.json.
const fs = require('fs');
const G = JSON.parse(fs.readFileSync(__dirname + '/glyphs.json', 'utf8'));
// ---- canvas / timing -------------------------------------------------------
const W = 96, H = 96, cx = 48;
const FR = 30, OP = 34; // ~1.13 s loop (dynamic, 25% faster)
// keyframe beats (frames): fast fall -> soft squash -> lift -> fast rise -> apex (no dwell)
const T_HIT = 13, T_PEAK = 18, T_LIFT = 19, T_REC = 28;
// ---- geometry --------------------------------------------------------------
const HW = 26, HH = 26, CR = 6; // tile half-width / half-height / corner radius
const FLOOR = 90, APEX = 60; // bottom-centre screen-y at rest / at the top of the bounce
// ---- palette ---------------------------------------------------------------
const col = h => [parseInt(h.slice(1,3),16)/255, parseInt(h.slice(3,5),16)/255, parseInt(h.slice(5,7),16)/255];
const FACE = col('#D9B978'); // wood face (flat)
const FACE_GLINT = col('#E7CD95'); // face flash caught on impact (gentle, not blinding)
const BORDER = col('#B49559'); // tile rim: a touch darker than the face, reads on any bg
const BLACK = col('#1A1A1A');
const BLACK_LIT = col('#421A0B'); // glyph warms to brown-red on the glint
const lerp = (a, b, t) => a.map((v, i) => v + (b[i] - v) * t);
// ---- property / easing helpers ---------------------------------------------
const still = v => ({ a: 0, k: v });
const EI = { o: { x: [0.82], y: [0] }, i: { x: [1], y: [1] } }; // strong accelerate (gravity fall)
const EO = { o: { x: [0], y: [0] }, i: { x: [0.18], y: [1] } }; // strong decelerate (rise to apex)
const ES = { o: { x: [0.42], y: [0] }, i: { x: [0.58], y: [1] } }; // soft/slow (the cushion)
function anim(samples) { // samples: {t, v, e?} ; e = easing toward the NEXT key
return { a: 1, k: samples.map((s, idx) => {
const kf = { t: s.t, s: s.v };
if (idx < samples.length - 1) { const e = s.e || ES; kf.o = e.o; kf.i = e.i; }
return kf;
}) };
}
const animColor = s => anim(s.map(x => ({ t: x.t, v: [...x.v, 1], e: x.e })));
// ---- shape helpers ---------------------------------------------------------
const tr = () => ({ ty: 'tr', p: still([0,0]), a: still([0,0]), s: still([100,100]), r: still(0), o: still(100) });
const grp = (items, nm) => ({ ty: 'gr', it: [...items, tr()], nm });
const fill = c => ({ ty: 'fl', c, o: still(100), r: 1, bm: 0 });
const stroke = (c,w) => ({ ty: 'st', c: still(c), o: still(100), w: still(w), lc: 2, lj: 2, ml: 4, bm: 0 });
function glyph(gd, hpx, px, py, bold, colour, nm) { // font glyph -> filled+stroked group
const sc = hpx / gd.bbox.h;
const bcx = gd.bbox.x + gd.bbox.w / 2, bcy = gd.bbox.y + gd.bbox.h / 2;
const shapes = gd.contours.map(ct => ({
ty: 'sh', d: 1, ks: still({
i: ct.i.map(p => [p[0]*sc, p[1]*sc]), o: ct.o.map(p => [p[0]*sc, p[1]*sc]),
v: ct.v.map(p => [(p[0]-bcx)*sc + px, (p[1]-bcy)*sc + py]), c: true,
}),
}));
return grp([...shapes, fill(colour), stroke(BLACK, bold)], nm);
}
// Sample the rest rounded-rect outline (clockwise): fine corner arcs + one mid point
// per straight edge, so a smooth interpolant reproduces it cleanly.
function arc(ccx, ccy, a0, a1, n) {
const out = [];
for (let i = 0; i < n; i++) { const a = (a0 + (a1 - a0) * i / (n - 1)) * Math.PI / 180; out.push([ccx + CR*Math.cos(a), ccy + CR*Math.sin(a)]); }
return out;
}
const REST = (() => {
const NC = 7, yi = HH - CR, xi = HW - CR;
const C = [ arc(xi,-yi,-90,0,NC), arc(xi,yi,0,90,NC), arc(-xi,yi,90,180,NC), arc(-xi,-yi,180,270,NC) ];
const pts = [];
for (let k = 0; k < 4; k++) {
pts.push(...C[k]);
const a = C[k][NC-1], b = C[(k+1)%4][0];
pts.push([(a[0]+b[0])/2, (a[1]+b[1])/2]); // straight-edge mid point (keeps the edge straight)
}
return pts;
})();
// The whole left/right contour (corners + side) bows out by one smooth barrel profile;
// Catmull-Rom tangents (from neighbours) make every junction C1-smooth -> no kink, the
// corners follow the cushion and the cushion melts into the corners, bulged or not.
function facePath(b) {
const f = y => b * (1 - (y/HH) * (y/HH)); // 0 at top/bottom, max at the middle
const V = REST.map(([x, y]) => [x + Math.sign(x) * f(y), y]); // push the sides out, top/bottom stay
const n = V.length, I = [], O = [];
for (let k = 0; k < n; k++) {
const p0 = V[(k-1+n)%n], p1 = V[k], p2 = V[(k+1)%n];
let dx = p2[0]-p0[0], dy = p2[1]-p0[1]; // tangent direction (neighbours)
const dl = Math.hypot(dx, dy) || 1; dx /= dl; dy /= dl;
const ln = Math.hypot(p2[0]-p1[0], p2[1]-p1[1]) / 3; // handles ~ 1/3 of the adjacent chord
const lp = Math.hypot(p1[0]-p0[0], p1[1]-p0[1]) / 3; // -> chordal spline, no overshoot/facets
O.push([dx*ln, dy*ln]); I.push([-dx*lp, -dy*lp]);
}
return { i: I, o: O, v: V, c: true };
}
const facePathKeys = samples => ({ a: 1, k: samples.map((s, idx) => {
const kf = { t: s.t, s: [facePath(s.b)] };
if (idx < samples.length - 1) { const e = s.e || ES; kf.o = e.o; kf.i = e.i; }
return kf;
}) });
// ---- animation tracks ------------------------------------------------------
// bottom-centre screen-y (the tile rests/squashes on its bottom edge)
const posY = anim([
{ t: 0, v: [cx, APEX, 0], e: EI }, // apex -> immediately falls (no dwell)
{ t: T_HIT, v: [cx, FLOOR, 0], e: ES }, // FAST fall (accelerate) -> floor
{ t: T_LIFT, v: [cx, FLOOR, 0], e: EO }, // sit on the floor while the cushion absorbs
{ t: OP, v: [cx, APEX, 0] }, // FAST rise (decelerate) -> apex = loop start
]);
// scale about the bottom anchor: stretch while falling, gentle/slow cushion squash on impact
const scl = anim([
{ t: 0, v: [100, 100, 100], e: ES },
{ t: T_HIT-5, v: [95, 106, 100], e: ES }, // stretch while falling fast
{ t: T_HIT, v: [104, 95, 100], e: ES }, // touch down
{ t: T_PEAK, v: [112, 86, 100], e: ES }, // soft squash peak (gentler, less plче)
{ t: T_REC, v: [100, 100, 100], e: ES }, // slow cushion release -> soft landing
{ t: OP, v: [100, 100, 100] },
]);
// a touch of skew through the squash (organic deform), back to 0
const skw = anim([
{ t: T_HIT, v: [0], e: ES }, { t: T_PEAK, v: [4], e: ES }, { t: T_REC, v: [0] }, { t: OP, v: [0] },
]);
// left/right cushion bulge: 0 -> gentle peak -> 0 (never inward)
const bulge = facePathKeys([
{ t: T_HIT, b: 0, e: ES }, { t: T_PEAK, b: 4, e: ES }, { t: T_REC, b: 0 }, { t: OP, b: 0 },
]);
// glint: face + glyph catch the light at the bounce
const faceCol = animColor([
{ t: T_HIT-2, v: FACE, e: ES }, { t: T_PEAK, v: FACE_GLINT, e: ES }, { t: T_REC, v: FACE }, { t: OP, v: FACE },
]);
const glyphCol = animColor([
{ t: T_HIT-2, v: BLACK, e: ES }, { t: T_PEAK, v: BLACK_LIT, e: ES }, { t: T_REC, v: BLACK }, { t: OP, v: BLACK },
]);
// ---- layer (face + glyphs share the bounce/squash transform) ---------------
const faceShape = grp([ { ty: 'sh', d: 1, ks: bulge }, fill(faceCol), stroke(BORDER, 1.8) ], 'face');
const glyphE = glyph(G.E, 32, 0, -1, 1.0, glyphCol, 'E'); // centred Э
const glyph8 = glyph(G.D8, 8.5, HW-6.5, HH-7.5, 0.5, glyphCol, 'score'); // 8 in the corner
const tile = {
ddd: 0, ind: 1, ty: 4, nm: 'tile', sr: 1,
ks: { o: still(100), r: still(0), p: posY, a: still([0, HH, 0]), s: scl, sk: skw, sa: still(0) },
ao: 0, shapes: [ glyph8, glyphE, faceShape ], ip: 0, op: OP, st: 0, bm: 0,
};
const root = {
v: '5.7.4', fr: FR, ip: 0, op: OP, w: W, h: H, nm: 'erudit-vk-loader', ddd: 0,
assets: [], layers: [ tile ], markers: [],
};
const out = process.argv[2] || 'erudit.json';
const round = (k, v) => (typeof v === 'number' ? Math.round(v * 100) / 100 : v);
fs.writeFileSync(out, JSON.stringify(root, round));
console.log('wrote', out, fs.statSync(out).size, 'bytes');
File diff suppressed because one or more lines are too long
Binary file not shown.

After

Width:  |  Height:  |  Size: 91 KiB

File diff suppressed because one or more lines are too long
+48 -19
View File
@@ -33,18 +33,25 @@ real game seating the caller with an **empty opponent seat** (status `open`) or,
another player already waits for the same variant and per-turn word rule, seats the
caller into that open game and starts it — and
friend-game invitations (invite → accept, starting a 24 player game once every
invitee accepts). A **simultaneous-game cap** (`game.MaxActiveQuickGames` = 10) limits a
player's active quick games — status `active`/`open`, excluding invitation-linked friend
games (`game.Service.CountActiveQuickGames`); the server refuses `lobby/enqueue` and
`invitations` creation with **409 `game_limit_reached`** at the cap (accepting an invitation
is exempt), and the `games.list` response carries an `at_game_limit` flag for the lobby.
invitee accepts). **Per-tier, per-kind active-game caps** limit a player's simultaneous
unfinished games by kind — `vs_ai`, `random` (quick auto-match), `friends` — with separate
guest and durable-account tiers held in the single-row `backend.config` table (a `-1` means
unlimited), read through an in-memory cache (`internal/gamelimits`) and tuned live in the admin
(`/_gm/limits`, no redeploy). Each game is tagged with its `games.game_kind` on creation;
`game.Service.AtGameLimit` counts the account's `active`/`open` games of a kind against the
tier's cap. The server refuses `lobby/enqueue` (random/vs_ai) with **409 `game_limit_reached`**
at the cap, and the `invitations` (friends) path enforces the durable friends cap and refuses a
**guest** outright (guests cannot use friends); accepting an invitation is exempt. The
`games.list` response carries an `at_game_limit` flag (the random-kind cap) for the lobby.
`internal/social` owns the friend graph (request/accept),
per-user blocks, and per-game chat with nudges folded in as a message kind; chat
messages are length-capped, content-filtered (no links/emails/phone numbers,
including obfuscated forms) and stored with the sender's IP. Each message carries an
`unread_seats` read bitmask (a set bit per recipient seat still to read it); `MarkRead`
clears a reader's bit when they open the move history or chat, and a wired `NudgeClearer`
clears a nudge when its recipient moves — both record the publish-to-read latency.
clears a reader's bit when they open the move history or chat, a wired `NudgeClearer`
clears a nudge when its recipient moves, and a wired `NudgeExpirer` clears **all** of a game's
nudges when it finishes (any completion path) — the first two record the publish-to-read latency,
the completion expiry does not (it is not a read); chat messages stay unread on completion.
A friend request (or block) aimed at a **disguised pooled robot** is recorded per game+seat
in `robot_friend_requests` / `robot_blocks`, never against the shared robot account; a
background reaper drops a robot friend request once its game has been finished for **7 days**.
@@ -98,7 +105,10 @@ state, lobby enqueue, chat). The social/account/history operations under
`/api/v1/user`: `friends/*` (request/respond/cancel/unfriend,
list/incoming, the one-time `code` issue/redeem), `blocks/*`, `invitations/*`
(create/accept/decline/cancel/list), `PUT profile`, `email/{request,confirm}`,
`stats`, and `games/:id/gcg` (finished-only). The `internal/notify` hub feeds a
`stats`, `games/:id/gcg` (finished-only), and the payments `wallet` /
`wallet/catalog` (`GET` — the context-visible chip segments + benefits, and the
storefront catalog) / `wallet/buy` (`POST` — a chip spend on a value), served by
`internal/payments` behind the store-compliance gate. The `internal/notify` hub feeds a
second listener — `internal/pushgrpc`, a gRPC server (`BACKEND_GRPC_ADDR`) streaming
live events (your-turn, opponent-moved, chat, nudge, match-found, notify) to the
gateway. The gateway-only `POST /api/v1/internal/push-target` (a user's
@@ -130,21 +140,38 @@ so `/internal/push-target` returns the recipient's `preferred_language` as the r
language for out-of-app push; no per-bot routing remains. The console also manages the **advertising banner** (`/_gm/banners` +
`/_gm/banner-settings`, `internal/ads`): operator campaigns with a percent weight, an optional
window and bilingual messages, plus the global display timings. `GET /api/v1/user/profile` attaches
the resolved, weighted campaign feed for an **eligible** viewer (`!paid_account && hint_balance == 0
&& !no_banner` role, the message language picked by `preferred_language`); changing those inputs
publishes a `notify` `banner` re-poll signal so the client shows/hides it in place. The shared wire
the resolved, weighted campaign feed for an **eligible** viewer (no active **no-ads** benefit
applicable in the current context and no **`no_banner`** role; the message language picked by
`preferred_language`); changing those inputs
publishes a `notify` `banner` re-poll signal so the client shows/hides it in place.
The same gate drives the post-move interstitial config (`Profile.ads`, `adsFor`). The user card
also carries a **finance panel** (`payments.AccountStatement`): the account's chip balances per
funding segment, benefits per origin, the recorded refund risk, and the append-only ledger history
(newest first) — read straight from the payments tables, uncached. The **catalog editor**
(`/_gm/catalog`, `handlers_admin_catalog.go`) is the source of truth for products (D32): create /
edit / archive-unarchive (the `product.active` flag) products, their atoms and per-rail prices, and
hard-delete only a **never-transacted** product (an order/ledger reference forces archive-only,
backed by the FK); a `tournament`-bearing product is composable but not sellable yet. The user card
also carries an admin **grant** panel: grant raw benefit atoms (hints / no-ads days / forever) or a
defined **value product** (a reward bundle, including an archived one), origin-picked; both write an
`admin_grant` ledger row via `payments.Grant` / `GrantProduct` and **refuse** a chips or `tournament`
atom (never grant currency; no tournament target yet). Each fund row in the panel carries a **Refund**
action (`payments.RefundOrderFull`): a full-order refund the operator records after refunding on the
rail — a `refund` ledger row + a floor-0 chip revoke, idempotent. A **ledger CSV export**
(`/_gm/ledger.csv`, `payments.LedgerExport`) dumps the whole append-only ledger for tax +
reconciliation. The shared wire
contracts live in the sibling [`../pkg`](../pkg) module.
**Account linking & merge** (`/api/v1/user/link/*`). `internal/link`
orchestrates it: an email confirm-code or a gateway-validated Telegram identity is
attached to the current account, and when the identity already has its own account
the two are merged in one transaction (`internal/accountmerge`) — stats and the hint
wallet summed, `paid_account` ORed, identities/games/chat/complaints transferred,
the two are merged in one transaction (`internal/accountmerge`) — stats summed,
identities/games/chat/complaints transferred,
friends/blocks de-duplicated, the secondary kept as a `merged_into` tombstone (so a
shared finished game's foreign keys hold); a shared **active** game blocks the merge.
The current account is primary, except a guest initiator whose linked identity has a
durable owner — then the durable account wins and a fresh session is minted for it.
The `accounts.paid_account`/`merged_into`/`merged_at` columns back this. This supersedes the
The `accounts.merged_into`/`merged_at` columns back this. This supersedes the
former `email.bind.*` edge surface (the `RequestCode`/`ConfirmCode` primitives stay).
Rate-limit observability: the gateway posts its periodic rejection
@@ -212,11 +239,13 @@ internal/banview/ # gateway active-ban mirror: the console's Active IP bans p
| `BACKEND_LOBBY_ROBOT_WAIT` | `10s` | Auto-match wait before a robot is substituted for a missing human. |
| `BACKEND_LOBBY_REAPER_INTERVAL` | `1s` | How often the substitution reaper scans for over-waited players. |
| `BACKEND_ROBOT_DRIVE_INTERVAL` | `30s` | How often the robot driver scans for due robot turns. |
| `BACKEND_SMTP_HOST` | — | Email relay host. **Empty selects the development log mailer** (the confirm-code is logged, not sent). |
| `BACKEND_SMTP_PORT` | `587` | Email relay port. |
| `BACKEND_SMTP_USERNAME` | — | SMTP user; empty relays without authentication. |
| `BACKEND_SMTP_PASSWORD` | — | SMTP password. |
| `BACKEND_SMTP_FROM` | `no-reply@localhost` | Envelope/From address for confirm-codes. |
| `BACKEND_SMTP_HOST` | — | Confirm-code relay host. **Empty selects the development log mailer** (the code is logged, not sent). |
| `BACKEND_SMTP_PORT` | `587` | Relay port. No client certificate is needed (the server cert is validated against the system roots). |
| `BACKEND_SMTP_TLS` | — | Transport security: `ssl` (implicit TLS from connect) or `starttls`. Empty derives it from the port (implicit on `465`, STARTTLS otherwise); set it for a relay on a non-standard port (e.g. Selectel's `1127` = SSL, `1126` = STARTTLS). |
| `BACKEND_SMTP_USERNAME` | — | SMTP AUTH user; empty relays without authentication. |
| `BACKEND_SMTP_PASSWORD` | — | SMTP AUTH password. |
| `BACKEND_SMTP_FROM` | `no-reply@localhost` | From address. A deployed contour must use the prod domain (the relay only accepts its verified sender domain). |
| `BACKEND_PUBLIC_BASE_URL` | — | Canonical public origin (scheme + host) for links in the email. **Required when `BACKEND_SMTP_HOST` is set.** Never derived from a request Host header (anti-injection). |
| `BACKEND_CONNECTOR_ADDR` | — | the gateway bot-link relay gRPC address for admin-console operator broadcasts. Empty disables broadcasts. |
| `BACKEND_GUEST_REAP_INTERVAL` | `1h` | How often the abandoned-guest reaper sweeps. |
| `BACKEND_GUEST_RETENTION` | `720h` | Account age past which a guest with no game seat is deleted. |
+134 -3
View File
@@ -20,6 +20,7 @@ import (
"scrabble/backend/internal/account"
"scrabble/backend/internal/accountmerge"
"scrabble/backend/internal/adminalert"
"scrabble/backend/internal/ads"
"scrabble/backend/internal/banview"
"scrabble/backend/internal/config"
@@ -27,12 +28,15 @@ import (
"scrabble/backend/internal/engine"
"scrabble/backend/internal/feedback"
"scrabble/backend/internal/game"
"scrabble/backend/internal/gamelimits"
"scrabble/backend/internal/link"
"scrabble/backend/internal/lobby"
"scrabble/backend/internal/notify"
"scrabble/backend/internal/payments"
"scrabble/backend/internal/postgres"
"scrabble/backend/internal/pushgrpc"
"scrabble/backend/internal/ratewatch"
"scrabble/backend/internal/render"
"scrabble/backend/internal/robot"
"scrabble/backend/internal/server"
"scrabble/backend/internal/session"
@@ -43,6 +47,10 @@ import (
// telemetryShutdownTimeout bounds the OpenTelemetry flush during process exit.
const telemetryShutdownTimeout = 5 * time.Second
// adminAlertInterval is how often the operator-alert worker checks for new feedback /
// complaints; a burst within one interval coalesces into a single digest email.
const adminAlertInterval = 5 * time.Minute
func main() {
cfg, err := config.Load()
if err != nil {
@@ -149,6 +157,17 @@ func run(ctx context.Context, cfg config.Config, logger *zap.Logger) error {
logger.Info("active dictionary version", zap.String("version", games.ActiveVersion()))
games.SetNotifier(hub)
games.SetMetrics(tel.MeterProvider().Meter("scrabble/backend/game"))
// Active-game limit config: the per-tier, per-kind caps in backend.config, read once into an
// in-memory cache at boot and refreshed when the admin edits them. A boot-time load fails fast if
// the single config row is missing; the game domain reads the cache on every new-game gate.
gameLimits := gamelimits.NewService(gamelimits.NewStore(db))
if err := gameLimits.Load(ctx); err != nil {
return fmt.Errorf("load game-limit config: %w", err)
}
games.SetGameLimits(gameLimits)
logger.Info("game-limit config loaded")
go games.RunSweeper(ctx, cfg.Game.TimeoutSweepInterval)
logger.Info("game turn-timeout sweeper started",
zap.Duration("interval", cfg.Game.TimeoutSweepInterval))
@@ -161,6 +180,15 @@ func run(ctx context.Context, cfg config.Config, logger *zap.Logger) error {
zap.Duration("interval", cfg.GuestReapInterval),
zap.Duration("retention", cfg.GuestRetention))
// Purge the account-deletion legal dossier past its retention TTL: the
// retained-identities journal, and the feedback thread + dossier PII of long-deleted
// accounts (chat is kept). Checked daily; the TTL is a two-year policy constant.
retentionReaper := account.NewRetentionReaper(accounts, account.RetentionTTL, logger)
go retentionReaper.Run(ctx, 24*time.Hour)
logger.Info("retention reaper started",
zap.Duration("interval", 24*time.Hour),
zap.Duration("retention", account.RetentionTTL))
// Re-evaluate moderated-chat write access when a temporary block self-expires:
// no operator action fires then, so the sweeper emits the chat-access-changed
// event for lapsed blocks and the gateway re-pushes the chat-gate command.
@@ -173,15 +201,21 @@ func run(ctx context.Context, cfg config.Config, logger *zap.Logger) error {
// Lobby & social domains. Their REST and stream surface lives in the gateway,
// so they are handed to the server (like the route groups) for the handlers.
mailer := newMailer(cfg.SMTP, logger)
emails := account.NewEmailService(accounts, mailer)
emails := account.NewEmailService(accounts, mailer, cfg.PublicBaseURL)
// Throttle confirm-code sends per recipient: at most one per minute and five per
// rolling hour, guarding against email bombing and the relay's own quota.
emails.SetSendLimiter(account.NewSendLimiter(time.Minute, 5))
// Account linking & merge: the orchestrator over the account, merge and
// session layers. Wired to the /api/v1/user/link REST surface below.
links := link.NewService(emails, accounts, accountmerge.NewMerger(db), sessions)
merger := accountmerge.NewMerger(db)
links := link.NewService(emails, accounts, merger, sessions)
socialSvc := social.NewService(social.NewStore(db), accounts, games)
socialSvc.SetNotifier(hub)
socialSvc.SetMetrics(tel.MeterProvider().Meter("scrabble/backend/social"))
// A nudge the recipient answered by moving is marked read on the move path.
// A nudge the recipient answered by moving is marked read on the move path; every nudge in a
// game is marked read when the game finishes (a stale badge), on any completion path.
games.SetNudgeClearer(socialSvc.ClearNudges)
games.SetNudgeExpirer(socialSvc.ExpireNudges)
// Reap per-game disguised-robot friend requests once their game is long finished
// (the robot ignores them; the row only pins the in-game "request sent" state).
robotReqReaper := social.NewRobotFriendRequestReaper(socialSvc, logger)
@@ -192,6 +226,16 @@ func run(ctx context.Context, cfg config.Config, logger *zap.Logger) error {
feedbackSvc := feedback.NewService(feedback.NewStore(db), accounts)
feedbackSvc.SetNotifier(hub)
// Operator alert emails on new feedback / word complaints, coalesced into one digest
// per interval. Inert unless a distinct admin sender and recipient are configured.
if cfg.SMTP.AdminFrom != "" && cfg.SMTP.AdminTo != "" {
// The alert digest carries no admin-console link on purpose — an admin URL must not
// travel in an email (a mail provider could cache or index it); see adminalert.New.
alerts := adminalert.New(mailer, feedbackSvc, games, cfg.SMTP.AdminFrom, cfg.SMTP.AdminTo, logger)
go alerts.Run(ctx, adminAlertInterval)
logger.Info("admin alert worker started", zap.Duration("interval", adminAlertInterval))
}
// Robot opponent: provision its durable account pool (a hard startup
// dependency, like the dictionaries) and start its move driver. The matchmaker
// substitutes a pooled robot for a missing human after the wait window.
@@ -230,6 +274,35 @@ func run(ctx context.Context, cfg config.Config, logger *zap.Logger) error {
// block and the banner admin console section.
adsSvc := ads.NewService(ads.NewStore(db))
// In-game currency domain (data foundation): the payments schema behind a
// narrow interface. A boot-time reachability check fails fast if the schema
// did not migrate; the wallet routes are registered when that surface lands.
paymentsSvc := payments.NewService(payments.NewStore(db))
if err := paymentsSvc.Ping(ctx); err != nil {
return fmt.Errorf("payments schema unreachable: %w", err)
}
logger.Info("payments domain ready")
// Warm the public-offer price list cache so /offer/ serves the current catalog from the first
// request; it is reprojected lazily thereafter on any catalog edit. Non-fatal — a transient
// failure here only defers the projection to the first read.
if _, err := paymentsSvc.OfferPricing(ctx); err != nil {
logger.Warn("offer pricing warm failed; will project on first request", zap.Error(err))
}
// Wire the payments surface into the domains that consume it: the online-game hint wallet
// and the account-merge wallet fold. Done after the reachability check so a broken payments
// schema fails boot before anything depends on it.
games.SetHintWallet(paymentsSvc)
merger.SetPayments(paymentsSvc)
// The image-render sidecar client for the PNG export artifact; nil (PNG
// download answers 404) when BACKEND_RENDERER_URL is unset.
var renderer *render.Client
if cfg.RendererURL != "" {
renderer = render.New(cfg.RendererURL)
}
srv := server.New(cfg.HTTPAddr, server.Deps{
Logger: logger,
DB: db,
@@ -250,7 +323,12 @@ func run(ctx context.Context, cfg config.Config, logger *zap.Logger) error {
RateWatch: rateWatch,
BanView: banView,
Ads: adsSvc,
Payments: paymentsSvc,
GameLimits: gameLimits,
Notifier: hub,
ExportSignKey: cfg.ExportSignKey,
Renderer: renderer,
Robokassa: cfg.Robokassa,
})
pushSrv := pushgrpc.NewServer(cfg.GRPCAddr, hub, logger)
@@ -259,6 +337,13 @@ func run(ctx context.Context, cfg config.Config, logger *zap.Logger) error {
logger.Info("servers starting",
zap.String("http_addr", cfg.HTTPAddr),
zap.String("grpc_addr", cfg.GRPCAddr))
// Sweep expired pending payment orders on a cadence (cosmetic hygiene; a late valid callback
// still credits). Runs until ctx is cancelled.
go runOrderReaper(ctx, paymentsSvc, logger)
// Deliver pending payment_events to connected clients as an in-app wallet-refresh push (the
// credit already landed in the ledger; a return-focus poll is the client-side fallback).
go runPaymentDispatcher(ctx, paymentsSvc, hub, logger)
errc := make(chan error, 2)
go func() { errc <- pushSrv.Run(ctx) }()
go func() { errc <- srv.Run(ctx) }()
@@ -268,6 +353,52 @@ func run(ctx context.Context, cfg config.Config, logger *zap.Logger) error {
return err
}
// runOrderReaper periodically expires pending payment orders past their configured lifetime, until
// ctx is cancelled. Expiry is cosmetic: a later valid provider callback still credits an expired
// order.
func runOrderReaper(ctx context.Context, p *payments.Service, log *zap.Logger) {
t := time.NewTicker(5 * time.Minute)
defer t.Stop()
for {
select {
case <-ctx.Done():
return
case <-t.C:
if n, err := p.ExpireOrders(ctx); err != nil {
log.Warn("order reaper: sweep failed", zap.Error(err))
} else if n > 0 {
log.Info("order reaper: expired pending orders", zap.Int("count", n))
}
}
}
}
// runPaymentDispatcher delivers pending payment_events to connected clients as a wallet-refresh
// signal (KindNotification / "payment"), marking each delivered, until ctx is cancelled. The credit
// already committed to the ledger; this is only the in-app push so an open wallet updates in place.
func runPaymentDispatcher(ctx context.Context, p *payments.Service, pub notify.Publisher, log *zap.Logger) {
t := time.NewTicker(3 * time.Second)
defer t.Stop()
for {
select {
case <-ctx.Done():
return
case <-t.C:
evs, err := p.UndispatchedEvents(ctx, 50)
if err != nil {
log.Warn("payment dispatcher: read failed", zap.Error(err))
continue
}
for _, e := range evs {
pub.Publish(notify.Notification(e.AccountID, notify.NotifyPayment))
if err := p.MarkEventDispatched(ctx, e.EventID); err != nil {
log.Warn("payment dispatcher: mark failed", zap.String("event", e.EventID.String()), zap.Error(err))
}
}
}
}
}
// newMailer builds the confirm-code mailer: an SMTP relay when a host is
// configured, otherwise the development log mailer (the code is logged, not sent).
func newMailer(cfg account.SMTPConfig, logger *zap.Logger) account.Mailer {
+193
View File
@@ -0,0 +1,193 @@
// Command dictgen dumps golden parity vectors from the committed dawg
// dictionaries so the TypeScript dawg reader can be checked byte-for-byte
// against the authoritative Go dafsa reader.
//
// For each *.dawg file it writes, into the output directory:
//
// - <name>.words.bin — every stored word as alphabet-index bytes, in index
// order, framed as [1-byte length][length index bytes]. The word at stream
// position k has IndexOfB == k.
// - <name>.neg.bin — negative lookups (sequences whose IndexOfB is -1), same
// framing, to exercise the not-found path at varying depths.
// - <name>.meta.json — NumAdded/NumNodes/NumEdges plus the alphabet size, for
// a header-parse sanity cross-check on the TS side.
//
// It is a development tool (not built into any service), analogous to
// cmd/jetgen. Run it from the repository root:
//
// go run ./backend/cmd/dictgen -dawg-dir ../scrabble-solver/dawg -out <dir>
package main
import (
"bufio"
"bytes"
"encoding/json"
"flag"
"fmt"
"math/rand"
"os"
"path/filepath"
"sort"
"strings"
dawg "github.com/iliadenisov/dafsa"
)
// meta is the per-dictionary sanity payload cross-checked by the TS reader.
type meta struct {
NumAdded int `json:"numAdded"`
NumNodes int `json:"numNodes"`
NumEdges int `json:"numEdges"`
Alphabet int `json:"alphabet"`
}
func main() {
dawgDir := flag.String("dawg-dir", "../scrabble-solver/dawg", "directory holding the .dawg files")
outDir := flag.String("out", "", "output directory for the golden files (required)")
negCount := flag.Int("neg", 20000, "number of negative lookups to emit per dictionary")
flag.Parse()
if *outDir == "" {
fail("-out is required")
}
if err := os.MkdirAll(*outDir, 0o755); err != nil {
fail("mkdir out: %v", err)
}
files, err := filepath.Glob(filepath.Join(*dawgDir, "*.dawg"))
if err != nil {
fail("glob: %v", err)
}
sort.Strings(files)
if len(files) == 0 {
fail("no .dawg files in %s", *dawgDir)
}
for _, f := range files {
if err := process(f, *outDir, *negCount); err != nil {
fail("%s: %v", filepath.Base(f), err)
}
}
}
// process emits the golden files for a single dawg dictionary.
func process(path, outDir string, negCount int) error {
name := strings.TrimSuffix(filepath.Base(path), ".dawg")
data, err := os.ReadFile(path)
if err != nil {
return err
}
finder, err := dawg.Read(bytes.NewReader(data), 0)
if err != nil {
return fmt.Errorf("read dawg: %w", err)
}
defer finder.Close()
// Stream every stored word in index order; keep a decimated sample and the
// maximum alphabet index for negative generation.
wf, err := os.Create(filepath.Join(outDir, name+".words.bin"))
if err != nil {
return err
}
bw := bufio.NewWriter(wf)
var (
count int
maxIx byte
sample [][]byte
)
finder.EnumerateB(func(index int, word []byte, final bool) int {
if !final {
return 0 // Continue
}
if index != count {
panic(fmt.Sprintf("%s: enumerate index gap: got %d want %d", name, index, count))
}
writeWord(bw, word)
for _, b := range word {
if b > maxIx {
maxIx = b
}
}
if count%4 == 0 && len(sample) < 60000 {
sample = append(sample, append([]byte(nil), word...))
}
count++
return 0 // Continue
})
if err := bw.Flush(); err != nil {
return err
}
if err := wf.Close(); err != nil {
return err
}
if count != finder.NumAdded() {
return fmt.Errorf("word count %d != NumAdded %d", count, finder.NumAdded())
}
alphabet := int(maxIx) + 1
// Negatives: mutate sampled real words and keep the ones the reader rejects.
nf, err := os.Create(filepath.Join(outDir, name+".neg.bin"))
if err != nil {
return err
}
nbw := bufio.NewWriter(nf)
rng := rand.New(rand.NewSource(1))
neg := 0
for neg < negCount && len(sample) > 0 {
base := sample[rng.Intn(len(sample))]
cand := append([]byte(nil), base...)
switch rng.Intn(3) {
case 0: // extend by one index
cand = append(cand, byte(rng.Intn(alphabet)))
case 1: // flip one index
if len(cand) > 0 {
cand[rng.Intn(len(cand))] = byte(rng.Intn(alphabet))
}
case 2: // drop the tail and flip the new last index
if len(cand) > 1 {
cand = cand[:len(cand)-1]
cand[len(cand)-1] = byte(rng.Intn(alphabet))
}
}
if finder.IndexOfB(cand) == -1 {
writeWord(nbw, cand)
neg++
}
}
if err := nbw.Flush(); err != nil {
return err
}
if err := nf.Close(); err != nil {
return err
}
m := meta{NumAdded: finder.NumAdded(), NumNodes: finder.NumNodes(), NumEdges: finder.NumEdges(), Alphabet: alphabet}
mb, err := json.MarshalIndent(m, "", " ")
if err != nil {
return err
}
if err := os.WriteFile(filepath.Join(outDir, name+".meta.json"), mb, 0o644); err != nil {
return err
}
fmt.Printf("%-12s words=%d negatives=%d alphabet=%d nodes=%d edges=%d\n",
name, count, neg, alphabet, finder.NumNodes(), finder.NumEdges())
return nil
}
// writeWord frames one index-byte word as [length][bytes].
func writeWord(w *bufio.Writer, word []byte) {
if len(word) > 255 {
panic(fmt.Sprintf("word too long to frame: %d", len(word)))
}
w.WriteByte(byte(len(word)))
w.Write(word)
}
func fail(format string, args ...any) {
fmt.Fprintf(os.Stderr, "dictgen: "+format+"\n", args...)
os.Exit(1)
}
+12 -5
View File
@@ -9,7 +9,8 @@
// 1. start a postgres:17-alpine container via testcontainers-go
// 2. open it with search_path=backend and apply the embedded goose migrations
// 3. drop goose's bookkeeping table so jet does not generate a model for it
// 4. run jet's PostgreSQL generator for schema=backend into internal/postgres/jet
// 4. run jet's PostgreSQL generator for the backend and payments schemas into
// internal/postgres/jet (one subdirectory per schema)
package main
import (
@@ -36,6 +37,7 @@ const (
superuserPassword = "scrabble"
superuserDatabase = "scrabble_backend"
backendSchema = "backend"
paymentsSchema = "payments"
containerStartup = 90 * time.Second
jetOutputDirSuffix = "internal/postgres/jet"
)
@@ -105,11 +107,16 @@ func run(ctx context.Context) error {
return fmt.Errorf("drop goose_db_version: %w", err)
}
if err := jetpostgres.GenerateDB(db, backendSchema, outputDir); err != nil {
return fmt.Errorf("jet generate: %w", err)
// Each schema generates into its own jet/<schema>/ subdirectory (the
// generator wipes only that subtree), so the two calls do not clobber each
// other. GenerateDB takes the schema explicitly, so the connection's
// search_path=backend does not affect the payments introspection.
for _, schema := range []string{backendSchema, paymentsSchema} {
if err := jetpostgres.GenerateDB(db, schema, outputDir); err != nil {
return fmt.Errorf("jet generate schema=%s: %w", schema, err)
}
log.Printf("jetgen: generated jet code into %s (schema=%s)", outputDir, schema)
}
log.Printf("jetgen: generated jet code into %s (schema=%s)", outputDir, backendSchema)
return nil
}
+417
View File
@@ -0,0 +1,417 @@
// Command movegen emits golden conformance fixtures for the client-side move
// generator port (ui/src/lib/dict). It is a dev tool, run by hand; its output is
// committed so the TypeScript parity tests run without a Go toolchain.
//
// For each small sample dictionary (English and Russian — the latter reaches
// alphabet index 32, exercising the 33-letter cross-set boundary) it writes:
//
// - sample_<tag>.dawg the serialized dictionary (the reader/cursor fixture)
// - sample_<tag>.words.json the stored words + their alphabet indexes
// - sample_<tag>.gen.json ranked move-generation results from the real solver,
// for a handful of positions, plus the ruleset the TS
// side rebuilds to score identically
//
// Positions are built with only the solver's public API: an empty board, and
// two-ply positions reached by applying the solver's own top move (so no internal
// encoding is needed). Regenerate with:
//
// go run ./backend/cmd/movegen -out ui/src/lib/dict/testdata
package main
import (
"bytes"
"encoding/json"
"flag"
"log"
"os"
"path/filepath"
"strings"
"gitea.iliadenisov.ru/developer/scrabble-solver/board"
"gitea.iliadenisov.ru/developer/scrabble-solver/rack"
"gitea.iliadenisov.ru/developer/scrabble-solver/rules"
"gitea.iliadenisov.ru/developer/scrabble-solver/scrabble"
dawg "github.com/iliadenisov/dafsa"
)
// sampleWordsEN is the English sample dictionary, in strictly increasing
// alphabet-index order (the builder requires it). Shared prefixes (car/care/cars),
// shared suffixes (cats/dogs), internal-final nodes (do, an) and a one-letter word.
var sampleWordsEN = []string{
"a", "an", "and", "ant",
"car", "care", "cared", "cares", "cars", "cat", "cats",
"do", "doe", "does", "dog", "dogs", "done", "dot",
}
// sampleWordsRU is the Russian sample dictionary, in strictly increasing index
// order. It deliberately includes words starting with я (index 32) so the ported
// cross-set handles alphabet indexes past JS's 31-bit shift boundary.
var sampleWordsRU = []string{"ад", "ар", "оса", "я", "яд", "яр"}
// sampleFixture is the JSON committed with the .dawg so the TypeScript cursor test
// knows the exact word set (as alphabet indexes) to expect from enumeration.
type sampleFixture struct {
Alphabet string `json:"alphabet"`
NumAdded int `json:"numAdded"`
Words []string `json:"words"`
Indexes [][]int `json:"indexes"`
}
// genFixture is the move-generation golden set for one sample dictionary.
type genFixture struct {
Ruleset genRuleset `json:"ruleset"`
Cases []genCase `json:"cases"`
}
// genRuleset is the scoring data the TS side rebuilds so evaluate() matches the Go
// solver: letter values, premium multipliers per square, the centre, rack size and bonus.
type genRuleset struct {
Size int `json:"size"`
Cols int `json:"cols"`
Center int `json:"center"`
RackSize int `json:"rackSize"`
Bingo int `json:"bingo"`
Values []int `json:"values"`
LetterMult [][]int `json:"letterMult"`
WordMult [][]int `json:"wordMult"`
}
// genTile is one placed tile (a board tile or a move placement).
type genTile struct {
Row int `json:"row"`
Col int `json:"col"`
Letter int `json:"letter"`
Blank bool `json:"blank"`
}
// genRack is a rack as a multiset of letter indexes plus a blank count.
type genRack struct {
Letters []int `json:"letters"`
Blanks int `json:"blanks"`
}
// genMove is one ranked generated play: its orientation, placed tiles and total score.
type genMove struct {
Dir int `json:"dir"`
Tiles []genTile `json:"tiles"`
Score int `json:"score"`
}
// genCase is one generation position: the tiles already on the board (empty when
// none), the rack, the mode/rule and the ranked moves the solver returns.
type genCase struct {
Name string `json:"name"`
Placed []genTile `json:"placed"`
Rack genRack `json:"rack"`
Mode int `json:"mode"`
IgnoreCrossWords bool `json:"ignoreCrossWords"`
Moves []genMove `json:"moves"`
}
func main() {
out := flag.String("out", "ui/src/lib/dict/testdata", "output directory for fixtures")
dawgDir := flag.String("dawg-dir", "", "when set, emit real-dictionary move-gen golden from the .dawg files in this dir (conformance mode) instead of the committed samples")
flag.Parse()
if err := os.MkdirAll(*out, 0o755); err != nil {
log.Fatalf("movegen: mkdir %s: %v", *out, err)
}
if *dawgDir != "" {
buildReal(*dawgDir, *out)
return
}
emitRulesets()
buildSample(*out, "en", rules.English(), sampleWordsEN, []genCase{
emptyCase("empty-cared", englishRack("caredts", 0), scrabble.Both, false),
emptyCase("empty-dogs", englishRack("dogsent", 0), scrabble.Both, false),
emptyCase("empty-blank", englishRack("caret", 1), scrabble.Both, false),
emptyCase("empty-single-word", englishRack("caredts", 0), scrabble.Both, true),
})
buildSample(*out, "ru", rules.RussianScrabble(), sampleWordsRU, []genCase{
emptyCase("empty-yad", russianRack("ядрасо", 0), scrabble.Both, false),
})
}
// buildSample writes the dawg, the word fixture and the generation golden set for
// one sample dictionary. Two-ply cases are appended: the solver's own top move from
// the first non-empty result is applied, then generation runs again on the new rack.
func buildSample(out, tag string, rs *rules.Ruleset, words []string, cases []genCase) {
idx := rs.Alphabet
b := dawg.New(idx)
indexes := make([][]int, 0, len(words))
for _, w := range words {
if err := b.Add(w); err != nil {
log.Fatalf("movegen[%s]: add %q: %v", tag, w, err)
}
enc, err := idx.Encode(w)
if err != nil {
log.Fatalf("movegen[%s]: encode %q: %v", tag, w, err)
}
ints := make([]int, len(enc))
for i, x := range enc {
ints[i] = int(x)
}
indexes = append(indexes, ints)
}
finder := b.Finish()
writeJSON(filepath.Join(out, "sample_"+tag+".words.json"), sampleFixture{
Alphabet: tag, NumAdded: finder.NumAdded(), Words: words, Indexes: indexes,
})
dawgPath := filepath.Join(out, "sample_"+tag+".dawg")
if _, err := finder.Save(dawgPath); err != nil {
log.Fatalf("movegen[%s]: save %s: %v", tag, dawgPath, err)
}
s := scrabble.NewSolver(rs, finder)
for i := range cases {
runCase(s, rs, &cases[i], nil)
}
// A two-ply position from the first standard case that produced a move.
if two := twoPly(s, rs, cases); two != nil {
cases = append(cases, *two)
}
writeJSON(filepath.Join(out, "sample_"+tag+".gen.json"), genFixture{
Ruleset: rulesetOf(rs), Cases: cases,
})
log.Printf("movegen[%s]: %d words, %d cases", tag, finder.NumAdded(), len(cases))
}
// realVariant maps a shipped dictionary file to the ruleset that scores it and the racks
// the conformance positions use. smallRack/blankRack keep the first-move (empty board)
// lists bounded on a dense dictionary; fullRack drives a deep 7-tile mid-game position,
// kept small by the anchors around the already-placed word.
type realVariant struct {
file, variant, smallRack, blankRack, fullRack string
rs *rules.Ruleset
}
// buildReal emits move-generation golden from the real shipped dictionaries in dawgDir —
// the full alphabets and deep graphs the tiny samples cannot reach — one
// <variant>.movegen.json per variant. Like the dictgen/validategen vectors it is
// regenerated in CI and never committed, so it pins no dictionary version into the repo.
func buildReal(dawgDir, out string) {
reals := []realVariant{
{"en_sowpods", "scrabble_en", "aine", "ain", "aeinrst", rules.English()},
{"ru_scrabble", "scrabble_ru", "аеин", "аен", "аеиноср", rules.RussianScrabble()},
{"ru_erudit", "erudit_ru", "аеин", "аен", "аеиноср", rules.Erudit()},
}
for _, v := range reals {
data, err := os.ReadFile(filepath.Join(dawgDir, v.file+".dawg"))
if err != nil {
log.Fatalf("movegen[%s]: read dawg: %v", v.variant, err)
}
finder, err := dawg.Read(bytes.NewReader(data), 0)
if err != nil {
log.Fatalf("movegen[%s]: parse dawg: %v", v.variant, err)
}
s := scrabble.NewSolver(v.rs, finder)
cases := []genCase{
emptyCase("first-move", encRack(v.rs, v.smallRack, 0), scrabble.Both, false),
emptyCase("first-move-blank", encRack(v.rs, v.blankRack, 1), scrabble.Both, false),
}
for i := range cases {
runCase(s, v.rs, &cases[i], nil)
}
// A deep 7-tile mid-game: place the top first move, then generate again. The
// anchors around the placed word bound the list while still exercising a full rack,
// deep left/right extension and wide cross-sets over the real graph.
full := encRack(v.rs, v.fullRack, 0)
b := board.New(v.rs.Rows, v.rs.Cols)
if m1 := s.GenerateMovesOpts(b, toRack(v.rs.Size(), full), scrabble.Both, scrabble.PlayOptions{}); len(m1) > 0 {
mid := genCase{Name: "mid-game", Rack: full, Mode: int(scrabble.Both)}
runCase(s, v.rs, &mid, tilesOf(m1[0].Tiles))
cases = append(cases, mid)
}
writeJSON(filepath.Join(out, v.variant+".movegen.json"), genFixture{Ruleset: rulesetOf(v.rs), Cases: cases})
total := 0
for _, c := range cases {
total += len(c.Moves)
}
_ = finder.Close()
log.Printf("movegen[%s]: %d cases, %d golden moves", v.variant, len(cases), total)
}
}
// encRack encodes a rack given as the variant's letters (plus a blank count) into the
// index-based genRack the fixtures carry.
func encRack(rs *rules.Ruleset, letters string, blanks int) genRack {
enc, err := rs.Alphabet.Encode(letters)
if err != nil {
log.Fatalf("movegen: encode rack %q: %v", letters, err)
}
idx := make([]int, len(enc))
for i, b := range enc {
idx[i] = int(b)
}
return genRack{Letters: idx, Blanks: blanks}
}
// runCase fills a case's Moves by generating on a board holding the given placed
// tiles (nil = empty board).
func runCase(s *scrabble.Solver, rs *rules.Ruleset, c *genCase, placed []genTile) {
bd := board.New(rs.Rows, rs.Cols)
for _, t := range placed {
bd.Set(t.Row, t.Col, cellByte(t.Letter, t.Blank))
}
c.Placed = placed
rk := toRack(rs.Size(), c.Rack)
moves := s.GenerateMovesOpts(bd, rk, scrabble.Mode(c.Mode), scrabble.PlayOptions{IgnoreCrossWords: c.IgnoreCrossWords})
c.Moves = movesOf(moves)
}
// twoPly reaches a mid-game position by applying the top move of the first standard
// case that generated one, then generates again with a fresh rack of the same tiles.
func twoPly(s *scrabble.Solver, rs *rules.Ruleset, cases []genCase) *genCase {
for _, c := range cases {
if c.IgnoreCrossWords || len(c.Moves) == 0 {
continue
}
placed := c.Moves[0].Tiles
next := genCase{Name: "two-ply", Rack: c.Rack, Mode: int(scrabble.Both)}
runCase(s, rs, &next, placed)
return &next
}
return nil
}
// cellByte encodes a board cell the way internal/encoding.Cell does (bits 0-5 hold
// letter+1, bit 7 marks a blank). Duplicated here because that package is internal
// to the solver module and cannot be imported.
func cellByte(letter int, blank bool) byte {
v := byte(letter+1) & 0x3f
if blank {
v |= 0x80
}
return v
}
func toRack(size int, r genRack) rack.Rack {
rk := rack.New(size)
for _, l := range r.Letters {
rk.Add(byte(l))
}
for i := 0; i < r.Blanks; i++ {
rk.AddBlank()
}
return rk
}
func rulesetOf(rs *rules.Ruleset) genRuleset {
lm := make([][]int, rs.Rows)
wm := make([][]int, rs.Rows)
for r := 0; r < rs.Rows; r++ {
lm[r] = make([]int, rs.Cols)
wm[r] = make([]int, rs.Cols)
for c := 0; c < rs.Cols; c++ {
p := rs.Premium(r, c)
lm[r][c] = p.LetterMult()
wm[r][c] = p.WordMult()
}
}
return genRuleset{
Size: rs.Size(), Cols: rs.Cols, Center: rs.Center, RackSize: rs.RackSize,
Bingo: rs.Bingo, Values: rs.Values, LetterMult: lm, WordMult: wm,
}
}
func movesOf(ms []scrabble.Move) []genMove {
out := make([]genMove, len(ms))
for i, m := range ms {
out[i] = genMove{Dir: int(m.Dir), Tiles: tilesOf(m.Tiles), Score: m.Score}
}
return out
}
func tilesOf(ps []scrabble.Placement) []genTile {
out := make([]genTile, len(ps))
for i, p := range ps {
out[i] = genTile{Row: p.Row, Col: p.Col, Letter: int(p.Letter), Blank: p.Blank}
}
return out
}
// emptyCase builds an empty-board case (Moves filled later by runCase).
func emptyCase(name string, r genRack, mode scrabble.Mode, ignoreCross bool) genCase {
return genCase{Name: name, Rack: r, Mode: int(mode), IgnoreCrossWords: ignoreCross}
}
// englishRack builds a rack from lowercase a-z letters (index = letter-'a').
func englishRack(letters string, blanks int) genRack {
idx := make([]int, 0, len(letters))
for _, ch := range letters {
idx = append(idx, int(ch-'a'))
}
return genRack{Letters: idx, Blanks: blanks}
}
// russianRack builds a rack from the Russian sample letters used above.
func russianRack(letters string, blanks int) genRack {
m := map[rune]int{'а': 0, 'д': 4, 'о': 15, 'р': 17, 'с': 18, 'я': 32}
idx := make([]int, 0, len([]rune(letters)))
for _, ch := range letters {
i, ok := m[ch]
if !ok {
log.Fatalf("movegen: russianRack: no index for %q", string(ch))
}
idx = append(idx, i)
}
return genRack{Letters: idx, Blanks: blanks}
}
// emitRulesets writes the per-variant static ruleset data (tile values, bag counts, blanks,
// bingo, rack size) the offline engine mirrors in ui/src/lib/localgame/ruleset.ts, so a TS
// parity test can pin that hand-copied table to the Go rulesets (scrabble-solver/rules).
func emitRulesets() {
type rsFix struct {
Size int `json:"size"`
RackSize int `json:"rackSize"`
Bingo int `json:"bingo"`
Blanks int `json:"blanks"`
Values []int `json:"values"`
Counts []int `json:"counts"`
Letters []string `json:"letters"`
}
out := map[string]rsFix{}
for _, v := range []struct {
name string
rs *rules.Ruleset
}{
{"scrabble_en", rules.English()},
{"scrabble_ru", rules.RussianScrabble()},
{"erudit_ru", rules.Erudit()},
} {
letters := make([]string, v.rs.Size())
for i := range letters {
ch, err := v.rs.Alphabet.Character(byte(i))
if err != nil {
log.Fatalf("movegen: %s letter %d: %v", v.name, i, err)
}
letters[i] = strings.ToUpper(ch)
}
out[v.name] = rsFix{Size: v.rs.Size(), RackSize: v.rs.RackSize, Bingo: v.rs.Bingo, Blanks: v.rs.Blanks, Values: v.rs.Values, Counts: v.rs.Counts, Letters: letters}
}
dir := filepath.Join("ui", "src", "lib", "localgame", "testdata")
if err := os.MkdirAll(dir, 0o755); err != nil {
log.Fatalf("movegen: mkdir %s: %v", dir, err)
}
writeJSON(filepath.Join(dir, "rulesets.json"), out)
log.Printf("movegen: wrote %s (3 variants)", filepath.Join(dir, "rulesets.json"))
}
func writeJSON(path string, v any) {
data, err := json.MarshalIndent(v, "", " ")
if err != nil {
log.Fatalf("movegen: marshal %s: %v", path, err)
}
if err := os.WriteFile(path, append(data, '\n'), 0o644); err != nil {
log.Fatalf("movegen: write %s: %v", path, err)
}
}
+486
View File
@@ -0,0 +1,486 @@
// Command validategen produces golden conformance fixtures for the TypeScript
// move validator (ui/src/lib/dict/validate.ts). For each variant it self-plays
// greedy games with the authoritative scrabble-solver engine to build realistic
// board positions, then records a battery of candidate plays — the engine's own
// top move, letter-mutated variants, random scatters and (on the empty board) an
// off-centre translation — each paired with the ground-truth result of
// ValidatePlayOpts (legal, score, the words formed). The TS conformance test
// replays these and must agree exactly.
//
// It is a development tool (not built into any service), analogous to
// cmd/dictgen. Run it from the repository root:
//
// go run ./backend/cmd/validategen -dawg-dir ../scrabble-solver/dawg -out <dir>
package main
import (
"bytes"
"encoding/json"
"flag"
"fmt"
"math/rand"
"os"
"path/filepath"
"gitea.iliadenisov.ru/developer/scrabble-solver/board"
"gitea.iliadenisov.ru/developer/scrabble-solver/rack"
"gitea.iliadenisov.ru/developer/scrabble-solver/rules"
"gitea.iliadenisov.ru/developer/scrabble-solver/scrabble"
"gitea.iliadenisov.ru/developer/scrabble-solver/selfplay"
dawg "github.com/iliadenisov/dafsa"
)
// blankTile marks a blank tile in a drawn hand (matches selfplay).
const blankTile byte = 0xff
// variantSpec pairs a variant label with its ruleset and dawg file.
type variantSpec struct {
name string
rules *rules.Ruleset
dawg string
}
// cell is an occupied board square or a placement (alphabet-index letter).
type cell struct {
R, C, Letter int
Blank bool
}
// word mirrors scrabble.Word in index space.
type word struct {
Row, Col, Dir int
Letters []int
Blanks []bool
Score int
}
// fixture is one candidate play with the engine's ground-truth verdict.
type fixture struct {
Board int `json:"board"` // index into the boards list
Dir int `json:"dir"`
IgnoreCrossWords bool `json:"ignoreCrossWords"`
Tiles []cell `json:"tiles"`
Legal bool `json:"legal"`
Score int `json:"score"`
Bonus int `json:"bonus"`
Main *word `json:"main,omitempty"`
Cross []word `json:"cross,omitempty"`
}
// alphaEntry mirrors one row of the per-variant alphabet table the server sends the
// client (index, concrete letter as the ruleset emits it, tile value), so the adapter
// cross-test can drive the letter-space client path exactly as production does.
type alphaEntry struct {
Index int `json:"index"`
Letter string `json:"letter"`
Value int `json:"value"`
}
// variantFile is the whole conformance payload for one variant.
type variantFile struct {
Variant string `json:"variant"`
Rows int `json:"rows"`
Cols int `json:"cols"`
Center int `json:"center"`
RackSize int `json:"rackSize"`
Bingo int `json:"bingo"`
Values []int `json:"values"`
Premiums []int `json:"premiums"` // row-major rules.Premium codes
Alphabet []alphaEntry `json:"alphabet"`
Boards [][]cell `json:"boards"`
Fixtures []fixture `json:"fixtures"`
}
func main() {
dawgDir := flag.String("dawg-dir", "../scrabble-solver/dawg", "directory holding the .dawg files")
outDir := flag.String("out", "", "output directory for the fixture files (required)")
games := flag.Int("games", 6, "self-play games per (variant, rule)")
plies := flag.Int("plies", 40, "maximum plies captured per game")
flag.Parse()
if *outDir == "" {
fail("-out is required")
}
if err := os.MkdirAll(*outDir, 0o755); err != nil {
fail("mkdir out: %v", err)
}
specs := []variantSpec{
{"scrabble_en", rules.English(), "en_sowpods.dawg"},
{"scrabble_ru", rules.RussianScrabble(), "ru_scrabble.dawg"},
{"erudit_ru", rules.Erudit(), "ru_erudit.dawg"},
}
for _, sp := range specs {
if err := generate(sp, *dawgDir, *outDir, *games, *plies); err != nil {
fail("%s: %v", sp.name, err)
}
}
}
func generate(sp variantSpec, dawgDir, outDir string, games, plies int) error {
data, err := os.ReadFile(filepath.Join(dawgDir, sp.dawg))
if err != nil {
return err
}
finder, err := dawg.Read(bytes.NewReader(data), 0)
if err != nil {
return fmt.Errorf("read dawg: %w", err)
}
defer finder.Close()
rs := sp.rules
solver := scrabble.NewSolver(rs, finder)
out := variantFile{
Variant: sp.name, Rows: rs.Rows, Cols: rs.Cols, Center: rs.Center,
RackSize: rs.RackSize, Bingo: rs.Bingo, Values: rs.Values,
Premiums: premiumCodes(rs), Alphabet: alphabetOf(rs),
}
// Capture under both the standard rule and the single-word rule, building the
// board with the same rule so positions are reachable under it.
for _, ignore := range []bool{false, true} {
opts := scrabble.PlayOptions{IgnoreCrossWords: ignore}
for g := range games {
seed := int64(g*1000) + boolseed(ignore) + variantSeed(sp.name)
playAndCapture(&out, rs, solver, opts, seed, plies)
}
}
b, err := json.Marshal(&out)
if err != nil {
return err
}
if err := os.WriteFile(filepath.Join(outDir, sp.name+".fixtures.json"), b, 0o644); err != nil {
return err
}
fmt.Printf("%-12s boards=%d fixtures=%d\n", sp.name, len(out.Boards), len(out.Fixtures))
return nil
}
// playAndCapture greedily self-plays one game, recording candidate plays against
// each board position along the way.
func playAndCapture(out *variantFile, rs *rules.Ruleset, solver *scrabble.Solver, opts scrabble.PlayOptions, seed int64, plies int) {
rng := rand.New(rand.NewSource(seed))
bag := selfplay.NewBag(rs, seed)
b := board.New(rs.Rows, rs.Cols)
hands := [2][]byte{bag.Draw(rs.RackSize), bag.Draw(rs.RackSize)}
passes := 0
for turn := range plies {
p := turn % 2
rk := rackOf(hands[p], rs.Size())
moves := solver.GenerateMovesOpts(b, rk, scrabble.Both, opts)
if len(moves) == 0 {
if passes++; passes >= 4 {
break
}
continue
}
passes = 0
top := moves[0]
boardIdx := len(out.Boards)
out.Boards = append(out.Boards, boardCells(b))
captureCandidates(out, rs, solver, opts, b, boardIdx, top, rng)
scrabble.Apply(b, top)
hands[p] = removeUsed(hands[p], top)
if need := rs.RackSize - len(hands[p]); need > 0 {
hands[p] = append(hands[p], bag.Draw(need)...)
}
if len(hands[p]) == 0 && bag.Len() == 0 {
break
}
}
}
// captureCandidates records the engine's top move plus derived candidates for one
// board, each with its ValidatePlayOpts verdict.
func captureCandidates(out *variantFile, rs *rules.Ruleset, solver *scrabble.Solver, opts scrabble.PlayOptions, b *board.Board, boardIdx int, top scrabble.Move, rng *rand.Rand) {
size := rs.Size()
record := func(tiles []scrabble.Placement) {
if len(tiles) == 0 {
return
}
out.Fixtures = append(out.Fixtures, makeFixture(solver, opts, b, boardIdx, tiles))
}
// The engine's own top move (legal).
record(top.Tiles)
// Letter-mutated variants: usually reject on the dictionary, occasionally form
// a different legal word.
for range 3 {
mut := clonePlacements(top.Tiles)
i := rng.Intn(len(mut))
mut[i].Letter = byte((int(mut[i].Letter) + 1 + rng.Intn(size-1)) % size)
record(mut)
}
// Random scatters: exercise geometry, dictionary and connectivity paths.
for range 3 {
record(randomScatter(b, size, 2+rng.Intn(4), rng))
}
// Single tiles abutting the board exercise the direction inference — a single
// tile is ambiguous, its orientation resolved from which axis it extends.
for range 3 {
if t, ok := randomAdjacentSingle(b, size, rng); ok {
record([]scrabble.Placement{t})
}
}
// On the empty board, an off-centre translation of the first move exercises the
// first-move centre rule.
if b.IsEmpty() {
shifted := clonePlacements(top.Tiles)
ok := true
for i := range shifted {
shifted[i].Row++
shifted[i].Col++
if !b.InBounds(shifted[i].Row, shifted[i].Col) {
ok = false
break
}
}
if ok {
record(shifted)
}
}
}
// makeFixture validates a candidate against board b and serializes it with its
// ground truth. Word breakdown is recorded only for legal plays (the TS test
// checks words only then); an illegal play records legal=false alone.
func makeFixture(solver *scrabble.Solver, opts scrabble.PlayOptions, b *board.Board, boardIdx int, tiles []scrabble.Placement) fixture {
// Infer the orientation exactly as the backend evaluate does (dir-less), so the
// fixture matches the real eval path and pins the client's ported inference.
dir := playDirectionMirror(solver, b, tiles, opts)
fx := fixture{
Board: boardIdx,
Dir: int(dir),
IgnoreCrossWords: opts.IgnoreCrossWords,
Tiles: placementCells(tiles),
}
m, err := solver.ValidatePlayOpts(b, dir, tiles, opts)
if err == nil {
fx.Legal = true
fx.Score = m.Score
fx.Bonus = m.Bonus
fx.Main = toWord(m.Main)
for _, cw := range m.Cross {
fx.Cross = append(fx.Cross, *toWord(cw))
}
}
return fx
}
func placementCells(ts []scrabble.Placement) []cell {
cs := make([]cell, len(ts))
for i, t := range ts {
cs[i] = cell{R: t.Row, C: t.Col, Letter: int(t.Letter), Blank: t.Blank}
}
return cs
}
func toWord(w scrabble.Word) *word {
letters := make([]int, len(w.Letters))
for i, l := range w.Letters {
letters[i] = int(l)
}
return &word{
Row: w.Row, Col: w.Col, Dir: int(w.Dir),
Letters: letters, Blanks: append([]bool(nil), w.Blanks...), Score: w.Score,
}
}
func alphabetOf(rs *rules.Ruleset) []alphaEntry {
n := rs.Alphabet.Size()
out := make([]alphaEntry, n)
for i := range n {
ch, _ := rs.Alphabet.Character(byte(i))
out[i] = alphaEntry{Index: i, Letter: ch, Value: rs.Values[i]}
}
return out
}
func premiumCodes(rs *rules.Ruleset) []int {
codes := make([]int, rs.Rows*rs.Cols)
for i := range codes {
codes[i] = int(rs.PremiumAt(i))
}
return codes
}
func boardCells(b *board.Board) []cell {
var cs []cell
for r := 0; r < b.Rows(); r++ {
for c := 0; c < b.Cols(); c++ {
if b.Filled(r, c) {
v := b.At(r, c)
cs = append(cs, cell{R: r, C: c, Letter: int(v&0x3f) - 1, Blank: v&0x80 != 0})
}
}
}
return cs
}
func clonePlacements(ts []scrabble.Placement) []scrabble.Placement {
return append([]scrabble.Placement(nil), ts...)
}
// randomScatter picks n distinct empty in-bounds squares with random letters.
func randomScatter(b *board.Board, size, n int, rng *rand.Rand) []scrabble.Placement {
seen := map[[2]int]bool{}
var ts []scrabble.Placement
for tries := 0; tries < n*20 && len(ts) < n; tries++ {
r := rng.Intn(b.Rows())
c := rng.Intn(b.Cols())
if seen[[2]int{r, c}] || b.Filled(r, c) {
continue
}
seen[[2]int{r, c}] = true
ts = append(ts, scrabble.Placement{Row: r, Col: c, Letter: byte(rng.Intn(size)), Blank: rng.Intn(10) == 0})
}
return ts
}
// randomAdjacentSingle picks a random empty in-bounds square abutting at least one
// filled square, with a random letter — a single-tile play whose orientation the
// inference must resolve. It returns ok=false on an empty board.
func randomAdjacentSingle(b *board.Board, size int, rng *rand.Rand) (scrabble.Placement, bool) {
var cands [][2]int
for r := 0; r < b.Rows(); r++ {
for c := 0; c < b.Cols(); c++ {
if b.Filled(r, c) {
continue
}
if b.Filled(r-1, c) || b.Filled(r+1, c) || b.Filled(r, c-1) || b.Filled(r, c+1) {
cands = append(cands, [2]int{r, c})
}
}
}
if len(cands) == 0 {
return scrabble.Placement{}, false
}
rc := cands[rng.Intn(len(cands))]
return scrabble.Placement{Row: rc[0], Col: rc[1], Letter: byte(rng.Intn(size)), Blank: rng.Intn(10) == 0}, true
}
// playDirectionMirror mirrors engine (*Game).playDirection: the geometric
// resolution, except a single tile under the single-word rule tries both
// orientations through the solver and keeps the higher-scoring legal one (H wins
// ties). It reproduces the orientation the backend evaluate infers.
func playDirectionMirror(solver *scrabble.Solver, b *board.Board, placements []scrabble.Placement, opts scrabble.PlayOptions) scrabble.Direction {
geo := resolveDirectionMirror(b, placements)
if len(placements) != 1 || !opts.IgnoreCrossWords {
return geo
}
best, found, bestScore := geo, false, 0
for _, dir := range [...]scrabble.Direction{scrabble.Horizontal, scrabble.Vertical} {
m, err := solver.ValidatePlayOpts(b, dir, placements, opts)
if err != nil {
continue
}
if !found || m.Score > bestScore {
best, found, bestScore = dir, true, m.Score
}
}
return best
}
// resolveDirectionMirror mirrors engine.resolveDirection.
func resolveDirectionMirror(b *board.Board, placements []scrabble.Placement) scrabble.Direction {
if len(placements) >= 2 {
row := placements[0].Row
for _, p := range placements[1:] {
if p.Row != row {
return scrabble.Vertical
}
}
return scrabble.Horizontal
}
if len(placements) == 1 {
p := placements[0]
h := runLengthMirror(b, p.Row, p.Col, scrabble.Horizontal)
v := runLengthMirror(b, p.Row, p.Col, scrabble.Vertical)
if v >= 2 && v > h {
return scrabble.Vertical
}
if h >= 2 {
return scrabble.Horizontal
}
if v >= 2 {
return scrabble.Vertical
}
}
return scrabble.Horizontal
}
// runLengthMirror mirrors engine.runLength.
func runLengthMirror(b *board.Board, row, col int, dir scrabble.Direction) int {
dr, dc := 0, 1
if dir == scrabble.Vertical {
dr, dc = 1, 0
}
n := 1
for r, c := row-dr, col-dc; b.Filled(r, c); r, c = r-dr, c-dc {
n++
}
for r, c := row+dr, col+dc; b.Filled(r, c); r, c = r+dr, c+dc {
n++
}
return n
}
// rackOf builds a generation rack from a hand of tiles (reimplemented from the
// unexported selfplay helper).
func rackOf(tiles []byte, size int) rack.Rack {
r := rack.New(size)
for _, t := range tiles {
if t == blankTile {
r.AddBlank()
} else {
r.Add(t)
}
}
return r
}
// removeUsed returns the hand with the tiles consumed by m removed.
func removeUsed(tiles []byte, m scrabble.Move) []byte {
out := append([]byte(nil), tiles...)
for _, p := range m.Tiles {
want := p.Letter
if p.Blank {
want = blankTile
}
for i, t := range out {
if t == want {
out = append(out[:i], out[i+1:]...)
break
}
}
}
return out
}
func boolseed(b bool) int64 {
if b {
return 500000
}
return 0
}
func variantSeed(name string) int64 {
var s int64
for _, r := range name {
s = s*131 + int64(r)
}
return s
}
func fail(format string, args ...any) {
fmt.Fprintf(os.Stderr, "validategen: "+format+"\n", args...)
os.Exit(1)
}
+2 -1
View File
@@ -13,6 +13,7 @@ require (
github.com/pressly/goose/v3 v3.27.1
github.com/testcontainers/testcontainers-go v0.42.0
github.com/testcontainers/testcontainers-go/modules/postgres v0.42.0
github.com/wneessen/go-mail v0.7.3
go.opentelemetry.io/otel v1.43.0
go.opentelemetry.io/otel/exporters/stdout/stdoutmetric v1.43.0
go.opentelemetry.io/otel/exporters/stdout/stdouttrace v1.43.0
@@ -109,7 +110,7 @@ require (
golang.org/x/net v0.53.0 // indirect
golang.org/x/sync v0.20.0 // indirect
golang.org/x/sys v0.43.0 // indirect
golang.org/x/text v0.36.0 // indirect
golang.org/x/text v0.37.0 // indirect
google.golang.org/grpc v1.80.0
google.golang.org/protobuf v1.36.11 // indirect
gopkg.in/yaml.v3 v3.0.1 // indirect
+4
View File
@@ -279,6 +279,8 @@ github.com/twitchyliquid64/golang-asm v0.15.1 h1:SU5vSMR7hnwNxj24w34ZyCi/FmDZTkS
github.com/twitchyliquid64/golang-asm v0.15.1/go.mod h1:a1lVb/DtPvCB8fslRZhAngC2+aY1QWCk3Cedj/Gdt08=
github.com/ugorji/go/codec v1.3.1 h1:waO7eEiFDwidsBN6agj1vJQ4AG7lh2yqXyOXqhgQuyY=
github.com/ugorji/go/codec v1.3.1/go.mod h1:pRBVtBSKl77K30Bv8R2P+cLSGaTtex6fsA2Wjqmfxj4=
github.com/wneessen/go-mail v0.7.3 h1:g3DravXC5SMlVdboFrQA8Jx95A8sOzoBeS5F+vzNRK0=
github.com/wneessen/go-mail v0.7.3/go.mod h1:QGhBX0yNbc1J+Mkjcu7z2rpj4B4l+BmDY8gYznPC9sk=
github.com/yuin/goldmark v1.4.13/go.mod h1:6yULJ656Px+3vBD8DxQVa3kxgyrAnzto9xy5taEt/CY=
github.com/yusufpapurcu/wmi v1.2.4 h1:zFUKzehAFReQwLys1b/iSMl+JQGSCSjtVqQn9bBrPo0=
github.com/yusufpapurcu/wmi v1.2.4/go.mod h1:SBZ9tNy3G9/m5Oi98Zks0QjeHVDvuK0qfxQmPyzfmi0=
@@ -400,6 +402,8 @@ golang.org/x/text v0.9.0/go.mod h1:e1OnstbJyHTd6l/uOt8jFFHp6TRDWZR/bV3emEE/zU8=
golang.org/x/text v0.14.0/go.mod h1:18ZOQIKpY8NJVqYksKHtTdi31H5itFRjB5/qKTNYzSU=
golang.org/x/text v0.36.0 h1:JfKh3XmcRPqZPKevfXVpI1wXPTqbkE5f7JA92a55Yxg=
golang.org/x/text v0.36.0/go.mod h1:NIdBknypM8iqVmPiuco0Dh6P5Jcdk8lJL0CUebqK164=
golang.org/x/text v0.37.0 h1:Cqjiwd9eSg8e0QAkyCaQTNHFIIzWtidPahFWR83rTrc=
golang.org/x/text v0.37.0/go.mod h1:a5sjxXGs9hsn/AJVwuElvCAo9v8QYLzvavO5z2PiM38=
golang.org/x/tools v0.0.0-20180917221912-90fa682c2a6e/go.mod h1:n7NCudcB/nEzxVGmLbDWY5pfWTLqBcC2KZ6jyYvM4mQ=
golang.org/x/tools v0.0.0-20190311212946-11955173bddd/go.mod h1:LCzVGOaR6xXOjkQ3onu1FJEFr0SW1gC7cKk1uF8kGRs=
golang.org/x/tools v0.0.0-20190425163242-31fd60d6bfdc/go.mod h1:RgjU9mgBXZiqYHBnxXauZ1Gv1EHHAz9KjViQ78xBX0Q=
+107 -70
View File
@@ -22,12 +22,13 @@ import (
"scrabble/backend/internal/postgres/jet/backend/table"
)
// Identity kinds recognised by the backend. Email is modelled as an identity
// alongside platform identities; its confirmed flag is driven by the email
// confirm-code flow. Robot is a synthetic kind: each pooled
// robot opponent is a durable account bound to one robot identity.
// Identity kinds recognised by the backend. Telegram and VK are platform identities,
// auto-confirmed on first contact. Email is modelled as an identity alongside them; its
// confirmed flag is driven by the email confirm-code flow. Robot is a synthetic kind:
// each pooled robot opponent is a durable account bound to one robot identity.
const (
KindTelegram = "telegram"
KindVK = "vk"
KindEmail = "email"
KindRobot = "robot"
)
@@ -42,9 +43,7 @@ var ErrNotFound = errors.New("account: not found")
// local-time window (in TimeZone) during which the player is asleep, so the
// turn-timeout sweeper does not auto-resign them inside it. (The robot opponent's
// own sleep is anchored to its human opponent's timezone with a per-game drift,
// computed in internal/robot, not from a robot account's away window.) HintBalance
// is the player's wallet of purchasable hints, spent after a game's per-seat
// allowance.
// computed in internal/robot, not from a robot account's away window.)
type Account struct {
ID uuid.UUID
DisplayName string
@@ -52,7 +51,6 @@ type Account struct {
TimeZone string
AwayStart time.Time
AwayEnd time.Time
HintBalance int
BlockChat bool
BlockFriendRequests bool
// VariantPreferences is the set of game variants (engine.Variant stable labels:
@@ -68,10 +66,6 @@ type Account struct {
// true (the default): the platform side-service skips out-of-app push for the
// account.
NotificationsInAppOnly bool
// PaidAccount marks a lifetime one-time-payment account. It is a service field
// (no purchase flow yet); an account linking & merge ORs it so a paid status is
// never lost when accounts are consolidated.
PaidAccount bool
// MergedInto is the primary account a retired (merged) secondary points at, or
// uuid.Nil for a live account. A tombstone keeps the row so the no-cascade
// foreign keys of a shared finished game stay valid.
@@ -120,13 +114,44 @@ func (s *Store) ProvisionByIdentity(ctx context.Context, kind, externalID string
}
// ProvisionEmail returns the account owning the email identity externalID, creating
// it (unconfirmed) on first contact with browserTZ — the client's detected "±HH:MM"
// UTC offset — seeded into its time zone. Like ProvisionByIdentity it is race-safe
// and leaves an existing account untouched, so a returning user's saved zone is never
// overwritten. The email account is created here (the code-request step), not at the
// later login, so this is where its zone is seeded.
func (s *Store) ProvisionEmail(ctx context.Context, externalID, browserTZ string) (Account, error) {
return s.provision(ctx, KindEmail, externalID, provisionSeed{timeZone: seedZone(browserTZ)})
// it on first contact with browserTZ — the client's detected "±HH:MM" UTC offset —
// seeded into its time zone, language seeded from the client's UI language, and its
// display name seeded from the email's local part (so it is not left nameless). Like
// ProvisionByIdentity it is race-safe and leaves an existing account untouched, so a
// returning user's saved zone, language and name are never overwritten. The email account is
// created here (the code-request step), not at the later login, so this is where its
// zone and language are seeded. It is created flagged is_guest with an unconfirmed
// email identity: an abandoned, never-confirmed login is then reaped like any guest,
// freeing the reserved address, and confirming the code clears the guest flag.
func (s *Store) ProvisionEmail(ctx context.Context, externalID, browserTZ, language string) (Account, error) {
return s.provision(ctx, KindEmail, externalID, provisionSeed{
displayName: emailDisplayName(externalID),
timeZone: seedZone(browserTZ),
preferredLanguage: supportedLanguage(language),
isGuest: true,
})
}
// emailDisplayName derives a display name from an email address — the local part
// before '@', trimmed and capped to the column width — so a new email account is not
// left nameless. It is only the first-contact seed; the user can rename it later.
func emailDisplayName(email string) string {
local, _, _ := strings.Cut(email, "@")
local = strings.TrimSpace(local)
if r := []rune(local); len(r) > maxDisplayName {
local = strings.TrimRight(string(r[:maxDisplayName]), " ")
}
return local
}
// supportedLanguage returns code normalised to a supported UI language ("en" or
// "ru"), or "" when it maps to neither, so a new account keeps the 'en' default. It
// accepts region-tagged codes ("ru-RU").
func supportedLanguage(code string) string {
if lang, _, _ := strings.Cut(strings.ToLower(strings.TrimSpace(code)), "-"); lang == "en" || lang == "ru" {
return lang
}
return ""
}
// ProvisionRobot provisions (or finds) the durable account backing a robot pool
@@ -185,6 +210,28 @@ func (s *Store) ProvisionTelegram(ctx context.Context, externalID, languageCode,
return acc, created, err
}
// ProvisionVK provisions (or finds) the account bound to a VK identity, reporting
// whether this call created it (first contact). On first contact only, it seeds the new
// account's preferred language from the VK languageCode (vk_language, when it maps to a
// supported language) and its display name sanitized from displayName — the name read
// client-side via VKWebAppGetUserInfo, since VK omits it from the signed launch params —
// falling back to a generated placeholder when it yields no letters; an already-existing
// account is returned unchanged, so a later profile edit is never overwritten.
func (s *Store) ProvisionVK(ctx context.Context, externalID, languageCode, displayName, browserTZ string) (Account, bool, error) {
// Pre-check whether the identity already exists so the caller can act on first
// contact (mirrors ProvisionTelegram); a create race only mis-reports created for
// that one call.
_, err := s.findByIdentity(ctx, KindVK, externalID)
created := errors.Is(err, ErrNotFound)
if err != nil && !created {
return Account{}, false, err
}
seed := vkSeed(languageCode, displayName)
seed.timeZone = seedZone(browserTZ)
acc, err := s.provision(ctx, KindVK, externalID, seed)
return acc, created, err
}
// provision finds the account for (kind, externalID) or creates it with seed,
// collapsing a concurrent-create race on the identity unique constraint into a
// re-read of the winner's account.
@@ -216,6 +263,11 @@ type provisionSeed struct {
preferredLanguage string
displayName string
timeZone string
// isGuest creates the account flagged is_guest. It is set for an email-login
// account, which stays a guest until the address is confirmed (so an abandoned,
// never-confirmed login is reaped and its address freed); confirming clears the
// flag. Platform identities (telegram/vk) are durable from creation.
isGuest bool
}
// seedZone returns browserTZ when it is a well-formed zone to persist at account
@@ -258,6 +310,24 @@ func telegramSeed(languageCode, username, firstName string) provisionSeed {
return seed
}
// vkSeed derives the create-time seed from VK launch fields: a supported preferred
// language from languageCode (vk_language, normally a 2-letter code) and a display name
// from displayName (sanitized to the editable format), falling back to a generated
// placeholder in the seeded language when the name yields no usable letters. Unlike
// telegramSeed there is no @username fallback — VK provides only the name.
func vkSeed(languageCode, displayName string) provisionSeed {
var seed provisionSeed
if lang, _, _ := strings.Cut(strings.ToLower(strings.TrimSpace(languageCode)), "-"); lang == "en" || lang == "ru" {
seed.preferredLanguage = lang
}
name := sanitizeDisplayName(displayName)
if name == "" {
name = placeholderDisplayName(seed.preferredLanguage)
}
seed.displayName = name
return seed
}
// GetByID loads the account identified by id, or ErrNotFound when it is absent.
func (s *Store) GetByID(ctx context.Context, id uuid.UUID) (Account, error) {
stmt := postgres.SELECT(table.Accounts.AllColumns).
@@ -316,6 +386,21 @@ func (s *Store) Identities(ctx context.Context, accountID uuid.UUID) ([]Identity
return out, nil
}
// HasConfirmedEmail reports whether the account owns a confirmed email identity — the direct-rail
// recovery anchor a first purchase requires (D36).
func (s *Store) HasConfirmedEmail(ctx context.Context, accountID uuid.UUID) (bool, error) {
ids, err := s.Identities(ctx, accountID)
if err != nil {
return false, err
}
for _, id := range ids {
if id.Kind == "email" && id.Confirmed {
return true, nil
}
}
return false, nil
}
// ListAccounts returns accounts for the admin user list, newest first, paginated
// by limit and offset.
func (s *Store) ListAccounts(ctx context.Context, limit, offset int) ([]Account, error) {
@@ -406,8 +491,8 @@ func (s *Store) create(ctx context.Context, kind, externalID string, seed provis
tz = "UTC"
}
insertAccount := table.Accounts.
INSERT(table.Accounts.AccountID, table.Accounts.DisplayName, table.Accounts.PreferredLanguage, table.Accounts.TimeZone).
VALUES(accountID, seed.displayName, lang, tz).
INSERT(table.Accounts.AccountID, table.Accounts.DisplayName, table.Accounts.PreferredLanguage, table.Accounts.TimeZone, table.Accounts.IsGuest).
VALUES(accountID, seed.displayName, lang, tz, seed.isGuest).
RETURNING(table.Accounts.AllColumns)
var row model.Accounts
@@ -421,7 +506,7 @@ func (s *Store) create(ctx context.Context, kind, externalID string, seed provis
table.Identities.Kind,
table.Identities.ExternalID,
table.Identities.Confirmed,
).VALUES(identityID, accountID, kind, externalID, kind == KindTelegram)
).VALUES(identityID, accountID, kind, externalID, kind == KindTelegram || kind == KindVK)
if _, err := insertIdentity.ExecContext(ctx, tx); err != nil {
return err
}
@@ -471,52 +556,6 @@ func (s *Store) ProvisionGuest(ctx context.Context, browserTZ string) (Account,
return modelToAccount(row), nil
}
// SpendHint atomically decrements the account's hint wallet by one, returning
// true when a hint was spent and false when the balance was already empty. The
// guarded UPDATE keeps it safe under concurrent spends across the player's games.
func (s *Store) SpendHint(ctx context.Context, id uuid.UUID) (bool, error) {
stmt := table.Accounts.
UPDATE(table.Accounts.HintBalance, table.Accounts.UpdatedAt).
SET(table.Accounts.HintBalance.SUB(postgres.Int(1)), postgres.TimestampzT(time.Now().UTC())).
WHERE(
table.Accounts.AccountID.EQ(postgres.UUID(id)).
AND(table.Accounts.HintBalance.GT(postgres.Int(0))),
)
res, err := stmt.ExecContext(ctx, s.db)
if err != nil {
return false, fmt.Errorf("account: spend hint %s: %w", id, err)
}
n, err := res.RowsAffected()
if err != nil {
return false, fmt.Errorf("account: spend hint rows %s: %w", id, err)
}
return n > 0, nil
}
// GrantHints adds n hints to the account's wallet and returns the new balance. n must be
// positive: the additive update can only raise the balance, never lower it, so it enforces the
// admin console's raise-only rule by construction and stays correct under a concurrent SpendHint.
// It returns ErrNotFound when no account matches.
func (s *Store) GrantHints(ctx context.Context, id uuid.UUID, n int) (int, error) {
if n <= 0 {
return 0, fmt.Errorf("account: grant hints %s: n must be positive, got %d", id, n)
}
stmt := table.Accounts.
UPDATE(table.Accounts.HintBalance, table.Accounts.UpdatedAt).
SET(table.Accounts.HintBalance.ADD(postgres.Int(int64(n))), postgres.TimestampzT(time.Now().UTC())).
WHERE(table.Accounts.AccountID.EQ(postgres.UUID(id))).
RETURNING(table.Accounts.HintBalance)
var row model.Accounts
if err := stmt.QueryContext(ctx, s.db, &row); err != nil {
if errors.Is(err, qrm.ErrNoRows) {
return 0, ErrNotFound
}
return 0, fmt.Errorf("account: grant hints %s: %w", id, err)
}
return int(row.HintBalance), nil
}
// FlagHighRate stamps the soft "suspected high-rate" marker with at, only when
// the account is not already flagged — the first sustained episode wins, and a
// re-flag after an operator clear starts a fresh timestamp. An infra marker, not
@@ -572,12 +611,10 @@ func modelToAccount(row model.Accounts) Account {
TimeZone: row.TimeZone,
AwayStart: row.AwayStart,
AwayEnd: row.AwayEnd,
HintBalance: int(row.HintBalance),
BlockChat: row.BlockChat,
BlockFriendRequests: row.BlockFriendRequests,
IsGuest: row.IsGuest,
NotificationsInAppOnly: row.NotificationsInAppOnly,
PaidAccount: row.PaidAccount,
MergedInto: mergedInto,
FlaggedHighRateAt: flaggedHighRateAt,
CreatedAt: row.CreatedAt,
+364 -37
View File
@@ -5,6 +5,7 @@ import (
crand "crypto/rand"
"crypto/sha256"
"database/sql"
"encoding/base64"
"encoding/hex"
"errors"
"fmt"
@@ -26,6 +27,22 @@ const (
emailCodeTTL = 15 * time.Minute
// emailCodeMaxAttempts caps wrong-code submissions before a code is dead.
emailCodeMaxAttempts = 5
// linkTokenBytes is the entropy of a confirm deeplink token: 256 bits.
linkTokenBytes = 32
// emailConfirmPath is the SPA route the one-tap confirm deeplink opens (the token
// is appended). The SPA is served under /app/ behind a hash router.
emailConfirmPath = "/app/#/confirm/"
)
// Confirmation purposes recorded on a pending confirm-code row. They select what
// verifying the code or the deeplink token does: sign in (login), link/confirm the
// address on the current account (link), or replace the account's confirmed email with
// a new address (change). Account deletion adds a further purpose in a later stage.
const (
purposeLogin = "login"
purposeLink = "link"
purposeChange = "change"
purposeDelete = "delete"
)
// Errors returned by the email confirm-code flow.
@@ -46,6 +63,12 @@ var (
ErrTooManyAttempts = errors.New("account: too many confirmation attempts")
// ErrCodeMismatch is returned when the submitted code does not match.
ErrCodeMismatch = errors.New("account: confirmation code does not match")
// ErrTooManyRequests is returned when confirm-code sends to an address are being
// requested too frequently (the resend cooldown or the rolling-hour cap).
ErrTooManyRequests = errors.New("account: too many code requests")
// ErrNoEmail is returned when an email-code step-up is requested for an account that
// holds no confirmed email (the caller must use the typed-phrase path instead).
ErrNoEmail = errors.New("account: no confirmed email")
)
// EmailService runs the email confirm-code flow: it issues a 6-digit code over a
@@ -55,14 +78,82 @@ var (
// account is refused (ErrEmailTaken) — merging two accounts is the link/merge flow —
// and using an email as a login reuses this mechanism.
type EmailService struct {
store *Store
mailer Mailer
now func() time.Time
store *Store
mailer Mailer
baseURL string
limiter *SendLimiter
now func() time.Time
}
// NewEmailService constructs an EmailService over store, sending via mailer.
func NewEmailService(store *Store, mailer Mailer) *EmailService {
return &EmailService{store: store, mailer: mailer, now: func() time.Time { return time.Now().UTC() }}
// NewEmailService constructs an EmailService over store, sending via mailer. baseURL
// is the canonical public origin (scheme + host) used to build the one-tap confirm
// deeplink and the email footer landing link; an empty baseURL omits the deeplink
// (development / log mailer).
func NewEmailService(store *Store, mailer Mailer, baseURL string) *EmailService {
return &EmailService{store: store, mailer: mailer, baseURL: baseURL, now: func() time.Time { return time.Now().UTC() }}
}
// SetSendLimiter installs a per-recipient send throttle. When unset (nil), sends are
// not throttled — production wires a limiter; tests leave it off.
func (s *EmailService) SetSendLimiter(l *SendLimiter) { s.limiter = l }
// allowSend reports whether a confirm-code send to email is permitted now, recording
// it when so. A nil limiter permits every send.
func (s *EmailService) allowSend(email string) bool {
return s.limiter == nil || s.limiter.Allow(email)
}
// issueCode generates a fresh confirm-code and one-tap deeplink token for (accountID,
// email), replaces any prior pending confirmation, and mails the branded code in
// locale; purpose selects the email wording and what verifying does. omitLink drops the
// one-tap deeplink from the email (account deletion, or a login requested from an installed
// PWA). Only the SHA-256 hashes of the code and token are stored.
func (s *EmailService) issueCode(ctx context.Context, accountID uuid.UUID, email, purpose, locale string, omitLink bool) error {
code, codeHash, err := generateCode()
if err != nil {
return err
}
token, tokenHash, err := generateLinkToken()
if err != nil {
return err
}
if err := s.store.replacePendingConfirmation(ctx, accountID, email, codeHash, tokenHash, purpose, s.now().Add(emailCodeTTL)); err != nil {
return err
}
// Omit the one-tap deeplink when asked: for a login from an installed PWA (the link would
// open in a separate browser whose minted session cannot reach the PWA), or for account
// deletion (a prefetch or stray click must not delete an account — the delete code is entered
// in the app only, and ConfirmByToken refuses a delete token).
deeplink := s.confirmURL(token, locale)
if purpose == purposeDelete || omitLink {
deeplink = ""
}
msg, err := renderConfirmationEmail(purpose, code, deeplink, s.baseURL, locale)
if err != nil {
return err
}
msg.To = email
return s.mailer.Send(ctx, msg)
}
// confirmURL builds the absolute one-tap confirm deeplink for token in locale, or ""
// when no public base URL is configured. The locale rides the fragment as ?lang so the
// confirm screen (opened in a browser with no session) renders in the email's language.
func (s *EmailService) confirmURL(token, locale string) string {
if s.baseURL == "" {
return ""
}
return strings.TrimRight(s.baseURL, "/") + emailConfirmPath + token + "?lang=" + normalizeLocale(locale)
}
// accountLocale returns the account's preferred UI language for localising email,
// defaulting to "en" when the account cannot be loaded.
func (s *EmailService) accountLocale(ctx context.Context, accountID uuid.UUID) string {
acc, err := s.store.GetByID(ctx, accountID)
if err != nil {
return "en"
}
return acc.PreferredLanguage
}
// RequestCode issues a fresh confirm-code for email to accountID and mails it,
@@ -73,6 +164,9 @@ func (s *EmailService) RequestCode(ctx context.Context, accountID uuid.UUID, ema
if err != nil {
return err
}
if !s.allowSend(addr) {
return ErrTooManyRequests
}
owner, ok, err := s.store.confirmedEmailAccount(ctx, addr)
if err != nil {
return err
@@ -83,16 +177,7 @@ func (s *EmailService) RequestCode(ctx context.Context, accountID uuid.UUID, ema
}
return ErrEmailTaken
}
code, hash, err := generateCode()
if err != nil {
return err
}
if err := s.store.replacePendingConfirmation(ctx, accountID, addr, hash, s.now().Add(emailCodeTTL)); err != nil {
return err
}
subject := "Your Scrabble confirmation code"
body := fmt.Sprintf("Your confirmation code is %s. It expires in %d minutes.", code, int(emailCodeTTL/time.Minute))
return s.mailer.Send(ctx, addr, subject, body)
return s.issueCode(ctx, accountID, addr, purposeLink, s.accountLocale(ctx, accountID), false)
}
// ConfirmCode verifies code for accountID and email. On success it attaches a
@@ -123,36 +208,38 @@ func (s *EmailService) ConfirmCode(ctx context.Context, accountID uuid.UUID, ema
if err := s.store.confirmEmailIdentity(ctx, conf.id, accountID, addr, s.now()); err != nil {
return Account{}, err
}
// Binding the first confirmed email promotes a guest to a durable account, matching the
// link and deeplink flows (defence-in-depth: no confirmed-email path leaves is_guest set).
if err := s.store.ClearGuest(ctx, accountID); err != nil {
return Account{}, err
}
return s.store.GetByID(ctx, accountID)
}
// RequestLoginCode issues a login confirm-code to the account that owns email,
// provisioning a fresh (unconfirmed) durable account when the email is new. It is
// the unauthenticated email-login entry point and, unlike RequestCode,
// does not refuse an already-confirmed email — that is the ordinary returning-user
// login. The code is mailed to the address, so only its real owner can complete
// the login. On first contact browserTZ (the client's detected "±HH:MM" UTC offset)
// seeds the new account's time zone. It returns the target account id for the
// subsequent LoginWithCode.
func (s *EmailService) RequestLoginCode(ctx context.Context, email, browserTZ string) (uuid.UUID, error) {
// provisioning a fresh (unconfirmed) guest account when the email is new — it becomes
// durable once the code is confirmed. It is the unauthenticated email-login entry
// point and, unlike RequestCode, does not refuse an already-confirmed email — that is
// the ordinary returning-user login. The code is mailed to the address, so only its
// real owner can complete the login. On first contact browserTZ (the client's
// detected "±HH:MM" UTC offset) seeds the new account's time zone and language its UI
// language. When pwa is set (the request came from an installed PWA) the login email omits the
// one-tap confirm link — it would open in a separate browser, out of the PWA's reach — so the
// code is entered in the same window. It returns the target account id for the subsequent
// LoginWithCode.
func (s *EmailService) RequestLoginCode(ctx context.Context, email, browserTZ, language string, pwa bool) (uuid.UUID, error) {
addr, err := normalizeEmail(email)
if err != nil {
return uuid.UUID{}, err
}
acc, err := s.store.ProvisionEmail(ctx, addr, browserTZ)
if !s.allowSend(addr) {
return uuid.UUID{}, ErrTooManyRequests
}
acc, err := s.store.ProvisionEmail(ctx, addr, browserTZ, language)
if err != nil {
return uuid.UUID{}, err
}
code, hash, err := generateCode()
if err != nil {
return uuid.UUID{}, err
}
if err := s.store.replacePendingConfirmation(ctx, acc.ID, addr, hash, s.now().Add(emailCodeTTL)); err != nil {
return uuid.UUID{}, err
}
subject := "Your Scrabble login code"
body := fmt.Sprintf("Your login code is %s. It expires in %d minutes.", code, int(emailCodeTTL/time.Minute))
if err := s.mailer.Send(ctx, addr, subject, body); err != nil {
if err := s.issueCode(ctx, acc.ID, addr, purposeLogin, language, pwa); err != nil {
return uuid.UUID{}, err
}
return acc.ID, nil
@@ -191,9 +278,104 @@ func (s *EmailService) LoginWithCode(ctx context.Context, email, code string) (A
if err := s.store.confirmEmailLogin(ctx, conf.id, acc.ID, addr, s.now()); err != nil {
return Account{}, err
}
if err := s.store.ClearGuest(ctx, acc.ID); err != nil {
return Account{}, err
}
return s.store.GetByID(ctx, acc.ID)
}
// LinkConfirmation is the outcome of confirming a one-tap deeplink token: what the
// transport layer must finish. Purpose is the pending row's purpose. For a login,
// Account is the account to sign in. For a link, Account is the account the email was
// (or would be) attached to; NeedsMerge is set when another account (MergeOwner)
// already owns the address, so the caller drives the interactive merge instead of a
// plain link — the token is left unconsumed for that merge step.
type LinkConfirmation struct {
Purpose string
Account uuid.UUID
NeedsMerge bool
MergeOwner uuid.UUID
}
// IsLogin reports whether the confirmation is a login (the caller mints a session)
// rather than a link (attach the identity, or drive a merge).
func (r LinkConfirmation) IsLogin() bool { return r.Purpose == purposeLogin }
// ConfirmByToken verifies a one-tap deeplink token and performs its purpose. A login
// confirms the email identity, clears the guest flag and returns the account to sign
// in. A link attaches the confirmed email to the pending account when the address is
// free, or reports NeedsMerge when another account already owns it (leaving the token
// live so the caller's merge step can re-verify). It returns ErrNoPendingCode when the
// token matches no live confirmation and ErrCodeExpired when it has lapsed. The token
// is high-entropy, so there is no wrong-attempt counter.
func (s *EmailService) ConfirmByToken(ctx context.Context, token string) (LinkConfirmation, error) {
pend, err := s.store.pendingByTokenHash(ctx, hashCode(token))
if err != nil {
return LinkConfirmation{}, err
}
if s.now().After(pend.expiresAt) {
return LinkConfirmation{}, ErrCodeExpired
}
switch pend.purpose {
case purposeLogin:
if err := s.store.confirmEmailLogin(ctx, pend.id, pend.accountID, pend.email, s.now()); err != nil {
return LinkConfirmation{}, err
}
if err := s.store.ClearGuest(ctx, pend.accountID); err != nil {
return LinkConfirmation{}, err
}
return LinkConfirmation{Purpose: purposeLogin, Account: pend.accountID}, nil
case purposeLink:
owner, ok, err := s.store.confirmedEmailAccount(ctx, pend.email)
if err != nil {
return LinkConfirmation{}, err
}
if ok {
if owner == pend.accountID {
if err := s.store.consumeConfirmation(ctx, pend.id, s.now()); err != nil {
return LinkConfirmation{}, err
}
return LinkConfirmation{Purpose: purposeLink, Account: pend.accountID}, nil
}
return LinkConfirmation{Purpose: purposeLink, Account: pend.accountID, NeedsMerge: true, MergeOwner: owner}, nil
}
if err := s.store.confirmEmailIdentity(ctx, pend.id, pend.accountID, pend.email, s.now()); err != nil {
return LinkConfirmation{}, err
}
// Binding the first email promotes a guest to a durable account, matching the
// code-based link flow (which clears the guest flag in the link service).
if err := s.store.ClearGuest(ctx, pend.accountID); err != nil {
return LinkConfirmation{}, err
}
return LinkConfirmation{Purpose: purposeLink, Account: pend.accountID}, nil
case purposeChange:
owner, ok, err := s.store.confirmedEmailAccount(ctx, pend.email)
if err != nil {
return LinkConfirmation{}, err
}
if ok && owner != pend.accountID {
// The new address is confirmed by a different account: refuse without
// disclosing it (anti-enumeration). Unlike a link, a change never merges.
return LinkConfirmation{}, ErrEmailTaken
}
if ok && owner == pend.accountID {
if err := s.store.consumeConfirmation(ctx, pend.id, s.now()); err != nil {
return LinkConfirmation{}, err
}
return LinkConfirmation{Purpose: purposeChange, Account: pend.accountID}, nil
}
if err := s.store.replaceEmailIdentity(ctx, pend.id, pend.accountID, pend.email, s.now()); err != nil {
return LinkConfirmation{}, err
}
return LinkConfirmation{Purpose: purposeChange, Account: pend.accountID}, nil
case purposeDelete:
// Deletion is confirmed in the app with the code, never via a one-tap link.
return LinkConfirmation{}, fmt.Errorf("account: deletion cannot be confirmed by link")
default:
return LinkConfirmation{}, fmt.Errorf("account: unsupported confirmation purpose %q", pend.purpose)
}
}
// emailConfirmation is a pending confirm-code row in domain form.
type emailConfirmation struct {
id uuid.UUID
@@ -222,9 +404,30 @@ func (s *Store) confirmedEmailAccount(ctx context.Context, email string) (uuid.U
return row.AccountID, true, nil
}
// confirmedEmailOf returns the account's confirmed email address and true, or ("", false)
// when it holds none. It backs the deletion step-up, which mails a code to the account's
// own address.
func (s *Store) confirmedEmailOf(ctx context.Context, accountID uuid.UUID) (string, bool, error) {
stmt := postgres.SELECT(table.Identities.ExternalID).
FROM(table.Identities).
WHERE(
table.Identities.AccountID.EQ(postgres.UUID(accountID)).
AND(table.Identities.Kind.EQ(postgres.String(KindEmail))).
AND(table.Identities.Confirmed.EQ(postgres.Bool(true))),
).LIMIT(1)
var row model.Identities
if err := stmt.QueryContext(ctx, s.db, &row); err != nil {
if errors.Is(err, qrm.ErrNoRows) {
return "", false, nil
}
return "", false, fmt.Errorf("account: confirmed email of %s: %w", accountID, err)
}
return row.ExternalID, true, nil
}
// replacePendingConfirmation clears any pending code for (accountID, email) and
// inserts a fresh one, inside one transaction.
func (s *Store) replacePendingConfirmation(ctx context.Context, accountID uuid.UUID, email, codeHash string, expiresAt time.Time) error {
func (s *Store) replacePendingConfirmation(ctx context.Context, accountID uuid.UUID, email, codeHash, linkTokenHash, purpose string, expiresAt time.Time) error {
id, err := uuid.NewV7()
if err != nil {
return fmt.Errorf("account: new confirmation id: %w", err)
@@ -241,7 +444,8 @@ func (s *Store) replacePendingConfirmation(ctx context.Context, accountID uuid.U
ins := table.EmailConfirmations.INSERT(
table.EmailConfirmations.ConfirmationID, table.EmailConfirmations.AccountID,
table.EmailConfirmations.Email, table.EmailConfirmations.CodeHash, table.EmailConfirmations.ExpiresAt,
).VALUES(id, accountID, email, codeHash, expiresAt)
table.EmailConfirmations.LinkTokenHash, table.EmailConfirmations.Purpose,
).VALUES(id, accountID, email, codeHash, expiresAt, linkTokenHash, purpose)
if _, err := ins.ExecContext(ctx, tx); err != nil {
return fmt.Errorf("insert confirmation: %w", err)
}
@@ -274,6 +478,54 @@ func (s *Store) latestPendingConfirmation(ctx context.Context, accountID uuid.UU
}, nil
}
// pendingConfirmation is a pending confirm-code row loaded by its deeplink token, in
// domain form.
type pendingConfirmation struct {
id uuid.UUID
accountID uuid.UUID
email string
purpose string
expiresAt time.Time
}
// pendingByTokenHash loads the unconsumed confirmation whose deeplink token hashes to
// tokenHash, or ErrNoPendingCode. The high-entropy token needs no attempt counter, so
// a partial-unique index guarantees at most one match.
func (s *Store) pendingByTokenHash(ctx context.Context, tokenHash string) (pendingConfirmation, error) {
stmt := postgres.SELECT(table.EmailConfirmations.AllColumns).
FROM(table.EmailConfirmations).
WHERE(
table.EmailConfirmations.LinkTokenHash.EQ(postgres.String(tokenHash)).
AND(table.EmailConfirmations.ConsumedAt.IS_NULL()),
).LIMIT(1)
var row model.EmailConfirmations
if err := stmt.QueryContext(ctx, s.db, &row); err != nil {
if errors.Is(err, qrm.ErrNoRows) {
return pendingConfirmation{}, ErrNoPendingCode
}
return pendingConfirmation{}, fmt.Errorf("account: load confirmation by token: %w", err)
}
return pendingConfirmation{
id: row.ConfirmationID,
accountID: row.AccountID,
email: row.Email,
purpose: row.Purpose,
expiresAt: row.ExpiresAt,
}, nil
}
// consumeConfirmation marks a confirmation consumed without writing an identity, used
// for the idempotent already-linked deeplink path.
func (s *Store) consumeConfirmation(ctx context.Context, id uuid.UUID, now time.Time) error {
upd := table.EmailConfirmations.UPDATE(table.EmailConfirmations.ConsumedAt).
SET(postgres.TimestampzT(now)).
WHERE(table.EmailConfirmations.ConfirmationID.EQ(postgres.UUID(id)))
if _, err := upd.ExecContext(ctx, s.db); err != nil {
return fmt.Errorf("account: consume confirmation: %w", err)
}
return nil
}
// bumpConfirmationAttempts increments a code's wrong-attempt counter by one.
func (s *Store) bumpConfirmationAttempts(ctx context.Context, id uuid.UUID) error {
stmt := table.EmailConfirmations.
@@ -320,6 +572,69 @@ func (s *Store) confirmEmailIdentity(ctx context.Context, confirmationID, accoun
return nil
}
// replaceEmailIdentity consumes the confirmation, deletes the account's existing email
// identity (freeing the old address) and inserts newEmail as its confirmed email, inside
// one transaction. It backs the change-email flow. A unique-constraint violation — the
// new address was confirmed elsewhere in the meantime — surfaces as ErrEmailTaken. When
// the account holds no email identity yet the delete is a no-op, so this doubles as an
// attach.
func (s *Store) replaceEmailIdentity(ctx context.Context, confirmationID, accountID uuid.UUID, newEmail string, now time.Time) error {
identityID, err := uuid.NewV7()
if err != nil {
return fmt.Errorf("account: new identity id: %w", err)
}
err = withTx(ctx, s.db, func(tx *sql.Tx) error {
upd := table.EmailConfirmations.
UPDATE(table.EmailConfirmations.ConsumedAt).
SET(postgres.TimestampzT(now)).
WHERE(table.EmailConfirmations.ConfirmationID.EQ(postgres.UUID(confirmationID)))
if _, err := upd.ExecContext(ctx, tx); err != nil {
return fmt.Errorf("consume confirmation: %w", err)
}
// Journal the outgoing email before replacing it, so the legal dossier keeps the
// address the account used to hold (see retention.go).
var old model.Identities
sel := postgres.SELECT(
table.Identities.ExternalID, table.Identities.Confirmed, table.Identities.CreatedAt,
).FROM(table.Identities).WHERE(
table.Identities.AccountID.EQ(postgres.UUID(accountID)).
AND(table.Identities.Kind.EQ(postgres.String(KindEmail))),
).LIMIT(1)
switch err := sel.QueryContext(ctx, tx, &old); {
case err == nil:
if err := retainIdentityTx(ctx, tx, accountID, KindEmail, old.ExternalID, old.Confirmed, old.CreatedAt, retainChange); err != nil {
return err
}
case errors.Is(err, qrm.ErrNoRows):
// No prior email (this doubles as an attach); nothing to retain.
default:
return fmt.Errorf("load outgoing email identity: %w", err)
}
del := table.Identities.DELETE().WHERE(
table.Identities.AccountID.EQ(postgres.UUID(accountID)).
AND(table.Identities.Kind.EQ(postgres.String(KindEmail))),
)
if _, err := del.ExecContext(ctx, tx); err != nil {
return fmt.Errorf("delete old email identity: %w", err)
}
ins := table.Identities.INSERT(
table.Identities.IdentityID, table.Identities.AccountID, table.Identities.Kind,
table.Identities.ExternalID, table.Identities.Confirmed,
).VALUES(identityID, accountID, KindEmail, newEmail, true)
if _, err := ins.ExecContext(ctx, tx); err != nil {
return err
}
return nil
})
if err != nil {
if isUniqueViolation(err) {
return ErrEmailTaken
}
return fmt.Errorf("account: replace email identity: %w", err)
}
return nil
}
// confirmEmailLogin consumes the login code and marks the existing email
// identity confirmed, inside one transaction. The identity already exists (a
// login provisioned it), so this updates rather than inserts and is idempotent
@@ -367,6 +682,18 @@ func generateCode() (code, hash string, err error) {
return code, hashCode(code), nil
}
// generateLinkToken returns a fresh opaque one-tap confirm deeplink token (URL-safe
// base64, 256-bit) and its hex SHA-256 hash. Only the hash is stored; the token
// travels only in the emailed link, mirroring the session-token model.
func generateLinkToken() (token, hash string, err error) {
buf := make([]byte, linkTokenBytes)
if _, err := crand.Read(buf); err != nil {
return "", "", fmt.Errorf("account: generate link token: %w", err)
}
token = base64.RawURLEncoding.EncodeToString(buf)
return token, hashCode(token), nil
}
// hashCode returns the hex-encoded SHA-256 of a confirm-code.
func hashCode(code string) string {
sum := sha256.Sum256([]byte(code))
+239
View File
@@ -0,0 +1,239 @@
package account
import (
"fmt"
"html/template"
"strings"
tmpltext "text/template"
"time"
)
// emailBrandColor is the single accent used in the confirmation email — a calm
// tile green, matching the "no riot of colours" brief.
const emailBrandColor = "#2f7d4f"
// confirmEmailView is the fully-localised data the confirmation email templates
// render. Every string is resolved before rendering, so the templates carry no
// localisation logic.
type confirmEmailView struct {
Brand string
Heading string
Intro string
Code string
Expiry string
CTALabel string
DeeplinkURL string
FooterIgnore string
LandingURL string
LandingLabel string
Preheader string
Locale string
Accent string
}
// emailCopy is the purpose- and locale-specific wording of a confirmation email.
type emailCopy struct {
Subject string
Preheader string
Heading string
Intro string
CTALabel string
FooterIgnore string
}
// confirmEmailCopy holds the wording per (purpose, locale). Unknown purposes fall
// back to the neutral link wording and unknown locales fall back to English.
var confirmEmailCopy = map[string]map[string]emailCopy{
purposeLogin: {
"en": {
Subject: "Your Erudit sign-in code",
Preheader: "Your sign-in code",
Heading: "Sign in to Erudit",
Intro: "Enter this code to sign in:",
CTALabel: "Sign in with one tap",
FooterIgnore: "If you didn't request this email, you can safely ignore it.",
},
"ru": {
Subject: "Код для входа в Эрудит",
Preheader: "Ваш код для входа",
Heading: "Вход в Эрудит",
Intro: "Введите этот код, чтобы войти в игру:",
CTALabel: "Войти одним нажатием",
FooterIgnore: "Если вы не запрашивали это письмо, просто проигнорируйте его.",
},
},
purposeLink: {
"en": {
Subject: "Your Erudit confirmation code",
Preheader: "Your confirmation code",
Heading: "Confirm your e-mail",
Intro: "Enter this code to confirm your address:",
CTALabel: "Confirm with one tap",
FooterIgnore: "If you didn't request this email, you can safely ignore it.",
},
"ru": {
Subject: "Код подтверждения Эрудит",
Preheader: "Ваш код подтверждения",
Heading: "Подтверждение e-mail",
Intro: "Введите этот код, чтобы подтвердить адрес:",
CTALabel: "Подтвердить одним нажатием",
FooterIgnore: "Если вы не запрашивали это письмо, просто проигнорируйте его.",
},
},
purposeChange: {
"en": {
Subject: "Confirm your new Erudit e-mail",
Preheader: "Confirm your new address",
Heading: "Confirm your new e-mail",
Intro: "Enter this code to switch your account to this address:",
CTALabel: "Confirm with one tap",
FooterIgnore: "If you didn't request this change, you can safely ignore it — your address stays the same.",
},
"ru": {
Subject: "Подтвердите новый e-mail в Эрудит",
Preheader: "Подтвердите новый адрес",
Heading: "Смена e-mail",
Intro: "Введите этот код, чтобы привязать аккаунт к новому адресу:",
CTALabel: "Подтвердить одним нажатием",
FooterIgnore: "Если вы не запрашивали смену, просто проигнорируйте письмо — адрес останется прежним.",
},
},
purposeDelete: {
"en": {
Subject: "Confirm your Erudit account deletion",
Preheader: "Confirm account deletion",
Heading: "Delete your account",
Intro: "Enter this code in the app to permanently delete your account:",
CTALabel: "",
FooterIgnore: "If you didn't request this, ignore it — your account stays as it is.",
},
"ru": {
Subject: "Подтвердите удаление аккаунта Эрудит",
Preheader: "Подтверждение удаления аккаунта",
Heading: "Удаление аккаунта",
Intro: "Введите этот код в приложении, чтобы удалить аккаунт без восстановления:",
CTALabel: "",
FooterIgnore: "Если вы не запрашивали удаление, проигнорируйте письмо — аккаунт останется.",
},
},
}
// emailBrand is the brand wordmark per locale.
var emailBrand = map[string]string{"en": "Erudit", "ru": "Эрудит"}
// emailExpiry formats the code-lifetime line per locale (abbreviated minutes to
// avoid plural agreement).
func emailExpiry(locale string, d time.Duration) string {
min := int(d / time.Minute)
if locale == "ru" {
return fmt.Sprintf("Код действует %d мин.", min)
}
return fmt.Sprintf("The code is valid for %d minutes.", min)
}
// normalizeLocale maps an account language to a supported email locale, defaulting
// to English.
func normalizeLocale(locale string) string {
if locale == "ru" {
return "ru"
}
return "en"
}
// renderConfirmationEmail builds the branded confirmation email for purpose in
// locale: a large readable code plus a one-tap deeplink button, with an
// ignore-notice footer and a landing link. Both a plain-text body and an HTML
// alternative are produced. deeplinkURL is the absolute /confirm link and
// landingURL the public landing origin.
func renderConfirmationEmail(purpose, code, deeplinkURL, landingURL, locale string) (Message, error) {
loc := normalizeLocale(locale)
byLocale, ok := confirmEmailCopy[purpose]
if !ok {
byLocale = confirmEmailCopy[purposeLink]
}
cp := byLocale[loc]
view := confirmEmailView{
Brand: emailBrand[loc],
Heading: cp.Heading,
Intro: cp.Intro,
Code: code,
Expiry: emailExpiry(loc, emailCodeTTL),
CTALabel: cp.CTALabel,
DeeplinkURL: deeplinkURL,
FooterIgnore: cp.FooterIgnore,
LandingURL: landingURL,
LandingLabel: emailBrand[loc],
Preheader: cp.Preheader,
Locale: loc,
Accent: emailBrandColor,
}
var html strings.Builder
if err := confirmEmailHTML.Execute(&html, view); err != nil {
return Message{}, fmt.Errorf("account: render confirmation email (html): %w", err)
}
var text strings.Builder
if err := confirmEmailText.Execute(&text, view); err != nil {
return Message{}, fmt.Errorf("account: render confirmation email (text): %w", err)
}
return Message{Subject: cp.Subject, Text: text.String(), HTML: html.String()}, nil
}
// confirmEmailHTML is a compact, image-free, mobile-friendly HTML email. Layout is
// table-based for broad mail-client compatibility and all styling is inlined
// because clients strip <style> blocks.
var confirmEmailHTML = template.Must(template.New("confirmEmailHTML").Parse(`<!DOCTYPE html>
<html lang="{{.Locale}}">
<head>
<meta charset="utf-8">
<meta name="viewport" content="width=device-width, initial-scale=1">
<title>{{.Brand}}</title>
</head>
<body style="margin:0;padding:0;background:#f4f5f7;">
<span style="display:none;max-height:0;overflow:hidden;opacity:0;">{{.Preheader}}</span>
<table role="presentation" width="100%" cellpadding="0" cellspacing="0" style="background:#f4f5f7;padding:24px 12px;">
<tr><td align="center">
<table role="presentation" width="100%" cellpadding="0" cellspacing="0" style="max-width:460px;background:#ffffff;border:1px solid #e5e7eb;border-radius:14px;overflow:hidden;font-family:-apple-system,'Segoe UI',Roboto,Helvetica,Arial,sans-serif;">
<tr><td style="padding:28px 32px 4px;">
<div style="font-size:15px;font-weight:700;letter-spacing:.04em;color:{{.Accent}};">{{.Brand}}</div>
</td></tr>
<tr><td style="padding:8px 32px 0;">
<h1 style="margin:0;font-size:20px;line-height:1.3;color:#111827;font-weight:600;">{{.Heading}}</h1>
<p style="margin:12px 0 0;font-size:15px;line-height:1.5;color:#374151;">{{.Intro}}</p>
</td></tr>
<tr><td style="padding:18px 32px 0;">
<div style="font-size:34px;font-weight:700;letter-spacing:8px;text-align:center;color:#111827;background:#f3f4f6;border-radius:10px;padding:18px 0;font-family:'SFMono-Regular',Consolas,Menlo,monospace;">{{.Code}}</div>
<p style="margin:10px 0 0;font-size:13px;line-height:1.5;color:#6b7280;text-align:center;">{{.Expiry}}</p>
</td></tr>
{{if .DeeplinkURL}}<tr><td style="padding:22px 32px 0;" align="center">
<a href="{{.DeeplinkURL}}" style="display:inline-block;background:{{.Accent}};color:#ffffff;text-decoration:none;font-size:15px;font-weight:600;padding:12px 26px;border-radius:9px;">{{.CTALabel}}</a>
</td></tr>
{{end}}<tr><td style="padding:26px 32px 28px;">
<hr style="border:none;border-top:1px solid #eceef1;margin:0 0 16px;">
<p style="margin:0;font-size:12px;line-height:1.6;color:#9ca3af;">{{.FooterIgnore}}</p>
<p style="margin:10px 0 0;font-size:12px;color:#9ca3af;"><a href="{{.LandingURL}}" style="color:#6b7280;text-decoration:none;">{{.LandingLabel}}</a></p>
</td></tr>
</table>
</td></tr>
</table>
</body>
</html>
`))
// confirmEmailText is the plain-text alternative (and multipart fallback).
var confirmEmailText = tmpltext.Must(tmpltext.New("confirmEmailText").Parse(`{{.Brand}}
{{.Heading}}
{{.Intro}}
{{.Code}}
{{.Expiry}}
{{if .DeeplinkURL}}{{.CTALabel}}:
{{.DeeplinkURL}}
{{end}}
{{.FooterIgnore}}
{{.LandingLabel}}{{.LandingURL}}
`))
@@ -0,0 +1,54 @@
package account
import (
"strings"
"testing"
)
// TestRenderConfirmationEmail checks that each (purpose, locale) renders a localised
// subject, embeds the code and the one-tap deeplink in both bodies, and produces HTML.
func TestRenderConfirmationEmail(t *testing.T) {
const deeplink = "https://erudit-game.ru/app/#/confirm/tok123"
cases := []struct {
name, purpose, locale, subjectSub string
}{
{"login ru", purposeLogin, "ru", "вход"},
{"login en", purposeLogin, "en", "sign-in"},
{"link ru", purposeLink, "ru", "подтвержд"},
{"link en", purposeLink, "en", "confirmation"},
{"change ru", purposeChange, "ru", "новый"},
{"change en", purposeChange, "en", "new"},
}
for _, c := range cases {
t.Run(c.name, func(t *testing.T) {
msg, err := renderConfirmationEmail(c.purpose, "123456", deeplink, "https://erudit-game.ru", c.locale)
if err != nil {
t.Fatalf("render: %v", err)
}
if !strings.Contains(strings.ToLower(msg.Subject), c.subjectSub) {
t.Errorf("subject %q does not contain %q", msg.Subject, c.subjectSub)
}
if !strings.Contains(msg.Text, "123456") || !strings.Contains(msg.HTML, "123456") {
t.Error("code missing from a body")
}
if !strings.Contains(msg.Text, deeplink) || !strings.Contains(msg.HTML, "confirm/tok123") {
t.Error("deeplink missing from a body")
}
if !strings.Contains(msg.HTML, "<html") {
t.Error("HTML body is not HTML")
}
})
}
}
// TestRenderConfirmationEmailUnknownLocaleDefaultsEnglish falls back to English for an
// unsupported locale rather than erroring or emitting an empty subject.
func TestRenderConfirmationEmailUnknownLocaleDefaultsEnglish(t *testing.T) {
msg, err := renderConfirmationEmail(purposeLogin, "000000", "", "https://erudit-game.ru", "de")
if err != nil {
t.Fatalf("render: %v", err)
}
if !strings.Contains(strings.ToLower(msg.Subject), "sign-in") {
t.Errorf("unknown locale should default to English, got subject %q", msg.Subject)
}
}
+160 -9
View File
@@ -2,6 +2,7 @@ package account
import (
"context"
"database/sql"
"errors"
"fmt"
"time"
@@ -16,6 +17,68 @@ import (
// belongs to another account; the caller turns it into a merge.
var ErrIdentityTaken = errors.New("account: identity already linked to another account")
// ErrLastIdentity is returned when removing an identity would leave the account with
// none, making it unreachable after logout. The admin email-erase refuses it.
var ErrLastIdentity = errors.New("account: cannot remove the last identity")
// RemoveIdentity deletes the account's identity of the given kind (and, for an email,
// any pending confirmations for it), freeing it for reuse. It refuses when that is the
// account's only identity (ErrLastIdentity) — which would leave the account
// unreachable — and returns ErrNotFound when the account has no identity of that kind.
// It backs the profile Unlink control and the admin "erase email" action.
func (s *Store) RemoveIdentity(ctx context.Context, accountID uuid.UUID, kind string) error {
ids, err := s.Identities(ctx, accountID)
if err != nil {
return err
}
var toRetain []Identity
others := 0
for _, id := range ids {
if id.Kind == kind {
toRetain = append(toRetain, id)
} else {
others++
}
}
if len(toRetain) == 0 {
return ErrNotFound
}
if others == 0 {
return ErrLastIdentity
}
return withTx(ctx, s.db, func(tx *sql.Tx) error {
// Journal the detached credential before removing it, so the legal dossier
// survives while the identity frees for reuse (see retention.go).
for _, id := range toRetain {
if err := retainIdentityTx(ctx, tx, accountID, id.Kind, id.ExternalID, id.Confirmed, id.CreatedAt, retainUnlink); err != nil {
return err
}
}
delID := table.Identities.DELETE().WHERE(
table.Identities.AccountID.EQ(postgres.UUID(accountID)).
AND(table.Identities.Kind.EQ(postgres.String(kind))),
)
if _, err := delID.ExecContext(ctx, tx); err != nil {
return fmt.Errorf("account: delete %s identity %s: %w", kind, accountID, err)
}
if kind == KindEmail {
delConf := table.EmailConfirmations.DELETE().WHERE(
table.EmailConfirmations.AccountID.EQ(postgres.UUID(accountID)),
)
if _, err := delConf.ExecContext(ctx, tx); err != nil {
return fmt.Errorf("account: delete email confirmations %s: %w", accountID, err)
}
}
return nil
})
}
// RemoveEmailIdentity erases the account's email identity. It backs the admin console's
// "erase email" action; the user-facing profile never unlinks email (it is changed).
func (s *Store) RemoveEmailIdentity(ctx context.Context, accountID uuid.UUID) error {
return s.RemoveIdentity(ctx, accountID, KindEmail)
}
// RequestLinkCode issues and mails a confirm-code for email to accountID,
// replacing any prior pending code. Unlike RequestCode it never refuses up front
// (taken or already-confirmed): possession of the address is the authorization for
@@ -26,16 +89,10 @@ func (s *EmailService) RequestLinkCode(ctx context.Context, accountID uuid.UUID,
if err != nil {
return err
}
code, hash, err := generateCode()
if err != nil {
return err
if !s.allowSend(addr) {
return ErrTooManyRequests
}
if err := s.store.replacePendingConfirmation(ctx, accountID, addr, hash, s.now().Add(emailCodeTTL)); err != nil {
return err
}
subject := "Your Scrabble confirmation code"
body := fmt.Sprintf("Your confirmation code is %s. It expires in %d minutes.", code, int(emailCodeTTL/time.Minute))
return s.mailer.Send(ctx, addr, subject, body)
return s.issueCode(ctx, accountID, addr, purposeLink, s.accountLocale(ctx, accountID), false)
}
// ConfirmLink verifies code for (accountID, email) and reports the address's
@@ -70,6 +127,100 @@ func (s *EmailService) ConfirmLink(ctx context.Context, accountID uuid.UUID, ema
return accountID, true, nil
}
// RequestChangeCode issues and mails a confirm-code to newEmail for an authenticated
// email change on accountID, replacing any prior pending code. Like RequestLinkCode it
// never refuses up front on "taken" (anti-enumeration): possession of newEmail is the
// authorization, and a conflict with another account is revealed only at confirm — as a
// non-disclosing refusal, never a merge.
func (s *EmailService) RequestChangeCode(ctx context.Context, accountID uuid.UUID, newEmail string) error {
addr, err := normalizeEmail(newEmail)
if err != nil {
return err
}
if !s.allowSend(addr) {
return ErrTooManyRequests
}
return s.issueCode(ctx, accountID, addr, purposeChange, s.accountLocale(ctx, accountID), false)
}
// ConfirmChange verifies code for (accountID, newEmail) and atomically replaces the
// account's confirmed email with newEmail, freeing the old address. When newEmail is
// already confirmed by another account it refuses with ErrEmailTaken (surfaced to the
// user as a non-disclosing "check the address or contact support"), never merging; when
// the account already owns newEmail it is an idempotent no-op. It returns the usual
// confirm-code errors (ErrNoPendingCode, ErrCodeExpired, ErrTooManyAttempts,
// ErrCodeMismatch) and the updated account on success.
func (s *EmailService) ConfirmChange(ctx context.Context, accountID uuid.UUID, newEmail, code string) (Account, error) {
addr, err := normalizeEmail(newEmail)
if err != nil {
return Account{}, err
}
conf, err := s.verifyPendingCode(ctx, accountID, addr, code)
if err != nil {
return Account{}, err
}
owner, ok, err := s.store.confirmedEmailAccount(ctx, addr)
if err != nil {
return Account{}, err
}
if ok && owner != accountID {
return Account{}, ErrEmailTaken
}
if ok && owner == accountID {
if err := s.store.consumeConfirmation(ctx, conf.id, s.now()); err != nil {
return Account{}, err
}
return s.store.GetByID(ctx, accountID)
}
if err := s.store.replaceEmailIdentity(ctx, conf.id, accountID, addr, s.now()); err != nil {
return Account{}, err
}
return s.store.GetByID(ctx, accountID)
}
// HasEmail reports whether accountID owns a confirmed email. The account-deletion step-up
// mails a confirm-code when it does, and falls back to a typed phrase otherwise.
func (s *EmailService) HasEmail(ctx context.Context, accountID uuid.UUID) (bool, error) {
_, ok, err := s.store.confirmedEmailOf(ctx, accountID)
return ok, err
}
// RequestDeleteCode mails an account-deletion confirm-code to the account's own confirmed
// email (no deeplink — deletion is confirmed in the app). It returns ErrNoEmail when the
// account holds no email, ErrTooManyRequests when throttled.
func (s *EmailService) RequestDeleteCode(ctx context.Context, accountID uuid.UUID) error {
addr, ok, err := s.store.confirmedEmailOf(ctx, accountID)
if err != nil {
return err
}
if !ok {
return ErrNoEmail
}
if !s.allowSend(addr) {
return ErrTooManyRequests
}
return s.issueCode(ctx, accountID, addr, purposeDelete, s.accountLocale(ctx, accountID), false)
}
// VerifyDeleteCode verifies the account-deletion code against the account's own email and
// consumes it on success. It returns ErrNoEmail (no email), the usual confirm-code errors
// (ErrNoPendingCode, ErrCodeExpired, ErrTooManyAttempts, ErrCodeMismatch), or nil when the
// code is valid — the caller then performs the deletion.
func (s *EmailService) VerifyDeleteCode(ctx context.Context, accountID uuid.UUID, code string) error {
addr, ok, err := s.store.confirmedEmailOf(ctx, accountID)
if err != nil {
return err
}
if !ok {
return ErrNoEmail
}
conf, err := s.verifyPendingCode(ctx, accountID, addr, code)
if err != nil {
return err
}
return s.store.consumeConfirmation(ctx, conf.id, s.now())
}
// verifyPendingCode loads and checks the pending confirm-code for (accountID,
// addr), counting a wrong attempt. It returns the confirmation on success.
func (s *EmailService) verifyPendingCode(ctx context.Context, accountID uuid.UUID, addr, code string) (emailConfirmation, error) {
+122 -29
View File
@@ -3,33 +3,99 @@ package account
import (
"context"
"fmt"
"net"
"net/smtp"
"strconv"
"strings"
"time"
"github.com/wneessen/go-mail"
"go.uber.org/zap"
)
// Message is a transactional email to send through a Mailer. Text is the
// required plain-text body and doubles as the multipart/alternative fallback;
// HTML, when non-empty, is the preferred body a capable client renders instead.
type Message struct {
// To is the recipient address, or several comma-separated (all get the one message).
To string
// From, when non-empty, overrides the configured sender for this message — the admin
// alert path uses a distinct From from the user-facing confirm-code sender.
From string
Subject string
Text string
HTML string
}
// Mailer delivers a transactional email. It is the seam behind which the email
// confirm-code flow sends codes, so the relay is swappable and unit tests use a
// fixture (see docs/TESTING.md: no real network in tests). The context is offered
// for cancellation; the standard-library SMTP implementation sends synchronously
// and ignores it.
// fixture (see docs/TESTING.md: no real network in tests). The context bounds the
// delivery and is honoured by the SMTP implementation.
type Mailer interface {
Send(ctx context.Context, to, subject, body string) error
Send(ctx context.Context, msg Message) error
}
// splitAddrs splits a comma-separated recipient list into trimmed, non-empty addresses.
func splitAddrs(list string) []string {
parts := strings.Split(list, ",")
out := make([]string, 0, len(parts))
for _, p := range parts {
if a := strings.TrimSpace(p); a != "" {
out = append(out, a)
}
}
return out
}
// SMTPConfig configures the SMTP relay. An empty Host selects the LogMailer
// instead, so a deployment without a relay still runs (the code lands in the log).
// TLS is always used and no client certificate is required — only the server
// certificate is validated against the system roots.
type SMTPConfig struct {
Host string
Port string
Username string
Password string
From string
// TLS selects the transport security: "ssl" for implicit TLS from connect, or
// "starttls" to upgrade a plaintext connection. Empty derives the mode from the
// port (implicit TLS on 465, STARTTLS otherwise); set it explicitly for a relay on
// a non-standard port (e.g. Selectel's 1127 = SSL, 1126 = STARTTLS).
TLS string
// AdminFrom / AdminTo drive the operator alert emails (new feedback / word complaints),
// distinct from the user-facing confirm-code sender. AdminTo may be several
// comma-separated addresses. Both empty disables the alert worker.
AdminFrom string
AdminTo string
}
// SMTPMailer sends mail through an SMTP relay using the standard library. When a
// username is set it authenticates with PLAIN; otherwise it relays unauthenticated.
const (
// SMTP transport-security modes for SMTPConfig.TLS.
smtpTLSImplicit = "ssl"
smtpTLSSTARTTLS = "starttls"
// smtpDialTimeout bounds a single relay connect-and-send. The confirm-code send
// is synchronous on the request path, so an unreachable relay must fail fast
// rather than hold the request open.
smtpDialTimeout = 15 * time.Second
)
// tlsMode resolves the transport-security mode for the relay: the explicitly
// configured SMTPConfig.TLS, or — when unset — implicit TLS on the conventional SSL
// port 465 and STARTTLS on any other port.
func (cfg SMTPConfig) tlsMode(port int) string {
switch strings.ToLower(strings.TrimSpace(cfg.TLS)) {
case smtpTLSImplicit, "tls":
return smtpTLSImplicit
case smtpTLSSTARTTLS:
return smtpTLSSTARTTLS
}
if port == 465 {
return smtpTLSImplicit
}
return smtpTLSSTARTTLS
}
// SMTPMailer sends mail through an SMTP relay using go-mail. When a username is
// set it authenticates, auto-discovering the strongest mechanism the relay
// advertises; otherwise it relays unauthenticated.
type SMTPMailer struct {
cfg SMTPConfig
}
@@ -39,29 +105,55 @@ func NewSMTPMailer(cfg SMTPConfig) SMTPMailer {
return SMTPMailer{cfg: cfg}
}
// Send delivers a plain-text UTF-8 message to to via the configured relay.
func (m SMTPMailer) Send(_ context.Context, to, subject, body string) error {
addr := net.JoinHostPort(m.cfg.Host, m.cfg.Port)
var auth smtp.Auth
if m.cfg.Username != "" {
auth = smtp.PlainAuth("", m.cfg.Username, m.cfg.Password, m.cfg.Host)
// Send delivers a UTF-8 message to msg.To via the configured relay. When msg.HTML
// is set the message is multipart/alternative (plain text plus HTML); otherwise
// it is plain text only.
func (m SMTPMailer) Send(ctx context.Context, msg Message) error {
port, err := strconv.Atoi(m.cfg.Port)
if err != nil {
return fmt.Errorf("account: invalid SMTP port %q: %w", m.cfg.Port, err)
}
if err := smtp.SendMail(addr, auth, m.cfg.From, []string{to}, message(m.cfg.From, to, subject, body)); err != nil {
return fmt.Errorf("account: send mail to %s: %w", to, err)
opts := []mail.Option{mail.WithPort(port), mail.WithTimeout(smtpDialTimeout)}
if m.cfg.tlsMode(port) == smtpTLSImplicit {
opts = append(opts, mail.WithSSL())
} else {
opts = append(opts, mail.WithTLSPortPolicy(mail.TLSMandatory))
}
if m.cfg.Username != "" {
opts = append(opts,
mail.WithSMTPAuth(mail.SMTPAuthAutoDiscover),
mail.WithUsername(m.cfg.Username),
mail.WithPassword(m.cfg.Password),
)
}
client, err := mail.NewClient(m.cfg.Host, opts...)
if err != nil {
return fmt.Errorf("account: build mail client: %w", err)
}
out := mail.NewMsg()
from := m.cfg.From
if msg.From != "" {
from = msg.From
}
if err := out.From(from); err != nil {
return fmt.Errorf("account: set From %q: %w", from, err)
}
// To may carry several comma-separated recipients; go-mail wants them as separate
// arguments (a single joined string parses as one malformed address).
if err := out.To(splitAddrs(msg.To)...); err != nil {
return fmt.Errorf("account: set To %q: %w", msg.To, err)
}
out.Subject(msg.Subject)
out.SetBodyString(mail.TypeTextPlain, msg.Text)
if msg.HTML != "" {
out.AddAlternativeString(mail.TypeTextHTML, msg.HTML)
}
if err := client.DialAndSendWithContext(ctx, out); err != nil {
return fmt.Errorf("account: send mail to %s: %w", msg.To, err)
}
return nil
}
// message renders a minimal RFC 5322 plain-text email.
func message(from, to, subject, body string) []byte {
return []byte("From: " + from + "\r\n" +
"To: " + to + "\r\n" +
"Subject: " + subject + "\r\n" +
"MIME-Version: 1.0\r\n" +
"Content-Type: text/plain; charset=UTF-8\r\n" +
"\r\n" + body + "\r\n")
}
// LogMailer logs the message instead of sending it. It is the default when no
// SMTP relay is configured and is intended for development only: it logs the body,
// which carries the confirm-code, so it must not be used in production.
@@ -74,11 +166,12 @@ func NewLogMailer(log *zap.Logger) LogMailer {
return LogMailer{log: log}
}
// Send logs the message at info level and reports success.
func (m LogMailer) Send(_ context.Context, to, subject, body string) error {
// Send logs the message at info level and reports success. It logs the plain-text
// body only (which carries the confirm-code); the HTML alternative is omitted.
func (m LogMailer) Send(_ context.Context, msg Message) error {
if m.log != nil {
m.log.Info("email not sent (log mailer)",
zap.String("to", to), zap.String("subject", subject), zap.String("body", body))
zap.String("to", msg.To), zap.String("subject", msg.Subject), zap.String("body", msg.Text))
}
return nil
}
+51
View File
@@ -0,0 +1,51 @@
package account
import (
"slices"
"testing"
)
// TestSplitAddrs covers the comma-separated recipient parsing used for the admin alert
// To (several operator mailboxes in one message), including trimming and empty entries.
func TestSplitAddrs(t *testing.T) {
cases := []struct {
in string
want []string
}{
{"a@x.ru", []string{"a@x.ru"}},
{"a@x.ru, b@y.ru", []string{"a@x.ru", "b@y.ru"}},
{" a@x.ru ,, b@y.ru ,", []string{"a@x.ru", "b@y.ru"}},
{"", nil},
}
for _, c := range cases {
if got := splitAddrs(c.in); !slices.Equal(got, c.want) {
t.Errorf("splitAddrs(%q) = %v, want %v", c.in, got, c.want)
}
}
}
// TestSMTPTLSMode covers the explicit TLS mode and the port-based fallback, including
// the non-standard Selectel ports (1127 = SSL, 1126 = STARTTLS) that the 465 heuristic
// alone cannot classify.
func TestSMTPTLSMode(t *testing.T) {
cases := []struct {
name string
tls string
port int
want string
}{
{"explicit ssl on a custom port (Selectel 1127)", "ssl", 1127, smtpTLSImplicit},
{"explicit starttls on a custom port (Selectel 1126)", "starttls", 1126, smtpTLSSTARTTLS},
{"tls is an alias for ssl", "TLS", 2525, smtpTLSImplicit},
{"empty derives implicit TLS on 465", "", 465, smtpTLSImplicit},
{"empty derives STARTTLS on 587", "", 587, smtpTLSSTARTTLS},
{"an unknown value falls back to the port heuristic", "bogus", 465, smtpTLSImplicit},
}
for _, c := range cases {
t.Run(c.name, func(t *testing.T) {
if got := (SMTPConfig{TLS: c.tls}).tlsMode(c.port); got != c.want {
t.Errorf("tlsMode(TLS=%q, port=%d) = %q, want %q", c.tls, c.port, got, c.want)
}
})
}
}
+60
View File
@@ -101,6 +101,66 @@ func validateVariantPreferences(prefs []string) ([]string, error) {
return out, nil
}
// variantSeedPrefix marks a Telegram start-param payload that seeds a brand-new
// account's variant preferences (e.g. "verudit_ru-scrabble_en"): the prefix, then the
// canonical variant labels joined by "-". It is deliberately distinct from the routing
// deep links (g/i/f; see platform/telegram .../deeplink) so the client's start-param
// router falls through to the lobby for it.
const variantSeedPrefix = "v"
// SeedVariantsFromStartParam decodes a promo deep-link start-param into the variant
// preference set to seed onto a brand-new account: the variantSeedPrefix followed by
// the canonical variant labels joined by "-" (e.g. "verudit_ru-scrabble_en"). It
// returns nil for any payload that is not a variant-seed link or that fails validation
// against the known variants, so a malformed, empty or unrelated start-param simply
// leaves the account on its default preferences rather than failing the login.
func SeedVariantsFromStartParam(startParam string) []string {
if !strings.HasPrefix(startParam, variantSeedPrefix) {
return nil
}
body := strings.TrimPrefix(startParam, variantSeedPrefix)
if body == "" {
return nil
}
prefs, err := validateVariantPreferences(strings.Split(body, "-"))
if err != nil {
return nil
}
return prefs
}
// SetVariantPreferences overwrites only the variant-preference set of the account,
// cleaning it to a deduplicated, canonically ordered subset of the known variants
// (rejecting an empty or unknown set with ErrInvalidProfile) and bumping updated_at; it
// reports ErrNotFound when no account matches id. It is the narrow counterpart to
// UpdateProfile used to seed a promo-onboarded account's variants at first contact
// without disturbing its other profile fields.
func (s *Store) SetVariantPreferences(ctx context.Context, id uuid.UUID, prefs []string) (Account, error) {
clean, err := validateVariantPreferences(prefs)
if err != nil {
return Account{}, err
}
stmt := table.Accounts.UPDATE(
table.Accounts.VariantPreferences, table.Accounts.UpdatedAt,
).SET(
// clean is validated against the closed knownVariants set; bind as a text[]
// parameter (lib/pq encodes the array, the cast pins the column type), mirroring
// UpdateProfile.
postgres.Raw("#variant_prefs::text[]", map[string]interface{}{"#variant_prefs": pq.StringArray(clean)}),
postgres.TimestampzT(time.Now().UTC()),
).WHERE(table.Accounts.AccountID.EQ(postgres.UUID(id))).
RETURNING(table.Accounts.AllColumns)
var row model.Accounts
if err := stmt.QueryContext(ctx, s.db, &row); err != nil {
if errors.Is(err, qrm.ErrNoRows) {
return Account{}, ErrNotFound
}
return Account{}, fmt.Errorf("account: set variant preferences %s: %w", id, err)
}
return modelToAccount(row), nil
}
// UpdateProfile validates and overwrites the editable fields of the account, then
// returns the stored row. It reports ErrInvalidProfile for a bad language,
// timezone or display name and ErrNotFound when no account matches id.
@@ -75,3 +75,55 @@ func TestTelegramSeedTruncatesLongName(t *testing.T) {
t.Errorf("display name rune count = %d, want %d", n, maxDisplayName)
}
}
// TestVKSeed covers the pure mapping from VK launch fields to the create-time account
// seed: supported-language detection from vk_language (bare and region-tagged) and the
// display name sanitized from the client-supplied name. Unlike Telegram there is no
// @username fallback — VK provides only the name.
func TestVKSeed(t *testing.T) {
cases := map[string]struct {
languageCode, displayName string
wantLang, wantName string
}{
"ru bare": {"ru", "Иван", "ru", "Иван"},
"en region-tagged": {"en-US", "John", "en", "John"},
"full name kept": {"ru", "Иван Петров", "ru", "Иван Петров"},
"unknown language": {"uk", "Тарас", "", "Тарас"},
"empty language": {"", "Neo", "", "Neo"},
"trimmed": {" RU ", " Anna ", "ru", "Anna"},
"emoji stripped": {"en", "🎮Kaya🎮", "en", "Kaya"},
}
for name, tc := range cases {
t.Run(name, func(t *testing.T) {
got := vkSeed(tc.languageCode, tc.displayName)
if got.preferredLanguage != tc.wantLang {
t.Errorf("preferredLanguage = %q, want %q", got.preferredLanguage, tc.wantLang)
}
if got.displayName != tc.wantName {
t.Errorf("displayName = %q, want %q", got.displayName, tc.wantName)
}
})
}
}
// TestVKSeedPlaceholder checks a VK name with no usable letters falls back to a
// generated placeholder in the seeded language ("Player-NNNNN" / "Игрок-NNNNN").
func TestVKSeedPlaceholder(t *testing.T) {
cases := map[string]struct {
languageCode, displayName string
wantRe string
}{
"en empty": {"en", "", `^Player-\d{5}$`},
"ru empty": {"ru", "", `^Игрок-\d{5}$`},
"default en": {"uk", "", `^Player-\d{5}$`},
"name garbage": {"ru", "123!@#", `^Игрок-\d{5}$`},
}
for name, tc := range cases {
t.Run(name, func(t *testing.T) {
got := vkSeed(tc.languageCode, tc.displayName).displayName
if !regexp.MustCompile(tc.wantRe).MatchString(got) {
t.Errorf("displayName = %q, want match %s", got, tc.wantRe)
}
})
}
}
+66
View File
@@ -0,0 +1,66 @@
package account
import (
"sync"
"time"
)
// SendLimiter throttles confirm-code sends per recipient address: it enforces a
// minimum cooldown between two sends and a cap over a rolling hour. It guards against
// email bombing and protects the relay's own quota. State is in-memory (per process,
// reset on restart) and keyed by the normalised recipient address, which is adequate
// for the single-instance backend. Safe for concurrent use.
type SendLimiter struct {
mu sync.Mutex
cooldown time.Duration
perHour int
now func() time.Time
sends map[string][]time.Time
}
// NewSendLimiter returns a SendLimiter allowing at most one send per cooldown and at
// most perHour sends over any rolling hour, to the same recipient.
func NewSendLimiter(cooldown time.Duration, perHour int) *SendLimiter {
return &SendLimiter{
cooldown: cooldown,
perHour: perHour,
now: func() time.Time { return time.Now() },
sends: make(map[string][]time.Time),
}
}
// Allow reports whether a send to key is permitted now, recording the send when it is.
// It is denied when the last send was within the cooldown or the rolling-hour cap is
// already reached.
func (l *SendLimiter) Allow(key string) bool {
l.mu.Lock()
defer l.mu.Unlock()
now := l.now()
cutoff := now.Add(-time.Hour)
kept := l.sends[key][:0]
for _, t := range l.sends[key] {
if t.After(cutoff) {
kept = append(kept, t)
}
}
if n := len(kept); n > 0 && now.Sub(kept[n-1]) < l.cooldown {
l.set(key, kept)
return false
}
if len(kept) >= l.perHour {
l.set(key, kept)
return false
}
l.set(key, append(kept, now))
return true
}
// set stores the retained send times for key, dropping the entry entirely once empty
// so the map stays bounded to recipients active within the last hour.
func (l *SendLimiter) set(key string, times []time.Time) {
if len(times) == 0 {
delete(l.sends, key)
return
}
l.sends[key] = times
}
@@ -0,0 +1,43 @@
package account
import (
"testing"
"time"
)
// TestSendLimiter checks the per-recipient cooldown and the rolling-hour cap, and
// that recipients are throttled independently.
func TestSendLimiter(t *testing.T) {
base := time.Now()
now := base
l := NewSendLimiter(time.Minute, 3)
l.now = func() time.Time { return now }
if !l.Allow("a") {
t.Fatal("send 1 should be allowed")
}
if l.Allow("a") {
t.Fatal("immediate resend must be blocked by the cooldown")
}
if !l.Allow("b") {
t.Fatal("a different recipient is independent")
}
now = base.Add(time.Minute)
if !l.Allow("a") {
t.Fatal("send 2 after the cooldown should be allowed")
}
now = base.Add(2 * time.Minute)
if !l.Allow("a") {
t.Fatal("send 3 should be allowed")
}
now = base.Add(3 * time.Minute)
if l.Allow("a") {
t.Fatal("send 4 within the hour must be blocked by the cap")
}
now = base.Add(time.Hour + time.Minute)
if !l.Allow("a") {
t.Fatal("after the rolling hour the cap resets")
}
}
+224
View File
@@ -0,0 +1,224 @@
package account
import (
"context"
"database/sql"
"errors"
"fmt"
"time"
"github.com/go-jet/jet/v2/postgres"
"github.com/go-jet/jet/v2/qrm"
"github.com/google/uuid"
"go.uber.org/zap"
"scrabble/backend/internal/postgres/jet/backend/model"
"scrabble/backend/internal/postgres/jet/backend/table"
)
// RetentionTTL bounds how long the account-deletion legal dossier is kept before the
// reaper purges it: two years from the detach/deletion event (owner policy, 2026-07-03).
const RetentionTTL = 2 * 365 * 24 * time.Hour
// Reasons recorded on a retained_identities row: what detached the credential from its
// account (unlink / email change / account deletion here; an account merge that drops a
// same-kind colliding identity writes reason "merge" from the accountmerge package). The
// row is written just before the live identities row is removed, preserving the legal
// dossier (which email/vk/tg was linked, and when) even as the identity frees for reuse.
// See docs/ARCHITECTURE.md §9.1.
const (
retainUnlink = "unlink"
retainChange = "change"
retainDelete = "delete"
)
// retainIdentityTx appends a retention-journal row for one identity being detached, inside
// tx. linkedAt is the identity's original creation time; detached_at defaults to now(). It
// must run in the same transaction as the identity removal, so the dossier and the live
// state can never diverge.
func retainIdentityTx(ctx context.Context, tx *sql.Tx, accountID uuid.UUID, kind, externalID string, confirmed bool, linkedAt time.Time, reason string) error {
id, err := uuid.NewV7()
if err != nil {
return fmt.Errorf("account: new retained id: %w", err)
}
ins := table.RetainedIdentities.INSERT(
table.RetainedIdentities.RetainedID, table.RetainedIdentities.AccountID,
table.RetainedIdentities.Kind, table.RetainedIdentities.ExternalID,
table.RetainedIdentities.Confirmed, table.RetainedIdentities.LinkedAt,
table.RetainedIdentities.Reason,
).VALUES(id, accountID, kind, externalID, confirmed, linkedAt, reason)
if _, err := ins.ExecContext(ctx, tx); err != nil {
return fmt.Errorf("account: retain identity (%s, %s): %w", kind, externalID, err)
}
return nil
}
// StampLastLogin records the account's last cold-load time and client IP, but only when
// the stored value is missing or older than an hour — so it costs at most one write per
// account per hour (its caller, the profile fetch, runs once per cold app-load). It is a
// best-effort audit signal that feeds the account-deletion dossier.
func (s *Store) StampLastLogin(ctx context.Context, accountID uuid.UUID, ip string) error {
now := time.Now().UTC()
upd := table.Accounts.UPDATE(table.Accounts.LastLoginAt, table.Accounts.LastLoginIP).
SET(postgres.TimestampzT(now), postgres.String(ip)).
WHERE(
table.Accounts.AccountID.EQ(postgres.UUID(accountID)).
AND(
table.Accounts.LastLoginAt.IS_NULL().
OR(table.Accounts.LastLoginAt.LT(postgres.TimestampzT(now.Add(-time.Hour)))),
),
)
if _, err := upd.ExecContext(ctx, s.db); err != nil {
return fmt.Errorf("account: stamp last login %s: %w", accountID, err)
}
return nil
}
// ReapExpiredRetention purges retention data whose event is older than cutoff: every
// retained_identities row by its detached_at (covering unlink/change on live accounts as
// well as deleted ones), plus — for accounts tombstoned before cutoff — the retained
// feedback thread and the dossier PII (deleted_display_name, last_login_ip). Chat is kept
// (a shared game artifact), and the tombstone account row itself stays (its no-cascade
// foreign keys). It returns how many journal rows and feedback messages were removed.
func (s *Store) ReapExpiredRetention(ctx context.Context, cutoff time.Time) (identities, feedback int64, err error) {
cut := postgres.TimestampzT(cutoff)
delJournal := table.RetainedIdentities.DELETE().
WHERE(table.RetainedIdentities.DetachedAt.LT(cut))
res, err := delJournal.ExecContext(ctx, s.db)
if err != nil {
return 0, 0, fmt.Errorf("account: reap retained identities: %w", err)
}
identities, _ = res.RowsAffected()
expired := postgres.SELECT(table.Accounts.AccountID).
FROM(table.Accounts).
WHERE(table.Accounts.DeletedAt.IS_NOT_NULL().AND(table.Accounts.DeletedAt.LT(cut)))
delFeedback := table.FeedbackMessages.DELETE().
WHERE(table.FeedbackMessages.AccountID.IN(expired))
fbRes, err := delFeedback.ExecContext(ctx, s.db)
if err != nil {
return identities, 0, fmt.Errorf("account: reap deleted feedback: %w", err)
}
feedback, _ = fbRes.RowsAffected()
clearPII := table.Accounts.UPDATE(table.Accounts.DeletedDisplayName, table.Accounts.LastLoginIP).
SET(postgres.NULL, postgres.NULL).
WHERE(
table.Accounts.DeletedAt.IS_NOT_NULL().
AND(table.Accounts.DeletedAt.LT(cut)).
AND(table.Accounts.DeletedDisplayName.IS_NOT_NULL().
OR(table.Accounts.LastLoginIP.IS_NOT_NULL())),
)
if _, err := clearPII.ExecContext(ctx, s.db); err != nil {
return identities, feedback, fmt.Errorf("account: clear expired dossier PII: %w", err)
}
return identities, feedback, nil
}
// RetainedIdentity is one row of the retention journal, for the admin dossier.
type RetainedIdentity struct {
Kind string
ExternalID string
Reason string
Confirmed bool
LinkedAt time.Time
DetachedAt time.Time
}
// RetainedIdentities returns the account's retention-journal rows (the legal dossier of
// detached credentials), newest detach first, for the admin console.
func (s *Store) RetainedIdentities(ctx context.Context, accountID uuid.UUID) ([]RetainedIdentity, error) {
var rows []model.RetainedIdentities
err := postgres.SELECT(table.RetainedIdentities.AllColumns).
FROM(table.RetainedIdentities).
WHERE(table.RetainedIdentities.AccountID.EQ(postgres.UUID(accountID))).
ORDER_BY(table.RetainedIdentities.DetachedAt.DESC()).
QueryContext(ctx, s.db, &rows)
if err != nil && !errors.Is(err, qrm.ErrNoRows) {
return nil, fmt.Errorf("account: retained identities %s: %w", accountID, err)
}
out := make([]RetainedIdentity, 0, len(rows))
for _, r := range rows {
out = append(out, RetainedIdentity{
Kind: r.Kind, ExternalID: r.ExternalID, Reason: r.Reason,
Confirmed: r.Confirmed, LinkedAt: r.LinkedAt, DetachedAt: r.DetachedAt,
})
}
return out, nil
}
// DeletionInfo is a tombstoned account's dossier header, for the admin console.
type DeletionInfo struct {
DeletedAt *time.Time
DeletedDisplayName string
LastLoginAt *time.Time
LastLoginIP string
}
// DeletionInfo reads the account's deletion tombstone + last-login dossier fields.
func (s *Store) DeletionInfo(ctx context.Context, accountID uuid.UUID) (DeletionInfo, error) {
var row model.Accounts
err := postgres.SELECT(
table.Accounts.DeletedAt, table.Accounts.DeletedDisplayName,
table.Accounts.LastLoginAt, table.Accounts.LastLoginIP,
).FROM(table.Accounts).
WHERE(table.Accounts.AccountID.EQ(postgres.UUID(accountID))).
QueryContext(ctx, s.db, &row)
if err != nil {
if errors.Is(err, qrm.ErrNoRows) {
return DeletionInfo{}, ErrNotFound
}
return DeletionInfo{}, fmt.Errorf("account: deletion info %s: %w", accountID, err)
}
info := DeletionInfo{DeletedAt: row.DeletedAt, LastLoginAt: row.LastLoginAt}
if row.DeletedDisplayName != nil {
info.DeletedDisplayName = *row.DeletedDisplayName
}
if row.LastLoginIP != nil {
info.LastLoginIP = *row.LastLoginIP
}
return info, nil
}
// RetentionReaper periodically purges expired account-deletion retention data via
// Store.ReapExpiredRetention, mirroring GuestReaper: one background goroutine started once
// from main.
type RetentionReaper struct {
store *Store
ttl time.Duration
clock func() time.Time
log *zap.Logger
}
// NewRetentionReaper constructs a reaper purging retention data older than ttl. log may be
// nil.
func NewRetentionReaper(store *Store, ttl time.Duration, log *zap.Logger) *RetentionReaper {
if log == nil {
log = zap.NewNop()
}
return &RetentionReaper{
store: store,
ttl: ttl,
clock: func() time.Time { return time.Now().UTC() },
log: log,
}
}
// Run purges expired retention data on each tick until ctx is cancelled.
func (r *RetentionReaper) Run(ctx context.Context, interval time.Duration) {
ticker := time.NewTicker(interval)
defer ticker.Stop()
for {
select {
case <-ctx.Done():
return
case <-ticker.C:
idn, fb, err := r.store.ReapExpiredRetention(ctx, r.clock().Add(-r.ttl))
if err != nil {
r.log.Warn("retention reap failed", zap.Error(err))
} else if idn > 0 || fb > 0 {
r.log.Info("reaped expired retention", zap.Int64("identities", idn), zap.Int64("feedback", fb))
}
}
}
}
+45 -13
View File
@@ -19,6 +19,9 @@ type UserListItem struct {
PreferredLanguage string
IsGuest bool
IsRobot bool
// IsDeleted marks a tombstoned account (deleted_at set), shown as a badge — a search
// spans both lists, so a result can be either live or deleted.
IsDeleted bool
// FlaggedHighRateAt is the soft high-rate marker (zero when unflagged), shown
// as a badge in the console list.
FlaggedHighRateAt time.Time
@@ -26,13 +29,17 @@ type UserListItem struct {
}
// UserFilter narrows the admin user list: Robots selects robot accounts (otherwise the
// non-robot "people"); NameMask and ExternalIDMask are glob masks ('*' = any run, '?' =
// one char) matched case-insensitively against the display name / any identity's external
// id. An empty mask means no filter on that field.
// non-robot "people"); Deleted selects tombstoned accounts (every other scope hides them);
// NameMask and ExternalIDMask are glob masks ('*' = any run, '?' = one char) matched
// case-insensitively against the display name / any identity's external id; EmailExact is a
// strict (exact) match against an account's email identity. An empty value means no filter
// on that field.
type UserFilter struct {
Robots bool
Deleted bool
NameMask string
ExternalIDMask string
EmailExact string
}
// robotExists is the correlated subquery testing whether account a is a robot.
@@ -51,17 +58,42 @@ func (s *Store) IsRobot(ctx context.Context, accountID uuid.UUID) (bool, error)
return ok, nil
}
// userListWhere builds the shared WHERE clause and its positional args (from $1).
// userListWhere builds the shared WHERE clause and its positional args (from $1). On the
// Robots tab it lists/searches robots only. Otherwise a search (any of the name /
// external-id / email filters) spans live and deleted people alike — never robots — so the
// operator finds a match from one query regardless of the People / Deleted tab; the search
// also looks in the retention journal, so a deleted account is still found by the email /
// external id it held (those rows moved from identities to retained_identities on deletion)
// and by its retained real name. With no search, the People / Deleted tab scope applies.
func userListWhere(f UserFilter) (string, []any) {
args := []any{f.Robots}
where := robotExists + ` = $1`
if name := LikePattern(f.NameMask); name != "" {
args = append(args, name)
where += fmt.Sprintf(` AND a.display_name ILIKE $%d ESCAPE '\'`, len(args))
name := LikePattern(f.NameMask)
ext := LikePattern(f.ExternalIDMask)
email := strings.ToLower(strings.TrimSpace(f.EmailExact))
searching := name != "" || ext != "" || email != ""
var args []any
var where string
switch {
case f.Robots:
where = robotExists + ` = true`
case searching:
where = robotExists + ` = false`
case f.Deleted:
where = robotExists + ` = false AND a.deleted_at IS NOT NULL`
default:
where = robotExists + ` = false AND a.deleted_at IS NULL`
}
if ext := LikePattern(f.ExternalIDMask); ext != "" {
if name != "" {
args = append(args, name)
where += fmt.Sprintf(` AND (a.display_name ILIKE $%d ESCAPE '\' OR a.deleted_display_name ILIKE $%d ESCAPE '\')`, len(args), len(args))
}
if ext != "" {
args = append(args, ext)
where += fmt.Sprintf(` AND EXISTS (SELECT 1 FROM backend.identities i WHERE i.account_id = a.account_id AND i.external_id ILIKE $%d ESCAPE '\')`, len(args))
where += fmt.Sprintf(` AND (EXISTS (SELECT 1 FROM backend.identities i WHERE i.account_id = a.account_id AND i.external_id ILIKE $%d ESCAPE '\') OR EXISTS (SELECT 1 FROM backend.retained_identities r WHERE r.account_id = a.account_id AND r.external_id ILIKE $%d ESCAPE '\'))`, len(args), len(args))
}
if email != "" {
args = append(args, email)
where += fmt.Sprintf(` AND (EXISTS (SELECT 1 FROM backend.identities i WHERE i.account_id = a.account_id AND i.kind = 'email' AND i.external_id = $%d) OR EXISTS (SELECT 1 FROM backend.retained_identities r WHERE r.account_id = a.account_id AND r.kind = 'email' AND r.external_id = $%d))`, len(args), len(args))
}
return where, args
}
@@ -69,7 +101,7 @@ func userListWhere(f UserFilter) (string, []any) {
// ListUsers returns the filtered admin user list, newest first, paginated.
func (s *Store) ListUsers(ctx context.Context, f UserFilter, limit, offset int) ([]UserListItem, error) {
where, args := userListWhere(f)
q := `SELECT a.account_id, a.display_name, a.preferred_language, a.is_guest, a.flagged_high_rate_at, a.created_at, ` + robotExists + ` AS is_robot
q := `SELECT a.account_id, a.display_name, a.preferred_language, a.is_guest, a.flagged_high_rate_at, a.created_at, ` + robotExists + ` AS is_robot, (a.deleted_at IS NOT NULL) AS is_deleted
FROM backend.accounts a WHERE ` + where +
fmt.Sprintf(` ORDER BY a.created_at DESC LIMIT $%d OFFSET $%d`, len(args)+1, len(args)+2)
args = append(args, limit, offset)
@@ -82,7 +114,7 @@ FROM backend.accounts a WHERE ` + where +
for rows.Next() {
var it UserListItem
var flagged sql.NullTime
if err := rows.Scan(&it.ID, &it.DisplayName, &it.PreferredLanguage, &it.IsGuest, &flagged, &it.CreatedAt, &it.IsRobot); err != nil {
if err := rows.Scan(&it.ID, &it.DisplayName, &it.PreferredLanguage, &it.IsGuest, &flagged, &it.CreatedAt, &it.IsRobot, &it.IsDeleted); err != nil {
return nil, fmt.Errorf("account: scan user: %w", err)
}
if flagged.Valid {
@@ -0,0 +1,37 @@
package account
import (
"slices"
"testing"
)
// TestSeedVariantsFromStartParam covers decoding a promo deep-link start-param into the
// variant-preference set to seed: a valid "v"-prefixed, "-"-joined label list is cleaned
// to the canonical order and deduplicated, while anything that is not a variant-seed link
// or that names an unknown variant yields nil (leaving the account on its defaults).
func TestSeedVariantsFromStartParam(t *testing.T) {
tests := []struct {
name string
param string
want []string
}{
{"english promo", "verudit_ru-scrabble_en", []string{"erudit_ru", "scrabble_en"}},
{"single variant", "vscrabble_en", []string{"scrabble_en"}},
{"canonical order regardless of payload order", "vscrabble_en-erudit_ru", []string{"erudit_ru", "scrabble_en"}},
{"deduplicated", "verudit_ru-erudit_ru", []string{"erudit_ru"}},
{"empty", "", nil},
{"prefix only", "v", nil},
{"routing game link is not a seed", "g0190abcd", nil},
{"friend code link is not a seed", "f123456", nil},
{"unknown variant rejected", "vscrabble_de", nil},
{"one unknown label rejects the whole set", "verudit_ru-scrabble_de", nil},
}
for _, tc := range tests {
t.Run(tc.name, func(t *testing.T) {
got := SeedVariantsFromStartParam(tc.param)
if !slices.Equal(got, tc.want) {
t.Errorf("SeedVariantsFromStartParam(%q) = %v, want %v", tc.param, got, tc.want)
}
})
}
}
+223
View File
@@ -0,0 +1,223 @@
// Package accountdelete deactivates an account as legal retention, not erasure: it keeps
// the account row as a tombstone (its chat/complaint foreign keys have no cascade, so a
// hard delete is impossible) while journalling and freeing the account's credentials,
// anonymising the live surfaces, and dropping the account's own social/ephemeral rows.
// The retained_identities journal plus the tombstone (deleted_at, deleted_display_name,
// last_login_at/ip) form the admin/legal dossier; messages are deliberately kept. Session
// revocation and active-game forfeit are orchestrated one layer up (they need the session
// cache and the game service). See docs/ARCHITECTURE.md §9.1 and the retention TTL reaper.
package accountdelete
import (
"context"
"database/sql"
"errors"
"fmt"
"time"
"github.com/go-jet/jet/v2/postgres"
"github.com/go-jet/jet/v2/qrm"
"github.com/google/uuid"
"scrabble/backend/internal/postgres/jet/backend/model"
"scrabble/backend/internal/postgres/jet/backend/table"
)
// AnonymizedName is the label a deleted account shows to opponents. Display names are
// stored strings resolved identically for every viewer (no per-viewer localisation in this
// codebase), so a single canonical label is used. The brackets are deliberate: the
// editable-name rule (account.displayNameRe) forbids them, so a live player can never set a
// name that impersonates a deleted account.
const AnonymizedName = "[Deleted]"
// retainDelete is the retained_identities reason written when a credential is journalled
// because its account is being deleted.
const retainDelete = "delete"
// Deleter performs the SQL-atomic part of account deletion over a Postgres handle.
type Deleter struct {
db *sql.DB
now func() time.Time
}
// NewDeleter constructs a Deleter over db.
func NewDeleter(db *sql.DB) *Deleter {
return &Deleter{db: db, now: func() time.Time { return time.Now().UTC() }}
}
// AnonymizeAndTombstone retires accountID atomically: it journals every live identity into
// retained_identities (reason=delete) then removes them so the credentials free for reuse,
// snapshots the real display name into deleted_display_name and scrubs the live one to
// AnonymizedName, sets deleted_at, anonymises the account's game-seat snapshots, and drops
// its friendships, blocks, invitations, friend codes, drafts and pending codes. Chat,
// feedback and complaints are kept (the surviving tombstone keeps their no-cascade foreign
// keys valid). It is idempotent-safe on an already-tombstoned account (re-journalling
// nothing, since the identities are already gone).
func (d *Deleter) AnonymizeAndTombstone(ctx context.Context, accountID uuid.UUID) error {
now := d.now()
return withTx(ctx, d.db, func(tx *sql.Tx) error {
if err := journalAndDropIdentities(ctx, tx, accountID, now); err != nil {
return err
}
if err := tombstone(ctx, tx, accountID, now); err != nil {
return err
}
if _, err := table.GamePlayers.UPDATE(table.GamePlayers.DisplayName).
SET(postgres.String(AnonymizedName)).
WHERE(table.GamePlayers.AccountID.EQ(postgres.UUID(accountID))).
ExecContext(ctx, tx); err != nil {
return fmt.Errorf("accountdelete: anonymise seats: %w", err)
}
return dropSocialAndEphemerals(ctx, tx, accountID)
})
}
// dropAllRobotGamesSQL deletes every game in which the account plays and no other seat is a
// human — a robot seat is one whose account holds a 'robot' identity, so this covers both
// honest vs-AI games and disguised auto-match substitutes. The game rows are deleted; their
// moves/chat/players/complaints fall away through ON DELETE CASCADE.
const dropAllRobotGamesSQL = `
DELETE FROM games g
WHERE EXISTS (
SELECT 1 FROM game_players p WHERE p.game_id = g.game_id AND p.account_id = $1
) AND NOT EXISTS (
SELECT 1 FROM game_players o
WHERE o.game_id = g.game_id AND o.account_id <> $1
AND NOT EXISTS (
SELECT 1 FROM identities i WHERE i.account_id = o.account_id AND i.kind = 'robot'
)
)`
// DropAllRobotGames deletes the account's games that have no human opponent (solo vs-AI or
// auto-match-robot games), returning how many were removed. Games with any human seat are
// kept — their seat is anonymised by AnonymizeAndTombstone instead. Run it after the
// account's active games are resigned, so no live game is removed under the robot driver.
func (d *Deleter) DropAllRobotGames(ctx context.Context, accountID uuid.UUID) (int64, error) {
res, err := d.db.ExecContext(ctx, dropAllRobotGamesSQL, accountID)
if err != nil {
return 0, fmt.Errorf("accountdelete: drop all-robot games: %w", err)
}
n, err := res.RowsAffected()
if err != nil {
return 0, fmt.Errorf("accountdelete: dropped games count: %w", err)
}
return n, nil
}
// journalAndDropIdentities copies the account's live identities into the retention journal
// (reason=delete) and then removes them, freeing each (kind, external_id) for reuse.
func journalAndDropIdentities(ctx context.Context, tx *sql.Tx, accountID uuid.UUID, now time.Time) error {
var ids []model.Identities
err := postgres.SELECT(table.Identities.AllColumns).
FROM(table.Identities).
WHERE(table.Identities.AccountID.EQ(postgres.UUID(accountID))).
QueryContext(ctx, tx, &ids)
if err != nil && !errors.Is(err, qrm.ErrNoRows) {
return fmt.Errorf("accountdelete: load identities: %w", err)
}
for _, id := range ids {
rid, err := uuid.NewV7()
if err != nil {
return fmt.Errorf("accountdelete: new retained id: %w", err)
}
ins := table.RetainedIdentities.INSERT(
table.RetainedIdentities.RetainedID, table.RetainedIdentities.AccountID,
table.RetainedIdentities.Kind, table.RetainedIdentities.ExternalID,
table.RetainedIdentities.Confirmed, table.RetainedIdentities.LinkedAt,
table.RetainedIdentities.DetachedAt, table.RetainedIdentities.Reason,
).VALUES(rid, accountID, id.Kind, id.ExternalID, id.Confirmed, id.CreatedAt, now, retainDelete)
if _, err := ins.ExecContext(ctx, tx); err != nil {
return fmt.Errorf("accountdelete: retain identity %s: %w", id.Kind, err)
}
}
if _, err := table.Identities.DELETE().
WHERE(table.Identities.AccountID.EQ(postgres.UUID(accountID))).
ExecContext(ctx, tx); err != nil {
return fmt.Errorf("accountdelete: delete identities: %w", err)
}
return nil
}
// tombstone marks the account deleted, snapshotting the real display name into
// deleted_display_name (evaluated from the old row) before scrubbing the live one.
func tombstone(ctx context.Context, tx *sql.Tx, accountID uuid.UUID, now time.Time) error {
upd := table.Accounts.UPDATE(
table.Accounts.DeletedAt, table.Accounts.DeletedDisplayName,
table.Accounts.DisplayName, table.Accounts.UpdatedAt,
).SET(
postgres.TimestampzT(now), table.Accounts.DisplayName,
postgres.String(AnonymizedName), postgres.TimestampzT(now),
).WHERE(table.Accounts.AccountID.EQ(postgres.UUID(accountID)))
if _, err := upd.ExecContext(ctx, tx); err != nil {
return fmt.Errorf("accountdelete: tombstone account: %w", err)
}
return nil
}
// dropSocialAndEphemerals removes the account's own friendships, blocks, invitations
// (as inviter and as invitee), friend codes, drafts and pending confirm-codes. These are
// the deleting user's private data with no dossier value; chat and feedback are kept.
func dropSocialAndEphemerals(ctx context.Context, tx *sql.Tx, accountID uuid.UUID) error {
id := postgres.UUID(accountID)
// Friendships and blocks are two-account edges keyed on either endpoint.
if _, err := table.Friendships.DELETE().
WHERE(table.Friendships.RequesterID.EQ(id).OR(table.Friendships.AddresseeID.EQ(id))).
ExecContext(ctx, tx); err != nil {
return fmt.Errorf("accountdelete: delete friendships: %w", err)
}
if _, err := table.Blocks.DELETE().
WHERE(table.Blocks.BlockerID.EQ(id).OR(table.Blocks.BlockedID.EQ(id))).
ExecContext(ctx, tx); err != nil {
return fmt.Errorf("accountdelete: delete blocks: %w", err)
}
// Invitations: drop the account's invitee rows, then its own invitations' invitees and
// the invitations themselves (children first, to respect the foreign key).
if _, err := table.GameInvitationInvitees.DELETE().
WHERE(table.GameInvitationInvitees.AccountID.EQ(id)).
ExecContext(ctx, tx); err != nil {
return fmt.Errorf("accountdelete: delete invitee rows: %w", err)
}
ownInvitations := postgres.SELECT(table.GameInvitations.InvitationID).
FROM(table.GameInvitations).
WHERE(table.GameInvitations.InviterID.EQ(id))
if _, err := table.GameInvitationInvitees.DELETE().
WHERE(table.GameInvitationInvitees.InvitationID.IN(ownInvitations)).
ExecContext(ctx, tx); err != nil {
return fmt.Errorf("accountdelete: delete own invitation invitees: %w", err)
}
if _, err := table.GameInvitations.DELETE().
WHERE(table.GameInvitations.InviterID.EQ(id)).
ExecContext(ctx, tx); err != nil {
return fmt.Errorf("accountdelete: delete invitations: %w", err)
}
// Ephemerals: friend codes, move drafts, pending confirm-codes.
if _, err := table.FriendCodes.DELETE().
WHERE(table.FriendCodes.AccountID.EQ(id)).ExecContext(ctx, tx); err != nil {
return fmt.Errorf("accountdelete: delete friend codes: %w", err)
}
if _, err := table.GameDrafts.DELETE().
WHERE(table.GameDrafts.AccountID.EQ(id)).ExecContext(ctx, tx); err != nil {
return fmt.Errorf("accountdelete: delete drafts: %w", err)
}
if _, err := table.EmailConfirmations.DELETE().
WHERE(table.EmailConfirmations.AccountID.EQ(id)).ExecContext(ctx, tx); err != nil {
return fmt.Errorf("accountdelete: delete confirmations: %w", err)
}
return nil
}
// withTx runs fn inside a transaction, committing on success and rolling back on error.
func withTx(ctx context.Context, db *sql.DB, fn func(tx *sql.Tx) error) error {
tx, err := db.BeginTx(ctx, nil)
if err != nil {
return fmt.Errorf("accountdelete: begin tx: %w", err)
}
if err := fn(tx); err != nil {
_ = tx.Rollback()
return err
}
if err := tx.Commit(); err != nil {
return fmt.Errorf("accountdelete: commit tx: %w", err)
}
return nil
}
+119 -20
View File
@@ -27,6 +27,11 @@ import (
// without taking a dependency on the game package.
const statusActive = "active"
// retainReasonMerge is the retained_identities.reason for a credential dropped by a merge
// collision (both accounts held the same kind). It mirrors the account package's retain
// reasons, kept local to avoid importing that package's unexported constants.
const retainReasonMerge = "merge"
// Friendship statuses, highest precedence first, mirroring internal/social.
const (
friendAccepted = "accepted"
@@ -46,8 +51,24 @@ var ErrSameAccount = errors.New("accountmerge: primary and secondary are the sam
type Merger struct {
db *sql.DB
now func() time.Time
// payments, when set, merges the two accounts' chip segments and benefits by origin inside
// the merge transaction (SetPayments). Nil leaves payments untouched (tests that do not
// exercise the wallet).
payments PaymentsMerger
}
// PaymentsMerger is the payments surface the account merge enlists: fold the secondary's
// segments and benefits into the primary within the merge transaction, then invalidate the
// affected read caches after the commit. *payments.Service satisfies it.
type PaymentsMerger interface {
MergeTx(ctx context.Context, tx *sql.Tx, primary, secondary uuid.UUID) error
Invalidate(ids ...uuid.UUID)
}
// SetPayments installs the payments merge hook. It must be called during startup wiring; the
// default (nil) merges no wallet state.
func (m *Merger) SetPayments(p PaymentsMerger) { m.payments = p }
// NewMerger constructs a Merger over db.
func NewMerger(db *sql.DB) *Merger {
return &Merger{db: db, now: func() time.Time { return time.Now().UTC() }}
@@ -62,7 +83,7 @@ func (m *Merger) Merge(ctx context.Context, primary, secondary uuid.UUID) error
return ErrSameAccount
}
now := m.now()
return withTx(ctx, m.db, func(tx *sql.Tx) error {
if err := withTx(ctx, m.db, func(tx *sql.Tx) error {
if err := guardActiveSharedGame(ctx, tx, primary, secondary); err != nil {
return err
}
@@ -75,6 +96,9 @@ func (m *Merger) Merge(ctx context.Context, primary, secondary uuid.UUID) error
if err := mergeAccountFields(ctx, tx, primary, secondary, now); err != nil {
return err
}
if err := dedupeIdentities(ctx, tx, primary, secondary); err != nil {
return err
}
if err := reassignColumn(ctx, tx, table.Identities, table.Identities.AccountID, primary, secondary); err != nil {
return fmt.Errorf("accountmerge: identities: %w", err)
}
@@ -99,8 +123,21 @@ func (m *Merger) Merge(ctx context.Context, primary, secondary uuid.UUID) error
if err := deleteEphemerals(ctx, tx, secondary); err != nil {
return err
}
if m.payments != nil {
if err := m.payments.MergeTx(ctx, tx, primary, secondary); err != nil {
return fmt.Errorf("accountmerge: payments: %w", err)
}
}
return tombstone(ctx, tx, primary, secondary, now)
})
}); err != nil {
return err
}
// The payments read cache is invalidated only after the merge commits, so a read racing the
// transaction cannot re-cache pre-merge state (both accounts' rows are moved or dropped).
if m.payments != nil {
m.payments.Invalidate(primary, secondary)
}
return nil
}
// guardActiveSharedGame returns ErrActiveGameConflict when primary and secondary
@@ -240,25 +277,16 @@ func mergeBestMoves(ctx context.Context, tx *sql.Tx, primary, secondary uuid.UUI
return nil
}
// mergeAccountFields adds secondary's hint wallet to primary and ORs the paid flag;
// all other profile fields stay the primary's.
func mergeAccountFields(ctx context.Context, tx *sql.Tx, primary, secondary uuid.UUID, now time.Time) error {
var sec model.Accounts
if err := postgres.SELECT(table.Accounts.AllColumns).
FROM(table.Accounts).
WHERE(table.Accounts.AccountID.EQ(postgres.UUID(secondary))).
QueryContext(ctx, tx, &sec); err != nil {
return fmt.Errorf("accountmerge: load secondary account: %w", err)
}
upd := table.Accounts.UPDATE(
table.Accounts.HintBalance, table.Accounts.PaidAccount, table.Accounts.UpdatedAt,
).SET(
table.Accounts.HintBalance.ADD(postgres.Int(int64(sec.HintBalance))),
table.Accounts.PaidAccount.OR(postgres.Bool(sec.PaidAccount)),
postgres.TimestampzT(now),
).WHERE(table.Accounts.AccountID.EQ(postgres.UUID(primary)))
// mergeAccountFields bumps the primary account's updated_at to reflect the merge. The former
// hint-wallet and paid-flag merge moved to the payments domain, where segments and benefits
// merge by origin (see the payments MergeTx step and docs/PAYMENTS.md §6); the legacy
// accounts.hint_balance / paid_account columns are deprecated and no longer read or written.
func mergeAccountFields(ctx context.Context, tx *sql.Tx, primary, _ uuid.UUID, now time.Time) error {
upd := table.Accounts.UPDATE(table.Accounts.UpdatedAt).
SET(postgres.TimestampzT(now)).
WHERE(table.Accounts.AccountID.EQ(postgres.UUID(primary)))
if _, err := upd.ExecContext(ctx, tx); err != nil {
return fmt.Errorf("accountmerge: update primary account: %w", err)
return fmt.Errorf("accountmerge: touch primary account: %w", err)
}
return nil
}
@@ -300,6 +328,77 @@ func reassignColumn(ctx context.Context, tx *sql.Tx, tbl postgres.Table, col pos
return err
}
// dedupeIdentities resolves a same-kind identity collision before the blanket identity
// reassign: when both accounts already hold an identity of the same kind (e.g. each has a
// confirmed email — reachable when two email-bearing accounts merge), the primary keeps
// its own and the secondary's is journaled to retained_identities (reason=merge) and
// removed. Without this the blanket reassign would leave the survivor with two identities
// of one kind (there is no per-account-kind unique on identities), which the profile and
// the retention dossier both treat as singular. Non-colliding identities are untouched and
// move with the blanket reassign.
func dedupeIdentities(ctx context.Context, tx *sql.Tx, primary, secondary uuid.UUID) error {
var prows []model.Identities
if err := postgres.SELECT(table.Identities.Kind).
FROM(table.Identities).
WHERE(table.Identities.AccountID.EQ(postgres.UUID(primary))).
QueryContext(ctx, tx, &prows); err != nil && !errors.Is(err, qrm.ErrNoRows) {
return fmt.Errorf("accountmerge: primary identity kinds: %w", err)
}
occupied := make(map[string]struct{}, len(prows))
for _, r := range prows {
occupied[r.Kind] = struct{}{}
}
if len(occupied) == 0 {
return nil
}
var srows []model.Identities
if err := postgres.SELECT(
table.Identities.Kind, table.Identities.ExternalID,
table.Identities.Confirmed, table.Identities.CreatedAt,
).FROM(table.Identities).
WHERE(table.Identities.AccountID.EQ(postgres.UUID(secondary))).
QueryContext(ctx, tx, &srows); err != nil && !errors.Is(err, qrm.ErrNoRows) {
return fmt.Errorf("accountmerge: secondary identities: %w", err)
}
for _, s := range srows {
if _, dup := occupied[s.Kind]; !dup {
continue
}
if err := retainMergedIdentity(ctx, tx, secondary, s); err != nil {
return err
}
del := table.Identities.DELETE().WHERE(
table.Identities.AccountID.EQ(postgres.UUID(secondary)).
AND(table.Identities.Kind.EQ(postgres.String(s.Kind))).
AND(table.Identities.ExternalID.EQ(postgres.String(s.ExternalID))),
)
if _, err := del.ExecContext(ctx, tx); err != nil {
return fmt.Errorf("accountmerge: drop colliding %s identity: %w", s.Kind, err)
}
}
return nil
}
// retainMergedIdentity appends a retained_identities row for a secondary identity dropped
// by a merge collision (reason=merge), preserving it in the legal dossier. It mirrors
// account.retainIdentityTx, which is unexported; detached_at falls to the column default.
func retainMergedIdentity(ctx context.Context, tx *sql.Tx, accountID uuid.UUID, id model.Identities) error {
rid, err := uuid.NewV7()
if err != nil {
return fmt.Errorf("accountmerge: new retained id: %w", err)
}
ins := table.RetainedIdentities.INSERT(
table.RetainedIdentities.RetainedID, table.RetainedIdentities.AccountID,
table.RetainedIdentities.Kind, table.RetainedIdentities.ExternalID,
table.RetainedIdentities.Confirmed, table.RetainedIdentities.LinkedAt,
table.RetainedIdentities.Reason,
).VALUES(rid, accountID, id.Kind, id.ExternalID, id.Confirmed, id.CreatedAt, retainReasonMerge)
if _, err := ins.ExecContext(ctx, tx); err != nil {
return fmt.Errorf("accountmerge: retain merged %s identity: %w", id.Kind, err)
}
return nil
}
// friendRank ranks a friendship status for dedupe precedence (higher wins).
func friendRank(status string) int {
switch status {
+115
View File
@@ -0,0 +1,115 @@
// Package adminalert emails the operator when new player feedback or word complaints
// arrive, coalescing a burst into a single digest per interval so a flood is one email,
// not N. It is inert unless an admin sender and recipient are configured. The sender is
// distinct from the user-facing confirm-code From, and the recipient may be several
// comma-separated addresses (the mailer splits them).
package adminalert
import (
"context"
"fmt"
"strings"
"time"
"go.uber.org/zap"
"scrabble/backend/internal/account"
)
// FeedbackCounter counts feedback created since a time (satisfied by feedback.Service).
type FeedbackCounter interface {
CountSince(ctx context.Context, since time.Time) (int, error)
}
// ComplaintCounter counts word complaints filed since a time (satisfied by game.Service).
type ComplaintCounter interface {
CountComplaintsSince(ctx context.Context, since time.Time) (int, error)
}
// Notifier polls for new feedback and complaints and emails the operator a digest.
type Notifier struct {
mailer account.Mailer
feedback FeedbackCounter
complaints ComplaintCounter
from string
to string
clock func() time.Time
log *zap.Logger
last time.Time
}
// New constructs a Notifier. from and to are the alert sender and recipient(s); log may be
// nil. The watermark starts at "now", so only items arriving after start-up are reported. The
// digest deliberately carries no admin-console link — an admin URL must never travel in an
// email, where a mail provider could cache or index it.
func New(mailer account.Mailer, fb FeedbackCounter, cp ComplaintCounter, from, to string, log *zap.Logger) *Notifier {
if log == nil {
log = zap.NewNop()
}
return &Notifier{
mailer: mailer, feedback: fb, complaints: cp, from: from, to: to,
clock: func() time.Time { return time.Now().UTC() }, log: log, last: time.Now().UTC(),
}
}
// Run polls on each tick until ctx is cancelled.
func (n *Notifier) Run(ctx context.Context, interval time.Duration) {
ticker := time.NewTicker(interval)
defer ticker.Stop()
for {
select {
case <-ctx.Done():
return
case <-ticker.C:
n.tick(ctx)
}
}
}
// tick counts what arrived since the last watermark and, if anything did, emails one
// digest. The watermark only advances after a successful send (or a quiet tick), so a
// transient send failure is retried on the next tick — the counts simply grow.
func (n *Notifier) tick(ctx context.Context) {
now := n.clock()
fb, err := n.feedback.CountSince(ctx, n.last)
if err != nil {
n.log.Warn("admin alert: count feedback failed", zap.Error(err))
return
}
cp, err := n.complaints.CountComplaintsSince(ctx, n.last)
if err != nil {
n.log.Warn("admin alert: count complaints failed", zap.Error(err))
return
}
if fb == 0 && cp == 0 {
n.last = now
return
}
if err := n.mailer.Send(ctx, n.digest(fb, cp)); err != nil {
n.log.Warn("admin alert: send failed", zap.Error(err))
return
}
n.log.Info("admin alert sent", zap.Int("feedback", fb), zap.Int("complaints", cp))
n.last = now
}
// digest builds the operator alert email for fb new feedback and cp new complaints.
func (n *Notifier) digest(fb, cp int) account.Message {
var parts []string
if fb > 0 {
parts = append(parts, fmt.Sprintf("%d new feedback message(s)", fb))
}
if cp > 0 {
parts = append(parts, fmt.Sprintf("%d new word complaint(s)", cp))
}
summary := strings.Join(parts, ", ")
// No admin-console link in the body: an admin URL must never travel in an email (a mail
// provider could cache or index it). The operator opens the console directly.
text := summary + "."
return account.Message{
From: n.from,
To: n.to,
Subject: "Erudit — " + summary,
Text: text,
}
}
@@ -0,0 +1,57 @@
package adminalert
import (
"context"
"strings"
"testing"
"time"
"scrabble/backend/internal/account"
)
// The fakes ignore the watermark and return a fixed count, which is all the digest logic
// needs.
type fbCounter struct{ n int }
func (f fbCounter) CountSince(context.Context, time.Time) (int, error) { return f.n, nil }
type cpCounter struct{ n int }
func (c cpCounter) CountComplaintsSince(context.Context, time.Time) (int, error) { return c.n, nil }
type recordingMailer struct{ sent []account.Message }
func (m *recordingMailer) Send(_ context.Context, msg account.Message) error {
m.sent = append(m.sent, msg)
return nil
}
func TestNotifierSkipsWhenNothingNew(t *testing.T) {
mailer := &recordingMailer{}
n := New(mailer, fbCounter{0}, cpCounter{0}, "alerts@erudit-game.ru", "op@x.ru", nil)
n.tick(context.Background())
if len(mailer.sent) != 0 {
t.Fatalf("sent %d emails, want 0 when nothing is new", len(mailer.sent))
}
}
func TestNotifierDigestsNewItems(t *testing.T) {
mailer := &recordingMailer{}
n := New(mailer, fbCounter{2}, cpCounter{1}, "alerts@erudit-game.ru", "op@x.ru, two@x.ru", nil)
n.tick(context.Background())
if len(mailer.sent) != 1 {
t.Fatalf("sent %d emails, want 1 digest", len(mailer.sent))
}
msg := mailer.sent[0]
if msg.From != "alerts@erudit-game.ru" || msg.To != "op@x.ru, two@x.ru" {
t.Errorf("digest addressing = From %q To %q", msg.From, msg.To)
}
if !strings.Contains(msg.Subject, "2 new feedback") || !strings.Contains(msg.Subject, "1 new word complaint") {
t.Errorf("digest subject = %q, want the feedback + complaint counts", msg.Subject)
}
// The digest must never carry an admin-console link — an admin URL in an email is a leak
// (mail providers cache/index it).
if strings.Contains(msg.Text, "/_gm") || strings.Contains(strings.ToLower(msg.Text), "admin console") {
t.Errorf("digest body = %q, must not carry an admin-console link", msg.Text)
}
}
@@ -193,3 +193,24 @@ code { background: var(--bg); padding: 0.05rem 0.3rem; border-radius: 4px; }
.replay-log { margin: 0.4rem 0 0; padding-left: 1.4rem; max-height: 14rem; overflow: auto; font-size: 0.85rem; }
.replay-log li { color: var(--ink-dim); padding: 0.1rem 0; }
.replay-log li.cur { color: var(--ink); font-weight: 600; }
/* Banner colour override editor + live preview (banner_detail). The override
fieldsets group the enable toggle with the native colour swatches; the preview
renders a sample strip on both themes from those inputs (see the inline script). */
.ovr { border: 1px solid var(--line); border-radius: 6px; padding: 0.3rem 0.8rem 0.7rem; margin: 0.2rem 0; }
.ovr legend { padding: 0 0.3rem; font-size: 0.85rem; color: var(--ink); }
.ovr legend label { flex-direction: row; align-items: center; gap: 0.4rem; color: var(--ink); }
.ovr .note { margin: 0.2rem 0 0.4rem; }
.ovr .swatches { display: flex; flex-wrap: wrap; gap: 1rem; }
.ovr .swatches label { flex-direction: column; gap: 0.25rem; align-items: flex-start; }
.ovr input[type=color] { width: 3rem; height: 1.8rem; padding: 0; border: 1px solid var(--line); border-radius: 4px; background: var(--bg); cursor: pointer; }
.ovr input[type=color]:disabled { opacity: 0.4; cursor: default; }
.ovr .hex { font-size: 0.72rem; color: var(--ink-dim); font-variant-numeric: tabular-nums; }
.banner-preview { display: flex; flex-wrap: wrap; gap: 0.8rem; margin: 0.7rem 0 0.2rem; }
.banner-preview .bp { flex: 1 1 18rem; }
.banner-preview .bp-label { display: block; font-size: 0.75rem; color: var(--ink-dim); margin-bottom: 0.25rem; }
.ad-frame { padding: 0.7rem; border-radius: 6px; border: 1px solid var(--line); }
.ad-frame.light { background: #f4f6f9; }
.ad-frame.dark { background: #0f1420; }
.ad-sample { padding: 0.35rem 0.7rem; font-size: 0.85rem; white-space: nowrap; overflow: hidden; text-overflow: ellipsis; }
.ad-sample .ad-link { text-decoration: underline; }
@@ -24,6 +24,7 @@ func TestRendererRendersEveryPage(t *testing.T) {
{"dashboard", DashboardView{Accounts: 3, Variants: []VariantVersions{{Variant: "scrabble_en", Latest: "v1", Versions: []string{"v1"}}}}, "Dashboard"},
{"users", UsersView{Items: []UserRow{{ID: "a1", DisplayName: "Kaya", FlaggedHighRate: true}}, Pager: NewPager(1, 50, 1)}, "high-rate"},
{"user_detail", UserDetailView{ID: "a1", DisplayName: "Kaya", HasStats: true, Stats: StatsRow{Wins: 2}, TelegramID: "123", ConnectorEnabled: true}, "Send Telegram message"},
{"user_detail", UserDetailView{ID: "a1", DisplayName: "Kaya", VKID: "494075"}, "vk.com/id494075"},
{"user_detail", UserDetailView{ID: "a1", DisplayName: "Kaya", FlaggedHighRateAt: "2026-06-10 12:00"}, "Clear high-rate flag"},
{"user_detail", UserDetailView{ID: "a1", DisplayName: "Kaya", Roles: []string{"feedback_banned"}, KnownRoles: []string{"feedback_banned"}}, "feedback_banned"},
{"user_detail", UserDetailView{ID: "a1", DisplayName: "Kaya",
@@ -15,12 +15,14 @@
<a href="/_gm/"{{if eq .ActiveNav "dashboard"}} class="active"{{end}}>Dashboard</a>
<a href="/_gm/users"{{if eq .ActiveNav "users"}} class="active"{{end}}>Users</a>
<a href="/_gm/games"{{if eq .ActiveNav "games"}} class="active"{{end}}>Games</a>
<a href="/_gm/limits"{{if eq .ActiveNav "limits"}} class="active"{{end}}>Limits</a>
<a href="/_gm/complaints"{{if eq .ActiveNav "complaints"}} class="active"{{end}}>Complaints</a>
<a href="/_gm/feedback"{{if eq .ActiveNav "feedback"}} class="active"{{end}}>Feedback</a>
<a href="/_gm/messages"{{if eq .ActiveNav "messages"}} class="active"{{end}}>Messages</a>
<a href="/_gm/throttled"{{if eq .ActiveNav "throttled"}} class="active"{{end}}>Throttled</a>
<a href="/_gm/reasons"{{if eq .ActiveNav "reasons"}} class="active"{{end}}>Reasons</a>
<a href="/_gm/banners"{{if eq .ActiveNav "banners"}} class="active"{{end}}>Banners</a>
<a href="/_gm/catalog"{{if eq .ActiveNav "catalog"}} class="active"{{end}}>Catalog</a>
<a href="/_gm/dictionary"{{if eq .ActiveNav "dictionary"}} class="active"{{end}}>Dictionary</a>
<a href="/_gm/broadcast"{{if eq .ActiveNav "broadcast"}} class="active"{{end}}>Broadcast</a>
<a href="/_gm/grafana/">Grafana ↗</a>
@@ -11,10 +11,72 @@
<label>Starts (UTC) <input type="datetime-local" name="starts_at" value="{{.StartsAt}}"></label>
<label>Ends (UTC) <input type="datetime-local" name="ends_at" value="{{.EndsAt}}"></label>
<label><input type="checkbox" name="enabled"{{if .Enabled}} checked{{end}}> Enabled</label>
<fieldset class="ovr">
<legend><label><input type="checkbox" name="urgent"{{if .Urgent}} checked{{end}}> Urgent</label></legend>
<p class="note">Shows to <em>everyone</em>, always — bypassing paid accounts, hint wallets and the no-banner role. While any urgent campaign is live it is the only thing the strip shows (other campaigns and the default are suppressed). For system alerts.</p>
</fieldset>
<fieldset class="ovr" data-ovr-group>
<legend><label><input type="checkbox" name="override_all_on" data-ovr="all"{{if .OverrideAllOn}} checked{{end}}> Colour override — all themes</label></legend>
<div class="swatches">
<label>Background <input type="color" name="override_bg" value="{{.AllBg}}" data-ovr-color{{if not .OverrideAllOn}} disabled{{end}}><span class="hex"></span></label>
<label>Text <input type="color" name="override_fg" value="{{.AllFg}}" data-ovr-color{{if not .OverrideAllOn}} disabled{{end}}><span class="hex"></span></label>
<label>Link <input type="color" name="override_link" value="{{.AllLink}}" data-ovr-color{{if not .OverrideAllOn}} disabled{{end}}><span class="hex"></span></label>
</div>
</fieldset>
<fieldset class="ovr" data-ovr-group>
<legend><label><input type="checkbox" name="override_dark_on" data-ovr="dark"{{if .OverrideDarkOn}} checked{{end}}> Colour override — dark theme only</label></legend>
<div class="swatches">
<label>Background <input type="color" name="override_bg_dark" value="{{.DarkBg}}" data-ovr-color{{if not .OverrideDarkOn}} disabled{{end}}><span class="hex"></span></label>
<label>Text <input type="color" name="override_fg_dark" value="{{.DarkFg}}" data-ovr-color{{if not .OverrideDarkOn}} disabled{{end}}><span class="hex"></span></label>
<label>Link <input type="color" name="override_link_dark" value="{{.DarkLink}}" data-ovr-color{{if not .OverrideDarkOn}} disabled{{end}}><span class="hex"></span></label>
</div>
</fieldset>
{{end}}
<div><button type="submit">Save</button></div>
</form>
{{if not .IsDefault}}
<div class="banner-preview">
<div class="bp"><span class="bp-label">Light theme</span><div class="ad-frame light"><div class="ad-sample" id="prev-light"><span>Sample banner text — <span class="ad-link">a link</span></span></div></div></div>
<div class="bp"><span class="bp-label">Dark theme</span><div class="ad-frame dark"><div class="ad-sample" id="prev-dark"><span>Sample banner text — <span class="ad-link">a link</span></span></div></div></div>
</div>
<p class="note">Live preview of the strip on both themes. An empty override falls back to the neutral theme colours; the top/bottom border is derived from the background.</p>
<script>
(function(){
// Neutral fallbacks — mirror ui/src/app.css (--ad-bg / --text-muted / --accent).
var TOK={light:{bg:'#e3e7ee',fg:'#6b7280',link:'#2f6df6'},dark:{bg:'#272f3c',fg:'#9aa3b2',link:'#5b8cff'}};
function byName(n){return document.querySelector('[name="'+n+'"]');}
var allOn=byName('override_all_on'), darkOn=byName('override_dark_on');
if(!allOn||!darkOn){return;}
var f={ab:byName('override_bg'),af:byName('override_fg'),al:byName('override_link'),db:byName('override_bg_dark'),df:byName('override_fg_dark'),dl:byName('override_link_dark')};
var lightEl=document.getElementById('prev-light'), darkEl=document.getElementById('prev-dark');
function hexToRgb(h){h=h.replace('#','');return [parseInt(h.slice(0,2),16),parseInt(h.slice(2,4),16),parseInt(h.slice(4,6),16)];}
function pad(x){x=Math.max(0,Math.min(255,Math.round(x))).toString(16);return x.length<2?'0'+x:x;}
function rgbToHex(r){return '#'+pad(r[0])+pad(r[1])+pad(r[2]);}
function mix(a,b,t){return [a[0]+(b[0]-a[0])*t,a[1]+(b[1]-a[1])*t,a[2]+(b[2]-a[2])*t];}
function lum(r){return (0.2126*r[0]+0.7152*r[1]+0.0722*r[2])/255;}
// Derived border: nudge the background 14% toward black on a light bg, toward white on a dark bg.
function border(bg){var r=hexToRgb(bg);return rgbToHex(mix(r, lum(r)>0.5?[0,0,0]:[255,255,255], 0.14));}
function paint(el,c){
el.style.background=c.bg; el.style.color=c.fg;
el.style.borderTop='1px solid '+border(c.bg); el.style.borderBottom='1px solid '+border(c.bg);
var a=el.querySelector('.ad-link'); if(a){a.style.color=c.link;}
}
function resolve(){
var light=allOn.checked?{bg:f.ab.value,fg:f.af.value,link:f.al.value}:TOK.light;
var dark=darkOn.checked?{bg:f.db.value,fg:f.df.value,link:f.dl.value}
:(allOn.checked?{bg:f.ab.value,fg:f.af.value,link:f.al.value}:TOK.dark);
paint(lightEl,light); paint(darkEl,dark);
document.querySelectorAll('.ovr .swatches label').forEach(function(lab){
var inp=lab.querySelector('input[type=color]'), hx=lab.querySelector('.hex');
if(inp&&hx){hx.textContent=inp.value;}
});
}
function toggleGroup(chk){chk.closest('fieldset').querySelectorAll('[data-ovr-color]').forEach(function(inp){inp.disabled=!chk.checked;});}
[allOn,darkOn].forEach(function(chk){chk.addEventListener('change',function(){toggleGroup(chk);resolve();});});
Object.keys(f).forEach(function(k){f[k].addEventListener('input',resolve);});
resolve();
})();
</script>
<form class="form" method="post" action="/_gm/banners/{{.ID}}/delete" onsubmit="return confirm('Delete this campaign and its messages?')">
<button type="submit" class="danger">Delete campaign</button>
</form>
@@ -0,0 +1,66 @@
{{define "content" -}}
<h1>Product catalog</h1>
{{with .Data}}
<p class="note">A <strong>pack</strong> funds chips (a money price per rail — RUB via direct, VOTE via vk, XTR via telegram); a <strong>value</strong> buys benefits with chips (a CHIP price). Archived products are hidden from players but still credit an in-flight payment and can be granted. A product with transactions can only be archived, not deleted. Amounts are in minor units (RUB kopecks; VOTE/XTR/CHIP whole). The <code>tournament</code> atom is not sellable yet — keep such a product archived.</p>
<section class="panel"><h2>Add product</h2>
<form class="form col" method="post" action="/_gm/catalog">
<label>Title <input type="text" name="title" maxlength="120" required></label>
<fieldset><legend>Atoms (quantity; blank = none)</legend>
<label>Chips <input type="number" name="chips" min="0"></label>
<label>Hints <input type="number" name="hints" min="0"></label>
<label>No-ads days <input type="number" name="noads" min="0"></label>
<label>Tournament <input type="number" name="tournament" min="0"></label>
</fieldset>
<fieldset><legend>Prices (minor units; blank = none)</legend>
<label>RUB — direct (kopecks) <input type="number" name="price_rub" min="0"></label>
<label>VOTE — vk <input type="number" name="price_vote" min="0"></label>
<label>XTR — telegram <input type="number" name="price_star" min="0"></label>
<label>CHIP — value <input type="number" name="price_chip" min="0"></label>
</fieldset>
<label><input type="checkbox" name="active" value="true"> Active (on sale)</label>
<div><button type="submit">Add</button></div>
</form>
</section>
<section class="panel"><h2>Rewarded ads (watch for chips)</h2>
<p class="note">The chips a player earns per rewarded-video view (VK only), plus the per-day and per-hour caps that bound free chips — <strong>0 payout turns rewarded off</strong>. This is the shared reward config, not a product.</p>
<form class="form col" method="post" action="/_gm/catalog/reward">
<label>Chips per view <input type="number" name="reward_payout" min="0" value="{{.RewardPayout}}"></label>
<label>Daily cap <input type="number" name="reward_daily" min="0" value="{{.RewardDailyCap}}"></label>
<label>Hourly cap <input type="number" name="reward_hourly" min="0" value="{{.RewardHourlyCap}}"></label>
<div><button type="submit">Save</button></div>
</form>
</section>
<section class="panel"><h2>Payment availability (kill switch)</h2>
<p class="note">Turn purchases off on a rail/channel and show the user a localized reason on their next attempt. Unchecked = off; an empty message shows a built-in &ldquo;temporarily unavailable&rdquo;. Fail-open: a rail you never touch stays on. A per-user &ldquo;allow&rdquo; override (on a user card) bypasses this switch.</p>
{{range .Rails}}
<form class="form col" method="post" action="/_gm/catalog/rail-status">
<input type="hidden" name="rail" value="{{.Rail}}">
<label><input type="checkbox" name="enabled" {{if .Enabled}}checked{{end}}> <code>{{.Rail}}</code> &mdash; purchases enabled</label>
<label>Message RU <input type="text" name="message_ru" maxlength="200" value="{{.MessageRU}}"></label>
<label>Message EN <input type="text" name="message_en" maxlength="200" value="{{.MessageEN}}"></label>
<div><button type="submit">Save</button></div>
</form>
{{end}}
</section>
<section class="panel"><h2>Products</h2>
<p class="note">Showing {{if .ShowAll}}<strong>all</strong> products (active + archived) — <a href="/_gm/catalog">active only</a>{{else}}<strong>active</strong> products — <a href="/_gm/catalog?all=1">show all (incl. archived)</a>{{end}}.</p>
<table class="list">
<thead><tr><th>Title</th><th>Status</th><th>Atoms</th><th>Prices</th><th></th></tr></thead>
<tbody>
{{range .Products}}
<tr>
<td><a href="/_gm/catalog/{{.ID}}">{{.Title}}</a></td>
<td>{{if .Active}}<span class="ok">active</span>{{else}}<span class="warn">archived</span>{{end}}{{if .Transacted}} <span class="pill">transacted</span>{{end}}</td>
<td>{{range .Atoms}}<code>{{.Atom}}×{{.Quantity}}</code> {{end}}</td>
<td>{{range .Prices}}<code>{{.Currency}}{{if .Method}}/{{.Method}}{{end}} {{.Amount}}</code> {{end}}</td>
<td class="row-actions">
<form class="form" method="post" action="/_gm/catalog/{{.ID}}/archive"><input type="hidden" name="active" value="{{if .Active}}false{{else}}true{{end}}"><button type="submit">{{if .Active}}Archive{{else}}Unarchive{{end}}</button></form>
{{if not .Transacted}}<form class="form" method="post" action="/_gm/catalog/{{.ID}}/delete" onsubmit="return confirm('Delete this product? It has never been transacted, so this is safe and permanent.')"><button type="submit">Delete</button></form>{{end}}
</td>
</tr>
{{else}}<tr><td colspan="5"><span class="note">no products</span></td></tr>{{end}}
</tbody>
</table>
</section>
{{end}}
{{- end}}
@@ -8,6 +8,7 @@
<li><b>Dictionary</b> {{.DictVersion}}</li>
<li><b>Status</b> {{.Status}}{{if .EndReason}} ({{.EndReason}}){{end}}</li>
<li><b>AI game</b> {{if .VsAI}}🤖 yes{{else}}no{{end}}</li>
<li><b>Word rule</b> {{if .MultipleWordsPerTurn}}multiple words per turn{{else}}single word per turn{{end}}</li>
<li><b>Players</b> {{.Players}}</li>
<li><b>To move</b> seat {{.ToMove}}</li>
<li><b>Moves</b> {{.MoveCount}}</li>
@@ -8,11 +8,11 @@
<a href="/_gm/games?status=finished"{{if eq .Status "finished"}} class="active"{{end}}>finished</a>
</nav>
<table class="list">
<thead><tr><th>Game</th><th>Variant</th><th>Status</th><th>🤖</th><th class="num">Players</th><th>Updated</th></tr></thead>
<thead><tr><th>Game</th><th>Variant</th><th>Kind</th><th>Status</th><th>🤖</th><th class="num">Players</th><th>Updated</th></tr></thead>
<tbody>
{{range .Items}}
<tr><td><a href="/_gm/games/{{.ID}}">{{.ID}}</a></td><td>{{.Variant}}</td><td>{{.Status}}</td><td>{{if .VsAI}}🤖{{end}}</td><td class="num">{{.Players}}</td><td>{{.UpdatedAt}}</td></tr>
{{else}}<tr><td colspan="6"><span class="note">no games</span></td></tr>{{end}}
<tr><td><a href="/_gm/games/{{.ID}}">{{.ID}}</a></td><td>{{.Variant}}</td><td>{{.Kind}}</td><td>{{.Status}}</td><td>{{if .VsAI}}🤖{{end}}</td><td class="num">{{.Players}}</td><td>{{.UpdatedAt}}</td></tr>
{{else}}<tr><td colspan="7"><span class="note">no games</span></td></tr>{{end}}
</tbody>
</table>
<nav class="pager">
@@ -0,0 +1,19 @@
{{define "content" -}}
<h1>Active-game limits</h1>
{{with .Data}}
<p class="note">Per-tier, per-kind caps on a player's simultaneous unfinished games. <strong>-1</strong> = unlimited, <strong>0</strong> = the kind is blocked, a positive number caps concurrent games of that kind. Guests are additionally blocked from friend games outright. Changes apply immediately (no redeploy); games already in progress are never affected.</p>
<section class="panel">
<form class="form col" method="post" action="/_gm/limits">
<h2>Guest</h2>
<label>vs AI <input type="number" name="guest_vs_ai" min="-1" value="{{.GuestVsAI}}" required></label>
<label>Random <input type="number" name="guest_random" min="-1" value="{{.GuestRandom}}" required></label>
<label>Friends <input type="number" name="guest_friends" min="-1" value="{{.GuestFriends}}" required></label>
<h2>Durable account</h2>
<label>vs AI <input type="number" name="durable_vs_ai" min="-1" value="{{.DurableVsAI}}" required></label>
<label>Random <input type="number" name="durable_random" min="-1" value="{{.DurableRandom}}" required></label>
<label>Friends <input type="number" name="durable_friends" min="-1" value="{{.DurableFriends}}" required></label>
<div><button type="submit">Save</button></div>
</form>
</section>
{{end}}
{{- end}}
@@ -0,0 +1,25 @@
{{define "content" -}}
{{with .Data}}
<p class="note"><a href="/_gm/catalog">← all products</a></p>
<h1>{{.Title}} {{if .Active}}<span class="ok">active</span>{{else}}<span class="warn">archived</span>{{end}}{{if .Transacted}} <span class="pill">transacted</span>{{end}}</h1>
<section class="panel"><h2>Edit</h2>
<p class="note">A zero quantity / blank price removes that atom / price. Amounts are in minor units. Saving revalidates the sellable shape when the product is active. Archive / unarchive from the <a href="/_gm/catalog">catalog list</a>.</p>
<form class="form col" method="post" action="/_gm/catalog/{{.ID}}">
<label>Title <input type="text" name="title" value="{{.Title}}" maxlength="120" required></label>
<fieldset><legend>Atoms (quantity; 0 = none)</legend>
<label>Chips <input type="number" name="chips" min="0" value="{{.Chips}}"></label>
<label>Hints <input type="number" name="hints" min="0" value="{{.Hints}}"></label>
<label>No-ads days <input type="number" name="noads" min="0" value="{{.NoAds}}"></label>
<label>Tournament <input type="number" name="tournament" min="0" value="{{.Tournament}}"></label>
</fieldset>
<fieldset><legend>Prices (minor units; 0 = none)</legend>
<label>RUB — direct (kopecks) <input type="number" name="price_rub" min="0" value="{{.PriceRUB}}"></label>
<label>VOTE — vk <input type="number" name="price_vote" min="0" value="{{.PriceVote}}"></label>
<label>XTR — telegram <input type="number" name="price_star" min="0" value="{{.PriceStar}}"></label>
<label>CHIP — value <input type="number" name="price_chip" min="0" value="{{.PriceChip}}"></label>
</fieldset>
<div><button type="submit">Save</button></div>
</form>
</section>
{{end}}
{{- end}}
@@ -10,8 +10,6 @@
<li><b>Timezone</b> {{.TimeZone}}</li>
<li><b>Guest</b> {{if .Guest}}yes{{else}}no{{end}}</li>
<li><b>Push</b> {{if .NotificationsInAppOnly}}in-app only{{else}}out-of-app{{end}}</li>
<li><b>Paid</b> {{if .PaidAccount}}yes{{else}}no{{end}}</li>
<li><b>Hint wallet</b> {{.HintBalance}}</li>
{{if .MergedInto}}<li><b>Merged into</b> {{.MergedInto}}</li>{{end}}
{{if .FlaggedHighRateAt}}<li><b>High-rate flag</b> <span class="warn">{{.FlaggedHighRateAt}}</span></li>{{end}}
<li><b>Created</b> {{.CreatedAt}}</li>
@@ -21,10 +19,6 @@
<button type="submit">Clear high-rate flag</button>
</form>
{{end}}
<form class="form" method="post" action="/_gm/users/{{.ID}}/grant-hints">
<label>Add hints <input type="number" name="amount" min="1" max="{{.HintGrantMax}}" value="1"></label>
<button type="submit">Grant</button>
</form>
</section>
<section class="panel"><h2>Statistics</h2>
{{if .HasStats}}
@@ -69,6 +63,61 @@
<div><button type="submit">{{if .Suspension.Blocked}}Re-block{{else}}Block{{end}}</button></div>
</form>
</section>
<section class="panel"><h2>Finance</h2>
{{if .Finance.Present}}
{{if or .Finance.Segments .Finance.Benefits .Finance.Abuse .Finance.Loss}}
<ul class="kv">
{{range .Finance.Segments}}<li><b>Chips ({{.Source}})</b> {{.Chips}}</li>{{end}}
{{range .Finance.Benefits}}<li><b>Benefits ({{.Origin}})</b> {{.Hints}} hints{{if .Forever}} · no-ads forever{{else if .AdsUntil}} · no-ads until {{.AdsUntil}} (UTC){{end}}</li>{{end}}
{{if or .Finance.Abuse .Finance.Loss}}<li><b>Refund risk</b> <span class="warn">{{if .Finance.Abuse}}abuse-flagged{{end}}{{if .Finance.Loss}} · loss {{.Finance.Loss}} chips{{end}}</span></li>{{end}}
</ul>
{{else}}<p class="note">no balances or benefits</p>{{end}}
<h3>Payment override</h3>
<form class="form" method="post" action="/_gm/users/{{.ID}}/purchase-override">
<label>Purchases for this account
<select name="override">
<option value="default" {{if eq .PurchaseOverride "default"}}selected{{end}}>Default (follow the rail switch)</option>
<option value="allow" {{if eq .PurchaseOverride "allow"}}selected{{end}}>Always allow (bypasses the rail switch only, not security)</option>
<option value="deny" {{if eq .PurchaseOverride "deny"}}selected{{end}}>Always deny</option>
</select></label>
<div><button type="submit">Save</button></div>
</form>
<h3>Ledger</h3>
{{$uid := .ID}}
{{if .Finance.Ledger}}
<table class="list">
<thead><tr><th>Time</th><th>Kind</th><th>Source</th><th>Origin</th><th>Chips</th><th>Order</th><th>Provider</th><th>Shop</th><th>Detail</th><th></th></tr></thead>
<tbody>
{{range .Finance.Ledger}}
<tr><td>{{.At}}</td><td>{{.Kind}}</td><td>{{.Source}}</td><td>{{.Origin}}</td><td>{{.ChipsDelta}}</td><td>{{if .Order}}<code>{{.Order}}</code>{{end}}</td><td>{{.Provider}}</td><td>{{.Shop}}</td><td>{{if .Snapshot}}<code>{{.Snapshot}}</code>{{end}}</td>
<td>{{if and (eq .Kind "fund") .Order}}<form class="form" method="post" action="/_gm/users/{{$uid}}/refund" onsubmit="return confirm('Refund this order in full? Record the money refund on the rail first; this revokes the chips (floored at 0).')"><input type="hidden" name="order_id" value="{{.Order}}"><button type="submit">Refund</button></form>{{end}}</td></tr>
{{end}}
</tbody>
</table>
<p class="note"><a href="/_gm/ledger.csv">Export the full ledger (CSV)</a> — all accounts, for tax + reconciliation.</p>
{{else}}<p class="note">no ledger entries</p>{{end}}
{{else}}<p class="note">payments not enabled</p>{{end}}
</section>
<section class="panel"><h2>Grant benefits</h2>
{{if .Grant.Present}}
<p class="note">A zero-price admin sale of a value — <strong>never chips</strong>. The origin is your compliance choice. The by-product grant applies a defined bundle, including an archived reward product.</p>
<form class="form col" method="post" action="/_gm/users/{{.ID}}/grant">
<label>Origin <select name="origin">{{range .Grant.Origins}}<option value="{{.}}">{{.}}</option>{{end}}</select></label>
<label>Hints <input type="number" name="hints" min="0" value="0"></label>
<label>No-ads days <input type="number" name="noads" min="0" value="0"></label>
<label><input type="checkbox" name="forever" value="true"> No-ads forever</label>
<div><button type="submit">Grant</button></div>
</form>
{{if .Grant.Products}}
<h3>Grant a product</h3>
<form class="form col" method="post" action="/_gm/users/{{.ID}}/grant-product">
<label>Origin <select name="origin">{{range .Grant.Origins}}<option value="{{.}}">{{.}}</option>{{end}}</select></label>
<label>Product <select name="product_id">{{range .Grant.Products}}<option value="{{.ID}}">{{.Title}} ({{.Summary}}){{if .Archived}} — archived{{end}}</option>{{end}}</select></label>
<div><button type="submit">Grant product</button></div>
</form>
{{else}}<p class="note">no grantable products — create a value product in the <a href="/_gm/catalog">catalog</a></p>{{end}}
{{else}}<p class="note">payments not enabled</p>{{end}}
</section>
<section class="panel"><h2>Roles</h2>
{{$id := .ID}}
{{if .Roles}}
@@ -101,6 +150,29 @@
{{else}}<tr><td colspan="4"><span class="note">no identities (guest)</span></td></tr>{{end}}
</tbody>
</table>
{{if .HasEmail}}
<form class="form" method="post" action="/_gm/users/{{.ID}}/remove-email" onsubmit="return confirm('Erase the email identity from this account? The address will be freed.')">
<button type="submit">Erase email</button>
</form>
{{end}}
</section>
<section class="panel"><h2>Deletion &amp; retention</h2>
{{if .LastLoginAt}}<p class="note">Last login: {{.LastLoginAt}}{{if .LastLoginIP}} — <code>{{.LastLoginIP}}</code>{{end}}</p>{{end}}
{{if .Deleted}}<p><span class="warn">Deleted</span> at {{.DeletedAt}}{{if .DeletedName}} — was <code>{{.DeletedName}}</code>{{end}}</p>{{end}}
{{if .Retained}}
<h3>Retention journal (legal dossier of detached credentials)</h3>
<table class="list">
<thead><tr><th>Kind</th><th>Credential</th><th>Reason</th><th>Detached</th></tr></thead>
<tbody>
{{range .Retained}}<tr><td>{{.Kind}}</td><td><code>{{.ExternalID}}</code></td><td>{{.Reason}}</td><td>{{.DetachedAt}}</td></tr>{{end}}
</tbody>
</table>
{{end}}
{{if not .Deleted}}
<form class="form" method="post" action="/_gm/users/{{.ID}}/delete" onsubmit="return confirm('Delete this account? Its credentials are journalled and freed, its data anonymised, and its sessions revoked. This cannot be undone.')">
<button type="submit">Delete user</button>
</form>
{{end}}
</section>
<section class="panel"><h2>Friends</h2>
<table class="list">
@@ -142,13 +214,18 @@
{{else}}<p class="note">connector not configured (set BACKEND_CONNECTOR_ADDR)</p>{{end}}
</section>
{{end}}
{{if .VKID}}
<section class="panel"><h2>VK</h2>
<p>VK ID: <code>{{.VKID}}</code> · <a href="https://vk.com/id{{.VKID}}" target="_blank" rel="noopener">open profile</a></p>
</section>
{{end}}
<section class="panel"><h2>Games</h2>
<table class="list">
<thead><tr><th>Game</th><th>Variant</th><th>Status</th><th class="num">Players</th><th>Updated</th></tr></thead>
<thead><tr><th>Game</th><th>Variant</th><th>Kind</th><th>Status</th><th class="num">Players</th><th>Updated</th></tr></thead>
<tbody>
{{range .Games}}
<tr><td><a href="/_gm/games/{{.ID}}">{{.ID}}</a></td><td>{{.Variant}}</td><td>{{.Status}}</td><td class="num">{{.Players}}</td><td>{{.UpdatedAt}}</td></tr>
{{else}}<tr><td colspan="5"><span class="note">no games</span></td></tr>{{end}}
<tr><td><a href="/_gm/games/{{.ID}}">{{.ID}}</a></td><td>{{.Variant}}</td><td>{{.Kind}}</td><td>{{.Status}}</td><td class="num">{{.Players}}</td><td>{{.UpdatedAt}}</td></tr>
{{else}}<tr><td colspan="6"><span class="note">no games</span></td></tr>{{end}}
</tbody>
</table>
</section>
@@ -2,13 +2,15 @@
<h1>Users</h1>
{{with .Data}}
<nav class="subnav">
<a href="/_gm/users"{{if not .Robots}} class="active"{{end}}>People</a> ·
<a href="/_gm/users"{{if and (not .Robots) (not .Deleted)}} class="active"{{end}}>People</a> ·
<a href="/_gm/users?kind=deleted"{{if .Deleted}} class="active"{{end}}>Deleted</a> ·
<a href="/_gm/users?kind=robots"{{if .Robots}} class="active"{{end}}>Robots</a>
</nav>
<form class="form" method="get" action="/_gm/users">
{{if .Robots}}<input type="hidden" name="kind" value="robots">{{end}}
{{if .Robots}}<input type="hidden" name="kind" value="robots">{{end}}{{if .Deleted}}<input type="hidden" name="kind" value="deleted">{{end}}
<input name="name" value="{{.NameMask}}" placeholder="display name mask (* ?)">
<input name="ext" value="{{.ExternalIDMask}}" placeholder="external id mask (* ?)">
<input name="email" value="{{.EmailExact}}" placeholder="email (exact)" type="search">
<button type="submit">Filter</button>
</form>
<table class="list">
@@ -17,7 +19,7 @@
{{range .Items}}
<tr>
<td><a href="/_gm/users/{{.ID}}">{{.ID}}</a></td>
<td>{{.DisplayName}}{{if .Guest}} <span class="pill">guest</span>{{end}}{{if .FlaggedHighRate}} <span class="pill">high-rate</span>{{end}}</td>
<td>{{.DisplayName}}{{if .Deleted}} <span class="pill">deleted</span>{{end}}{{if .Guest}} <span class="pill">guest</span>{{end}}{{if .FlaggedHighRate}} <span class="pill">high-rate</span>{{end}}</td>
<td>{{.Kind}}</td>
<td>{{.Language}}</td>
<td>{{.CreatedAt}}</td>
+216 -20
View File
@@ -60,8 +60,10 @@ type UsersView struct {
// be emitted verbatim — interpolated as a plain string it would have its "=" and "&"
// percent-encoded again by the contextual escaper.
Robots bool
Deleted bool
NameMask string
ExternalIDMask string
EmailExact string
FilterQuery template.URL
}
@@ -74,6 +76,7 @@ type UserRow struct {
Kind string
Language string
Guest bool
Deleted bool
FlaggedHighRate bool
CreatedAt string
HasMoveStats bool
@@ -146,23 +149,33 @@ type UserDetailView struct {
TimeZone string
Guest bool
NotificationsInAppOnly bool
PaidAccount bool
// MergedInto is the primary account id when this account has been retired by a
// merge, or empty for a live account.
MergedInto string
// The account-deletion dossier. Deleted marks a tombstoned account; DeletedAt and
// DeletedName are its deletion time and retained real name; LastLoginAt/IP are the
// last cold-load stamp (shown for any account); Retained is the credential journal.
Deleted bool
DeletedAt string
DeletedName string
LastLoginAt string
LastLoginIP string
Retained []RetainedRow
// FlaggedHighRateAt is the pre-formatted soft high-rate marker timestamp,
// empty for an unflagged account; the card shows it with the Clear action.
FlaggedHighRateAt string
HintBalance int
// HintGrantMax is the per-grant cap the operator's "add hints" form enforces (it mirrors the
// server's maxHintGrant), passed through so the policy value lives in one place.
HintGrantMax int
CreatedAt string
HasStats bool
Stats StatsRow
Identities []IdentityRow
Games []GameRow
CreatedAt string
HasStats bool
Stats StatsRow
Identities []IdentityRow
// HasEmail gates the "Erase email" action; set when the account carries an email identity.
HasEmail bool
Games []GameRow
// TelegramID and VKID are the account's platform external ids (empty when absent).
// TelegramID gates the "Send Telegram message" operator action; VKID surfaces the VK
// user id with a link to the VK profile (there is no VK messaging to drive).
TelegramID string
VKID string
ConnectorEnabled bool
// MoveChart is the pre-rendered inline SVG of the account's per-move-number think
// time (min/mean/max), empty when the account has no timed move.
@@ -182,6 +195,59 @@ type UserDetailView struct {
Blocks []RelationRow
BlockedBy []RelationRow
Friends []RelationRow
// Finance is the account's payments picture (balances, benefits, refund risk, ledger). Present
// is false when the payments domain is unwired.
Finance FinanceView
// PurchaseOverride is the account's per-account purchase override ("default"/"allow"/"deny"),
// shown in and edited from the user card's payment-override control.
PurchaseOverride string
// Grant is the admin-grant panel (origin picker + grantable products). Present is false when the
// payments domain is unwired.
Grant GrantFormView
}
// FinanceView is the account's payments picture on the user card: chip balances per funding
// segment, benefits per origin, the recorded refund risk, and the append-only ledger history
// (newest first). Present is false when the payments domain is unwired.
type FinanceView struct {
Present bool
Segments []SegmentRow
Benefits []BenefitRow
// Abuse is the refund abuse flag; Loss is the unrecoverable chip loss from floor-0 refunds.
Abuse bool
Loss int
Ledger []LedgerRow
}
// SegmentRow is one funding segment's chip balance.
type SegmentRow struct {
Source string
Chips int
}
// BenefitRow is one origin's benefit: the hint wallet, the ad-free expiry (pre-formatted, empty
// when none) and the lifetime ad-free flag.
type BenefitRow struct {
Origin string
Hints int
AdsUntil string
Forever bool
}
// LedgerRow is one append-only ledger entry: its kind, funding source / benefit origin, signed chip
// delta, the product / order / provider / direct-rail shop it references (empty when none), the raw
// snapshot JSON and the pre-formatted time.
type LedgerRow struct {
Kind string
Source string
Origin string
ChipsDelta int
Product string
Order string
Provider string
Shop string
Snapshot string
At string
}
// RelationRow is one cross-linked account in the user card's blocks / blocked-by / friends
@@ -230,6 +296,17 @@ type IdentityRow struct {
CreatedAt string
}
// RetainedRow is one credential in the account-deletion retention journal (the legal
// dossier of detached credentials): what was detached, when, and why.
type RetainedRow struct {
Kind string
ExternalID string
Reason string
Confirmed bool
LinkedAt string
DetachedAt string
}
// GameRow is one game row in a list.
type GameRow struct {
ID string
@@ -239,6 +316,8 @@ type GameRow struct {
UpdatedAt string
// VsAI marks an honest-AI game (rendered as 🤖 in the list's AI column).
VsAI bool
// Kind is the game's origin tag label: vs_ai / random / friends / unknown.
Kind string
}
// GamesView is the paginated games list, optionally filtered by status.
@@ -248,6 +327,17 @@ type GamesView struct {
Pager Pager
}
// GameLimitsView is the per-tier, per-kind active-game limit form: each field is a cap where -1
// is unlimited, 0 blocks the kind, and a positive value caps concurrent games of that kind.
type GameLimitsView struct {
GuestVsAI int
GuestRandom int
GuestFriends int
DurableVsAI int
DurableRandom int
DurableFriends int
}
// GameDetailView is one game with its seats.
type GameDetailView struct {
ID string
@@ -262,8 +352,12 @@ type GameDetailView struct {
UpdatedAt string
FinishedAt string
// VsAI marks an honest-AI game (shown as a 🤖 flag in the summary).
VsAI bool
Seats []SeatRow
VsAI bool
// MultipleWordsPerTurn is the game's cross-word rule: true = standard Scrabble (every cross-word
// is validated and scored), false = the single-word rule (only the main word along the play
// direction counts). Shown in the summary so an operator can tell the rule at a glance.
MultipleWordsPerTurn bool
Seats []SeatRow
// HasRobot is true when any seat is a robot, gating the robot-target caption;
// RobotTargetPct is the configured global play-to-win rate, in percent.
HasRobot bool
@@ -469,15 +563,30 @@ type BannerCampaignRow struct {
// BannerDetailView is the campaign detail/edit page. StartsAt/EndsAt are the
// "YYYY-MM-DDTHH:MM" (UTC) values for the datetime-local inputs, empty when open.
//
// The colour-override and urgent fields drive the non-default campaign's editor:
// OverrideAllOn/OverrideDarkOn report whether each colour set is active, and the
// six *Bg/*Fg/*Link values seed the native colour inputs — the stored override
// when a set is on, otherwise the neutral theme token so the picker starts from a
// sensible colour and the live preview shows the real fallback.
type BannerDetailView struct {
ID string
Name string
Weight int
IsDefault bool
Enabled bool
StartsAt string
EndsAt string
Messages []BannerMessageRow
ID string
Name string
Weight int
IsDefault bool
Enabled bool
StartsAt string
EndsAt string
Urgent bool
OverrideAllOn bool
AllBg string
AllFg string
AllLink string
OverrideDarkOn bool
DarkBg string
DarkFg string
DarkLink string
Messages []BannerMessageRow
}
// BannerMessageRow is one bilingual message of a campaign. First/Last drive the
@@ -568,3 +677,90 @@ type FeedbackDetailView struct {
UserTZ string
Banned bool
}
// CatalogView is the product-catalog list page.
type CatalogView struct {
Products []ProductRow
// ShowAll reports whether archived products are listed alongside active ones (the active/all
// toggle); it drives the toggle link and the list heading.
ShowAll bool
// RewardPayout / RewardDailyCap / RewardHourlyCap are the rewarded-video config (chips earned per
// view and the per-day / per-hour anti-abuse caps), shown in and edited from the page's
// rewarded-ads form. A 0 payout means rewarded is inert.
RewardPayout int
RewardDailyCap int
RewardHourlyCap int
// Rails is the per-rail operational kill-switch state (enabled + per-language off-message), shown
// in and edited from the page's payment-availability form.
Rails []RailStatusRow
}
// RailStatusRow is one payment rail's operational availability in the kill-switch editor: the rail
// key, whether purchases are enabled, and the operator's per-language off-message shown to the user.
type RailStatusRow struct {
Rail string
Enabled bool
MessageRU string
MessageEN string
}
// ProductRow is one product in the catalog list: its composition, prices, the archived flag
// (Active) and the transacted flag (which forbids a hard delete).
type ProductRow struct {
ID string
Title string
Active bool
Atoms []AtomRow
Prices []PriceRow
Transacted bool
}
// AtomRow is one atom line of a product row.
type AtomRow struct {
Atom string
Quantity int
}
// PriceRow is one price of a product: the method ("" for a value's CHIP price), the currency, and
// the amount in that currency's minor units.
type PriceRow struct {
Method string
Currency string
Amount int64
}
// ProductFormView is the product edit form, pre-filled from the current composition. Atom quantities
// and prices are flattened to the fixed fields the form offers (0 = absent); Transacted disables the
// delete action.
type ProductFormView struct {
ID string
Title string
Active bool
Chips int
Hints int
NoAds int
Tournament int
PriceRUB int64
PriceVote int64
PriceStar int64
PriceChip int64
Transacted bool
}
// GrantFormView is the admin-grant panel on the user card: the origin picker and the grantable
// products (value bundles — hints / no-ads days — including archived ones; chips and tournament
// products are excluded). Present is false when the payments domain is unwired.
type GrantFormView struct {
Present bool
Origins []string
Products []GrantProductOption
}
// GrantProductOption is one grantable product in the by-product picker: its id, title, an atom
// summary, and whether it is archived (the common case for a non-public reward bundle).
type GrantProductOption struct {
ID string
Title string
Summary string
Archived bool
}
+77 -21
View File
@@ -30,6 +30,15 @@ var ErrDefaultImmutable = errors.New("ads: the default campaign cannot be modifi
// window or message body).
var ErrValidation = errors.New("ads: validation")
// ColorSet is an optional per-campaign colour override for the banner strip:
// background, foreground (text) and link, each a "#rrggbb" hex string. The three
// are set together or the whole set is absent (a nil *ColorSet).
type ColorSet struct {
Bg string
Fg string
Link string
}
// Campaign is one advertising placement order with its messages.
type Campaign struct {
ID uuid.UUID // uuid.Nil on create
@@ -40,6 +49,16 @@ type Campaign struct {
StartsAt *time.Time // nil = open-ended start; always nil for the default
EndsAt *time.Time // nil = open-ended end; always nil for the default
Messages []Message
// OverrideAll paints the strip on every theme; OverrideDark, when set, further
// overrides the dark theme (the client resolves dark ← dark ?? all ?? token,
// light ← all ?? token). Both are nil for the default campaign and for a
// campaign that keeps the neutral theme tokens. Non-default only.
OverrideAll *ColorSet
OverrideDark *ColorSet
// Urgent forces the banner on every viewer (bypassing eligibility) and, while
// any urgent campaign is active, suppresses every non-urgent campaign and the
// default remainder. Non-default only; always false for the default campaign.
Urgent bool
CreatedAt time.Time
UpdatedAt time.Time
}
@@ -70,29 +89,32 @@ type Timings struct {
// ActiveCampaign is one campaign in the resolved rotation feed sent to a client:
// its GCD-reduced show weight and its messages, already resolved to the viewer's
// language and in display (round-robin) order.
// language and in display (round-robin) order, plus the optional colour overrides
// the client applies to the strip (nil = the neutral theme tokens). Urgency is not
// carried here: it is resolved server-side into the set's membership and the
// eligibility bypass, so the client only ever renders what it is sent.
type ActiveCampaign struct {
Weight int
Messages []string
}
// Eligible reports whether an account should be shown the advertising banner: a
// free account (not paid) with an empty hint wallet and without the no_banner
// role. The no_banner role suppresses the banner unconditionally; buying a paid
// account or any hints also removes it.
func Eligible(paidAccount bool, hintBalance int, hasNoBanner bool) bool {
return !paidAccount && hintBalance <= 0 && !hasNoBanner
Weight int
Messages []string
OverrideAll *ColorSet
OverrideDark *ColorSet
}
// computeActiveSet builds the resolved rotation feed from the enabled campaigns
// at time now, in language lang. Campaigns outside their validity window, and
// campaigns with no messages, are dropped. The default campaign's effective
// weight is the remainder up to 100% — max(0, 100 - sum of active timed
// weights) — so it fills unsold inventory and is dropped entirely when timed
// campaigns already reach 100%. Weights are then reduced by their GCD so the
// fair rotation cycle stays short. The input is expected to be the enabled
// campaigns (ActiveCampaigns); disabled ones must already be excluded.
func computeActiveSet(campaigns []Campaign, now time.Time, lang string) []ActiveCampaign {
// at time now, in language lang, and reports whether the feed is an urgent one.
// Campaigns outside their validity window, and campaigns with no messages, are
// dropped.
//
// While any active campaign is urgent, the feed is the urgent campaigns alone —
// every non-urgent timed campaign and the default remainder are suppressed for
// the duration (and the caller shows the feed to every viewer, bypassing
// eligibility). Otherwise the default campaign's effective weight is the
// remainder up to 100% — max(0, 100 - sum of active timed weights) — so it fills
// unsold inventory and is dropped entirely when timed campaigns already reach
// 100%. Weights are then reduced by their GCD so the fair rotation cycle stays
// short. The input is expected to be the enabled campaigns (ActiveCampaigns);
// disabled ones must already be excluded.
func computeActiveSet(campaigns []Campaign, now time.Time, lang string) ([]ActiveCampaign, bool) {
var timed []Campaign
var def *Campaign
for i := range campaigns {
@@ -108,20 +130,54 @@ func computeActiveSet(campaigns []Campaign, now time.Time, lang string) []Active
timed = append(timed, c)
}
}
if urgent := filterUrgent(timed); len(urgent) > 0 {
out := make([]ActiveCampaign, 0, len(urgent))
for _, c := range urgent {
out = append(out, activeFrom(c, lang))
}
reduceByGCD(out)
return out, true
}
sumTimed := 0
for _, c := range timed {
sumTimed += c.Weight
}
out := make([]ActiveCampaign, 0, len(timed)+1)
for _, c := range timed {
out = append(out, ActiveCampaign{Weight: c.Weight, Messages: resolveBodies(c.Messages, lang)})
out = append(out, activeFrom(c, lang))
}
if def != nil {
if dw := 100 - sumTimed; dw > 0 {
out = append(out, ActiveCampaign{Weight: dw, Messages: resolveBodies(def.Messages, lang)})
a := activeFrom(*def, lang)
a.Weight = dw // the default's stored weight is nominal; it fills the remainder
out = append(out, a)
}
}
reduceByGCD(out)
return out, false
}
// activeFrom projects a campaign to its rotation-feed entry: its show weight, its
// language-resolved messages and its colour overrides (carried through by
// reference, they are read-only).
func activeFrom(c Campaign, lang string) ActiveCampaign {
return ActiveCampaign{
Weight: c.Weight,
Messages: resolveBodies(c.Messages, lang),
OverrideAll: c.OverrideAll,
OverrideDark: c.OverrideDark,
}
}
// filterUrgent returns the urgent campaigns among cs, preserving order. It is the
// preempt selector: a non-empty result makes the whole feed urgent.
func filterUrgent(cs []Campaign) []Campaign {
var out []Campaign
for _, c := range cs {
if c.Urgent {
out = append(out, c)
}
}
return out
}
+63 -31
View File
@@ -6,30 +6,6 @@ import (
"time"
)
func TestEligible(t *testing.T) {
tests := []struct {
name string
paidAccount bool
hintBalance int
hasNoBanner bool
want bool
}{
{name: "free, empty wallet, no role", want: true},
{name: "paid", paidAccount: true, want: false},
{name: "has hints", hintBalance: 3, want: false},
{name: "no_banner role", hasNoBanner: true, want: false},
{name: "paid and has hints", paidAccount: true, hintBalance: 5, want: false},
{name: "no_banner overrides everything", hasNoBanner: true, want: false},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
if got := Eligible(tt.paidAccount, tt.hintBalance, tt.hasNoBanner); got != tt.want {
t.Errorf("Eligible(%v,%d,%v) = %v, want %v", tt.paidAccount, tt.hintBalance, tt.hasNoBanner, got, tt.want)
}
})
}
}
func TestComputeActiveSet(t *testing.T) {
now := time.Date(2026, 6, 15, 12, 0, 0, 0, time.UTC)
past := now.Add(-24 * time.Hour)
@@ -46,12 +22,19 @@ func TestComputeActiveSet(t *testing.T) {
timed := func(name string, weight int, starts, ends *time.Time, msgs []Message) Campaign {
return Campaign{Name: name, Weight: weight, Enabled: true, StartsAt: starts, EndsAt: ends, Messages: msgs}
}
urgent := func(name string, weight int, msgs []Message) Campaign {
c := timed(name, weight, nil, nil, msgs)
c.Urgent = true
return c
}
red := &ColorSet{Bg: "#aa0000", Fg: "#ffffff", Link: "#ffdd00"}
tests := []struct {
name string
campaigns []Campaign
lang string
want []ActiveCampaign
name string
campaigns []Campaign
lang string
want []ActiveCampaign
wantUrgent bool
}{
{
name: "default only reduces to weight 1",
@@ -152,22 +135,71 @@ func TestComputeActiveSet(t *testing.T) {
lang: "en",
want: []ActiveCampaign{{Weight: 1, Messages: []string{"one-en", "two-en"}}},
},
{
name: "urgent preempts default and normal timed",
campaigns: []Campaign{
def(msg("house")),
timed("promo", 40, nil, nil, msg("promo")),
urgent("alert", 50, msg("alert")),
},
lang: "en",
// only the urgent campaign survives; a lone weight reduces to 1.
want: []ActiveCampaign{{Weight: 1, Messages: []string{"alert-en"}}},
wantUrgent: true,
},
{
name: "multiple urgent share the feed by gcd",
campaigns: []Campaign{
def(msg("house")),
urgent("a", 60, msg("a")),
urgent("b", 40, msg("b")),
},
lang: "en",
// default and any non-urgent dropped; gcd(60,40)=20 -> 3 and 2.
want: []ActiveCampaign{
{Weight: 3, Messages: []string{"a-en"}},
{Weight: 2, Messages: []string{"b-en"}},
},
wantUrgent: true,
},
{
name: "out-of-window urgent does not preempt",
campaigns: []Campaign{
def(msg("house")),
func() Campaign { c := urgent("future", 50, msg("future")); c.StartsAt = &future; return c }(),
},
lang: "en",
// the urgent campaign is not yet live, so the normal default feed stands.
want: []ActiveCampaign{{Weight: 1, Messages: []string{"house-en"}}},
},
{
name: "colour overrides ride the active campaign",
campaigns: []Campaign{
def(msg("house")),
func() Campaign { c := timed("promo", 100, nil, nil, msg("promo")); c.OverrideAll = red; return c }(),
},
lang: "en",
want: []ActiveCampaign{{Weight: 1, Messages: []string{"promo-en"}, OverrideAll: red}},
},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
got := computeActiveSet(tt.campaigns, now, tt.lang)
got, gotUrgent := computeActiveSet(tt.campaigns, now, tt.lang)
if !reflect.DeepEqual(got, tt.want) {
t.Errorf("computeActiveSet() =\n %#v\nwant\n %#v", got, tt.want)
}
if gotUrgent != tt.wantUrgent {
t.Errorf("computeActiveSet() urgent = %v, want %v", gotUrgent, tt.wantUrgent)
}
})
}
}
func TestComputeActiveSetEmpty(t *testing.T) {
// No campaigns at all yields an empty (non-nil-or-nil) feed without panicking.
if got := computeActiveSet(nil, time.Now(), "en"); len(got) != 0 {
t.Errorf("computeActiveSet(nil) = %#v, want empty", got)
if got, urgent := computeActiveSet(nil, time.Now(), "en"); len(got) != 0 || urgent {
t.Errorf("computeActiveSet(nil) = %#v urgent=%v, want empty non-urgent", got, urgent)
}
}
+62 -5
View File
@@ -3,6 +3,7 @@ package ads
import (
"context"
"fmt"
"regexp"
"strings"
"time"
@@ -40,17 +41,20 @@ func NewService(store *Store) *Service { return &Service{store: store} }
// ActiveSet returns the resolved rotation feed for a viewer in language lang
// (en/ru) together with the global display timings: the currently-active
// campaigns, each with its GCD-reduced show weight and its messages resolved to
// lang, ready for the client's weighted round-robin.
func (s *Service) ActiveSet(ctx context.Context, lang string) ([]ActiveCampaign, Timings, error) {
// lang, ready for the client's weighted round-robin. The bool result reports
// whether the feed is urgent — an urgent feed is shown to every viewer
// regardless of eligibility (the caller skips the eligibility gate for it).
func (s *Service) ActiveSet(ctx context.Context, lang string) ([]ActiveCampaign, Timings, bool, error) {
campaigns, err := s.store.ActiveCampaigns(ctx)
if err != nil {
return nil, Timings{}, err
return nil, Timings{}, false, err
}
timings, err := s.store.Settings(ctx)
if err != nil {
return nil, Timings{}, err
return nil, Timings{}, false, err
}
return computeActiveSet(campaigns, time.Now().UTC(), lang), timings, nil
set, urgent := computeActiveSet(campaigns, time.Now().UTC(), lang)
return set, timings, urgent, nil
}
// ListCampaigns returns every campaign with its messages, for the admin console.
@@ -76,8 +80,13 @@ func (s *Service) CreateCampaign(ctx context.Context, c Campaign) (uuid.UUID, er
if err := validWindow(c.StartsAt, c.EndsAt); err != nil {
return uuid.Nil, err
}
all, dark, err := validOverrides(c.OverrideAll, c.OverrideDark)
if err != nil {
return uuid.Nil, err
}
return s.store.CreateCampaign(ctx, Campaign{
Name: name, Weight: c.Weight, Enabled: c.Enabled, StartsAt: c.StartsAt, EndsAt: c.EndsAt,
OverrideAll: all, OverrideDark: dark, Urgent: c.Urgent,
})
}
@@ -99,6 +108,7 @@ func (s *Service) UpdateCampaign(ctx context.Context, c Campaign) error {
upd.Enabled = true
upd.StartsAt = nil
upd.EndsAt = nil
// The default (house) campaign stays plain: no colour overrides, never urgent.
} else {
if err := validWeight(c.Weight); err != nil {
return err
@@ -106,10 +116,17 @@ func (s *Service) UpdateCampaign(ctx context.Context, c Campaign) error {
if err := validWindow(c.StartsAt, c.EndsAt); err != nil {
return err
}
all, dark, err := validOverrides(c.OverrideAll, c.OverrideDark)
if err != nil {
return err
}
upd.Weight = c.Weight
upd.Enabled = c.Enabled
upd.StartsAt = c.StartsAt
upd.EndsAt = c.EndsAt
upd.OverrideAll = all
upd.OverrideDark = dark
upd.Urgent = c.Urgent
}
return s.store.UpdateCampaign(ctx, upd)
}
@@ -254,6 +271,46 @@ func validWindow(starts, ends *time.Time) error {
return nil
}
// hexColor matches a "#rrggbb" colour — the format the console's native colour
// input emits and the wire carries.
var hexColor = regexp.MustCompile(`^#[0-9a-fA-F]{6}$`)
// validOverrides validates the two optional colour sets together and returns
// their normalised copies (nil when a set is absent), so a caller can store them
// directly.
func validOverrides(all, dark *ColorSet) (*ColorSet, *ColorSet, error) {
va, err := validColorSet(all)
if err != nil {
return nil, nil, err
}
vd, err := validColorSet(dark)
if err != nil {
return nil, nil, err
}
return va, vd, nil
}
// validColorSet validates one optional colour override: a nil set passes (no
// override); otherwise all three colours must be present and "#rrggbb". It
// returns a trimmed, lower-cased copy.
func validColorSet(cs *ColorSet) (*ColorSet, error) {
if cs == nil {
return nil, nil
}
bg := strings.TrimSpace(cs.Bg)
fg := strings.TrimSpace(cs.Fg)
link := strings.TrimSpace(cs.Link)
if bg == "" || fg == "" || link == "" {
return nil, fmt.Errorf("%w: a colour override needs all of background, text and link", ErrValidation)
}
for _, h := range []string{bg, fg, link} {
if !hexColor.MatchString(h) {
return nil, fmt.Errorf("%w: colours must be #rrggbb hex", ErrValidation)
}
}
return &ColorSet{Bg: strings.ToLower(bg), Fg: strings.ToLower(fg), Link: strings.ToLower(link)}, nil
}
// validBodies trims and bounds both mandatory language bodies of a message.
func validBodies(bodyEn, bodyRu string) (string, string, error) {
en := strings.TrimSpace(bodyEn)
+49 -11
View File
@@ -90,15 +90,23 @@ func (s *Store) Campaign(ctx context.Context, id uuid.UUID) (Campaign, error) {
func (s *Store) CreateCampaign(ctx context.Context, c Campaign) (uuid.UUID, error) {
id := uuid.New()
now := time.Now().UTC()
abg, afg, alink := colorCols(c.OverrideAll)
dbg, dfg, dlink := colorCols(c.OverrideDark)
stmt := table.AdCampaigns.INSERT(
table.AdCampaigns.CampaignID, table.AdCampaigns.Name, table.AdCampaigns.Weight,
table.AdCampaigns.IsDefault, table.AdCampaigns.Enabled,
table.AdCampaigns.StartsAt, table.AdCampaigns.EndsAt,
table.AdCampaigns.OverrideBg, table.AdCampaigns.OverrideFg, table.AdCampaigns.OverrideLink,
table.AdCampaigns.OverrideBgDark, table.AdCampaigns.OverrideFgDark, table.AdCampaigns.OverrideLinkDark,
table.AdCampaigns.Urgent,
table.AdCampaigns.CreatedAt, table.AdCampaigns.UpdatedAt,
).VALUES(
postgres.UUID(id), postgres.String(c.Name), postgres.Int(int64(c.Weight)),
postgres.Bool(false), postgres.Bool(c.Enabled),
tsOrNull(c.StartsAt), tsOrNull(c.EndsAt),
abg, afg, alink,
dbg, dfg, dlink,
postgres.Bool(c.Urgent),
postgres.TimestampzT(now), postgres.TimestampzT(now),
)
if _, err := stmt.ExecContext(ctx, s.db); err != nil {
@@ -111,12 +119,20 @@ func (s *Store) CreateCampaign(ctx context.Context, c Campaign) (uuid.UUID, erro
// window. The default flag is never touched here. Returns ErrNotFound when no
// campaign matches.
func (s *Store) UpdateCampaign(ctx context.Context, c Campaign) error {
abg, afg, alink := colorCols(c.OverrideAll)
dbg, dfg, dlink := colorCols(c.OverrideDark)
stmt := table.AdCampaigns.UPDATE(
table.AdCampaigns.Name, table.AdCampaigns.Weight, table.AdCampaigns.Enabled,
table.AdCampaigns.StartsAt, table.AdCampaigns.EndsAt, table.AdCampaigns.UpdatedAt,
table.AdCampaigns.StartsAt, table.AdCampaigns.EndsAt,
table.AdCampaigns.OverrideBg, table.AdCampaigns.OverrideFg, table.AdCampaigns.OverrideLink,
table.AdCampaigns.OverrideBgDark, table.AdCampaigns.OverrideFgDark, table.AdCampaigns.OverrideLinkDark,
table.AdCampaigns.Urgent, table.AdCampaigns.UpdatedAt,
).SET(
postgres.String(c.Name), postgres.Int(int64(c.Weight)), postgres.Bool(c.Enabled),
tsOrNull(c.StartsAt), tsOrNull(c.EndsAt), postgres.TimestampzT(time.Now().UTC()),
tsOrNull(c.StartsAt), tsOrNull(c.EndsAt),
abg, afg, alink,
dbg, dfg, dlink,
postgres.Bool(c.Urgent), postgres.TimestampzT(time.Now().UTC()),
).WHERE(table.AdCampaigns.CampaignID.EQ(postgres.UUID(c.ID)))
return execOne(ctx, s.db, stmt, "update campaign")
}
@@ -257,18 +273,40 @@ func execOne(ctx context.Context, db qrm.Executable, stmt postgres.Statement, wh
func modelToCampaign(r model.AdCampaigns) Campaign {
return Campaign{
ID: r.CampaignID,
Name: r.Name,
Weight: int(r.Weight),
IsDefault: r.IsDefault,
Enabled: r.Enabled,
StartsAt: r.StartsAt,
EndsAt: r.EndsAt,
CreatedAt: r.CreatedAt,
UpdatedAt: r.UpdatedAt,
ID: r.CampaignID,
Name: r.Name,
Weight: int(r.Weight),
IsDefault: r.IsDefault,
Enabled: r.Enabled,
StartsAt: r.StartsAt,
EndsAt: r.EndsAt,
OverrideAll: colorSetFrom(r.OverrideBg, r.OverrideFg, r.OverrideLink),
OverrideDark: colorSetFrom(r.OverrideBgDark, r.OverrideFgDark, r.OverrideLinkDark),
Urgent: r.Urgent,
CreatedAt: r.CreatedAt,
UpdatedAt: r.UpdatedAt,
}
}
// colorSetFrom rebuilds an optional ColorSet from three nullable colour columns.
// The all-or-nothing CHECK keeps the trio consistent, so a nil in any one means
// the set is absent.
func colorSetFrom(bg, fg, link *string) *ColorSet {
if bg == nil || fg == nil || link == nil {
return nil
}
return &ColorSet{Bg: *bg, Fg: *fg, Link: *link}
}
// colorCols renders a *ColorSet as its three column value-expressions in
// bg, fg, link order — NULL for each when the set is absent.
func colorCols(cs *ColorSet) (bg, fg, link postgres.Expression) {
if cs == nil {
return postgres.NULL, postgres.NULL, postgres.NULL
}
return postgres.String(cs.Bg), postgres.String(cs.Fg), postgres.String(cs.Link)
}
func modelToMessage(r model.AdMessages) Message {
return Message{
ID: r.MessageID,
+69 -5
View File
@@ -4,6 +4,7 @@ package config
import (
"fmt"
"net/url"
"os"
"strconv"
"time"
@@ -13,6 +14,7 @@ import (
"scrabble/backend/internal/lobby"
"scrabble/backend/internal/postgres"
"scrabble/backend/internal/ratewatch"
"scrabble/backend/internal/robokassa"
"scrabble/backend/internal/robot"
"scrabble/backend/internal/telemetry"
)
@@ -42,6 +44,12 @@ type Config struct {
// SMTP configures the email relay used for confirm-codes. An empty Host
// selects the development log mailer (the code is logged, not sent).
SMTP account.SMTPConfig
// PublicBaseURL is the canonical public origin (scheme + host, e.g.
// https://erudit-game.ru) used to build absolute links in outgoing email — the
// confirm deeplink and the footer landing link. It is deliberately not derived
// from a request Host header, which would let an attacker inject a phishing link
// into the email. Required whenever an SMTP relay is configured.
PublicBaseURL string
// ConnectorAddr is the gRPC address of the Telegram platform connector
// side-service, used by the admin console to send operator broadcasts. Empty
// disables broadcasts (the admin broadcast actions report "not configured").
@@ -51,6 +59,15 @@ type Config struct {
// GuestRetention is the account age past which an unused guest (no game seat)
// is eligible for deletion by the reaper.
GuestRetention time.Duration
// ExportSignKey signs the finished-game export download URLs. Empty leaves
// the export-URL endpoints disabled (503 on mint, 404 on download).
ExportSignKey string
// 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 — 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.
@@ -130,11 +147,29 @@ func Load() (Config, error) {
}
smtp := account.SMTPConfig{
Host: os.Getenv("BACKEND_SMTP_HOST"),
Port: envOr("BACKEND_SMTP_PORT", "587"),
Username: os.Getenv("BACKEND_SMTP_USERNAME"),
Password: os.Getenv("BACKEND_SMTP_PASSWORD"),
From: envOr("BACKEND_SMTP_FROM", "no-reply@localhost"),
Host: os.Getenv("BACKEND_SMTP_HOST"),
Port: envOr("BACKEND_SMTP_PORT", "587"),
Username: os.Getenv("BACKEND_SMTP_USERNAME"),
Password: os.Getenv("BACKEND_SMTP_PASSWORD"),
From: envOr("BACKEND_SMTP_FROM", "no-reply@localhost"),
TLS: os.Getenv("BACKEND_SMTP_TLS"),
AdminFrom: os.Getenv("BACKEND_SMTP_ADMIN_FROM"),
AdminTo: os.Getenv("BACKEND_ADMIN_EMAIL"),
}
// Robokassa direct rail: one merchant shop per channel (D42). The legacy single-shop vars seed
// the web channel so existing deploys keep working; the per-channel vars add the rest. A shop
// with no MerchantLogin is dropped (the rail stays dormant when none is configured).
shops := robokassa.Shops{}
web := robokassaShop("BACKEND_ROBOKASSA_WEB")
if web.MerchantLogin == "" {
web = robokassaShop("BACKEND_ROBOKASSA") // legacy single-shop credentials seed the web channel
}
if web.MerchantLogin != "" {
shops[robokassa.ChannelWeb] = web
}
if android := robokassaShop("BACKEND_ROBOKASSA_ANDROID"); android.MerchantLogin != "" {
shops[robokassa.ChannelAndroid] = android
}
c := Config{
@@ -148,9 +183,13 @@ func Load() (Config, error) {
Robot: rb,
RateWatch: rw,
SMTP: smtp,
PublicBaseURL: os.Getenv("BACKEND_PUBLIC_BASE_URL"),
ConnectorAddr: os.Getenv("BACKEND_CONNECTOR_ADDR"),
GuestReapInterval: guestReapInterval,
GuestRetention: guestRetention,
ExportSignKey: os.Getenv("BACKEND_EXPORT_SIGN_KEY"),
RendererURL: os.Getenv("BACKEND_RENDERER_URL"),
Robokassa: shops,
}
if err := c.validate(); err != nil {
return Config{}, err
@@ -195,6 +234,19 @@ func (c Config) validate() error {
if c.GuestRetention <= 0 {
return fmt.Errorf("config: BACKEND_GUEST_RETENTION must be positive")
}
if c.SMTP.Host != "" {
if c.PublicBaseURL == "" {
return fmt.Errorf("config: BACKEND_PUBLIC_BASE_URL must be set when BACKEND_SMTP_HOST is configured")
}
if u, err := url.Parse(c.PublicBaseURL); err != nil || u.Scheme == "" || u.Host == "" {
return fmt.Errorf("config: BACKEND_PUBLIC_BASE_URL %q must be an absolute URL (scheme://host)", c.PublicBaseURL)
}
}
for channel, shop := range c.Robokassa {
if shop.Password1 == "" || shop.Password2 == "" {
return fmt.Errorf("config: robokassa shop %q: password1 and password2 must be set when its merchant login is", channel)
}
}
return nil
}
@@ -234,3 +286,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",
}
}
+123
View File
@@ -0,0 +1,123 @@
package engine
import (
"encoding/json"
"os"
"path/filepath"
"testing"
"gitea.iliadenisov.ru/developer/scrabble-solver/rules"
)
// The offline engine (ui/src/lib/localgame) reproduces the end-of-game rack settlement and the
// winner rule so a local game finishes with the same scores as the server. These golden fixtures
// pin the ported pure functions (applyEndAdjustment / winner / rackValue) to the real Go engine.
// Being in-package, this emitter constructs Game values directly and drives the unexported
// end-game math on chosen positions.
type endCaseIn struct {
Name string `json:"name"`
Variant string `json:"variant"`
Reason string `json:"reason"`
Hands [][]int `json:"hands"`
Scores []int `json:"scores"`
Resigned []bool `json:"resigned"`
ToMove int `json:"toMove"`
}
type endCaseOut struct {
endCaseIn
ScoresAfter []int `json:"scoresAfter"`
Winner int `json:"winner"`
}
func rulesetFor(variant string) *rules.Ruleset {
switch variant {
case "scrabble_ru":
return rules.RussianScrabble()
case "erudit_ru":
return rules.Erudit()
default:
return rules.English()
}
}
func reasonFor(s string) EndReason {
switch s {
case "out_of_tiles":
return EndOutOfTiles
case "scoreless":
return EndScoreless
case "resign":
return EndResign
case "aborted":
return EndAborted
}
return EndNotOver
}
func handsBytes(hands [][]int) [][]byte {
out := make([][]byte, len(hands))
for i, h := range hands {
b := make([]byte, len(h))
for j, x := range h {
b[j] = byte(x)
}
out[i] = b
}
return out
}
// TestEmitEndgameFixtures regenerates ui/src/lib/localgame/testdata/endgame.json. Gated by
// EMIT_ENGINE_FIXTURES. Regenerate with:
//
// EMIT_ENGINE_FIXTURES=1 go test ./backend/internal/engine -run TestEmitEndgameFixtures
func TestEmitEndgameFixtures(t *testing.T) {
if os.Getenv("EMIT_ENGINE_FIXTURES") == "" {
t.Skip("set EMIT_ENGINE_FIXTURES=1 to regenerate ui/src/lib/localgame/testdata/endgame.json")
}
cases := []endCaseIn{
{"out-basic", "scrabble_en", "out_of_tiles", [][]int{{}, {0, 1, 2}}, []int{50, 40}, []bool{false, false}, 0},
{"out-blank", "scrabble_en", "out_of_tiles", [][]int{{}, {255, 0}}, []int{30, 30}, []bool{false, false}, 0},
{"out-erudit-yo", "erudit_ru", "out_of_tiles", [][]int{{}, {6, 32}}, []int{10, 10}, []bool{false, false}, 0},
{"out-tie", "scrabble_en", "out_of_tiles", [][]int{{}, {}}, []int{30, 30}, []bool{false, false}, 0},
{"out-3p", "scrabble_ru", "out_of_tiles", [][]int{{}, {0, 1}, {2}}, []int{10, 10, 10}, []bool{false, false, false}, 0},
{"scoreless", "scrabble_en", "scoreless", [][]int{{0, 1}, {2, 3}}, []int{20, 20}, []bool{false, false}, 0},
{"resign-2p", "scrabble_en", "resign", [][]int{{}, {0}}, []int{100, 10}, []bool{true, false}, 1},
{"resign-3p", "scrabble_en", "resign", [][]int{{}, {}, {}}, []int{50, 60, 40}, []bool{false, true, false}, 0},
{"aborted", "scrabble_en", "aborted", [][]int{{0}, {1}}, []int{40, 30}, []bool{false, false}, 0},
}
out := make([]endCaseOut, 0, len(cases))
for _, c := range cases {
rs := rulesetFor(c.Variant)
scores := append([]int(nil), c.Scores...)
g := &Game{
rules: rs,
hands: handsBytes(c.Hands),
scores: scores,
resigned: c.Resigned,
toMove: c.ToMove,
}
reason := reasonFor(c.Reason)
g.over = true
g.reason = reason
g.applyEndAdjustment(reason)
out = append(out, endCaseOut{endCaseIn: c, ScoresAfter: g.scores, Winner: g.winner()})
}
dir := filepath.Join("..", "..", "..", "ui", "src", "lib", "localgame", "testdata")
if err := os.MkdirAll(dir, 0o755); err != nil {
t.Fatalf("mkdir %s: %v", dir, err)
}
data, err := json.MarshalIndent(map[string]any{"cases": out}, "", " ")
if err != nil {
t.Fatalf("marshal: %v", err)
}
path := filepath.Join(dir, "endgame.json")
if err := os.WriteFile(path, append(data, '\n'), 0o644); err != nil {
t.Fatalf("write %s: %v", path, err)
}
t.Logf("wrote %s (%d cases)", path, len(out))
}
+29 -3
View File
@@ -21,11 +21,13 @@ var dictFiles = map[Variant]string{
VariantErudit: "ru_erudit.dawg",
}
// entry is one resident dictionary: the loaded finder and the solver built over
// it. The finder is retained so Close can release it.
// entry is one resident dictionary: the loaded finder, the solver built over it
// and the file it was loaded from. The finder is retained so Close can release
// it; path is retained so the raw bytes can be re-read for the client download.
type entry struct {
finder dawg.Finder
solver *scrabble.Solver
path string
}
// Registry holds the dictionaries resident in memory, addressed by variant and
@@ -130,7 +132,7 @@ func (r *Registry) Load(v Variant, version, dir string) error {
if old, ok := r.entries[v][version]; ok {
_ = old.finder.Close()
}
r.entries[v][version] = entry{finder: finder, solver: scrabble.NewSolver(rs, finder)}
r.entries[v][version] = entry{finder: finder, solver: scrabble.NewSolver(rs, finder), path: path}
r.latest[v] = version
return nil
}
@@ -202,6 +204,30 @@ func (r *Registry) Versions(v Variant) []string {
return versions
}
// DictBytes returns the raw serialized DAWG for the (variant, version) pair,
// re-read from the file it was loaded from — the same immutable bytes the solver
// holds. It backs the client-side dictionary download for the local move
// preview. It returns ErrUnknownVariant or ErrUnknownVersion when that dictionary
// is not resident, and wraps any read error. The file is read outside the lock.
func (r *Registry) DictBytes(v Variant, version string) ([]byte, error) {
r.mu.RLock()
versions, ok := r.entries[v]
if !ok {
r.mu.RUnlock()
return nil, fmt.Errorf("%w: %s", ErrUnknownVariant, v)
}
e, ok := versions[version]
r.mu.RUnlock()
if !ok {
return nil, fmt.Errorf("%w: %s/%s", ErrUnknownVersion, v, version)
}
data, err := os.ReadFile(e.path)
if err != nil {
return nil, fmt.Errorf("engine: read %s/%s dictionary bytes from %s: %w", v, version, e.path, err)
}
return data, nil
}
// Lookup reports whether word is present in the (variant, version) dictionary,
// backing the unlimited word-check tool. It returns ErrUnknownVariant or
// ErrUnknownVersion when that dictionary is not resident, and an error when word
+5
View File
@@ -195,6 +195,11 @@ func (svc *Service) CountUnread(ctx context.Context) (int, error) {
return svc.store.CountUnread(ctx)
}
// CountSince counts feedback created after since, for the operator alert worker.
func (svc *Service) CountSince(ctx context.Context, since time.Time) (int, error) {
return svc.store.CountSince(ctx, since)
}
// Attachment returns a message's file name and bytes, reporting false when absent.
func (svc *Service) Attachment(ctx context.Context, id uuid.UUID) (string, []byte, bool, error) {
return svc.store.Attachment(ctx, id)
+12
View File
@@ -386,3 +386,15 @@ func (s *Store) CountUnread(ctx context.Context) (int, error) {
}
return n, nil
}
// CountSince counts feedback messages created strictly after since — the operator alert
// worker's "new since the last check" signal.
func (s *Store) CountSince(ctx context.Context, since time.Time) (int, error) {
var n int
if err := s.db.QueryRowContext(ctx,
`SELECT COUNT(*) FROM backend.feedback_messages WHERE created_at > $1`, since,
).Scan(&n); err != nil {
return 0, fmt.Errorf("feedback: count since: %w", err)
}
return n, nil
}
+1 -1
View File
@@ -52,6 +52,7 @@ func gameSummary(g Game, names []string) notify.GameSummary {
TurnTimeoutSecs: int(g.TurnTimeout.Seconds()),
MultipleWordsPerTurn: g.MultipleWordsPerTurn,
VsAI: g.VsAI,
Kind: int(g.Kind),
MoveCount: g.MoveCount,
EndReason: g.EndReason,
Seats: seats,
@@ -74,7 +75,6 @@ func playerState(v StateView, names []string, includeAlphabet bool) (notify.Play
Rack: rack,
BagLen: v.BagLen,
HintsRemaining: v.HintsRemaining,
WalletBalance: v.WalletBalance,
}
if includeAlphabet {
tab, err := engine.AlphabetTable(v.Game.Variant)
+31 -8
View File
@@ -55,16 +55,39 @@ func TestPayloadExchangeRoundTrip(t *testing.T) {
}
func TestHintsRemaining(t *testing.T) {
cases := []struct{ allowance, used, wallet, want int }{
{1, 0, 3, 4},
{1, 1, 3, 3},
{1, 2, 3, 3}, // used past allowance clamps to 0
{0, 0, 5, 5},
{2, 1, 0, 1},
cases := []struct{ allowance, used, want int }{
{1, 0, 1},
{1, 1, 0},
{1, 2, 0}, // used past allowance clamps to 0
{3, 1, 2},
{0, 0, 0},
}
for _, c := range cases {
if got := hintsRemaining(c.allowance, c.used, c.wallet); got != c.want {
t.Errorf("hintsRemaining(%d,%d,%d) = %d, want %d", c.allowance, c.used, c.wallet, got, c.want)
if got := hintsRemaining(c.allowance, c.used); got != c.want {
t.Errorf("hintsRemaining(%d,%d) = %d, want %d", c.allowance, c.used, got, c.want)
}
}
}
func TestHintUnlockLeftSeconds(t *testing.T) {
now := time.Now()
// A gated vs_ai game on the caller's turn, the robot having moved 10 min ago (turn started then).
gated := Game{VsAI: true, ToMove: 0, MoveCount: 2, TurnStartedAt: now.Add(-10 * time.Minute)}
cases := []struct {
name string
g Game
seat int
want int
}{
{"non-vs_ai is open", Game{VsAI: false, ToMove: 0, MoveCount: 2, TurnStartedAt: now.Add(-10 * time.Minute)}, 0, 0},
{"not the caller's turn is open", gated, 1, 0},
{"human first move (no robot move) is open", Game{VsAI: true, ToMove: 0, MoveCount: 0, TurnStartedAt: now}, 0, 0},
{"robot moved 10 min ago leaves 20 min", gated, 0, 20 * 60},
{"robot moved past the window is open", Game{VsAI: true, ToMove: 0, MoveCount: 2, TurnStartedAt: now.Add(-40 * time.Minute)}, 0, 0},
}
for _, c := range cases {
if got := hintUnlockLeftSeconds(c.g, c.seat, now); got != c.want {
t.Errorf("%s: hintUnlockLeftSeconds = %d, want %d", c.name, got, c.want)
}
}
}
+232 -43
View File
@@ -17,7 +17,10 @@ import (
"scrabble/backend/internal/account"
"scrabble/backend/internal/engine"
"scrabble/backend/internal/gamelimits"
"scrabble/backend/internal/notify"
"scrabble/backend/internal/payments"
"scrabble/backend/internal/session"
)
// Service is the game domain: it drives the engine over a single match, persists
@@ -50,12 +53,26 @@ type Service struct {
// its asynchronous TriggerMove); nil disables the fast path (the scan still covers
// these games). Kept as a func so the game package never imports the robot package.
aiTrigger func(gameID uuid.UUID)
// hintWallet is the payments surface the online-game hint path spends against (the
// segmented, context-aware hint balance). It is set by SetHintWallet during wiring; when
// nil, only the free per-game allowance is served (no purchased hints). vs_ai hints are
// wallet-free and never touch it.
hintWallet HintWallet
// limits is the per-tier active-game cap config (cached). Set by SetGameLimits during wiring;
// when nil, no active-game limit is enforced (a game is always creatable).
limits *gamelimits.Service
// clearNudges, when set, marks the actor's pending nudges in a game read once they
// have committed a move (a nudge answered by moving stops counting as unread). It is
// best-effort and kept as a func so the game package never imports the social package.
clearNudges func(ctx context.Context, gameID, accountID uuid.UUID) error
metrics *gameMetrics
log *zap.Logger
// expireNudges, when set, marks every pending nudge in a game read once the game
// finishes (the nudge badge is stale on a completed game). Unlike clearNudges it is
// keyed by game alone — it clears all seats' nudges, not one mover's — and runs on
// every completion path through commit. Best-effort; a func so the game package never
// imports the social package.
expireNudges func(ctx context.Context, gameID uuid.UUID) error
metrics *gameMetrics
log *zap.Logger
}
// NewService constructs a Service. store and accounts wrap the same pool;
@@ -99,6 +116,70 @@ func (svc *Service) SetAITrigger(trigger func(gameID uuid.UUID)) {
svc.aiTrigger = trigger
}
// HintWallet is the payments surface the online-game hint path uses: the context-aware hint
// balance (HintsAvailable) and a one-hint spend (SpendHint), both keyed by the trusted execution
// context and the account's present identity sources. *payments.Service satisfies it.
type HintWallet interface {
HintsAvailable(ctx context.Context, accountID uuid.UUID, cxt payments.Context, present []payments.Source) (int, error)
SpendHint(ctx context.Context, accountID uuid.UUID, cxt payments.Context, present []payments.Source) (bool, error)
}
// SetHintWallet installs the payments hint wallet the online-game hint path spends against. It
// must be called during startup wiring; the default (nil) serves only the free per-game allowance.
func (svc *Service) SetHintWallet(w HintWallet) {
svc.hintWallet = w
}
// SetGameLimits installs the active-game limit config (cached), enabling the per-tier caps. When
// unset (nil), a game is always creatable.
func (svc *Service) SetGameLimits(l *gamelimits.Service) {
svc.limits = l
}
// AtGameLimit reports whether accountID has reached its tier's active-game cap for kind — the
// per-tier, per-kind limits held in backend.config, read from the in-memory cache. It resolves
// the caller's tier (guest vs durable) from the account, then counts its open+active games of that
// kind. It reports false (not at the limit) when the limits config is not wired, the account is nil,
// or the resolved cap is gamelimits.Unlimited. It backs the new-game gate (the handler aborts 409
// game_limit_reached) and the lobby's at-limit flag.
func (svc *Service) AtGameLimit(ctx context.Context, accountID uuid.UUID, kind gamelimits.Kind) (bool, error) {
if svc.limits == nil || accountID == uuid.Nil {
return false, nil
}
acc, err := svc.accounts.GetByID(ctx, accountID)
if err != nil {
return false, err
}
limit := svc.limits.LimitsFor(acc.IsGuest).Cap(kind)
if limit == gamelimits.Unlimited {
return false, nil
}
n, err := svc.store.CountActiveByKind(ctx, accountID, kind)
if err != nil {
return false, err
}
return n >= limit, nil
}
// walletContext resolves the payments gate inputs for an account on the current request: the
// trusted execution context (from the session platform carried on ctx; absent ⇒ untrusted) and
// the account's present identity sources (which chip/benefit segments are awake, §6).
func (svc *Service) walletContext(ctx context.Context, accountID uuid.UUID) (payments.Context, []payments.Source, error) {
var cxt payments.Context
if p, ok := session.PlatformFromContext(ctx); ok {
cxt = payments.NewContext(p.Kind, p.Subtype)
}
ids, err := svc.accounts.Identities(ctx, accountID)
if err != nil {
return payments.Context{}, nil, err
}
kinds := make([]string, len(ids))
for i, id := range ids {
kinds[i] = id.Kind
}
return cxt, payments.PresentSources(kinds), nil
}
// SetNudgeClearer installs the hook that marks a mover's pending nudges read after
// their move commits. It must be called during startup wiring; the default (nil)
// leaves nudges to be cleared only when the recipient opens the move history or chat.
@@ -107,6 +188,15 @@ func (svc *Service) SetNudgeClearer(fn func(ctx context.Context, gameID, account
svc.clearNudges = fn
}
// SetNudgeExpirer installs the hook that marks every pending nudge in a game read once the
// game finishes, on any completion path (a closing move, a resignation, a turn-timeout or a
// forfeit). It must be called during startup wiring; the default (nil) leaves a finished
// game's nudges to expire only when a recipient opens the move history or chat. The social
// package wires its ExpireNudges here. Chat messages are deliberately left unread.
func (svc *Service) SetNudgeExpirer(fn func(ctx context.Context, gameID uuid.UUID) error) {
svc.expireNudges = fn
}
// SetFirstMoveEntropy overrides the entropy source for the first-move draw
// (docs/ARCHITECTURE.md §6). It must be called during wiring or test setup before any
// game is created; the production default is crypto/rand and is never overridden.
@@ -283,6 +373,7 @@ func (svc *Service) Create(ctx context.Context, params CreateParams) (Game, erro
dropoutTiles: params.DropoutTiles.String(),
multipleWordsPerTurn: params.MultipleWordsPerTurn,
vsAI: params.VsAI,
kind: params.Kind,
}
if err := svc.store.CreateGame(ctx, ins, seats, seeding.draws); err != nil {
return Game{}, err
@@ -346,6 +437,7 @@ func (svc *Service) OpenOrJoin(ctx context.Context, accountID uuid.UUID, params
multipleWordsPerTurn: params.MultipleWordsPerTurn,
status: StatusOpen,
openDeadline: &deadline,
kind: gamelimits.KindRandom,
}
// Decide the first move now by the official draw, with the not-yet-arrived opponent as a
// synthetic placeholder (uuid.Nil): the draw fixes who sits at seat 0 — and so moves
@@ -699,6 +791,16 @@ func (svc *Service) commit(ctx context.Context, gameID uuid.UUID, g *engine.Game
}
if c.finished {
svc.cache.remove(gameID)
// A finished game's nudges are stale, so clear them all here — every completion path
// funnels through commit (a closing move, a resignation, a forfeit or a turn-timeout),
// and only the move path also clears the mover's nudge on its own. Best-effort like
// clearNudges: the finish has committed, so a cleanup failure is logged, not surfaced.
// ExpireNudges leaves chat messages unread.
if svc.expireNudges != nil {
if err := svc.expireNudges(ctx, gameID); err != nil {
svc.log.Warn("expire nudges on game finish", zap.Error(err))
}
}
}
post, err := svc.store.GetGame(ctx, gameID)
if err != nil {
@@ -983,6 +1085,12 @@ func (svc *Service) CountComplaints(ctx context.Context, status string) (int, er
return svc.store.CountComplaints(ctx, status)
}
// CountComplaintsSince counts word complaints filed after since, for the operator alert
// worker.
func (svc *Service) CountComplaintsSince(ctx context.Context, since time.Time) (int, error) {
return svc.store.CountComplaintsSince(ctx, since)
}
// ResolveComplaint closes a complaint with an operator disposition (reject /
// accept_add / accept_remove) and an optional note. An accepted complaint then
// appears in DictionaryChanges until a rebuilt dictionary is loaded and the
@@ -1027,10 +1135,31 @@ func (svc *Service) MarkChangesApplied(ctx context.Context, variant engine.Varia
return svc.store.MarkChangesApplied(ctx, variant.String(), version)
}
// Hint reveals the top-scoring legal play for the requesting player on their
// turn, spending one hint from their per-game allowance and then their profile
// wallet. It returns ErrHintsDisabled, ErrNoHintsLeft or ErrNoHintAvailable as
// appropriate.
// hintIdleWindow is how long a vs_ai player must be stuck on a turn (since the robot's last move)
// before the idle hint unlocks. Mirrors the offline client (lib/hints HINT_GATE_MS = 30 min).
const hintIdleWindow = 30 * time.Minute
// hintUnlockLeftSeconds is the seconds until g's vs_ai idle hint unlocks for seat, measured from now:
// the robot's last move (the current turn's start, on the human's turn) plus the window, floored at
// 0 and ceiled to whole seconds. It is 0 for a non-vs_ai game, when it is not seat's turn, or on the
// human's first move (MoveCount 0, no robot move yet) — the gate is an anti-frustration aid, not a
// first-move tax. The client anchors a monotonic countdown to it, so a client clock cannot skew it.
func hintUnlockLeftSeconds(g Game, seat int, now time.Time) int {
if !g.VsAI || g.ToMove != seat || g.MoveCount < 1 {
return 0
}
left := g.TurnStartedAt.Add(hintIdleWindow).Sub(now)
if left <= 0 {
return 0
}
return int((left + time.Second - 1) / time.Second) // ceil to whole seconds (no math import)
}
// Hint reveals the top-scoring legal play for the requesting player on their turn. For a human game
// it spends one hint from the per-game allowance then the profile wallet (ErrHintsDisabled /
// ErrNoHintsLeft / ErrNoHintAvailable). For a vs_ai game the hint is unlimited and wallet-free but
// idle-gated from the server clock (ErrHintLocked until the window elapses) and counts toward no
// hint statistic.
func (svc *Service) Hint(ctx context.Context, gameID, accountID uuid.UUID) (HintResult, error) {
pre, err := svc.store.GetGame(ctx, gameID)
if err != nil {
@@ -1049,13 +1178,40 @@ func (svc *Service) Hint(ctx context.Context, gameID, accountID uuid.UUID) (Hint
if !pre.HintsAllowed {
return HintResult{}, ErrHintsDisabled
}
acc, err := svc.accounts.GetByID(ctx, accountID)
if pre.VsAI {
// vs_ai: unlimited and wallet-free, but idle-gated from the server clock — and counted toward
// no hint statistic. Enforce the gate (the client normally pre-gates from the view's
// HintUnlockLeftSeconds; this is the authoritative backstop), then serve the top move without
// touching the allowance, the wallet or hints_used.
if hintUnlockLeftSeconds(pre, seat, svc.clock()) > 0 {
return HintResult{}, ErrHintLocked
}
unlock := svc.locks.lock(gameID)
defer unlock()
g, err := svc.liveGame(ctx, pre)
if err != nil {
return HintResult{}, err
}
move, ok := g.HintView()
if !ok {
return HintResult{}, ErrNoHintAvailable
}
return HintResult{Move: move}, nil
}
cxt, present, err := svc.walletContext(ctx, accountID)
if err != nil {
return HintResult{}, err
}
wallet := 0
if svc.hintWallet != nil {
wallet, err = svc.hintWallet.HintsAvailable(ctx, accountID, cxt, present)
if err != nil {
return HintResult{}, err
}
}
used := pre.Seats[seat].HintsUsed
fromAllowance := used < pre.HintsPerPlayer
if !fromAllowance && acc.HintBalance <= 0 {
if !fromAllowance && wallet <= 0 {
return HintResult{}, ErrNoHintsLeft
}
@@ -1070,9 +1226,9 @@ func (svc *Service) Hint(ctx context.Context, gameID, accountID uuid.UUID) (Hint
return HintResult{}, ErrNoHintAvailable
}
walletAfter := acc.HintBalance
walletAfter := wallet
if !fromAllowance {
spent, err := svc.accounts.SpendHint(ctx, accountID)
spent, err := svc.hintWallet.SpendHint(ctx, accountID, cxt, present)
if err != nil {
return HintResult{}, err
}
@@ -1088,7 +1244,7 @@ func (svc *Service) Hint(ctx context.Context, gameID, accountID uuid.UUID) (Hint
return HintResult{}, err
}
used++
return HintResult{Move: move, HintsRemaining: hintsRemaining(pre.HintsPerPlayer, used, walletAfter), WalletBalance: walletAfter}, nil
return HintResult{Move: move, HintsRemaining: hintsRemaining(pre.HintsPerPlayer, used), WalletBalance: walletAfter}, nil
}
// Candidates returns the to-move player's legal plays for a seated player on
@@ -1153,10 +1309,6 @@ func (svc *Service) GameState(ctx context.Context, gameID, accountID uuid.UUID)
if !ok {
return StateView{}, ErrNotAPlayer
}
acc, err := svc.accounts.GetByID(ctx, accountID)
if err != nil {
return StateView{}, err
}
unlock := svc.locks.lock(gameID)
defer unlock()
@@ -1171,12 +1323,15 @@ func (svc *Service) GameState(ctx context.Context, gameID, accountID uuid.UUID)
}
}
return StateView{
Game: pre,
Seat: seat,
Rack: g.Hand(seat),
BagLen: g.BagLen(),
HintsRemaining: hintsRemaining(pre.HintsPerPlayer, pre.Seats[seat].HintsUsed, acc.HintBalance),
WalletBalance: acc.HintBalance,
Game: pre,
Seat: seat,
Rack: g.Hand(seat),
BagLen: g.BagLen(),
// HintsRemaining is the per-seat allowance only; the purchasable wallet lives on the profile
// (payments) and the client adds it (lib/hints.hintsLeft).
HintsRemaining: hintsRemaining(pre.HintsPerPlayer, pre.Seats[seat].HintsUsed),
// vs_ai idle-hint gate (seconds left; 0 for a human game / first move / not your turn).
HintUnlockLeftSeconds: hintUnlockLeftSeconds(pre, seat, svc.clock()),
}, nil
}
@@ -1268,14 +1423,6 @@ func (svc *Service) ListForLobby(ctx context.Context, accountID uuid.UUID) ([]Ga
return kept, nil
}
// CountActiveQuickGames reports how many in-progress quick games the account holds —
// the count the simultaneous-game limit (MaxActiveQuickGames) is checked against. It
// counts active and still-open quick games (including honest-AI ones) and excludes
// friend games created by invitation and finished games. See Store.CountActiveQuickGames.
func (svc *Service) CountActiveQuickGames(ctx context.Context, accountID uuid.UUID) (int, error) {
return svc.store.CountActiveQuickGames(ctx, accountID)
}
// HideGame hides a finished game from accountID's own lobby (it stays visible to the other
// players); it is irreversible by design. Only a player of a finished game may hide it
// (ErrNotAPlayer / ErrGameActive otherwise); hiding an already-hidden game is a no-op.
@@ -1338,30 +1485,53 @@ func (svc *Service) SetupDraws(ctx context.Context, gameID uuid.UUID) ([]SetupDr
return svc.store.SetupDraws(ctx, gameID)
}
// ExportGCG renders a game as GCG text from the journal alone (no dictionary). It
// is allowed only on a finished game: exporting an in-progress game would leak the
// full move journal mid-play, so an active game yields ErrGameActive.
func (svc *Service) ExportGCG(ctx context.Context, gameID uuid.UUID) (string, error) {
// ExportView returns a finished game with its journal and per-seat display names —
// the material every export artifact (the GCG text, the PNG render payload) is built
// from. It is allowed only on a finished game: exporting an in-progress game would
// leak the full move journal mid-play, so an active game yields ErrGameActive. In an
// honest-AI game the robot seat is labelled "AI", not its pool name.
func (svc *Service) ExportView(ctx context.Context, gameID uuid.UUID) (Game, []HistoryMove, []string, error) {
g, err := svc.store.GetGame(ctx, gameID)
if err != nil {
return "", err
return Game{}, nil, nil, err
}
if g.Status != StatusFinished {
return "", ErrGameActive
return Game{}, nil, nil, ErrGameActive
}
moves, err := svc.store.GetJournal(ctx, gameID)
if err != nil {
return "", err
return Game{}, nil, nil, err
}
names := svc.seatNames(ctx, g)
if g.VsAI {
// Label the robot seat "AI" in an honest-AI game's export, not its pool name.
for _, s := range g.Seats {
if robot, err := svc.accounts.IsRobot(ctx, s.AccountID); err == nil && robot {
names[s.Seat] = aiPlayerName
}
}
}
return g, moves, names, nil
}
// EnsureExportable reports whether a game may be exported (it exists and is
// finished) without loading the journal — the export-URL mint check.
func (svc *Service) EnsureExportable(ctx context.Context, gameID uuid.UUID) error {
g, err := svc.store.GetGame(ctx, gameID)
if err != nil {
return err
}
if g.Status != StatusFinished {
return ErrGameActive
}
return nil
}
// ExportGCG renders a game as GCG text from the journal alone (no dictionary).
func (svc *Service) ExportGCG(ctx context.Context, gameID uuid.UUID) (string, error) {
g, moves, names, err := svc.ExportView(ctx, gameID)
if err != nil {
return "", err
}
return writeGCG(g, names, moves), nil
}
@@ -1440,13 +1610,24 @@ func (svc *Service) voidGame(ctx context.Context, pre Game, g *engine.Game) erro
if err != nil {
return err
}
return svc.store.VoidGame(ctx, voidCommit{
if err := svc.store.VoidGame(ctx, voidCommit{
gameID: pre.ID,
endReason: g.Reason().String(),
scores: scores,
now: svc.clock(),
stats: buildStats(g, statSeats),
})
}); err != nil {
return err
}
// A voided game is finished (as a draw) but bypasses commit, so clear its now-stale nudges
// here too. Best-effort, like the commit path: the void has persisted, so a cleanup failure
// is logged, not surfaced.
if svc.expireNudges != nil {
if err := svc.expireNudges(ctx, pre.ID); err != nil {
svc.log.Warn("expire nudges on voided game", zap.Error(err))
}
}
return nil
}
// replayMove re-applies one journalled move to g through the decoded engine API.
@@ -1613,10 +1794,18 @@ func (svc *Service) lookupWord(variant engine.Variant, version, word string) (bo
return present, nil
}
// hintsRemaining is a player's remaining hint budget: the unspent per-game
// allowance plus the profile wallet.
func hintsRemaining(allowance, used, wallet int) int {
return max(0, allowance-used) + wallet
// DictBytes returns the raw serialized dictionary for the (variant, version) pair
// from the registry, backing the client-side dictionary download used by the
// local move preview. It surfaces engine.ErrUnknownVariant /
// engine.ErrUnknownVersion when that dictionary is not resident.
func (svc *Service) DictBytes(variant engine.Variant, version string) ([]byte, error) {
return svc.registry.DictBytes(variant, version)
}
// hintsRemaining is the unspent per-game hint allowance. The purchasable wallet is separate,
// carried on the profile (payments), and the client adds it (lib/hints.hintsLeft).
func hintsRemaining(allowance, used int) int {
return max(0, allowance-used)
}
// allowedTimeout reports whether d is one of the offered move clocks.
+33 -24
View File
@@ -15,6 +15,7 @@ import (
"scrabble/backend/internal/account"
"scrabble/backend/internal/engine"
"scrabble/backend/internal/gamelimits"
"scrabble/backend/internal/postgres/jet/backend/model"
"scrabble/backend/internal/postgres/jet/backend/table"
)
@@ -45,6 +46,8 @@ type gameInsert struct {
multipleWordsPerTurn bool
// vsAI marks an honest-AI game (games.vs_ai).
vsAI bool
// kind tags the game's origin (games.game_kind) for the active-game limits.
kind gamelimits.Kind
// status is the lifecycle state to create the game in: StatusActive for a normal
// seated game, StatusOpen for an auto-match game still awaiting an opponent. An
// empty string defaults to StatusActive.
@@ -138,6 +141,20 @@ func (s *Store) CreateGame(ctx context.Context, ins gameInsert, seats []seatInse
})
}
// CountActiveByKind counts the account's active (open or in-progress) games of the given kind — the
// per-tier active-game limit is checked against it before a new game of that kind is created.
func (s *Store) CountActiveByKind(ctx context.Context, accountID uuid.UUID, kind gamelimits.Kind) (int, error) {
var n int
if err := s.db.QueryRowContext(ctx,
`SELECT count(*) FROM backend.games g
JOIN backend.game_players p ON p.game_id = g.game_id
WHERE p.account_id = $1 AND g.game_kind = $2 AND g.status IN ('open', 'active')`,
accountID, int16(kind)).Scan(&n); err != nil {
return 0, fmt.Errorf("game: count active by kind %s: %w", accountID, err)
}
return n, nil
}
// insertGameTx inserts the games row and one game_players row per seat (seat 0
// first) on tx, stamping each seat's display-name snapshot. A seat whose account id is
// uuid.Nil is written with a NULL account_id (and an empty snapshot) — the still-empty
@@ -155,9 +172,9 @@ func insertGameTx(ctx context.Context, tx *sql.Tx, ins gameInsert, seats []seatI
table.Games.GameID, table.Games.Variant, table.Games.DictVersion, table.Games.Seed,
table.Games.Status, table.Games.Players, table.Games.TurnTimeoutSecs,
table.Games.HintsAllowed, table.Games.HintsPerPlayer, table.Games.OpenDeadlineAt,
table.Games.DropoutTiles, table.Games.MultipleWordsPerTurn, table.Games.VsAi,
table.Games.DropoutTiles, table.Games.MultipleWordsPerTurn, table.Games.VsAi, table.Games.GameKind,
).VALUES(ins.id, ins.variant, ins.dictVersion, ins.seed, status, ins.players,
ins.turnTimeoutSecs, ins.hintsAllowed, ins.hintsPerPlayer, deadline, ins.dropoutTiles, ins.multipleWordsPerTurn, ins.vsAI)
ins.turnTimeoutSecs, ins.hintsAllowed, ins.hintsPerPlayer, deadline, ins.dropoutTiles, ins.multipleWordsPerTurn, ins.vsAI, int16(ins.kind))
if _, err := gi.ExecContext(ctx, tx); err != nil {
return fmt.Errorf("insert game: %w", err)
}
@@ -501,28 +518,6 @@ func (s *Store) ListGamesForAccount(ctx context.Context, accountID uuid.UUID) ([
return out, nil
}
// CountActiveQuickGames counts the account's in-progress quick games — the ones the
// simultaneous-game limit (MaxActiveQuickGames) is checked against. It includes both
// active and still-open (awaiting-opponent) games, the honest-AI ones among them, and
// excludes friend games (those linked to a game_invitations row) and finished games.
// A hidden game still occupies a slot, so this is a dedicated count rather than a
// filter over ListGamesForAccount (which drops hidden games). Joining on the account's
// own seat counts each game once (an open game's empty opponent seat has no account).
func (s *Store) CountActiveQuickGames(ctx context.Context, accountID uuid.UUID) (int, error) {
// The status literals are game.StatusActive / game.StatusOpen, matching the
// games.status CHECK in the baseline migration.
const q = `
SELECT COUNT(*) FROM backend.games g
JOIN backend.game_players gp ON gp.game_id = g.game_id
LEFT JOIN backend.game_invitations gi ON gi.game_id = g.game_id
WHERE gp.account_id = $1 AND g.status IN ('active', 'open') AND gi.game_id IS NULL`
var n int
if err := s.db.QueryRowContext(ctx, q, accountID).Scan(&n); err != nil {
return 0, fmt.Errorf("game: count active quick games: %w", err)
}
return n, nil
}
// HideGame hides a game from the account's own lobby list (idempotent). The caller validates the
// game is finished and the account is a player.
func (s *Store) HideGame(ctx context.Context, accountID, gameID uuid.UUID) error {
@@ -1040,6 +1035,19 @@ func (s *Store) CountComplaints(ctx context.Context, status string) (int, error)
return int(dest.Count), nil
}
// CountComplaintsSince counts word complaints filed strictly after since — the operator
// alert worker's "new since the last check" signal.
func (s *Store) CountComplaintsSince(ctx context.Context, since time.Time) (int, error) {
stmt := postgres.SELECT(postgres.COUNT(table.Complaints.ComplaintID).AS("count")).
FROM(table.Complaints).
WHERE(table.Complaints.CreatedAt.GT(postgres.TimestampzT(since)))
var dest struct{ Count int64 }
if err := stmt.QueryContext(ctx, s.db, &dest); err != nil {
return 0, fmt.Errorf("game: count complaints since: %w", err)
}
return int(dest.Count), nil
}
// ActiveGames returns the turn clocks of every in-progress game; the sweeper
// filters them against the per-move deadline and the player's away window.
func (s *Store) ActiveGames(ctx context.Context) ([]activeGame, error) {
@@ -1348,6 +1356,7 @@ func projectGame(g model.Games, seats []model.GamePlayers) (Game, error) {
}
out.MultipleWordsPerTurn = g.MultipleWordsPerTurn
out.VsAI = g.VsAi
out.Kind = gamelimits.Kind(g.GameKind)
if g.EndReason != nil {
out.EndReason = *g.EndReason
}

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