Compare commits

...

62 Commits

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

Update the telegram README and FUNCTIONAL.md (+ _ru mirror).
2026-07-13 09:25:20 +02:00
developer a41281c495 Merge pull request 'release: v1.17.0 — implicit offline model + two-tier client-version gate + unified lobby' (#250) from development into master 2026-07-13 01:06:35 +00:00
developer 11f89f6477 Merge pull request 'feat(offline): implicit net-state model, two-tier version gate, unified lobby' (#249) from feature/android-native into development
CI / changes (push) Successful in 2s
CI / unit (push) Successful in 11s
CI / integration (push) Successful in 19s
CI / ui (push) Successful in 1m13s
CI / conformance (push) Successful in 10s
CI / gate (push) Successful in 0s
CI / deploy (push) Successful in 1m53s
CI / changes (pull_request) Successful in 1s
CI / unit (pull_request) Successful in 11s
CI / integration (pull_request) Successful in 19s
CI / ui (pull_request) Successful in 1m13s
CI / conformance (pull_request) Successful in 10s
CI / gate (pull_request) Successful in 0s
CI / deploy (pull_request) Has been skipped
2026-07-13 00:59:16 +00:00
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
developer 516ffbe5f0 Merge pull request 'release: v1.16.0 — offer live pricing, bot health telemetry, edge blocklist' (#247) from development into master 2026-07-11 11:56:09 +00:00
developer 6badc20078 Merge pull request 'Release v1.15.0 — in-game UX + wallet redesign + WAL-alert fix' (#243) from development into master 2026-07-10 16:15:20 +00:00
developer 0ca01133b5 Merge pull request 'Release v1.14.1 — ansible certs-dir fix + Robokassa go-live' (#239) from development into master 2026-07-10 10:58:38 +00:00
developer 45f0b34881 Merge pull request 'Release v1.14.0 — monetization launch (E5-E8)' (#237) from development into master 2026-07-10 10:11:02 +00:00
developer 18785efc8c Merge pull request 'release v1.13.0: payments wallet mechanics + database point-in-time recovery' (#220) from development into master 2026-07-08 23:42:15 +00:00
developer 780ff68ec2 Merge pull request 'release: offline mode + local pass-and-play (hotseat) — proposed v1.12.0' (#212) from development into master 2026-07-07 14:40:43 +00:00
developer 57ff2d03f8 Merge pull request 'Release v1.11.0: PWA install + code-only PWA login + email/metrics fixes' (#187) from development into master 2026-07-05 21:02:08 +00:00
developer a9d0986e74 Merge pull request 'Release v1.10.0: banner colours + urgent, and 3 UI fixes' (#183) from development into master 2026-07-05 14:10:25 +00:00
developer 45957bdcd6 Merge pull request 'Release: promote development → master (old Android WebView support + unsupported-engine telemetry)' (#178) from development into master 2026-07-04 21:23:29 +00:00
developer 829e29a726 Merge pull request 'Release v1.8.0: prod build fix (re-promote)' (#175) from development into master 2026-07-03 21:48:13 +00:00
developer 399508f2f0 Merge pull request 'Release v1.8.0: promote development → master' (#173) from development into master 2026-07-03 21:31:25 +00:00
developer 3a18e683ca Merge pull request 'release: VK Mini App + landscape UI + dict v1.3.1 seed (development→master)' (#144) from development into master 2026-06-30 05:37:43 +00:00
developer 93d086a8a3 Merge pull request 'release: v1.7.0 — Telegram Mini App embedding enhancements' (#138) from development into master 2026-06-24 11:49:44 +00:00
developer 8fe1bdba6b Merge pull request 'release: v1.6.0 — promo deep-link seeds EN variant (+ UI nits)' (#135) from development into master 2026-06-23 21:02:19 +00:00
developer 7923b3cc09 Merge pull request 'release v1.5.1: support-relay card + topic-reopen fixes' (#133) from development into master 2026-06-23 16:54:01 +00:00
developer 4891216749 Merge pull request 'release v1.5.0: Telegram bot support relay' (#131) from development into master 2026-06-23 16:16:04 +00:00
developer f1b8769c89 Merge pull request 'release: v1.4.1 — Telegram nav (windowed, own back button, debug panel)' (#129) from development into master 2026-06-23 13:27:31 +00:00
developer b6f28a2423 Merge pull request 'release: v1.4.0 — Telegram launch diagnostic + dynamic SDK load' (#127) from development into master 2026-06-23 08:40:09 +00:00
developer e32ee9ce68 Merge pull request 'Release: development → master' (#125) from development into master 2026-06-22 22:36:42 +00:00
developer dc946a1faf Merge pull request 'release v1.2.2: edge HTTP/3 stall fix + db-size dashboard threshold' (#121) from development into master 2026-06-22 19:50:58 +00:00
developer 384bd143d0 Merge pull request 'Promote development → master: banner tip set + banner/push language fix' (#114) from development into master 2026-06-22 18:28:00 +00:00
developer c5d22fceca Merge pull request 'Promote development → master: Erudit blank star + dictionary v1.3.0 pin' (#111) from development into master 2026-06-22 13:12:01 +00:00
developer deaa7a29c5 Merge pull request 'Promote development → master (docs finalize + UI tweaks + Telegram name fallback)' (#108) from development into master 2026-06-22 07:27:40 +00:00
developer 24017bcb7f Merge pull request 'Promote development → master (deploy v2: versioning + visible jobs + rollback)' (#106) from development into master 2026-06-22 06:01:03 +00:00
developer 2c4f4b10dc Merge pull request 'Promote development → master (initial production release: pre-release line + Stage 18)' (#104) from development into master 2026-06-22 05:05:48 +00:00
208 changed files with 6001 additions and 1608 deletions
+4 -4
View File
@@ -30,7 +30,7 @@ on it**, and prefer the authoritative docs where they overlap. Add to this file
## Native Android build (Capacitor) ## Native Android build (Capacitor)
The native Android app (`ANDROID_PLAN.md`) is a Capacitor 8 wrapper of the `ui` SPA, scaffolded under The native Android app is a Capacitor 8 wrapper of the `ui` SPA, scaffolded under
`ui/` (a Node project outside `go.work`). Hard-won bring-up facts — verify against current code: `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 - **Capacitor 8 needs JDK 21, not 17.** `@capacitor/android` compiles at `VERSION_21`; a JDK 17 Gradle
@@ -234,9 +234,9 @@ The native Android app (`ANDROID_PLAN.md`) is a Capacitor 8 wrapper of the `ui`
`--pg1-user=scrabble`. `--pg1-user=scrabble`.
- **Offline mode** — the robot brain, move generator/validator/scorer and DAWG reader are **JS ports** - **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 of the Go engine, bundled client-side and **parity-pinned** by golden tests. Multi-phase; native
offline-first bundles the dictionaries (see `ANDROID_PLAN.md`). offline-first bundles the dictionaries.
- **VK ID web login** — raw OAuth 2.1 against `id.vk.com`, a **separate VK "Web" app** from the Mini - **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. App, server-side confidential code exchange.
- **Email relay** — Selectel SMTP + confirm / link / unlink / deletion codes + alerts. - **Email relay** — Selectel SMTP + confirm / link / unlink / deletion codes + alerts.
- **Native Android** — see `ANDROID_PLAN.md` (Capacitor bundle model, client-version gate, - **Native Android** — Capacitor bundle model, client-version gate, offline-first, RuStore; the
offline-first, RuStore). design lives in `docs/ARCHITECTURE.md` (§2 gate, §3 identity, §13 native build).
+3 -3
View File
@@ -5,9 +5,9 @@
# #
# Signing degrades gracefully: with the ANDROID_KEYSTORE_* secrets present the APK is signed; without # 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). # them build.gradle produces an UNSIGNED release APK (so a dry run still proves the whole pipeline).
# The keystore is a publication prerequisite — see deploy/README.md (Android build/release runbook) and # The keystore is a publication prerequisite — see deploy/README.md (Android build/release runbook).
# ANDROID_PLAN.md §E. The toolchain is self-provisioned here (JDK 21 + a cached Android SDK), so the # The toolchain is self-provisioned here (JDK 21 + a cached Android SDK), so the runner host needs
# runner host needs nothing pre-installed. # nothing pre-installed.
name: android-build name: android-build
run-name: "android build ${{ github.sha }}" run-name: "android build ${{ github.sha }}"
+49 -4
View File
@@ -353,6 +353,11 @@ jobs:
# for the server-side confidential code exchange — a SEPARATE VK app from the Mini # for the server-side confidential code exchange — a SEPARATE VK app from the Mini
# App above. One VK ID "Web" app serves every contour -> unprefixed secret. # App above. One VK ID "Web" app serves every contour -> unprefixed secret.
GATEWAY_VK_ID_CLIENT_SECRET: ${{ secrets.GATEWAY_VK_ID_CLIENT_SECRET }} GATEWAY_VK_ID_CLIENT_SECRET: ${{ secrets.GATEWAY_VK_ID_CLIENT_SECRET }}
# Client-version gate (ARCHITECTURE.md §2): one plain (unprefixed) variable serves every
# contour. In the test contour the stamped client version is a commit hash (unparseable ⇒
# fail-open), so setting these only enforces on real semver prod builds. Empty ⇒ dormant.
GATEWAY_MIN_CLIENT_VERSION: ${{ vars.GATEWAY_MIN_CLIENT_VERSION }}
GATEWAY_RECOMMENDED_CLIENT_VERSION: ${{ vars.GATEWAY_RECOMMENDED_CLIENT_VERSION }}
# Planted honeytoken bearer: presenting it flags the caller (logs + a ban metric on # Planted honeytoken bearer: presenting it flags the caller (logs + a ban metric on
# test where the IP ban is off; a 24h IP ban on prod). Per-contour secret; empty = trap off. # test where the IP ban is off; a 24h IP ban on prod). Per-contour secret; empty = trap off.
GATEWAY_HONEYTOKEN: ${{ secrets.TEST_GATEWAY_HONEYTOKEN }} GATEWAY_HONEYTOKEN: ${{ secrets.TEST_GATEWAY_HONEYTOKEN }}
@@ -517,10 +522,21 @@ jobs:
# (the seller INN, §11) AND that the pricing marker was substituted (proves the fetch + # (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 # 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. # the marker with nothing, so "pricing_template" must be absent either way.
out="$(docker run --rm --network edge alpine:3.20 wget -q -O - http://scrabble/offer/ 2>&1 || true)" # Full body is needed (the INN is in §11 and the marker check spans the page), so this
if echo "$out" | grep -q "290210610742" && ! echo "$out" | grep -q "pricing_template"; then # cannot be a head-only probe like the legal one. Retry with a timeout instead: the offer is
echo "ok: /offer/ serves the rendered offer with the price list spliced in" # now bilingual (RU + EN, larger), and a single-shot fetch can race the renderer's restart in
else # 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)" 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-renderer || true
docker logs --tail 50 scrabble-backend || true docker logs --tail 50 scrabble-backend || true
@@ -528,6 +544,35 @@ jobs:
exit 1 exit 1
fi fi
- name: Probe the /privacy/ and /eula/ legal pages are served
run: |
set -u
# /privacy/ and /eula/ are static legal markdown rendered by the render sidecar. If the
# @legal caddy route is missing they fall to the landing shell (also 200, canonical
# erudit-game.ru/), so assert the page-specific canonical URL — it uniquely identifies the
# rendered legal doc reaching the edge, not the landing catch-all. Read only the <head>
# (head -c 4096; the canonical link sits in the first bytes): the check is then
# size-independent, so the large EULA page does not exceed the timeout while the monitoring
# stack is still booting post-deploy and starving the CPU-capped renderer. Retry like the
# landing/gateway/backend probe to ride out that window.
for route in privacy eula; do
ok=
for i in $(seq 1 20); do
head_out="$(docker run --rm --network edge alpine:3.20 sh -c "wget -q -T 10 -O - http://scrabble/${route}/ 2>/dev/null | head -c 4096" || true)"
if printf '%s' "$head_out" | grep -qF "erudit-game.ru/${route}/"; then
echo "ok: /${route}/ serves the rendered legal page"
ok=1
break
fi
sleep 3
done
if [ -z "$ok" ]; then
echo "FAIL: /${route}/ did not serve the rendered legal page (@legal route missing?)"
docker logs --tail 50 scrabble-renderer || true
exit 1
fi
done
- name: Probe the /pay/ callback route reaches the gateway - name: Probe the /pay/ callback route reaches the gateway
run: | run: |
set -u set -u
+6
View File
@@ -112,6 +112,12 @@ jobs:
# derived from PUBLIC_BASE_URL in deploy/write-prod-env.sh. # derived from PUBLIC_BASE_URL in deploy/write-prod-env.sh.
VITE_VK_APP_ID: ${{ vars.VITE_VK_APP_ID }} VITE_VK_APP_ID: ${{ vars.VITE_VK_APP_ID }}
GATEWAY_VK_ID_CLIENT_SECRET: ${{ secrets.GATEWAY_VK_ID_CLIENT_SECRET }} GATEWAY_VK_ID_CLIENT_SECRET: ${{ secrets.GATEWAY_VK_ID_CLIENT_SECRET }}
# Client-version gate (ARCHITECTURE.md §2): plain (unprefixed) vars, shared across contours
# (empty ⇒ dormant). On prod the stamped client version is a real semver, so the gate enforces
# here — set GATEWAY_MIN_CLIENT_VERSION to the release in the same rollout that ships a breaking
# wire change; bump GATEWAY_RECOMMENDED_CLIENT_VERSION (≥ min) to nudge upgrades softly.
GATEWAY_MIN_CLIENT_VERSION: ${{ vars.GATEWAY_MIN_CLIENT_VERSION }}
GATEWAY_RECOMMENDED_CLIENT_VERSION: ${{ vars.GATEWAY_RECOMMENDED_CLIENT_VERSION }}
# Planted honeytoken bearer: presenting it earns a 24h IP ban + a high-severity alarm. # Planted honeytoken bearer: presenting it earns a 24h IP ban + a high-severity alarm.
# Per-contour secret; empty = trap off. Rendered by deploy/write-prod-env.sh. # Per-contour secret; empty = trap off. Rendered by deploy/write-prod-env.sh.
GATEWAY_HONEYTOKEN: ${{ secrets.PROD_GATEWAY_HONEYTOKEN }} GATEWAY_HONEYTOKEN: ${{ secrets.PROD_GATEWAY_HONEYTOKEN }}
+245 -73
View File
@@ -62,15 +62,86 @@ client-only (no server change).
Kept current as parts land so a fresh session resumes without re-deriving. Verify against code. Kept current as parts land so a fresh session resumes without re-deriving. Verify against code.
> ### ▶ RESUME HERE — 2026-07-13 (documents track + release-prep)
>
> **Where we are.** The legal-documents track, the release-prep hygiene, **and the icon rebrand** are
> DONE. The RuStore release is now gated on **owner-side** items only (keystore, RuStore account) plus
> the merge/tag chain. Do not re-do or re-litigate the finished work; do not re-ask the locked decisions.
>
> **Branches / PRs (current working branch: `feature/android-release-prep`).**
> - `development` — has the **legal-documents track**, merged via **PR #253** (green, deployed to the
> test contour). `/privacy/`, `/eula/`, `/offer/` are live there, bilingual RU/EN.
> - `feature/android-release-prep` — the **release-prep hygiene**, **PR #254 OPEN, green, NOT merged**
> (the owner chose to hold the merge). It is branched off `development`, so it also contains the
> legal-documents track. Merge #254 into `development` when ready — needs **owner approval** (the
> agent cannot self-approve).
>
> **Done this session.**
> - **Legal documents** (satisfies the RuStore *hosted privacy-policy URL* prerequisite): authored
> `ui/legal/{privacy,eula,offer}_{ru,en}.md`; served as **bilingual (RU/EN)** standalone pages by the
> render sidecar at `/privacy/`, `/eula/`, `/offer/` via the shared `ui/src/lib/offer.ts`
> `renderLegalHtml`, with a client-side **language + theme switcher** (🌐 + ☼/☾, no "back", default
> Russian + persisted, mirroring the landing). The offer's EN view **transliterates** the Russian
> product names and **translates** the price-list section headings + column headers (`PRICE_TR`).
> Edge: `@legal` caddy matcher (`/offer/ /privacy/ /eula/`) + CI probes (size-independent head-only
> for the legal pages, retry for the offer). Landing footer links: Пользовательское соглашение |
> Политика конфиденциальности | Публичная оферта | Обратная связь (→ the Telegram bot). Docs baked
> into `docs/ARCHITECTURE.md`, `docs/FUNCTIONAL.md` (+`_ru`), `renderer/README.md`.
> **Locked legal decisions — do NOT re-litigate:** seller **INN `290210610742`**; EULA §14 leads
> with **RF law + jurisdiction** as residence tiers (France / England&Wales retained as foreign
> tiers); the "only the English version is legally binding" clause is **removed**; data collection is
> kept but **scoped to voluntary/support provision** (passport/payment/ID only when the user provides
> them); «Компания» is not shout-cased; the offer's EN catalog product names are **transliterated**
> (owner-chosen), while its headings/columns are translated.
> - **Release-prep hygiene (PR #254):** de-anchored `ANDROID_PLAN.md` from all code/docs — **no
> reference to this file remains anywhere outside it**; **re-enabled the `android-build` workflow**
> (`.gitea/workflows/android-build.yaml.disabled` → `.yaml`; it is `workflow_dispatch`-only + gated
> `if master`, so it never auto-runs; the CI host already has the Android SDK at `/opt/android-sdk`);
> added [`docs/ICONS.md`](docs/ICONS.md) (the single-master icon/logo format reference).
>
> **Remaining to release (owner-gated unless the agent is named).**
> 1. **Icon rebrand — DONE.** The SVG-tracer export failed, so the icon was designed **collaboratively
> from scratch** (wooden «Э» tile + ✻ subscript, Spectral Bold). The construction is fully specified
> in fractions of the side in [`docs/ICON_BRANDBOOK.md`](docs/ICON_BRANDBOOK.md), with the reference
> generator + pinned font + masters in [`assets/icons/brand/`](assets/icons/brand/). One master →
> the whole set sliced by `brand/build-set.mjs` (+ `build-android-res.mjs`): web (favicon light+dark,
> apple-touch, PWA any + maskable, og-image reskinned to «Эрудит» / Игра в слова), the Capacitor
> layers `ui/assets/{icon,icon-foreground,icon-background}.png`, the Android launcher res (all
> densities, **no inset**, **+ monochrome** themed layer), and manual-upload art (`brand/{vk,tg,store}`).
> Primary variant **light**; dark where the platform can theme (favicon.svg). The old LiberationSans
> generator (`assets/icons/build/`) is removed; targets are mapped in `docs/ICONS.md`.
> 2. **Merge PR #254** → `development` (owner approval), then **promote `development` → `master`** and
> **tag `vX.Y.Z`** for the release (the version stamps the APK `versionName`/`versionCode` and the
> `X-Client-Version` header).
> 3. **Release keystore.** OWNER generates it (`keytool -genkeypair -v -keystore erudit-release.jks
> -alias erudit -keyalg RSA -keysize 4096 -validity 10000`), base64-encodes it, sets the four Gitea
> **secrets** `ANDROID_KEYSTORE_BASE64` / `ANDROID_KEYSTORE_PASSWORD` / `ANDROID_KEY_ALIAS` /
> `ANDROID_KEY_PASSWORD`, and backs the `.jks` up **off-host** (loss ⇒ the app can never be updated).
> The AGENT provides the exact commands on request. Without the secrets the workflow builds an
> UNSIGNED APK (a dry run still proves the pipeline).
> 4. **RuStore developer account.** OWNER (verified legal entity or self-employed / самозанятый). Gates
> publication, not the build.
> 5. **Dispatch the signed build.** AGENT: on `master`, dispatch `android-build` (`confirm=build`) via
> `tea`, watch to green, retrieve the signed APK artifact (verify Gitea 1.26 `upload-artifact@v4` at
> the dispatch).
> 6. **On-device smoke.** OWNER installs the signed APK and runs the airplane-mode first-launch
> checklist (`docs/TESTING.md`). The AGENT can pre-run the emulator smoke.
> 7. **Store listing.** Reserve `ru.eruditgame.app`, title «Эрудит», RU description, phone screenshots,
> icon / feature graphic, age rating, the **privacy-policy URL `https://erudit-game.ru/privacy/`**
> (now available), the content declaration.
> 8. **Upload to RuStore.** OWNER. After publication, set the `ANDROID_RUSTORE_URL` Gitea variable to
> the store deep-link so the update button works on future gated releases (empty until then — the
> gate is dormant in the MVP).
- **A. Capacitor scaffolding — ✅ DONE & verified (2026-07-12).** Capacitor 8 native project generated - **A. Capacitor scaffolding — ✅ DONE & verified (2026-07-12).** Capacitor 8 native project generated
under `ui/android/` (tracked), `@capacitor/*` deps + `capacitor.config.ts`, hardware Back in under `ui/android/` (tracked), `@capacitor/*` deps + `capacitor.config.ts`, hardware Back in
`ui/src/lib/native.ts` wired from `App.svelte`. `pnpm build``cap sync``./gradlew assembleDebug` `ui/src/lib/native.ts` wired from `App.svelte`. `pnpm build``cap sync``./gradlew assembleDebug`
builds a debug APK that installs and **cold-launches to the Login screen** on the Pixel_10 emulator. builds a debug APK that installs and **cold-launches to the Login screen** on the Pixel_10 emulator.
`svelte-check` 0 errors, `vitest` 584 passed. Build recipe + toolchain gotchas live in `svelte-check` 0 errors, `vitest` 584 passed. Build recipe + toolchain gotchas live in
`.claude/CLAUDE.md` → "Native Android build (Capacitor)". `.claude/CLAUDE.md` → "Native Android build (Capacitor)".
**Launcher icon:** a **temporary** placeholder is in place — `ui/assets/icon.png` (the maskable «Э» **Launcher icon:** the temporary placeholder was **superseded by the icon rebrand** — the launcher
brand mark upscaled 512→1024) → `pnpm android:assets` regenerated the Android launcher/adaptive now uses the two-layer adaptive icon (background + foreground + monochrome) generated from the brand
icons + splashes. Superseded by the icon rebrand below. master; see the RESUME block (item 1), `docs/ICON_BRANDBOOK.md` and `docs/ICONS.md`.
- **B. Native web-build correctness — ✅ DONE & verified (2026-07-12).** New `ui/src/lib/origin.ts` - **B. Native web-build correctness — ✅ DONE & verified (2026-07-12).** New `ui/src/lib/origin.ts`
`gatewayOrigin()`; the two `location.origin` landmines fixed (`game/Game.svelte` export/share URL, `gatewayOrigin()`; the two `location.origin` landmines fixed (`game/Game.svelte` export/share URL,
`screens/Wallet.svelte` site-root) — `transport.ts:32` already resolves via `VITE_GATEWAY_URL`, left `screens/Wallet.svelte` site-root) — `transport.ts:32` already resolves via `VITE_GATEWAY_URL`, left
@@ -181,11 +252,10 @@ Kept current as parts land so a fresh session resumes without re-deriving. Verif
then O2O7; the release chain (PR→development→contour→master→tag→dispatch `android-build`) follows. None of then O2O7; the release chain (PR→development→contour→master→tag→dispatch `android-build`) follows. None of
O1O7 is started. Branch `feature/android-native`, local/unpushed. O1O7 is started. Branch `feature/android-native`, local/unpushed.
Open logistics (not code): **mandatory icon rebrand (future)** — author ONE vector master and generate Icon rebrand — **DONE** (was "one master → every icon"): a single Spectral «Э» master now drives web
*every* icon from it (web `favicon.svg` / PWA `icon-*` / maskable, Android adaptive, future iOS). Today (favicon light+dark / PWA any + maskable / apple-touch / og), the Capacitor layers and the Android
the formats drift — `favicon.svg` is a rounded bordered tile, `icon-maskable-512.png` a full-bleed adaptive res (no inset, + monochrome), plus VK/TG/store art. Specced in `docs/ICON_BRANDBOOK.md`,
square, visibly different — and the Android launcher icon is only a temporary upscale of the maskable mapped in `docs/ICONS.md`, generated from `assets/icons/brand/`. `~/.claude/bin/gitea-ci-watch.py` is present.
(`capacitor-assets` insets it 16.7% into the adaptive safe zone). `~/.claude/bin/gitea-ci-watch.py` is present.
RuStore publication prerequisites gate release only. RuStore publication prerequisites gate release only.
--- ---
@@ -334,8 +404,8 @@ is: scaffold → native-correct → gate → offline-first → CI → docs → r
``` ```
Call once from bootstrap. `atNavigationRoot` = current route is `lobby`/`login` Call once from bootstrap. `atNavigationRoot` = current route is `lobby`/`login`
(mirror `routeDepth(...) === 0` from `App.svelte:50-54`, reading `router.svelte.ts`). (mirror `routeDepth(...) === 0` from `App.svelte:50-54`, reading `router.svelte.ts`).
- **Launcher icon (owner asset):** owner supplies a 1024×1024 «Э» icon at `ui/assets/icon.png` - **Launcher icon:** DONE — generated from the brand master into the Capacitor layers
(+ optional `splash.png`); `pnpm android:assets` generates adaptive icons. `ui/assets/{icon,icon-foreground,icon-background}.png` and the Android res; see `docs/ICONS.md`.
**Done when:** `npx cap sync android` succeeds against a real `pnpm build`, and (from `ui/android/`) **Done when:** `npx cap sync android` succeeds against a real `pnpm build`, and (from `ui/android/`)
`./gradlew assembleDebug` produces a debug APK that installs and cold-launches to the login screen. `./gradlew assembleDebug` produces a debug APK that installs and cold-launches to the login screen.
@@ -695,12 +765,20 @@ Wire in `ui/android/app/build.gradle` `defaultConfig`:
### G. Release + owner handoff ### G. Release + owner handoff
0. **Land the offline-model redesign (design below — "Offline-model redesign").** This resolves the deferred > **Status 2026-07-13 — see the ▶ RESUME block at the top of §Progress.** Since this chain was
native local-game-visibility decision (a native guest must see / resume their device-local games once online > written, a **legal-documents track** was added and merged (PR #253 — the RuStore hosted
— today the online lobby hides them, `Lobby.svelte:42/54`) as part of a broader, owner-approved change: > privacy-policy URL and the `/privacy/ /eula/ /offer/` pages), and a **release-prep hygiene** patch is
remove the explicit offline toggle, make offline an implicit **net-state machine**, unify the lobby, and add > in flight (PR #254, open), and the **icon rebrand is DONE** (brand master → full set; see the RESUME
the **two-tier** version gate. Cross-cutting (web + native + a small additive backend/wire bit), its own > block). The remaining gates are the **keystore** and the **RuStore account** (owner-side). The steps
staged work; the Android release waits on it. > below are the original release chain; the resume
> block supersedes them with the current, exhaustive state.
0. **Land the offline-model redesign — ✅ DONE (O1O7, 2026-07-13; staged detail below).** Resolved the deferred
native local-game-visibility decision — a native guest now sees / resumes their device-local games once online
(the **unified lobby**, O5) — as part of the owner-approved change: the explicit offline toggle is gone, offline
is an implicit **net-state machine** (O1/O2), the lobby is unified (O5), and the **two-tier** version gate is in
(O4). Cross-cutting (web + native + a small additive backend/wire bit); **contour-safe** (both version vars empty
⇒ dormant; the wire add is additive). The remaining G steps below are the release chain.
1. Merge `feature/android-native` → `development`; verify on the contour (contour-safe; gate dormant). 1. Merge `feature/android-native` → `development`; verify on the contour (contour-safe; gate dormant).
2. Promote `development` → `master`; tag `vX.Y.0`. 2. Promote `development` → `master`; tag `vX.Y.0`.
3. Dispatch `android-build`; watch green; retrieve the signed APK. 3. Dispatch `android-build`; watch green; retrieve the signed APK.
@@ -863,73 +941,167 @@ untouched**; wire changes **additive-only**; **TG/VK inert** (always online); **
vars empty ⇒ dormant). Minimal-diff: keep `connection`/`offline` module paths as thin **derived shims** over vars empty ⇒ dormant). Minimal-diff: keep `connection`/`offline` module paths as thin **derived shims** over
`netState` so the ~14 `offlineMode.active` consumers are not mass-renamed (single source of truth underneath). `netState` so the ~14 `offlineMode.active` consumers are not mass-renamed (single source of truth underneath).
- **O1 — Pure net-state reducer + exhaustive unit tests (test-first).** - **O1 — Pure net-state reducer + exhaustive unit tests (test-first). — ✅ DONE (2026-07-12).**
- Files — Create: `ui/src/lib/netstate.ts`, `ui/src/lib/netstate.test.ts`. - Files — Create: `ui/src/lib/netstate.ts`, `ui/src/lib/netstate.test.ts`.
- Interfaces — Produces: `type NetState = 'online'|'connecting'|'offlineNoNetwork'|'offlineVersionLocked'`; - Interfaces (as built) — Produces: `type NetState = 'online'|'connecting'|'offlineNoNetwork'|'offlineVersionLocked'`;
`type NetEvent` (`callFailed`/`callOk`/`probeOk`/`probeFailed`/`osOffline`/`osOnline`/`versionRejected`/ `type NetEvent` (`boot`/`callOk`/`callFailed`/`probeOk`/`probeFailed`/`osOnline`/`osOffline`/`versionRejected`/
`versionRecommended`/`boot`); `reduce(prev, event, cfg) => { state: NetState; effects: Effect[] }` `versionRecommended`); `reduce(prev: NetSnapshot, ev: NetInput, cfg: NetConfig) => NetResult` where
(effects: `toast`, `startProbe`, `showVersionNotice`, `setNudge`); `cfg` carries `K` + `OFFLINE_DEBOUNCE_MS`. `NetInput = { type: NetEvent; at: number }` (**time-on-the-event** — owner-chosen option A: the reducer stays
Consumes: nothing (pure). a pure function of `(state, event)` and decides *both* hysteresis branches; O2 stamps `Date.now()`),
`NetSnapshot = { state; fails; connectingSince }` (the hysteresis bookkeeping is carried in the reduced value
so `reduce` is pure yet the whole anti-flap is unit-tested), `NetResult = { next: NetSnapshot; effects: Effect[] }`,
`Effect = {kind:'toast',toast:'offline'|'online'} | {kind:'startProbe'} | {kind:'showVersionNotice'} | {kind:'setNudge'}`,
`NetConfig = { k; debounceMs }` (K + `OFFLINE_DEBOUNCE_MS`), plus `INITIAL` (optimistic pre-boot `online`).
Consumes: nothing (pure). **As-built semantics:** entry via `callFailed`/`probeFailed` counts as `fails 1`;
`osOffline` is a hint (enters `connecting` with `fails 0`, only `startProbe`) — so a single blip stays `connecting`
for K≥2; `boot` resets from any state (the sole exit from the sticky lock) → `connecting` + `startProbe`;
`offlineVersionLocked` is sticky (no connectivity event exits it, notice shown once on entry); the soft nudge is
**effect-only** (`setNudge` from `online`), latched later in O4, not stored in the snapshot.
- Acceptance: every transition-table row + all 12 edge cases pass as pure `reduce` assertions; the blip vs - Acceptance: every transition-table row + all 12 edge cases pass as pure `reduce` assertions; the blip vs
sustained hysteresis (a single fail→ok stays `connecting`; K/debounce → `offlineNoNetwork`); `versionRejected` sustained hysteresis (a single fail→ok stays `connecting`; K/debounce → `offlineNoNetwork`); `versionRejected`
from **any** state → `offlineVersionLocked`; `versionRecommended` sets the nudge only from `online`. from **any** state → `offlineVersionLocked`; `versionRecommended` sets the nudge only from `online`.
- Targeted tests: `netstate.test.ts` (vitest, node — pure, no jsdom). - Targeted tests: `netstate.test.ts` (vitest, node — pure, no jsdom). **31 assertions, green** (transition table,
hysteresis blip/K/debounce, two-tier version gate, edge cases #1#12, purity).
- **O2 — Runtime store + wire the events; migrate the two modules.** - **O2 — Runtime store + wire the events; migrate the two modules. — ✅ DONE (2026-07-12).**
- Files — Create: `ui/src/lib/netstate.svelte.ts` (the `$state` store running `reduce`, the derived - Files (as built) — Create: `ui/src/lib/netstate.svelte.ts` (the `$state` store running `reduce`; the derived
`offline`/`connecting` getters, the probe timer). Modify: `ui/src/lib/connection.svelte.ts` + `state`/`online`/`connecting`/`offline`/`versionLocked` getters; one self-rescheduling probe/recovery watcher —
`ui/src/lib/offline.svelte.ts` (become thin shims re-exporting the derived getters), `ui/src/lib/transport.ts` backoff while `connecting`, a 4 s poll while `offlineNoNetwork`, idle otherwise; the OS-signal wiring). Modify:
(emit `callFailed`/`callOk`/`versionRejected`/`versionRecommended`), `ui/src/lib/native.ts` (wire `ui/src/lib/connection.svelte.ts` + `ui/src/lib/offline.svelte.ts` (thin shims over `netState` — `connection.online`
`@capacitor/network`), `ui/src/lib/app.svelte.ts` (boot event), `ui/package.json` (+`@capacitor/network`). = the online state, `offlineMode.active` = the machine `offline`; `reportOffline`→`callFailed` **once** from online,
- Interfaces — Produces: `netState` store `.state`/`.offline`/`.connecting`, `emit(event)`. Consumes: `reduce` `reportOnline`→`callOk`), `ui/src/lib/update.svelte.ts` (`reportUpdateRequired` also emits `versionRejected`),
(O1), the existing `registerProbe`. `ui/src/lib/native.ts` (+`initNativeNetwork` — `@capacitor/network` → `osOnline`/`osOffline`),
- Acceptance: airplane-mode → `offlineNoNetwork` → back → `online` via the machine; native uses `ui/src/lib/app.svelte.ts` (the boot/recovery-seam rewrite), `ui/src/lib/gateway.ts` (the mock `__net` hook),
`@capacitor/network`, web `navigator`; the derived `offline` matches the old `offlineMode.active` for every `ui/src/App.svelte` (dialog removal), `ui/src/lib/i18n/{en,ru}.ts` (`net.offline`/`net.online` toast), `ui/package.json`
consumer (no visual/behaviour regression). (+`@capacitor/network@8.0.1`). **Owner-confirmed scope A (full migration):** the store subsumes the old
- Targeted tests: `e2e/offline.spec.ts` still green; `e2e/native.spec.ts` extended (a `$state` store ⇒ e2e, like `update.svelte.ts`). `scheduleRecovery` poll + `initNetworkReactivity` listeners + the cold-boot `promptOfflineChoice` **dialog** (removed —
"auto + toast, no dialog"); the native offline-first boot + the web unreachable-cached boot now enter via `bootOffline()`.
- Interfaces (as built) — Produces: `netState` (`.state`/`.online`/`.connecting`/`.offline`/`.versionLocked`),
`emit(type)` (stamps `Date.now()`), `bootOffline(kickNow?)`, `checkReachable`, `resetNetState`, `initNetSignals`, and a
**register-hook inversion** so the store stays a leaf module (no cycle with `app.svelte.ts`): `registerProbe`
(transport's reachability read), `registerRecovery` (the app's smart reconcile-or-reachability), `registerNetToast`
(i18n toast). `NetConfig = { k: 3, debounceMs: 15000 }` (owner-confirmed). **Wiring:** `reportOffline` enters
`connecting` once (only from online) and the **probe decides** offline via K/debounce; the session-less native
reconcile IS the probe (`recover()` → `reconcileServerGuest` → `emit('callOk')` before adopt). **`boot` is not
emitted** — a real relaunch reloads the bundle to `INITIAL` online, which also clears the sticky version lock; O1's
`boot` handling stays valid but unused here. **TG/VK inertness falls out for free:** the registrations run only past
the Telegram/VK early-returns in `bootstrap`, so those channels are never fed an offline signal (they can show
`connecting` but never reach `offline`).
- Acceptance: airplane-mode → `offlineNoNetwork` → back → `online` via the machine; native uses `@capacitor/network`,
web `navigator`; the derived `offline` matches the old `offlineMode.active` for every consumer (no regression). **Verified.**
- Targeted tests: `e2e/offline.spec.ts` **rewritten** (implicit offline via the mock `__net` hook — no toggle — + the
toast + local play + persist + self-heal), `e2e/hotseat.spec.ts` migrated off the toggle, `e2e/native.spec.ts` green
(the native boot/offline/reconcile IS the store's e2e). **Full suite 119/119 on chromium + webkit; svelte-check 0/0;
vitest 622; `cap sync` registers `@capacitor/network`.**
- **O3 — Remove the explicit offline toggle + migrate the pref.** - **O3 — Remove the explicit offline toggle + migrate the pref. — ✅ DONE (2026-07-12).**
- Files — Modify: `ui/src/screens/Settings.svelte` (delete the `offlineEligible` toggle + `goOffline`), - Files (as built) — Modify: `ui/src/screens/Settings.svelte` (deleted the whole "Play mode" Online/Offline segment +
`ui/src/lib/offline.ts`/`offline.svelte.ts` (drop `requestOffline`/`loadOfflinePref`/`saveOfflinePref`; clear `offlineEligible` + `goOffline` + the `checking`/`needsData` state + the now-unused `isStandalone`/`insideVK`/offline
the stale key on boot), `ui/src/screens/SettingsHub.svelte` (tab-disable from `netState`). imports + the `.onote` CSS), `ui/src/lib/offline.svelte.ts` (dropped the inert `setOfflineMode`/`requestOffline`/
- Acceptance: no toggle in Settings; a seeded stale `offlinePref` boots **online** (nobody stuck); offline `TOGGLE_READY_BUDGET_MS` stubs + the dead `offlineMode.auto` getter — `offlineMode.active` = `netState.offline` stays),
chrome/tab-gating still driven from `netState`. `ui/src/lib/offline.ts` (dropped `loadOfflinePref`/`saveOfflinePref`/`shouldBootOffline`/`offlineReady`/`missingDicts`/
- Targeted tests: `e2e` — Settings has no toggle; a stale pref does not force offline. `raceOfflineReady`; **new** `clearOfflinePref`; only `offlinePreloadEligible` + the cleanup remain), `ui/src/lib/app.svelte.ts`
(calls `clearOfflinePref()` once at boot, before the Mini-App branches), `ui/src/lib/i18n/{en,ru}.ts` (pruned 7 orphaned
keys each: `settings.{offlineMode,online,offlineChecking,offlineNeedsData}` + `offline.prompt{Title,Yes,No}`). **Deleted**
`ui/src/lib/dict/offlineready.ts` (orphaned once `requestOffline` went). Tests: `ui/src/lib/offline.test.ts` slimmed to
`offlinePreloadEligible` + `clearOfflinePref`; `ui/e2e/offline.spec.ts` +1 case.
- **Kept, not changed:** `settings.offline` (the Header offline chip still uses it) + `offline.preloadWarning` (the lobby
dict-preload notice). **`SettingsHub.svelte` needed no change** — its tab-disable already reads `offlineMode.active`,
which is `netState.offline` (the O2 shim); the plan's "tab-disable from `netState`" is satisfied without a mass-rename.
- Acceptance: no toggle in Settings; a seeded stale `offlinePref` boots **online** (nobody stuck) and is cleared; offline
chrome/tab-gating still driven from `netState`. **Verified.**
- Targeted tests: `e2e/offline.spec.ts` new case (a stale `scrabble.offlineMode` → boots online, key cleared, Settings has
no `Offline` toggle even in the eligible context); `offline.test.ts` (`clearOfflinePref`). **Full suite 120/120 on
chromium + webkit; svelte-check 0/0; vitest 616.**
- **O4 — Two-tier version gate.** - **O4 — Two-tier version gate. — ✅ DONE (2026-07-13).**
- Files — Modify: `gateway/internal/config/config.go` (+`RecommendedClientVersion`, validate ≥ `MinClientVersion`), - Files (as built) — Backend: `gateway/internal/config/config.go` (+`RecommendedClientVersion` from
`gateway/internal/connectsrv/server.go` (soft band ⇒ `X-Update-Recommended: 1` response header; hard band `GATEWAY_RECOMMENDED_CLIENT_VERSION`; validate parseable + `≥ MinClientVersion`, an empty min imposing no lower bound),
unchanged), `gateway/cmd/gateway/main.go` (dep). Client: `ui/src/lib/transport.ts` (read the header ⇒ `gateway/internal/connectsrv/server.go` (soft-tier fields `recClient`/`recOn`; `clientUpdateRecommended` = `min ≤ v <
`versionRecommended`), `ui/src/lib/update.svelte.ts` (nudge state), rework `ui/src/components/UpdateOverlay.svelte` recommended`, fail-open; Execute sets `X-Update-Recommended: 1` on any served response in the band via a named-return
from terminal → the `offlineVersionLocked` notice ("Update"/"Play offline"), new `ui/src/components/UpdateNudge.svelte`. `defer`; hard band + Subscribe unchanged), `gateway/cmd/gateway/main.go` (dep). Client: `ui/src/lib/transport.ts` (a
- Interfaces — Produces: `GATEWAY_RECOMMENDED_CLIENT_VERSION`, the `X-Update-Recommended` header, `updateRecommended` store. Connect-ES **interceptor** reads `x-update-recommended` on unary responses ⇒ `reportUpdateRecommended`),
- Acceptance: `v<min` → notice → "Play offline" plays local; `min≤v<recommended` → dismissible banner, online `ui/src/lib/update.svelte.ts` (dropped the terminal `updateRequired` latch — `reportUpdateRequired` now only emits
continues; `v≥recommended`/both empty → nothing; fail-open on a garbled header. `versionRejected`; new `updateRecommended` store + `latchUpdateNudge`/`dismissUpdateNudge`/`reportUpdateRecommended` +
- Targeted tests: Go `config_test`/`server_test` (three bands + ordering + fail-open); `e2e/update.spec.ts` shared `openUpdate`), `ui/src/lib/netstate.svelte.ts` (`registerNetNudge`; the `setNudge` effect fires it),
(notice → play offline; nudge banner). `ui/src/lib/app.svelte.ts` (`registerNetNudge(latchUpdateNudge)`), `ui/src/lib/gateway.ts` (mock `__update.recommend`),
`ui/src/lib/i18n/{en,ru}.ts` (`update.playOffline` + `update.available`). Rework: `ui/src/components/UpdateOverlay.svelte`
(terminal → dismissable notice reading `netState.versionLocked`, "Update" + "Play offline"). New:
`ui/src/components/UpdateNudge.svelte` (the soft banner, rendered **bottom of the lobby, above the tab bar** — shown only
while `online && updateRecommended.active && !dismissed`).
- Owner-chosen (interview): the hard notice keeps the **full-screen** form (now dismissable); the soft nudge is a
**bottom-of-lobby** banner (not in-game); the header rides **Execute only** (Subscribe untouched); read via a transport
interceptor; nudge dismiss is **per-session**; the nudge latches via `setNudge → registerNetNudge` (machine is the source,
"only from online"). `update.body` copy kept as-is (owner).
- Acceptance: `v<min` → notice → "Play offline" → offline lobby; `min≤v<recommended` → dismissible banner, online continues;
`v≥recommended`/both empty → nothing; fail-open on an absent/garbled header. **Verified.**
- Targeted tests: Go `config_test` (`TestLoadRecommendedClientVersion`) + `server_test` (`TestExecuteUpdateRecommended` — 3
bands + at-min/at-recommended boundaries + fail-open + recommended-alone + dormant); `e2e/update.spec.ts` rewritten (notice
+ both actions; Update reloads; Play offline → offline lobby; the soft nudge shows in the lobby + dismisses). **Full gateway
go-test/build/vet/gofmt clean; e2e 122/122 chromium + webkit; svelte-check 0/0; vitest 616.**
- **O5 — Unified lobby.** - **O5 — Unified lobby. — ✅ DONE (2026-07-13).**
- Files — Modify: `ui/src/screens/Lobby.svelte` (merge `localSource.list()` + `gateway.gamesList()`; offline ⇒ - Files (as built) — `ui/src/screens/Lobby.svelte` (`load()` merges `localSource.list()` + `gateway.gamesList()`;
local active + cached server greyed; drop the mode-exclusive `loadSeq` branch), `ui/src/lib/lobbysort.ts` online ⇒ `[...local, ...server]`; offline ⇒ `[...localFresh, ...cachedServer]` greyed; the mode-exclusive branch is
(sort the merged set), a self-identity helper (recognise `session.userId` **and** `localGuestId()`). gone; `grey(g) = offline && !isLocalGameId` drives a `.rowwrap.greyed` dim + an un-openable `openGame` that toasts
- Acceptance: online ⇒ local + server both listed; offline ⇒ local active + server greyed-from-cache `net.offline`; **invitations hidden offline** — owner addition — via `{#if invitations.length && !offline}`), `ui/src/lib/lobbysort.ts`
(un-openable); a reconciled guest's local games stay visible online (**closes G-step-0**); TG/VK server-only. + `ui/src/lib/result.ts` (`myId: string` → `myIds: readonly string[]` self-set across `isMyTurn`/`scoreStanding`/
- Targeted tests: `e2e` unified lobby (greyed offline; both online); `lobbysort.test.ts` extended. `gamePhase`/`groupGames`/`resultBadge`; game seat-matching uses `myIds.includes(...)`), `ui/src/lib/lobbycache.ts`
(dropped the `offline` snapshot key — **one unified snapshot**; `getLobby()` no arg; offline reuses its server games),
`ui/src/screens/NewGame.svelte` (`getLobby()` + filter to server games — device-local games do not count toward the
server per-kind limit), `ui/src/lib/localguest`/`telegram`/`vk` imports in the lobby.
- **Identity split:** game functions take the self-set `myIds = [session.userId, localGuestId()]` (a reconciled
guest's local games seat you under `localGuestId`, server games under `userId`, ids never collide); **invitations
keep `session.userId`** (a server concept). **TG/VK server-only:** the local merge is gated on
`insideTelegram()`/`insideVK()` (the shared-origin local store must not leak into a mini-app).
- Acceptance: online ⇒ local + server both listed; offline ⇒ local active + server greyed-from-cache (un-openable);
a reconciled guest's local games stay visible online (**closes G-step-0**); invitations hidden offline; TG/VK
server-only. **Verified.**
- Targeted tests: `lobbysort.test.ts` + `result.test.ts` extended (two-id "self" recognition), `lobbycache.test.ts`
reworked (unified snapshot, mode-aware tests removed); `e2e/native.spec.ts` proves the local vs_ai game stays listed
online (G-step-0); `e2e/offline.spec.ts` + `hotseat.spec.ts` + `update.spec.ts` updated for greyed-from-cache server
games offline. **Full suite 122/122 on chromium + webkit; svelte-check 0/0; vitest 617.**
- **O6 — Create flows.** - **O6 — Create flows. — ✅ DONE (2026-07-13).**
- Files — Modify: `ui/src/screens/NewGame.svelte` (with-friends online/offline segmented control, online - Files (as built) — `ui/src/screens/NewGame.svelte`: **with-friends** now carries a `friendsMode` segmented control
disabled when `netState.offline`; vs_ai already branches on the flag — verify; the dict-availability guard). (`new.playRemote` = online invite / `new.playLocal` = offline hotseat); the online segment is `disabled` when
- Acceptance: with-friends online = invite / offline = hotseat; no network ⇒ online disabled, offline `offlineMode.active` and an `$effect` forces `friendsMode='offline'` there (a `new.needsNetwork` note explains it);
preselected, create works; vs_ai offline→local / online→server unchanged; create disabled with a reason when the friends markup is restructured from the mode-exclusive `{:else if offlineMode.active}` chain into
the variant's dawg is unavailable offline. `{:else} <segment> {#if friendsMode==='offline'} hotseat {:else if !friends} noFriends {:else} invite`. **vs_ai
- Targeted tests: `e2e` with-friends toggle (online disabled offline); vs_ai local-vs-server by state. unchanged** (verified: `find()` still offline→`localSource.create`, online→`lobbyEnqueue`). **Dict guard** (owner-chosen
**async getDawg**): a `dawgReady`/`dictBlocked` pair — an `$effect` awaits `getDawg(guardVariant, version)` while
offline (`guardVariant` = the quick pick or the hotseat pick) and disables create + shows a `new.dictUnavailable` note
only on a **confirmed** miss (`dawgReady === false`, never while `null`/checking, so a still-warming dawg does not
false-block). `ui/src/lib/i18n/{en,ru}.ts` (`new.{playRemote,playLocal,needsNetwork,dictUnavailable}`).
- Owner-chosen (interview): the dict guard uses the **async getDawg** precheck (no reliable sync signal; native bundles
every variant so it mostly guards web offline); **guest gating unchanged** — an online guest still sees vs_ai only (no
new online-guest hotseat); a guest offline keeps its hotseat.
- Acceptance: with-friends online = invite / offline = hotseat; no network ⇒ online disabled, offline preselected,
create works; vs_ai offline→local / online→server unchanged; create disabled with a reason on an unavailable offline
dawg. **Verified.**
- Targeted tests: `e2e/hotseat.spec.ts` asserts the offline segment (online disabled, hotseat forced); `offline.spec.ts`
+ `native.spec.ts` exercise vs_ai-local + hotseat create through the new segment. **Full suite 122/122 on chromium +
webkit; svelte-check 0/0; vitest 617.**
- **O7 — Docs + native e2e.** - **O7 — Docs + native e2e. — ✅ DONE (2026-07-13).**
- Files — Modify: `docs/ARCHITECTURE.md` (§2 gate → two tiers + graceful degrade; §3 the net-state model), - Files (as built) — `docs/ARCHITECTURE.md`: **§2** rewrote the connectivity signal into the **net-state machine**
`docs/FUNCTIONAL.md` (+`_ru`), `docs/TESTING.md`, `deploy/README.md` (the two version vars), `ui/e2e/native.spec.ts`. (four states, implicit offline, hysteresis, self-heal, TG/VK-exempt), the version gate into the **two tiers** (hard →
- Acceptance: the F-baked docs describe both tiers + the implicit model + the unified lobby; `deploy/README` `offlineVersionLocked` + dismissable notice, *not* terminal; soft → `X-Update-Recommended` nudge), and the gate×offline
lists `GATEWAY_RECOMMENDED_CLIENT_VERSION`. rule; **§13** (PWA/offline) rewrote the offline model from the deliberate toggle/dialog to implicit detection + the
- Targeted tests: n/a (docs); the native e2e reflects the new boot/offline. **unified lobby** (greyed cached server games) + the with-friends **segment** + the dict guard. `docs/FUNCTIONAL.md`
(+`_ru` mirror): rewrote *Offline mode* (implicit, unified lobby, segment) and added a *Staying up to date* /
*«Актуальная версия»* story (Update/Play-offline notice + the soft nudge). `docs/TESTING.md`: the `netstate.test.ts`
reducer layer, the `__net`-driven offline spec, the two-tier `update.spec`, the soft-tier Go server/config tests, and
the native G-step-0 assertion. `deploy/README.md`: a **`GATEWAY_RECOMMENDED_CLIENT_VERSION`** row (soft tier;
validated ≥ `MIN`). `ui/e2e/native.spec.ts` already reflects the new boot/offline (done in O5).
- Acceptance: the docs describe both tiers + the implicit model + the unified lobby; `deploy/README` lists
`GATEWAY_RECOMMENDED_CLIENT_VERSION`. **Verified** (a full-doc sweep leaves no stale deliberate-offline / terminal /
toggle / dialog reference). Code unchanged from O6 (docs only): gateway Go green, svelte-check 0/0, vitest 617,
e2e 122/122 chromium + webkit.
**Offline-model redesign COMPLETE (O1O7).** G-step-0 is resolved — the redesign is contour-safe (both version vars empty
⇒ dormant; the wire add is additive). Next is the normal G chain: PR → `development` → contour verify → `master` → tag →
dispatch `android-build`.
--- ---
+14 -37
View File
@@ -1,41 +1,18 @@
# Erudit — site icons + Open Graph card # Icons
The favicon set and the `og:image` link-preview card for the public landing The whole shipped icon set is sliced from **one master** — see
(`ui/landing.html`) and the SPA shell (`ui/index.html`). Same design language as the [`brand/`](brand/) for the reference generator, the pinned font and the committed
[VK loading-screen logo](../vk/README.md): the wooden Erudit «Э» tile (score `8`), outputs, and [`../../docs/ICON_BRANDBOOK.md`](../../docs/ICON_BRANDBOOK.md) for how the
with the wordmark on the app's dark board green (`ui/src/app.css` tokens). mark is constructed. The per-platform target map (which file goes where) is in
[`../../docs/ICONS.md`](../../docs/ICONS.md).
| Output (committed to `ui/public/`) | Purpose |
|------|---------|
| `favicon.svg` | Vector favicon, transparent; tile + «Э» only (the score is illegible below ~32 px). |
| `favicon.ico` | 32×32 PNG-in-ICO fallback (also answers the browsers' blind `/favicon.ico` probe). |
| `apple-touch-icon.png` | 180×180 opaque full-bleed tile; iOS masks its own corners. |
| `og-image.png` | 1200×630 card: tile + «Эрудит / Скрэббл — игра в слова». Referenced absolutely as `https://erudit-game.ru/og-image.png`. |
## How it works
`build/extract.js` extracts the needed glyph outlines (tile glyphs + every wordmark
character) from LiberationSans (Arial-metric, the game's font stack) with their
advance widths into `build/glyphs.json` (committed). `build/generate.js` composes
plain SVG from those outlines — no font is needed at generation time — writes
`favicon.svg` and rasterises the PNG/ICO outputs by screenshotting the SVGs with the
`ui` package's Playwright chromium (`@playwright/test`); the `.ico` container is
assembled in-script (a single PNG entry). Raster bytes therefore depend on the
installed chromium version; the SVG sources are deterministic.
## Regenerate
Requirements: Node ≥ 18, `ui` installed (`pnpm install`, provides Playwright).
`extract.js` additionally needs `opentype.js` (`npm i opentype.js`); **`generate.js`
needs no extra packages**.
```sh ```sh
cd assets/icons cd brand
npm i opentype.js
# 1. (optional) re-extract the glyphs — only if the font or the wordmark changes: node build-set.mjs # web (ui/public) + Capacitor layers (ui/assets) + vk/ tg/ store/
# default font: /usr/share/fonts/truetype/liberation/LiberationSans-Regular.ttf node build-android-res.mjs # Android launcher resources (all densities + monochrome)
node build/extract.js [/path/to/font.ttf] # -> build/glyphs.json
# 2. regenerate everything in ui/public/:
node build/generate.js
``` ```
> The earlier `build/` pipeline (LiberationSans glyph outlines → favicon/apple-touch/OG)
> was replaced by `brand/` when the icon was rebranded onto the Spectral «Э» master.
> The separate VK loading-screen animation lives in [`../vk/`](../vk/) and is unrelated.
+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

-78
View File
@@ -1,78 +0,0 @@
'use strict';
// Extract the glyph outlines the site icons and the og-image wordmark need from a
// grotesque font (LiberationSans = Arial-metric, matching the game's system-ui/Arial
// stack) and emit cubic-bezier contours per character, baseline at y=0, y-down,
// plus the advance width so generate.js can lay out words without the font.
// Same outline conversion as ../../vk/build/extract.js, generalised to a char set.
const opentype = require('opentype.js');
const fs = require('fs');
const FONT = process.argv[2] || '/usr/share/fonts/truetype/liberation/LiberationSans-Regular.ttf';
const b = fs.readFileSync(FONT);
const font = opentype.parse(b.buffer.slice(b.byteOffset, b.byteOffset + b.byteLength));
const FS = 1000; // em scale
// The tile glyphs («Э», «8») + every character of the og-image wordmark lines
// («Эрудит», «Скрэббл — игра в слова»). The space carries only an advance.
const CHARS = [...new Set('Э8рудитСкэббл—игра в слова')];
function glyphData(ch) {
const g = font.charToGlyph(ch);
const p = g.getPath(0, 0, FS); // baseline at y=0, y-down
const contours = [];
let cur = null, prev = null;
for (const c of p.commands) {
if (c.type === 'M') {
if (cur) contours.push(cur);
cur = [{ v: [c.x, c.y], i: [c.x, c.y], o: [c.x, c.y] }];
prev = { x: c.x, y: c.y };
} else if (c.type === 'L') {
cur.push({ v: [c.x, c.y], i: [c.x, c.y], o: [c.x, c.y] });
prev = { x: c.x, y: c.y };
} else if (c.type === 'C') {
cur[cur.length - 1].o = [c.x1, c.y1];
cur.push({ v: [c.x, c.y], i: [c.x2, c.y2], o: [c.x, c.y] });
prev = { x: c.x, y: c.y };
} else if (c.type === 'Q') {
const c1 = [prev.x + 2 / 3 * (c.x1 - prev.x), prev.y + 2 / 3 * (c.y1 - prev.y)];
const c2 = [c.x + 2 / 3 * (c.x1 - c.x), c.y + 2 / 3 * (c.y1 - c.y)];
cur[cur.length - 1].o = c1;
cur.push({ v: [c.x, c.y], i: c2, o: [c.x, c.y] });
prev = { x: c.x, y: c.y };
} else if (c.type === 'Z') {
if (cur && cur.length > 1) {
const last = cur[cur.length - 1], first = cur[0];
if (Math.hypot(last.v[0] - first.v[0], last.v[1] - first.v[1]) < 1e-3) {
first.i = last.i; // fold the duplicate closing point into the first
cur.pop();
}
}
if (cur) { contours.push(cur); cur = null; }
}
}
if (cur) contours.push(cur);
let minx = Infinity, miny = Infinity, maxx = -Infinity, maxy = -Infinity;
const out = contours.map(ct => {
const v = [], i = [], o = [];
ct.forEach(pt => {
v.push(pt.v);
i.push([pt.i[0] - pt.v[0], pt.i[1] - pt.v[1]]);
o.push([pt.o[0] - pt.v[0], pt.o[1] - pt.v[1]]);
minx = Math.min(minx, pt.v[0]); maxx = Math.max(maxx, pt.v[0]);
miny = Math.min(miny, pt.v[1]); maxy = Math.max(maxy, pt.v[1]);
});
return { i, o, v, c: true };
});
const bbox = out.length
? { x: minx, y: miny, w: maxx - minx, h: maxy - miny }
: { x: 0, y: 0, w: 0, h: 0 }; // the space has no outline
return { adv: g.advanceWidth * (FS / font.unitsPerEm), bbox, contours: out };
}
const glyphs = {};
for (const ch of CHARS) glyphs[ch] = glyphData(ch);
const out = __dirname + '/glyphs.json';
fs.writeFileSync(out, JSON.stringify({ em: FS, glyphs }));
console.log('wrote', out, fs.statSync(out).size, 'bytes;', CHARS.length, 'glyphs:', CHARS.join(''));
-128
View File
@@ -1,128 +0,0 @@
'use strict';
// Site icons + the Open Graph card for the public landing, drawn from the same
// design as ../../vk (the wooden Erudit tile: face «Э», score «8») and the app's
// board palette (ui/src/app.css). Everything is composed as SVG from the committed
// glyph outlines (build/extract.js -> glyphs.json), so no font is needed at build
// time; the PNG/ICO rasters are screenshots taken with the ui package's Playwright
// chromium (@playwright/test re-exports the browser API). Outputs go straight to
// ui/public/:
// favicon.svg 96 viewBox, transparent, tile + «Э» (the score is illegible small)
// favicon.ico 32x32 PNG-in-ICO render of the same
// apple-touch-icon.png 180x180 opaque full-bleed tile (iOS masks its own corners)
// og-image.png 1200x630 card: tile + wordmark on the board green
const fs = require('fs');
const path = require('path');
const G = JSON.parse(fs.readFileSync(path.join(__dirname, 'glyphs.json'), 'utf8'));
const UI = path.resolve(__dirname, '../../../ui');
const OUT = path.join(UI, 'public');
// ---- palette (vk loader tile + app.css board tokens) ------------------------
const FACE = '#D9B978', BORDER = '#B49559', GLYPH = '#1A1A1A';
const BOARD_DARK = '#2a3330', TEXT = '#e7ece8', TEXT_MUTED = '#cdd6cf';
// ---- glyph outlines -> SVG path data ----------------------------------------
const r2 = n => Math.round(n * 100) / 100;
// pathD renders one glyph's contours scaled by sc and translated by (tx, ty).
function pathD(g, sc, tx, ty) {
const pt = (v, d) => `${r2(v[0] * sc + tx + d[0] * sc)} ${r2(v[1] * sc + ty + d[1] * sc)}`;
const Z = [0, 0];
return g.contours.map(ct => {
const n = ct.v.length;
let d = `M${pt(ct.v[0], Z)}`;
for (let k = 1; k <= n; k++) {
const a = k - 1, b = k % n;
d += `C${pt(ct.v[a], ct.o[a])} ${pt(ct.v[b], ct.i[b])} ${pt(ct.v[b], Z)}`;
}
return d + 'Z';
}).join('');
}
// glyphAt centres a glyph's bbox at (cx, cy) with the given pixel cap height, the
// same placement rule as the vk loader's glyph(). stroke fattens it slightly.
function glyphAt(ch, capPx, cx, cy, stroke, colour) {
const g = G.glyphs[ch], sc = capPx / g.bbox.h;
const d = pathD(g, sc, cx - (g.bbox.x + g.bbox.w / 2) * sc, cy - (g.bbox.y + g.bbox.h / 2) * sc);
return `<path d="${d}" fill="${colour}" stroke="${colour}" stroke-width="${r2(stroke)}"/>`;
}
// textLine lays out a string on a baseline from the per-glyph advances; capPx sets
// the capital height (measured on «Э»). Returns the combined path + the width.
function textLine(str, capPx, x, y, colour) {
const sc = capPx / G.glyphs['Э'].bbox.h;
let d = '', w = 0;
for (const ch of str) {
const g = G.glyphs[ch];
if (g.contours.length) d += pathD(g, sc, x + w, y);
w += g.adv * sc;
}
return { svg: `<path d="${d}" fill="${colour}"/>`, width: w };
}
// tile draws the rounded wooden tile centred at (cx, cy): size px wide/high, with
// the vk loader's corner (6/52) and rim proportions, «Э» and optionally the «8».
function tile(cx, cy, size, withScore) {
const h = size / 2, rx = size * (6 / 52), rim = size * (1.8 / 52);
let s = `<rect x="${r2(cx - h + rim / 2)}" y="${r2(cy - h + rim / 2)}" width="${r2(size - rim)}" height="${r2(size - rim)}" rx="${r2(rx)}" fill="${FACE}" stroke="${BORDER}" stroke-width="${r2(rim)}"/>`;
s += glyphAt('Э', size * (32 / 52), cx, cy - size * (1 / 52), size * (1 / 52), GLYPH);
if (withScore) s += glyphAt('8', size * (8.5 / 52), cx + size * (19.5 / 52), cy + size * (18.5 / 52), size * (0.5 / 52), GLYPH);
return s;
}
const svg = (w, h, body) => `<svg xmlns="http://www.w3.org/2000/svg" width="${w}" height="${h}" viewBox="0 0 ${w} ${h}">${body}</svg>`;
// ---- favicon.svg (the committed vector master) -------------------------------
const favicon = svg(96, 96, tile(48, 48, 88, false)) + '\n';
// ---- apple-touch-icon: opaque full bleed, iOS applies its own corner mask ----
const appleTouch = svg(180, 180,
`<rect width="180" height="180" fill="${FACE}"/>` +
`<rect x="8" y="8" width="164" height="164" rx="18" fill="none" stroke="${BORDER}" stroke-width="4"/>` +
glyphAt('Э', 100, 90, 88, 3, GLYPH) +
glyphAt('8', 26, 146, 142, 1.5, GLYPH));
// ---- og-image: tile + wordmark, centred as one group on the board green ------
function ogImage() {
const W = 1200, H = 630, tileSize = 340, gap = 84;
const l1 = textLine('Эрудит', 112, 0, 0, TEXT);
const l2 = textLine('Скрэббл — игра в слова', 44, 0, 0, TEXT_MUTED);
const textW = Math.max(l1.width, l2.width);
const left = (W - (tileSize + gap + textW)) / 2;
const tx = left + tileSize + gap;
// Two baselines around the vertical centre; the tile centre sits between them.
const b1 = 295, b2 = 408;
const body =
`<rect width="${W}" height="${H}" fill="${BOARD_DARK}"/>` +
tile(left + tileSize / 2, H / 2, tileSize, true) +
textLine('Эрудит', 112, tx, b1, TEXT).svg +
textLine('Скрэббл — игра в слова', 44, tx, b2, TEXT_MUTED).svg;
console.log(`og-image: text ${Math.round(textW)}px wide, group left ${Math.round(left)}px`);
return svg(W, H, body);
}
// ---- rasterisation (Playwright chromium from ui/node_modules) ----------------
async function shoot(page, markup, w, h, transparent) {
await page.setViewportSize({ width: w, height: h });
await page.setContent(`<body style="margin:0">${markup}</body>`);
return page.screenshot({ omitBackground: transparent });
}
// icoFromPNG wraps one PNG as a single-entry .ico (ICONDIR + ICONDIRENTRY + PNG).
function icoFromPNG(png, sizePx) {
const h = Buffer.alloc(22);
h.writeUInt16LE(0, 0); h.writeUInt16LE(1, 2); h.writeUInt16LE(1, 4); // icon, 1 image
h.writeUInt8(sizePx, 6); h.writeUInt8(sizePx, 7); // 32x32
h.writeUInt16LE(1, 10); h.writeUInt16LE(32, 12); // planes, 32bpp
h.writeUInt32LE(png.length, 14); h.writeUInt32LE(22, 18); // size, offset
return Buffer.concat([h, png]);
}
(async () => {
fs.writeFileSync(path.join(OUT, 'favicon.svg'), favicon);
const { chromium } = require(path.join(UI, 'node_modules', '@playwright/test'));
const browser = await chromium.launch();
const page = await browser.newPage();
const fav32 = await shoot(page, svg(32, 32, tile(16, 16, 29.33, false)), 32, 32, true);
fs.writeFileSync(path.join(OUT, 'favicon.ico'), icoFromPNG(fav32, 32));
fs.writeFileSync(path.join(OUT, 'apple-touch-icon.png'), await shoot(page, appleTouch, 180, 180, false));
fs.writeFileSync(path.join(OUT, 'og-image.png'), await shoot(page, ogImage(), 1200, 630, false));
await browser.close();
for (const f of ['favicon.svg', 'favicon.ico', 'apple-touch-icon.png', 'og-image.png']) {
console.log('wrote', path.join(OUT, f), fs.statSync(path.join(OUT, f)).size, 'bytes');
}
})();
File diff suppressed because one or more lines are too long
@@ -21,7 +21,17 @@
<div><button type="submit">Add</button></div> <div><button type="submit">Add</button></div>
</form> </form>
</section> </section>
<section class="panel"><h2>Rewarded ads (watch for chips)</h2>
<p class="note">The chips a player earns per rewarded-video view (VK only), plus the per-day and per-hour caps that bound free chips — <strong>0 payout turns rewarded off</strong>. This is the shared reward config, not a product.</p>
<form class="form col" method="post" action="/_gm/catalog/reward">
<label>Chips per view <input type="number" name="reward_payout" min="0" value="{{.RewardPayout}}"></label>
<label>Daily cap <input type="number" name="reward_daily" min="0" value="{{.RewardDailyCap}}"></label>
<label>Hourly cap <input type="number" name="reward_hourly" min="0" value="{{.RewardHourlyCap}}"></label>
<div><button type="submit">Save</button></div>
</form>
</section>
<section class="panel"><h2>Products</h2> <section class="panel"><h2>Products</h2>
<p class="note">Showing {{if .ShowAll}}<strong>all</strong> products (active + archived) — <a href="/_gm/catalog">active only</a>{{else}}<strong>active</strong> products — <a href="/_gm/catalog?all=1">show all (incl. archived)</a>{{end}}.</p>
<table class="list"> <table class="list">
<thead><tr><th>Title</th><th>Status</th><th>Atoms</th><th>Prices</th><th></th></tr></thead> <thead><tr><th>Title</th><th>Status</th><th>Atoms</th><th>Prices</th><th></th></tr></thead>
<tbody> <tbody>
+9
View File
@@ -677,6 +677,15 @@ type FeedbackDetailView struct {
// CatalogView is the product-catalog list page. // CatalogView is the product-catalog list page.
type CatalogView struct { type CatalogView struct {
Products []ProductRow Products []ProductRow
// ShowAll reports whether archived products are listed alongside active ones (the active/all
// toggle); it drives the toggle link and the list heading.
ShowAll bool
// RewardPayout / RewardDailyCap / RewardHourlyCap are the rewarded-video config (chips earned per
// view and the per-day / per-hour anti-abuse caps), shown in and edited from the page's
// rewarded-ads form. A 0 payout means rewarded is inert.
RewardPayout int
RewardDailyCap int
RewardHourlyCap int
} }
// ProductRow is one product in the catalog list: its composition, prices, the archived flag // ProductRow is one product in the catalog list: its composition, prices, the archived flag
@@ -16,7 +16,7 @@ import (
// productByTitle finds a catalog product by its (unique in the test) title. // productByTitle finds a catalog product by its (unique in the test) title.
func productByTitle(t *testing.T, pay *payments.Service, title string) payments.AdminProduct { func productByTitle(t *testing.T, pay *payments.Service, title string) payments.AdminProduct {
t.Helper() t.Helper()
all, err := pay.AdminCatalog(context.Background()) all, err := pay.AdminCatalog(context.Background(), true)
if err != nil { if err != nil {
t.Fatalf("admin catalog: %v", err) t.Fatalf("admin catalog: %v", err)
} }
@@ -57,7 +57,7 @@ func TestConsoleCatalogEditor(t *testing.T) {
if code, body := consoleDo(h, http.MethodPost, catalog, "title=cat-bad&chips=100&active=true", origin); code != http.StatusOK || !strings.Contains(body, "Invalid product") { if code, body := consoleDo(h, http.MethodPost, catalog, "title=cat-bad&chips=100&active=true", origin); code != http.StatusOK || !strings.Contains(body, "Invalid product") {
t.Fatalf("bad pack = %d, has 'Invalid product' = %v", code, strings.Contains(body, "Invalid product")) t.Fatalf("bad pack = %d, has 'Invalid product' = %v", code, strings.Contains(body, "Invalid product"))
} }
if _, err := pay.AdminCatalog(ctx); err != nil { if _, err := pay.AdminCatalog(ctx, true); err != nil {
t.Fatalf("catalog: %v", err) t.Fatalf("catalog: %v", err)
} }
@@ -95,3 +95,59 @@ func TestConsoleCatalogEditor(t *testing.T) {
t.Fatalf("delete transacted = %d, has 'Cannot delete' = %v", code, strings.Contains(body, "Cannot delete")) t.Fatalf("delete transacted = %d, has 'Cannot delete' = %v", code, strings.Contains(body, "Cannot delete"))
} }
} }
// TestConsoleCatalogActiveToggle checks the catalog console lists only active products by default and
// includes the archived ones under ?all=1 (the active/all toggle).
func TestConsoleCatalogActiveToggle(t *testing.T) {
srv, _, _ := bannerServer(t)
h := srv.Handler()
const origin = "http://admin.test"
const catalog = "http://admin.test/_gm/catalog"
// An active value and an archived one (created without the active flag).
if code, _ := consoleDo(h, http.MethodPost, catalog, "title=toggle-active&hints=5&price_chip=50&active=true", origin); code != http.StatusOK {
t.Fatal("create active failed")
}
if code, _ := consoleDo(h, http.MethodPost, catalog, "title=toggle-archived&hints=9&price_chip=90", origin); code != http.StatusOK {
t.Fatal("create archived failed")
}
// Default view: active only.
if _, body := consoleDo(h, http.MethodGet, catalog, "", ""); !strings.Contains(body, "toggle-active") || strings.Contains(body, "toggle-archived") {
t.Errorf("default view: active listed=%v, archived listed=%v (want true/false)",
strings.Contains(body, "toggle-active"), strings.Contains(body, "toggle-archived"))
}
// ?all=1: active and archived.
if _, body := consoleDo(h, http.MethodGet, catalog+"?all=1", "", ""); !strings.Contains(body, "toggle-active") || !strings.Contains(body, "toggle-archived") {
t.Errorf("all view: active listed=%v, archived listed=%v (want true/true)",
strings.Contains(body, "toggle-active"), strings.Contains(body, "toggle-archived"))
}
}
// TestConsoleRewardConfig checks the rewarded-ads config form on the catalog page: a POST updates the
// payout and caps, the page pre-fills the current values, and a negative value is refused.
func TestConsoleRewardConfig(t *testing.T) {
srv, _, pay := bannerServer(t)
h := srv.Handler()
const origin = "http://admin.test"
const reward = "http://admin.test/_gm/catalog/reward"
// Set the payout and caps.
if code, body := consoleDo(h, http.MethodPost, reward, "reward_payout=7&reward_daily=40&reward_hourly=9", origin); code != http.StatusOK || !strings.Contains(body, "Saved") {
t.Fatalf("set reward = %d, has 'Saved' = %v", code, strings.Contains(body, "Saved"))
}
if payout, daily, hourly, err := pay.RewardConfig(context.Background()); err != nil || payout != 7 || daily != 40 || hourly != 9 {
t.Fatalf("reward config = %d/%d/%d (err %v), want 7/40/9", payout, daily, hourly, err)
}
// The catalog page renders the form pre-filled with the current payout.
if _, body := consoleDo(h, http.MethodGet, "http://admin.test/_gm/catalog", "", ""); !strings.Contains(body, "reward_payout") || !strings.Contains(body, `value="7"`) {
t.Error("catalog page did not render the reward form with the current payout")
}
// A negative value is refused (the service validates non-negativity).
if code, body := consoleDo(h, http.MethodPost, reward, "reward_payout=-1", origin); code != http.StatusOK || !strings.Contains(body, "non-negative") {
t.Errorf("negative payout = %d, has 'non-negative' = %v", code, strings.Contains(body, "non-negative"))
}
}
+5
View File
@@ -38,6 +38,11 @@ const atomChips = "chips"
// method are omitted (misconfigured or unavailable there). An untrusted context (empty Kind) has // method are omitted (misconfigured or unavailable there). An untrusted context (empty Kind) has
// no method, so only values show; buying is gated server-side regardless. // no method, so only values show; buying is gated server-side regardless.
func projectCatalog(entries []catalogEntry, cxt Context) CatalogView { func projectCatalog(entries []catalogEntry, cxt Context) CatalogView {
// Present products in the canonical listing order shared with the offer and the admin console
// (packs first by rouble price, then values grouped hints → no-ads → combo and by chip price); the
// client renders them as received. Ordered in place — the caller passes a fresh loadCatalog result
// it does not reuse.
sortCatalogEntries(entries)
var out CatalogView var out CatalogView
for _, e := range entries { for _, e := range entries {
isPack := false isPack := false
+7 -17
View File
@@ -1,7 +1,6 @@
package payments package payments
import ( import (
"cmp"
"context" "context"
"errors" "errors"
"fmt" "fmt"
@@ -144,10 +143,12 @@ func validateProduct(in ProductInput, sellable bool) error {
return nil return nil
} }
// AdminCatalog lists every product (active and archived) with its composition, prices and whether it // AdminCatalog lists products with their composition, prices and whether each has been transacted,
// has been transacted, for the admin editor. Read uncached, straight from the catalog tables. // for the admin editor. includeInactive adds the archived products to the active ones (the console's
func (s *Service) AdminCatalog(ctx context.Context) ([]AdminProduct, error) { // active/all toggle); with it false only active products are returned. Read uncached, straight from
return s.store.adminCatalog(ctx) // the catalog tables.
func (s *Service) AdminCatalog(ctx context.Context, includeInactive bool) ([]AdminProduct, error) {
return s.store.adminCatalog(ctx, includeInactive)
} }
// SortAdminCatalog orders an admin catalog list in place the same way the public offer lists products // SortAdminCatalog orders an admin catalog list in place the same way the public offer lists products
@@ -157,18 +158,7 @@ func (s *Service) AdminCatalog(ctx context.Context) ([]AdminProduct, error) {
// products the same as active ones (the admin list shows both). // products the same as active ones (the admin list shows both).
func SortAdminCatalog(products []AdminProduct) { func SortAdminCatalog(products []AdminProduct) {
slices.SortStableFunc(products, func(a, b AdminProduct) int { slices.SortStableFunc(products, func(a, b AdminProduct) int {
if pa, pb := adminIsPack(a), adminIsPack(b); pa != pb { return compareCatalogRank(adminRank(a), adminRank(b))
if pa {
return -1 // packs (sales) before values (chip exchange)
}
return 1
} else if pa {
return cmp.Compare(adminPriceAmount(a, string(SourceDirect), CurrencyRUB), adminPriceAmount(b, string(SourceDirect), CurrencyRUB))
}
if d := cmp.Compare(adminValueGroup(a), adminValueGroup(b)); d != 0 {
return d
}
return cmp.Compare(adminPriceAmount(a, "", CurrencyChip), adminPriceAmount(b, "", CurrencyChip))
}) })
} }
@@ -0,0 +1,61 @@
package payments
import (
"cmp"
"slices"
)
// catalogRank is the canonical listing key for a catalog product: whether it is a chip pack, its
// value subgroup (see [valueGroup]; unused for a pack) and the minor-unit amount its section sorts by
// (a pack's direct rouble price, a value's chip price; math.MaxInt64 when absent, so a misconfigured
// row sorts last rather than leading).
type catalogRank struct {
pack bool
group int
amount int64
}
// compareCatalogRank is the single canonical product order, shared by the storefront ([projectCatalog]),
// the public offer ([projectOfferPricing]) and the admin catalog ([SortAdminCatalog]): chip packs
// first, ascending by rouble price; then chip-priced values grouped hints → no-ads → combo →
// tournament and, within a group, ascending by chip price. A pack's group is unused (all packs share
// one section). Callers use it through [entryRank] / [adminRank], which build the key from each
// product shape, so the subgroup and ordering rules live in exactly one place.
func compareCatalogRank(a, b catalogRank) int {
if a.pack != b.pack {
if a.pack {
return -1 // packs (sales) before values (chip exchange)
}
return 1
}
if !a.pack {
if d := cmp.Compare(a.group, b.group); d != 0 {
return d
}
}
return cmp.Compare(a.amount, b.amount)
}
// entryRank builds the canonical rank of a storefront / offer catalog entry.
func entryRank(e catalogEntry) catalogRank {
if isPackEntry(e) {
return catalogRank{pack: true, amount: offerSortAmount(e, string(SourceDirect), CurrencyRUB)}
}
return catalogRank{group: offerValueGroup(e), amount: offerSortAmount(e, "", CurrencyChip)}
}
// adminRank builds the canonical rank of an admin catalog product.
func adminRank(p AdminProduct) catalogRank {
if adminIsPack(p) {
return catalogRank{pack: true, amount: adminPriceAmount(p, string(SourceDirect), CurrencyRUB)}
}
return catalogRank{group: adminValueGroup(p), amount: adminPriceAmount(p, "", CurrencyChip)}
}
// sortCatalogEntries orders storefront / offer entries in place into the canonical listing order
// ([compareCatalogRank]). Stable, so equal-keyed products keep their incoming (creation) order.
func sortCatalogEntries(entries []catalogEntry) {
slices.SortStableFunc(entries, func(a, b catalogEntry) int {
return compareCatalogRank(entryRank(a), entryRank(b))
})
}
+50
View File
@@ -135,6 +135,56 @@ func TestProjectCatalog_Empty(t *testing.T) {
} }
} }
// TestProjectCatalog_CanonicalOrder checks the storefront lists products in the shared canonical order
// (compareCatalogRank): chip packs first ascending by rouble price, then chip-priced values grouped
// hints → no-ads → combo and ascending by chip price — the same order as the offer and the admin
// console, regardless of catalog creation order.
func TestProjectCatalog_CanonicalOrder(t *testing.T) {
pack := func(title string, rub int64) catalogEntry {
return catalogEntry{
id: uuid.New(),
title: title,
atoms: []atomQty{{atomType: atomChips, quantity: 1}},
prices: []priceRow{{method: "direct", currency: CurrencyRUB, amount: rub}},
}
}
value := func(title string, chips int64, atoms ...string) catalogEntry {
e := catalogEntry{id: uuid.New(), title: title, prices: []priceRow{{method: "", currency: CurrencyChip, amount: chips}}}
for _, a := range atoms {
e.atoms = append(e.atoms, atomQty{atomType: a, quantity: 1})
}
return e
}
// Deliberately shuffled on input; the projection must impose the canonical order.
entries := []catalogEntry{
pack("packDear", 30000),
value("bundle", 500, "hints", "noads_days"),
pack("packCheap", 10000),
value("adsOnly", 150, "noads_days"),
value("hintsBig", 200, "hints"),
value("hintsSmall", 50, "hints"),
}
got := projectCatalog(entries, Context{Kind: SourceDirect})
want := []string{"packCheap", "packDear", "hintsSmall", "hintsBig", "adsOnly", "bundle"}
if len(got.Products) != len(want) {
t.Fatalf("projected %d products, want %d", len(got.Products), len(want))
}
for i, p := range got.Products {
if p.Title != want[i] {
t.Fatalf("order[%d] = %q, want %q (full: %v)", i, p.Title, want[i], productTitles(got.Products))
}
}
}
// productTitles extracts the projected product titles for a failure message.
func productTitles(products []CatalogProduct) []string {
out := make([]string, len(products))
for i, p := range products {
out[i] = p.Title
}
return out
}
// TestSortAdminCatalog checks the admin list is ordered like the public offer: chip packs first, // TestSortAdminCatalog checks the admin list is ordered like the public offer: chip packs first,
// ascending by rouble price; then values, grouped (hints only -> no-ads only -> no-ads + hints) and // ascending by rouble price; then values, grouped (hints only -> no-ads only -> no-ads + hints) and
// ascending by chip price within a group. Stable and applied to active + archived alike. // ascending by chip price within a group. Stable and applied to active + archived alike.
+6 -14
View File
@@ -1,7 +1,6 @@
package payments package payments
import ( import (
"cmp"
"context" "context"
"fmt" "fmt"
"math" "math"
@@ -60,8 +59,13 @@ func (s *Service) buildOfferPricing(ctx context.Context) (string, error) {
// omitted. Amounts are rendered through [Money] so no floating point ever reaches the page (roubles // omitted. Amounts are rendered through [Money] so no floating point ever reaches the page (roubles
// show kopecks as "200.00", whole-unit rails as integers); a missing rail price shows an em dash. // show kopecks as "200.00", whole-unit rails as integers); a missing rail price shows an em dash.
func projectOfferPricing(entries []catalogEntry) string { func projectOfferPricing(entries []catalogEntry) string {
// Order the whole catalog once by the shared canonical rank, then split it into the two offer
// tables (packs first, then the chip-exchange values); each keeps that order. Sort a copy so the
// caller's slice is left untouched.
sorted := slices.Clone(entries)
sortCatalogEntries(sorted)
var packs, values []catalogEntry var packs, values []catalogEntry
for _, e := range entries { for _, e := range sorted {
if isPackEntry(e) { if isPackEntry(e) {
packs = append(packs, e) packs = append(packs, e)
} else { } else {
@@ -69,18 +73,6 @@ func projectOfferPricing(entries []catalogEntry) string {
} }
} }
// Packs: ascending by the rouble price (the offer's base currency); a pack with no rouble price
// sorts last. Values: by group, then ascending chip price. Stable, so the catalog order breaks ties.
slices.SortStableFunc(packs, func(a, b catalogEntry) int {
return cmp.Compare(offerSortAmount(a, string(SourceDirect), CurrencyRUB), offerSortAmount(b, string(SourceDirect), CurrencyRUB))
})
slices.SortStableFunc(values, func(a, b catalogEntry) int {
if d := cmp.Compare(offerValueGroup(a), offerValueGroup(b)); d != 0 {
return d
}
return cmp.Compare(offerSortAmount(a, "", CurrencyChip), offerSortAmount(b, "", CurrencyChip))
})
var b strings.Builder var b strings.Builder
if len(packs) > 0 { if len(packs) > 0 {
b.WriteString("Приобретение внутриигровой валюты «Фишка»:\n\n") b.WriteString("Приобретение внутриигровой валюты «Фишка»:\n\n")
@@ -157,6 +157,22 @@ func (s *Service) RewardPayout(ctx context.Context, cxt Context, present []Sourc
return payout, err return payout, err
} }
// RewardConfig reads the rewarded-video config for the admin editor: the payout (chips per view) and
// the per-day and per-hour caps.
func (s *Service) RewardConfig(ctx context.Context) (payout, dailyCap, hourlyCap int, err error) {
return s.store.rewardConfig(ctx)
}
// SetRewardConfig updates the rewarded-video config (the payout and the two caps). All three must be
// non-negative; a 0 payout leaves rewarded inert. It is not a catalog product, so it does not mark the
// offer stale.
func (s *Service) SetRewardConfig(ctx context.Context, payout, dailyCap, hourlyCap int) error {
if payout < 0 || dailyCap < 0 || hourlyCap < 0 {
return errors.New("payments: reward payout and caps must be non-negative")
}
return s.store.setRewardConfig(ctx, payout, dailyCap, hourlyCap)
}
// CreditReward credits a rewarded-video view's chips to the VK segment, client-attested (VK Mini App // CreditReward credits a rewarded-video view's chips to the VK segment, client-attested (VK Mini App
// ads expose no server verify — the client's watch result is trusted for an honest user; a forger who // ads expose no server verify — the client's watch result is trusted for an honest user; a forger who
// skips the ad and calls the endpoint is bounded by the config daily cap). It is idempotent on the // skips the ad and calls the endpoint is bounded by the config daily cap). It is idempotent on the
@@ -19,11 +19,17 @@ import (
// row references — it must be archived instead, to keep the append-only ledger resolvable. // row references — it must be archived instead, to keep the append-only ledger resolvable.
var ErrProductTransacted = errors.New("payments: product has transactions; archive instead of delete") var ErrProductTransacted = errors.New("payments: product has transactions; archive instead of delete")
// adminCatalog lists every product (active and archived) with its atoms, prices and transacted flag. // adminCatalog lists products with their atoms, prices and transacted flag. includeInactive adds the
func (s *Store) adminCatalog(ctx context.Context) ([]AdminProduct, error) { // archived products to the active ones; with it false only active products are returned.
func (s *Store) adminCatalog(ctx context.Context, includeInactive bool) ([]AdminProduct, error) {
where := table.Product.Active.IS_TRUE()
if includeInactive {
where = postgres.Bool(true)
}
var prods []model.Product var prods []model.Product
if err := postgres.SELECT(table.Product.AllColumns). if err := postgres.SELECT(table.Product.AllColumns).
FROM(table.Product). FROM(table.Product).
WHERE(where).
ORDER_BY(table.Product.CreatedAt.ASC()). ORDER_BY(table.Product.CreatedAt.ASC()).
QueryContext(ctx, s.db, &prods); err != nil { QueryContext(ctx, s.db, &prods); err != nil {
return nil, fmt.Errorf("payments: admin list products: %w", err) return nil, fmt.Errorf("payments: admin list products: %w", err)
+12
View File
@@ -413,6 +413,18 @@ func (s *Store) rewardConfig(ctx context.Context) (payout, dailyCap, hourlyCap i
return int(cfg.RewardedPayoutChips), int(cfg.RewardDailyCap), int(cfg.RewardHourlyCap), nil return int(cfg.RewardedPayoutChips), int(cfg.RewardDailyCap), int(cfg.RewardHourlyCap), nil
} }
// setRewardConfig updates the rewarded payout and the per-day and per-hour caps on the singleton
// config row (no WHERE — the table holds exactly one row). Non-negativity is validated by the caller
// and also enforced by the config CHECK constraints.
func (s *Store) setRewardConfig(ctx context.Context, payout, dailyCap, hourlyCap int) error {
if _, err := s.db.ExecContext(ctx,
`UPDATE payments.config SET rewarded_payout_chips=$1, reward_daily_cap=$2, reward_hourly_cap=$3`,
payout, dailyCap, hourlyCap); err != nil {
return fmt.Errorf("payments: set reward config: %w", err)
}
return nil
}
// creditReward credits a rewarded-video view's chips to the funded segment, client-attested (VK Mini // creditReward credits a rewarded-video view's chips to the funded segment, client-attested (VK Mini
// App ads expose no server verify). It reads the payout and daily cap from config: a 0 payout // App ads expose no server verify). It reads the payout and daily cap from config: a 0 payout
// (unconfigured) or a reached cap credits nothing. It is idempotent on the client nonce (dedup on the // (unconfigured) or a reached cap credits nothing. It is idempotent on the client nonce (dedup on the
@@ -33,10 +33,12 @@ var priceFields = []struct {
{"price_chip", "", payments.CurrencyChip}, {"price_chip", "", payments.CurrencyChip},
} }
// consoleCatalog lists every product (active and archived) with its composition, prices, transacted // consoleCatalog lists the catalog products with their composition, prices, transacted flag, and the
// flag, and the inline create form. // inline create form. It shows active products by default; ?all=1 also lists the archived ones (the
// active/all toggle).
func (s *Server) consoleCatalog(c *gin.Context) { func (s *Server) consoleCatalog(c *gin.Context) {
products, err := s.payments.AdminCatalog(c.Request.Context()) showAll := c.Query("all") == "1"
products, err := s.payments.AdminCatalog(c.Request.Context(), showAll)
if err != nil { if err != nil {
s.consoleError(c, err) s.consoleError(c, err)
return return
@@ -44,13 +46,32 @@ func (s *Server) consoleCatalog(c *gin.Context) {
// Order the list like the public offer: sales (chip packs) first, then the chip-exchange values, // Order the list like the public offer: sales (chip packs) first, then the chip-exchange values,
// grouped and price-sorted the same way, so the console mirrors what a buyer sees. // grouped and price-sorted the same way, so the console mirrors what a buyer sees.
payments.SortAdminCatalog(products) payments.SortAdminCatalog(products)
var view adminconsole.CatalogView payout, daily, hourly, err := s.payments.RewardConfig(c.Request.Context())
if err != nil {
s.consoleError(c, err)
return
}
view := adminconsole.CatalogView{ShowAll: showAll, RewardPayout: payout, RewardDailyCap: daily, RewardHourlyCap: hourly}
for _, p := range products { for _, p := range products {
view.Products = append(view.Products, catalogRow(p)) view.Products = append(view.Products, catalogRow(p))
} }
s.renderConsole(c, "catalog", "catalog", "Catalog", view) s.renderConsole(c, "catalog", "catalog", "Catalog", view)
} }
// consoleSetReward updates the rewarded-video config (chips per view plus the per-day and per-hour
// caps) from the catalog page's rewarded-ads form. A blank field reads as 0; a negative value is
// refused by the service.
func (s *Server) consoleSetReward(c *gin.Context) {
payout, _ := strconv.Atoi(strings.TrimSpace(c.PostForm("reward_payout")))
daily, _ := strconv.Atoi(strings.TrimSpace(c.PostForm("reward_daily")))
hourly, _ := strconv.Atoi(strings.TrimSpace(c.PostForm("reward_hourly")))
if err := s.payments.SetRewardConfig(c.Request.Context(), payout, daily, hourly); err != nil {
s.renderConsoleMessage(c, "Update failed", err.Error(), catalogBack)
return
}
s.renderConsoleMessage(c, "Saved", "the rewarded-ads config was updated", catalogBack)
}
// catalogRow projects a product into its list row. // catalogRow projects a product into its list row.
func catalogRow(p payments.AdminProduct) adminconsole.ProductRow { func catalogRow(p payments.AdminProduct) adminconsole.ProductRow {
row := adminconsole.ProductRow{ID: p.ID.String(), Title: p.Title, Active: p.Active, Transacted: p.Transacted} row := adminconsole.ProductRow{ID: p.ID.String(), Title: p.Title, Active: p.Active, Transacted: p.Transacted}
@@ -69,7 +90,7 @@ func (s *Server) consoleCatalogDetail(c *gin.Context) {
if !ok { if !ok {
return return
} }
products, err := s.payments.AdminCatalog(c.Request.Context()) products, err := s.payments.AdminCatalog(c.Request.Context(), true) // include archived: the detail form edits any product
if err != nil { if err != nil {
s.consoleError(c, err) s.consoleError(c, err)
return return
@@ -242,7 +263,7 @@ func (s *Server) consoleGrantProduct(c *gin.Context) {
// bundles, including archived ones — chips and tournament products are excluded). // bundles, including archived ones — chips and tournament products are excluded).
func (s *Server) grantForm(ctx context.Context) adminconsole.GrantFormView { func (s *Server) grantForm(ctx context.Context) adminconsole.GrantFormView {
fv := adminconsole.GrantFormView{Present: true, Origins: []string{"direct", "vk", "telegram"}} fv := adminconsole.GrantFormView{Present: true, Origins: []string{"direct", "vk", "telegram"}}
products, err := s.payments.AdminCatalog(ctx) products, err := s.payments.AdminCatalog(ctx, true)
if err != nil { if err != nil {
return fv return fv
} }
@@ -112,6 +112,7 @@ func (s *Server) registerConsole(router *gin.Engine) {
if s.payments != nil { if s.payments != nil {
gm.GET("/catalog", s.consoleCatalog) gm.GET("/catalog", s.consoleCatalog)
gm.POST("/catalog", s.consoleCreateProduct) gm.POST("/catalog", s.consoleCreateProduct)
gm.POST("/catalog/reward", s.consoleSetReward)
gm.GET("/catalog/:id", s.consoleCatalogDetail) gm.GET("/catalog/:id", s.consoleCatalogDetail)
gm.POST("/catalog/:id", s.consoleUpdateProduct) gm.POST("/catalog/:id", s.consoleUpdateProduct)
gm.POST("/catalog/:id/archive", s.consoleArchiveProduct) gm.POST("/catalog/:id/archive", s.consoleArchiveProduct)
+8 -1
View File
@@ -86,7 +86,7 @@ GM_BASICAUTH_HASH= # required; `caddy hash-password` bcrypt
# --- UI build args (baked into the gateway image) --------------------------- # --- UI build args (baked into the gateway image) ---------------------------
VITE_TELEGRAM_BOT_ID= VITE_TELEGRAM_BOT_ID=
VITE_TELEGRAM_LINK= # friend-invite Mini App link (full URL, e.g. https://t.me/<bot>/<app>) VITE_TELEGRAM_LINK= # friend-invite Mini App link (full URL, e.g. https://telegram.me/<bot>/<app>)
VITE_TELEGRAM_GAME_CHANNEL_NAME= # landing "Play in Telegram" link, the bot's game channel VITE_TELEGRAM_GAME_CHANNEL_NAME= # landing "Play in Telegram" link, the bot's game channel
VITE_VK_APP_LINK= # landing "Play on VK" link (full URL, https://vk.com/app<id>) VITE_VK_APP_LINK= # landing "Play on VK" link (full URL, https://vk.com/app<id>)
VITE_VK_APP_ID= # VK ID "Web" app id (client_id) for VK web-login linking; also the gateway's GATEWAY_VK_ID_APP_ID VITE_VK_APP_ID= # VK ID "Web" app id (client_id) for VK web-login linking; also the gateway's GATEWAY_VK_ID_APP_ID
@@ -115,6 +115,13 @@ TELEGRAM_MINIAPP_URL= # required; deploy derives it as PUBLIC_B
TELEGRAM_TEST_ENV=false TELEGRAM_TEST_ENV=false
TELEGRAM_API_BASE_URL= TELEGRAM_API_BASE_URL=
# --- Client-version gate (ARCHITECTURE.md §2) -------------------------------
# The hard minimum + the soft recommended client build. Empty ⇒ dormant. Plain (unprefixed) Gitea
# variables, shared across contours; in the test contour the stamped client version is a commit hash
# (unparseable ⇒ fail-open), so these only enforce on real semver prod builds. Recommended must be ≥ min.
GATEWAY_MIN_CLIENT_VERSION=
GATEWAY_RECOMMENDED_CLIENT_VERSION=
# --- VK Mini App ------------------------------------------------------------ # --- VK Mini App ------------------------------------------------------------
# The VK app's "protected key" (client_secret): the gateway verifies the Mini App # The VK app's "protected key" (client_secret): the gateway verifies the Mini App
# launch-parameter signature in-process under it (a pure offline HMAC, no VK API call). # launch-parameter signature in-process under it (a pure offline HMAC, no VK API call).
+5 -4
View File
@@ -107,8 +107,8 @@ without it Docker's resolver handles `otelcol`, `gateway` and `api.telegram.org`
| `TELEGRAM_TEST_ENV` | _pinned_ | `false` | `true` routes the bot through Telegram's test environment (`.../bot<token>/test/METHOD`). **The CI test contour pins this to `true` in `ci.yaml`** (the contour is the test environment) — it is not a Gitea variable. Set it in `.env` for a local run; prod leaves it `false`. | | `TELEGRAM_TEST_ENV` | _pinned_ | `false` | `true` routes the bot through Telegram's test environment (`.../bot<token>/test/METHOD`). **The CI test contour pins this to `true` in `ci.yaml`** (the contour is the test environment) — it is not a Gitea variable. Set it in `.env` for a local run; prod leaves it `false`. |
| `TELEGRAM_API_BASE_URL` | variable | _(empty)_ | Override the Bot API host (a mock/self-hosted server); empty = `https://api.telegram.org`. | | `TELEGRAM_API_BASE_URL` | variable | _(empty)_ | Override the Bot API host (a mock/self-hosted server); empty = `https://api.telegram.org`. |
| `VITE_TELEGRAM_BOT_ID` | variable | _(empty)_ | UI build-arg: numeric bot id for the web Login Widget. | | `VITE_TELEGRAM_BOT_ID` | variable | _(empty)_ | UI build-arg: numeric bot id for the web Login Widget. |
| `VITE_TELEGRAM_LINK` | variable | _(empty)_ | UI build-arg: friend-invite Mini App link (full URL, `https://t.me/<bot>/<app>``<app>` is the Mini App short name from BotFather). | | `VITE_TELEGRAM_LINK` | variable | _(empty)_ | UI build-arg: friend-invite Mini App link (full URL, `https://telegram.me/<bot>/<app>``<app>` is the Mini App short name from BotFather). |
| `VITE_TELEGRAM_GAME_CHANNEL_NAME` | variable | _(empty)_ | UI build-arg: the landing "Play in Telegram" link, the bot's game channel (e.g. `https://t.me/Erudit_Game`). | | `VITE_TELEGRAM_GAME_CHANNEL_NAME` | variable | _(empty)_ | UI build-arg: the landing "Play in Telegram" link, the bot's game channel (e.g. `https://telegram.me/Erudit_Game`). |
| `VITE_VK_APP_LINK` | variable (shared) | _(empty)_ | UI build-arg: the landing "Play on VK" link, the VK Mini App (full URL, `https://vk.com/app<id>`). One VK Mini App for all contours. | | `VITE_VK_APP_LINK` | variable (shared) | _(empty)_ | UI build-arg: the landing "Play on VK" link, the VK Mini App (full URL, `https://vk.com/app<id>`). One VK Mini App for all contours. |
| `VITE_VK_APP_ID` | variable (shared) | _(empty)_ | VK ID "Web" app id (`client_id`) for VK web-login linking. Baked into the SPA authorize URL and reused as the gateway's `GATEWAY_VK_ID_APP_ID`. Empty disables the `link.vk.*` ops. One VK ID "Web" app for all contours. | | `VITE_VK_APP_ID` | variable (shared) | _(empty)_ | VK ID "Web" app id (`client_id`) for VK web-login linking. Baked into the SPA authorize URL and reused as the gateway's `GATEWAY_VK_ID_APP_ID`. Empty disables the `link.vk.*` ops. One VK ID "Web" app for all contours. |
| `VITE_VK_ID_REDIRECT_URL` | derived | _(empty)_ | VK ID trusted redirect URL — must match the app's registered redirect exactly. The deploy derives `PUBLIC_BASE_URL + /app/` (used by the SPA and as the gateway's exchange `redirect_uri`); set it only for a local run. | | `VITE_VK_ID_REDIRECT_URL` | derived | _(empty)_ | VK ID trusted redirect URL — must match the app's registered redirect exactly. The deploy derives `PUBLIC_BASE_URL + /app/` (used by the SPA and as the gateway's exchange `redirect_uri`); set it only for a local run. |
@@ -118,7 +118,8 @@ without it Docker's resolver handles `otelcol`, `gateway` and `api.telegram.org`
| `GATEWAY_BLOCKLIST_ENABLED` | variable | `false` | Enable the community IP blocklist at the edge (prod-only, same real-client-IP reason as the ban). Requires `GATEWAY_BLOCKLIST_URL`. Opt-in via `PROD_GATEWAY_BLOCKLIST_ENABLED` after verifying the feed. | | `GATEWAY_BLOCKLIST_ENABLED` | variable | `false` | Enable the community IP blocklist at the edge (prod-only, same real-client-IP reason as the ban). Requires `GATEWAY_BLOCKLIST_URL`. Opt-in via `PROD_GATEWAY_BLOCKLIST_ENABLED` after verifying the feed. |
| `GATEWAY_BLOCKLIST_URL` | variable | _(empty)_ | The curated CIDR feed to fetch (Spamhaus DROP). Required when enabled; refreshed every few hours and dropped fail-open once stale (48h). `PROD_GATEWAY_BLOCKLIST_URL`. | | `GATEWAY_BLOCKLIST_URL` | variable | _(empty)_ | The curated CIDR feed to fetch (Spamhaus DROP). Required when enabled; refreshed every few hours and dropped fail-open once stale (48h). `PROD_GATEWAY_BLOCKLIST_URL`. |
| `GATEWAY_BLOCKLIST_ALLOW` | variable | _(empty)_ | Comma-separated never-block set (CIDRs / bare IPs — own infra, monitoring) the feed can never block. `PROD_GATEWAY_BLOCKLIST_ALLOW`. | | `GATEWAY_BLOCKLIST_ALLOW` | variable | _(empty)_ | Comma-separated never-block set (CIDRs / bare IPs — own infra, monitoring) the feed can never block. `PROD_GATEWAY_BLOCKLIST_ALLOW`. |
| `GATEWAY_MIN_CLIENT_VERSION` | variable | _(empty)_ | Minimum client build the gateway will serve — the native client-version gate (ARCHITECTURE.md §2). Empty ⇒ **dormant** (every build served, the web default). Set it to the release `vMAJOR.MINOR.PATCH` in the **same** rollout that ships an incompatible wire change, so an older bundled APK is turned away with an *update required* signal instead of failing blind. Validated at load; a non-empty unparseable value fails startup. | | `GATEWAY_MIN_CLIENT_VERSION` | variable | _(empty)_ | **Hard** tier of the client-version gate (ARCHITECTURE.md §2). Minimum client build the gateway will serve. Empty ⇒ **dormant** (every build served, the web default). Set it to the release `vMAJOR.MINOR.PATCH` in the **same** rollout that ships an incompatible wire change, so an older bundled APK is turned away with an *update required* signal instead of failing blind — the client then degrades to an offline "Update / Play offline" notice, not a hard lockout. Validated at load; a non-empty unparseable value fails startup. |
| `GATEWAY_RECOMMENDED_CLIENT_VERSION` | variable | _(empty)_ | **Soft** tier of the client-version gate (ARCHITECTURE.md §2). A build at or above `GATEWAY_MIN_CLIENT_VERSION` but **below** this is served normally, but every gated `Execute` response carries an additive `X-Update-Recommended: 1` header ⇒ the client shows a dismissable *update available* nudge (play continues). Empty ⇒ off. Validated at load: unparseable, or **below** `GATEWAY_MIN_CLIENT_VERSION`, fails startup. Bump it (ahead of `MIN`) to nudge upgrades before a hard cut-over. |
| `VITE_GATEWAY_URL` | variable | _(empty)_ | UI build-arg: gateway origin; empty = same-origin (the usual single-origin deploy). | | `VITE_GATEWAY_URL` | variable | _(empty)_ | UI build-arg: gateway origin; empty = same-origin (the usual single-origin deploy). |
| `SMTP_RELAY_HOST` | variable (shared) | _(empty)_ | Selectel SMTP relay host for confirm-code email. Empty leaves the backend on the log mailer (email disabled) — the contour still boots. One relay for every contour (limit 100 msgs / 5 min). | | `SMTP_RELAY_HOST` | variable (shared) | _(empty)_ | Selectel SMTP relay host for confirm-code email. Empty leaves the backend on the log mailer (email disabled) — the contour still boots. One relay for every contour (limit 100 msgs / 5 min). |
| `SMTP_RELAY_PORT` | variable (shared) | `465` | Relay port. No client certificate is needed (the server cert is validated against the system roots). | | `SMTP_RELAY_PORT` | variable (shared) | `465` | Relay port. No client certificate is needed (the server cert is validated against the system roots). |
@@ -240,7 +241,7 @@ that survives losing the host.
## Android app build & release (RuStore) ## Android app build & release (RuStore)
The standalone Android app is built by a **manual** workflow, never automatically, and is separate from the The standalone Android app is built by a **manual** workflow, never automatically, and is separate from the
web/prod rollout — a signed APK uploaded to RuStore by hand. Full plan: [`../ANDROID_PLAN.md`](../ANDROID_PLAN.md). web/prod rollout — a signed APK uploaded to RuStore by hand. The build/release runbook follows.
- **Trigger:** `Actions → android-build → Run workflow` from **`master`**, input `confirm=build` (mirrors - **Trigger:** `Actions → android-build → Run workflow` from **`master`**, input `confirm=build` (mirrors
`prod-deploy`). **Tag the release first** (`git tag vX.Y.Z` on `master`) — the workflow refuses anything `prod-deploy`). **Tag the release first** (`git tag vX.Y.Z` on `master`) — the workflow refuses anything
+7 -6
View File
@@ -107,12 +107,13 @@
} }
} }
# The public offer page is rendered by the render sidecar: it splices the live catalog price # The public legal pages are rendered by the render sidecar: the offer (with the live catalog
# list (backend, internal) into the committed ui/legal/offer_ru.md and returns the HTML. Only # price list from the backend spliced into ui/legal/offer_ru.md), the privacy policy and the EULA
# /offer/ is exposed here — the sidecar's /render stays off this allow-list, internal-only. Kept # (both static markdown). Only these paths are exposed here — the sidecar's /render stays off this
# disjoint from the landing/app paths so the catch-all below never shadows it. # allow-list, internal-only. Kept disjoint from the landing/app paths so the catch-all below never
@offer path /offer /offer/* # shadows them.
handle @offer { @legal path /offer /offer/* /privacy /privacy/* /eula /eula/*
handle @legal {
reverse_proxy renderer:8090 reverse_proxy renderer:8090
} }
+6
View File
@@ -222,6 +222,12 @@ services:
GATEWAY_HTTP_ADDR: ":8081" GATEWAY_HTTP_ADDR: ":8081"
GATEWAY_BACKEND_HTTP_URL: http://backend:8080 GATEWAY_BACKEND_HTTP_URL: http://backend:8080
GATEWAY_BACKEND_GRPC_ADDR: backend:9090 GATEWAY_BACKEND_GRPC_ADDR: backend:9090
# Client-version gate (ARCHITECTURE.md §2): the hard minimum + the soft recommended client build.
# Empty ⇒ dormant. Plain (unprefixed) — the same value serves every contour; in the test contour
# the stamped client version is a commit hash (unparseable ⇒ fail-open), so the gate only bites
# real semver builds in prod. Validated at gateway start (recommended must be ≥ min).
GATEWAY_MIN_CLIENT_VERSION: ${GATEWAY_MIN_CLIENT_VERSION:-}
GATEWAY_RECOMMENDED_CLIENT_VERSION: ${GATEWAY_RECOMMENDED_CLIENT_VERSION:-}
# Telegram auth validates against the home validator (plaintext, internal). # Telegram auth validates against the home validator (plaintext, internal).
GATEWAY_VALIDATOR_ADDR: validator:9091 GATEWAY_VALIDATOR_ADDR: validator:9091
# VK Mini App auth verifies the launch-parameter signature in-process under the VK # VK Mini App auth verifies the launch-parameter signature in-process under the VK
+2
View File
@@ -49,6 +49,8 @@ export CADDY_SITE_ADDRESS='$CADDY_SITE_ADDRESS'
export LOG_LEVEL='${LOG_LEVEL:-info}' export LOG_LEVEL='${LOG_LEVEL:-info}'
export DICT_VERSION='$DICT_VERSION' export DICT_VERSION='$DICT_VERSION'
export APP_VERSION='$APP_VERSION' export APP_VERSION='$APP_VERSION'
export GATEWAY_MIN_CLIENT_VERSION='$GATEWAY_MIN_CLIENT_VERSION'
export GATEWAY_RECOMMENDED_CLIENT_VERSION='$GATEWAY_RECOMMENDED_CLIENT_VERSION'
export TELEGRAM_BOT_TOKEN='$TELEGRAM_BOT_TOKEN' export TELEGRAM_BOT_TOKEN='$TELEGRAM_BOT_TOKEN'
export TELEGRAM_MINIAPP_URL='$TELEGRAM_MINIAPP_URL' export TELEGRAM_MINIAPP_URL='$TELEGRAM_MINIAPP_URL'
export GATEWAY_VK_APP_SECRET='$GATEWAY_VK_APP_SECRET' export GATEWAY_VK_APP_SECRET='$GATEWAY_VK_APP_SECRET'
+91 -59
View File
@@ -100,15 +100,35 @@ dropped). Horizontal scaling is explicit future work.
auth operations are unauthenticated and return the minted token. A unary auth operations are unauthenticated and return the minted token. A unary
operation's domain outcome rides back in `ExecuteResponse.result_code` (HTTP operation's domain outcome rides back in `ExecuteResponse.result_code` (HTTP
200); only edge failures (rate limit, missing session, unknown type, internal) 200); only edge failures (rate limit, missing session, unknown type, internal)
surface as Connect error codes. The client treats a connectivity edge failure as surface as Connect error codes. The client treats connectivity as **state, not a per-call toast**,
**state, not a per-call toast**: a transport `unavailable` or a `rate_limited` flips a global via a single **net-state machine** — a pure reducer (`ui/src/lib/netstate.ts`) behind a reactive
`online` signal that drives a header **"Connecting…"** spinner and softly disables proactive store (`netstate.svelte.ts`); `connection`/`offline` are thin derived shims over it. It has four
actions, and the transport **auto-retries with capped exponential backoff** — every op on a states: `online`; `connecting` (a call or probe is failing but still inside the anti-flap window —
rate-limit (the gateway rejected it before processing, so it is safe), but only **read-only** the header **"Connecting…"** spinner, chrome stays online); `offlineNoNetwork` (sustained loss — blue
ops on `unavailable` (a mutation is never blindly re-sent, to avoid double-applying one whose chrome, a local-only lobby, the transport **kill switch**); and `offlineVersionLocked` (the gateway is
response was lost — its button is disabled while offline and the player re-issues it on reachable but the build is below the hard minimum — the *update* notice, the client-version gate below). **Offline is
reconnect). A reachability watcher (a lightweight `profile.get` probe) clears the signal when no implicit** — detected from failed calls, a reachability probe, a **live-stream heartbeat watchdog**
other traffic is in flight; the live `Subscribe` stream's drop/recovery feeds the same signal. (a silence past ~25 s of the gateway's 10 s keep-alive `heartbeat` means a silently-dead stream — e.g.
the radio was cut with no stream error; `ui/src/lib/streamwatchdog.ts`, background-aware) and the OS hint
(`navigator` / `@capacitor/network`), **never a user toggle** — with **hysteresis** so a brief blip lives entirely in
`connecting` (K consecutive probe failures *or* a debounce window trips `offlineNoNetwork`; the first
probe/call success heals back, with a toast). The transport **auto-retries with capped exponential
backoff** — every op on a rate-limit (the gateway rejected it before processing, so it is safe), but
only **read-only** ops on `unavailable` (a mutation is never blindly re-sent, to avoid double-applying
one whose response was lost — its button is disabled while offline and re-issued on reconnect). A
reachability watcher (a lightweight `profile.get` probe; a session-less native guest reconciles a
server guest instead — that reconcile IS the probe) drives recovery, and the live `Subscribe` stream's
drop/recovery feeds the same machine. **Telegram/VK are exempt from the offline-*play* model**
`offlineMode.active` is hard-gated on `offlineCapable()` (false in those mini-apps), so nothing — not
even a version lock — puts them in offline mode: no blue chrome, no local lobby, no transport kill
switch and no device-local create paths. The machine still tracks reachability there, though: a
connection lost **inside an online game** is surfaced on **every platform**, driven off the machine's
confirmed-offline state (`netState.offline`, not `offlineMode.active`) — the game screen shows a
*connection lost* banner, **freezes** the tray and move controls, and hides the resign, add-friend/block
and chat/dictionary controls (a started move stays a draft, re-committed by the player on reconnect), and
the word-check tool falls back to the game's pinned on-device dictionary (`ui/src/lib/dict/check.ts`,
exact when that dawg is cached, else *unavailable*) with its network-only complaint and external
look-up hidden.
**Edge hardening:** every request body on the public listener is capped at **Edge hardening:** every request body on the public listener is capped at
`GATEWAY_MAX_BODY_BYTES` (default 1 MiB — far above any legitimate payload), both at the HTTP `GATEWAY_MAX_BODY_BYTES` (default 1 MiB — far above any legitimate payload), both at the HTTP
layer (`http.MaxBytesReader`) and as the Connect per-message read limit, so an oversized layer (`http.MaxBytesReader`) and as the Connect per-message read limit, so an oversized
@@ -140,18 +160,26 @@ dropped). Horizontal scaling is explicit future work.
(your-turn, opponent-moved, chat, nudge). The gateway bridges them to the (your-turn, opponent-moved, chat, nudge). The gateway bridges them to the
client's in-app stream while the app is open. Out-of-app delivery uses client's in-app stream while the app is open. Out-of-app delivery uses
platform-native push via the platform side-service. platform-native push via the platform side-service.
- **Client-version gate** (native long-tail protection): a bundled native install (§13) can be - **Client-version gate — two tiers** (native long-tail protection): a bundled native install (§13) can be
arbitrarily old, so the client stamps its build into an **`X-Client-Version`** request header (the app arbitrarily old, so the client stamps its build into an **`X-Client-Version`** request header (the app
version from `pkg/version` / `__APP_VERSION__`), read by the gateway **before it decodes the FlatBuffers version from `pkg/version` / `__APP_VERSION__`), read by the gateway **before it decodes the FlatBuffers
payload**. The version rides the **outermost stable layer** — an HTTP header, never the FBS payload — payload**. The version rides the **outermost stable layer** — an HTTP header, never the FBS payload —
because protobuf envelopes and headers are version-tolerant by design while the FBS payload is the layer because protobuf envelopes and headers are version-tolerant by design while the FBS payload is the layer
that breaks. Enforcement is **per-call, not a Hello RPC**: `Execute` and `Subscribe` compare the header to that breaks. Enforcement is **per-call, not a Hello RPC** (`Execute`/`Subscribe` check it at the top,
`GATEWAY_MIN_CLIENT_VERSION` at the top (before registry lookup / auth / decode), so the first online call before registry lookup / auth / decode), so the first online call is already gated. Both tiers **fail
an app makes is already gated. A too-old client gets the domain envelope `result_code = "update_required"` open** (an absent or unparseable header passes) and are **dormant** until deliberately configured (both
(HTTP 200) on `Execute` and `CodeFailedPrecondition` on `Subscribe`, and makes **zero** successful vars empty ⇒ off, web behaviour unchanged).
requests. The gate **fails open** (an absent or unparseable header passes) and is **dormant** until - **Hard (critical) — `GATEWAY_MIN_CLIENT_VERSION`**: `version < min` ⇒ the domain envelope
`GATEWAY_MIN_CLIENT_VERSION` is deliberately set (empty ⇒ off, so web behaviour is unchanged). The client `result_code = "update_required"` (HTTP 200) on `Execute` and `CodeFailedPrecondition` on `Subscribe`;
raises a terminal *update* overlay — native → the store listing (`VITE_RUSTORE_URL`), web → reload. the client makes **zero** successful requests. Its reaction is **graceful, not terminal**: it enters
`offlineVersionLocked` (offline mode) and shows a **dismissable notice****"Update"** (native → the
store listing `VITE_RUSTORE_URL`, web → reload) or **"Play offline"** (dismiss → keep playing local
vs_ai / hotseat). The lock is sticky until an actual update on the next launch.
- **Soft (recommended) — `GATEWAY_RECOMMENDED_CLIENT_VERSION`**: `min ≤ version < recommended` ⇒ the
gateway sets an **additive** `X-Update-Recommended: 1` **response header** on the served `Execute`
response (the call still succeeds); a transport interceptor turns it into a **dismissable "update
available" nudge** in the lobby — play continues, nothing blocks. `recommended` must be **`min`**
(validated at config load); empty ⇒ the soft tier is off.
- **Frozen wire contract** (so any build, however old, can always recognise "update required"): three - **Frozen wire contract** (so any build, however old, can always recognise "update required"): three
things are permanent — (1) the protobuf envelope field numbers in `edge.proto` are never renumbered or things are permanent — (1) the protobuf envelope field numbers in `edge.proto` are never renumbered or
reused; (2) the `update_required` sentinel — the `result_code` string **and** the `FailedPrecondition` reused; (2) the `update_required` sentinel — the `result_code` string **and** the `FailedPrecondition`
@@ -160,10 +188,12 @@ dropped). Horizontal scaling is explicit future work.
the FBS payload, which is exactly what the gate guards. **Deploy discipline:** the production rollout that the FBS payload, which is exactly what the gate guards. **Deploy discipline:** the production rollout that
ships an incompatible wire change **also** bumps `GATEWAY_MIN_CLIENT_VERSION` to that release, in the same ships an incompatible wire change **also** bumps `GATEWAY_MIN_CLIENT_VERSION` to that release, in the same
rollout (see [`../deploy/README.md`](../deploy/README.md)). rollout (see [`../deploy/README.md`](../deploy/README.md)).
- **Gate × offline** (UX rule): deliberate offline uses the network kill switch, so the gate never fires - **Gate × offline** (UX rule): offline is the net-state machine's `offlineNoNetwork` / `offlineVersionLocked`
while playing offline — an old install can always play local vs_ai / hotseat. The terminal *update* (implicit — no toggle) and its kill switch refuses calls, so the hard gate never fires *while already
overlay is raised **only on a user-initiated online action**; the silent background guest reconciliation offline* — an old install always plays local vs_ai / hotseat. A hard `update_required` on a
(§3) swallows an `update_required` and stays a local guest rather than interrupting local play. **user-initiated online call** degrades to `offlineVersionLocked` + the dismissable notice (above); the
silent background guest reconciliation (§3) **swallows** an `update_required` and stays a local guest
rather than interrupting local play.
## 3. Authentication & sessions ## 3. Authentication & sessions
@@ -946,10 +976,14 @@ finished journal on each GET. The only degraded platform is a legacy Telegram cl
predating `downloadFile`, where the GCG falls back to the old clipboard copy and the predating `downloadFile`, where the GCG falls back to the old clipboard copy and the
image option is not offered. image option is not offered.
The same sidecar also serves the **public offer page** at `/offer/` the one edge-exposed The same sidecar also serves the **public legal pages** — the offer at `/offer/`, the privacy
route on it (caddy routes `/offer/` here; its `/render` stays internal). It reuses the shared policy at `/privacy/` and the EULA at `/eula/` — the edge-exposed routes on it (caddy routes them
`ui/src/lib/offer.ts` renderer (again one renderer, no drift) over the owner-edited here; its `/render` stays internal). All three reuse the shared `ui/src/lib/offer.ts`
`ui/legal/offer_ru.md`, baked into the image, splicing in the **live price list** (§4.4): it `renderLegalHtml` (one renderer, no drift) over their owner-edited `ui/legal/*_{ru,en}.md` — each is
**bilingual (RU + EN)** with a client-side language + theme switcher (default Russian, persisted;
the offer's EN view transliterates the Russian product names) — baked into the image. `/privacy/`
and `/eula/` are static; the offer additionally splices in the **live price list** (§4.4) into both
languages: it
fetches the two catalog tables as markdown from the backend's internal fetches the two catalog tables as markdown from the backend's internal
`/api/v1/internal/offer/pricing` at the `<#pricing_template#>` marker, then renders the page. The `/api/v1/internal/offer/pricing` at the `<#pricing_template#>` marker, then renders the page. The
backend projects the tables from the active catalog through `payments.Money` (no float reaches the backend projects the tables from the active catalog through `payments.Money` (no float reaches the
@@ -1344,7 +1378,9 @@ Single public origin, path-routed. The Vite build has two entries: a lightweight
`/app/` (web), `/telegram/` (the Telegram Mini App; on that path without sign-in data `/app/` (web), `/telegram/` (the Telegram Mini App; on that path without sign-in data
— no `initData` — the client renders a compact, shareable launch-diagnostic screen instead — no `initData` — the client renders a compact, shareable launch-diagnostic screen instead
of redirecting away) and `/vk/` (the VK Mini App; the client reads the signed `vk_*` launch of redirecting away) and `/vk/` (the VK Mini App; the client reads the signed `vk_*` launch
parameters from the URL and the gateway verifies them in-process — §12); a stray hit on the parameters from the URL and the gateway verifies them in-process — §12; on that path without a
signed launch the client renders the same shareable launch-diagnostic screen rather than starting
a throwaway guest); a stray hit on the
gateway's `/` 308-redirects to `/app/`. The **landing** ships in its own static container: the gateway's `/` 308-redirects to `/app/`. The **landing** ships in its own static container: the
`landing` target of `gateway/Dockerfile` (caddy:2-alpine + the same Vite build, `landing` target of `gateway/Dockerfile` (caddy:2-alpine + the same Vite build,
`deploy/landing/Caddyfile`) serves it at `/`, so stray public traffic is absorbed by `deploy/landing/Caddyfile`) serves it at `/`, so stray public traffic is absorbed by
@@ -1369,13 +1405,18 @@ route (its `directoryIndex` is `index.html`) would otherwise shadow it and serve
cache-first — the old build's version until a second load. The landing page and the conditional cache-first — the old build's version until a second load. The landing page and the conditional
polyfill bundle are excluded, and the Connect stream and runtime API POSTs are never precached nor polyfill bundle are excluded, and the Connect stream and runtime API POSTs are never precached nor
intercepted, so the live app is never served stale. This satisfies Chromium's installability requirement (a registered SW, needed intercepted, so the live app is never served stale. This satisfies Chromium's installability requirement (a registered SW, needed
for install on Android) and powers the opt-in **offline mode** (in progress): a deliberate, device-scoped Settings for install on Android) and powers **offline play**. Offline is **implicit** — the net-state machine
toggle — distinct from the transient gateway-reachability signal — that tints the header blue with (§2) detects lost connectivity and tints the header blue with an *Offline* chip; there is **no toggle**
an *Offline* chip and confines play to on-device `vs_ai` games. The **offline lobby lists only those (the old deliberate Settings switch and its cold-start dialog are gone). The **unified lobby** merges the
device-local games** (reconstructed by replaying the IndexedDB move journal) and its New-vs-AI entry device-local games (active — reconstructed by replaying the IndexedDB move journal) with the last-cached
creates one through the in-browser engine — the same game screen then drives it, the robot replying **server games shown greyed** (un-openable, a tap toasts *offline*); the Stats tab is disabled and
locally; online-only affordances (the Stats tab, the random-opponent option) are disabled invitations are hidden while offline. On offline-capable channels (native / plain web) New Game's *with
or hidden, and New Game's *with friends* becomes the entry to **local pass-and-play (hotseat)**. friends* carries an **online/offline segmented control** — online = a friend invite, offline = **local
pass-and-play (hotseat)** — with the online segment disabled and offline forced when there is no network;
the online-only Telegram/VK mini-apps skip the segment and show the remote invite alone. A `vs_ai` or
hotseat create is guarded when
the chosen variant's dictionary is not available offline. A device-local `vs_ai` game is created through
the in-browser engine and driven by the same game screen, the robot replying locally.
A hotseat game is a device-local **2-4 human** game built through the same engine (`hotseat` + A hotseat game is a device-local **2-4 human** game built through the same engine (`hotseat` +
per-seat/host PIN locks on the record; the seats carry names but no accounts). A **mandatory host per-seat/host PIN locks on the record; the seats carry names but no accounts). A **mandatory host
(referee) PIN** gates the roster at creation and, in-game, the referee overrides — **skip** the (referee) PIN** gates the roster at creation and, in-game, the referee overrides — **skip** the
@@ -1403,36 +1444,27 @@ eligible installed PWA (standalone web + confirmed email) **background-preloads*
— on lobby entry and on a variant-preference change — through the same three-tier loader, retried — on lobby entry and on a variant-preference change — through the same three-tier loader, retried
with backoff and honouring the session miss-breaker; the move generator, the loader and the preload with backoff and honouring the session miss-breaker; the move generator, the loader and the preload
orchestration stay in lazy chunks. A first-lobby preload failure shows a *poor-connection* notice in orchestration stay in lazy chunks. A first-lobby preload failure shows a *poor-connection* notice in
the ad-banner slot. Flipping the Settings toggle **to** offline runs that same cache-first fetch the ad-banner slot. A **cold launch with no network** boots offline-first from the persisted session +
bounded by a ~5 s UI wait (`raceOfflineReady` + the lazy `dict/offlineready`): it enters offline only profile (saved on every online adopt/refresh) — `bootstrap` skips the session adoption and profile fetch
once every enabled variant is ready, otherwise it stays online with a *needs internet* note while the that would otherwise hang, and lands straight in the offline lobby; a native launch with no server
fetch finishes in the background (the next flip is then instant). A **cold launch already in offline session enters as a device-local guest (§3), and any stale pre-redesign offline-preference key is cleared
mode** boots from the persisted session and on boot. Connectivity is **detected, not chosen** (the net-state machine, §2): a cold `navigator.onLine
profile (the profile is saved on every online adopt/refresh) — `bootstrap` skips the session === false` or a failed cold reachability probe (a bounded `profile.get`) enters offline, and mid-session
adoption and profile fetch that would otherwise hang with no network, and lands straight in the the `navigator` / `@capacitor/network` `online`/`offline` events plus the probe watcher drive it — with
offline lobby; without a cached profile (an install that was never online) it drops the sticky flag **hysteresis** so a brief blip lives in `connecting` and never flips the chrome, and **self-heal** (a
and boots online instead. Beyond the sticky toggle the app **auto-detects connectivity**: the back-online toast) when the network returns. Offline is a real **transport kill switch** (every gateway
reactive flag carries an *auto* bit, so a self-entered offline (no network) is transient call is refused, so no traffic leaks); the reachability probe is the one call exempt from it (it is the
(session-only, never persisted) and self-heals to online when the network returns, while a deliberate return-to-online mechanism). The gateway registers the `.webmanifest` MIME type
one (the toggle, or the cold-start dialog's *Switch*) persists. At cold start `navigator.onLine ===
false` enters offline for the session; otherwise a single bounded reachability probe (a `profile.get`,
~3 s) decides — success boots online, a timeout on an eligible install raises a *No connection* dialog
(*Switch* = sticky offline, *Wait for network* = boot online with the reachability watcher retrying).
Mid-session the `window` `online`/`offline` events drive it — `offline` auto-enters, `online`
re-verifies reachability before returning — backed by a `navigator.onLine` poll because those events
are unreliable on some platforms (notably iOS PWAs). Offline is a real **transport kill switch**
(every gateway call is refused, so no traffic leaks); the reachability probe is the one call exempt
from it (it is the return-to-online mechanism), and the transient reachability watcher is suppressed
while offline. The gateway registers the `.webmanifest` MIME type
in-process (the distroless image has no `/etc/mime.types`). Hash-named `/assets/*` are served in-process (the distroless image has no `/etc/mime.types`). Hash-named `/assets/*` are served
`immutable` (a relaunch is a cache hit, not a re-download); the HTML shells are `immutable` (a relaunch is a cache hit, not a re-download); the HTML shells are
`no-cache` so a new deploy is picked up — both containers apply the same caching. An `no-cache` so a new deploy is picked up — both containers apply the same caching. An
in-compose **caddy** is the contour's edge: it owns a single `/_gm` Basic-Auth and in-compose **caddy** is the contour's edge: it owns a single `/_gm` Basic-Auth and
routes `/_gm/grafana/*` to **Grafana** (anonymous-admin, so the one shared login gates routes `/_gm/grafana/*` to **Grafana** (anonymous-admin, so the one shared login gates
it with no per-user Grafana accounts) and the rest of `/_gm/*` to the backend-rendered it with no per-user Grafana accounts) and the rest of `/_gm/*` to the backend-rendered
**admin console**; `/app/`, `/telegram/`, `/vk/` and the Connect path go to the gateway; `/offer/` **admin console**; `/app/`, `/telegram/`, `/vk/` and the Connect path go to the gateway; the public
(the public offer, rendered by the `renderer` sidecar with the live catalog price list spliced in) legal pages `/offer/`, `/privacy/` and `/eula/` (rendered by the `renderer` sidecar the offer with
goes to the render sidecar; and the catch-all — notably the landing at `/` — goes to the landing the live catalog price list spliced in) go to the render sidecar; and the catch-all — notably the
landing at `/` — goes to the landing
container. The container. The
**Telegram validator** runs as a separate container with **no public ingress**, **Telegram validator** runs as a separate container with **no public ingress**,
answering only internal gRPC (HMAC, no Telegram egress). The **Telegram bot** holds answering only internal gRPC (HMAC, no Telegram egress). The **Telegram bot** holds
@@ -1520,8 +1552,8 @@ hidden in the MVP (`VITE_PAYMENTS_DISABLED`). The APK is built by a **manual** `
the dicts, and assembles a **release APK** artifact — signed when the `ANDROID_KEYSTORE_*` secrets are the dicts, and assembles a **release APK** artifact — signed when the `ANDROID_KEYSTORE_*` secrets are
present, unsigned otherwise. `versionCode`/`versionName` are deterministic from the release tag `vMA.MI.PA` present, unsigned otherwise. `versionCode`/`versionName` are deterministic from the release tag `vMA.MI.PA`
(`versionCode = MA*1_000_000 + MI*1_000 + PA`, `versionName = "MA.MI.PA"`). The runner is a host-executor (`versionCode = MA*1_000_000 + MI*1_000 + PA`, `versionName = "MA.MI.PA"`). The runner is a host-executor
with a host-provisioned Android SDK; RuStore upload is manual. Full plan + runbook: with a host-provisioned Android SDK; RuStore upload is manual. Runbook:
[`../ANDROID_PLAN.md`](../ANDROID_PLAN.md), [`../deploy/README.md`](../deploy/README.md). [`../deploy/README.md`](../deploy/README.md).
## 14. CI & branches ## 14. CI & branches
+66 -34
View File
@@ -30,11 +30,13 @@ render with arbitrary languages, so detection would make the indexed content
nondeterministic); a saved 🌐 choice still wins. The page carries a static Russian SEO head nondeterministic); a saved 🌐 choice still wins. The page carries a static Russian SEO head
(title/description, the Open Graph card Telegram/VK link previews use, a canonical link (title/description, the Open Graph card Telegram/VK link previews use, a canonical link
pinned to the production origin, JSON-LD, the favicon set and `robots.txt`), while the SPA pinned to the production origin, JSON-LD, the favicon set and `robots.txt`), while the SPA
shell is `noindex` — the landing is the only indexable page. The footer links to the **public shell is `noindex` — the landing is the only indexable page. The footer links to the three **legal
offer** (`/offer/`) and, beside it, **feedback** — the Telegram bot the offer names as the seller's documents** — the **user agreement** (`/eula/`), the **privacy policy** (`/privacy/`) and the
contact. The offer page (the legal document a purchase accepts) is rendered on demand from **public offer** (`/offer/`) — and, beside them, **feedback** (the Telegram bot the documents name as
`ui/legal/offer_ru.md` with its **price list** (§4.4) generated from the live product catalog, so the seller's contact). Each is **bilingual (RU/EN)** with a language + theme switcher and is rendered
the published prices always match what is currently on sale — no redeploy to update them. from its `ui/legal/*_{ru,en}.md` sources by the render sidecar; the offer additionally splices in its
**price list** (§4.4) generated from the live product catalog, so the published prices always match
what is currently on sale — no redeploy to update them.
On the plain web the client is an **installable PWA**: a logged-out player sees an install On the plain web the client is an **installable PWA**: a logged-out player sees an install
call-to-action under the login form (and at the bottom of Settings) that installs the app to the call-to-action under the login form (and at the bottom of Settings) that installs the app to the
@@ -70,7 +72,10 @@ A **VK Mini App** launch works the same way: it authenticates from VK's signed l
`vk_language` and its display name from the VK profile (read on the client, since VK does not put `vk_language` and its display name from the VK profile (read on the client, since VK does not put
the name in the signed launch). While the theme preference is "auto" the app follows the VK the name in the signed launch). While the theme preference is "auto" the app follows the VK
client's light/dark scheme, and the layout clears the VK mobile home bar. The same quiet-retry client's light/dark scheme, and the layout clears the VK mobile home bar. The same quiet-retry
"couldn't load" screen applies inside VK. "couldn't load" screen applies inside VK. Opening a Mini App link directly in an ordinary browser —
the `/vk/` entry with no signed VK launch, or `/telegram/` with no Telegram sign-in data — shows a
compact, shareable diagnostic screen instead of proceeding (as a throwaway guest on VK, or by
redirecting away on Telegram), so a player who ends up there wrongly can screenshot it for us.
**Email** sign-in (web) mails a branded six-digit confirmation code — localized **Email** sign-in (web) mails a branded six-digit confirmation code — localized
(en/ru), no images — to the address; entering it signs the player in. A new address (en/ru), no images — to the address; entering it signs the player in. A new address
is provisioned on the first request but only becomes a durable account once the code is provisioned on the first request but only becomes a durable account once the code
@@ -224,7 +229,10 @@ brief warm-up shows on the first open otherwise — and falls back to the server
local dictionary is unavailable. The dictionary check tool is local dictionary is unavailable. The dictionary check tool is
unlimited and offers a complaint on any result; for a word it finds, it also links out to an unlimited and offers a complaint on any result; for a word it finds, it also links out to an
external reference dictionary (gramota.ru for Russian games, scrabblewordfinder.org for external reference dictionary (gramota.ru for Russian games, scrabblewordfinder.org for
English) to look it up. Hints are governed per game — English) to look it up. While offline in an online game the check runs against the game's own
dictionary on the device — the same verdict as the server when that dictionary is available,
otherwise it reads *unavailable offline* — and the complaint and the external look-up, which both
need the network, are hidden. Hints are governed per game —
whether they are allowed and how many each player starts with — and draw on a whether they are allowed and how many each player starts with — and draw on a
personal hint wallet once the per-game allowance is spent. Against the robot personal hint wallet once the per-game allowance is spent. Against the robot
(**vs_ai**) hints are instead **unlimited and wallet-free**, but idle-gated as an (**vs_ai**) hints are instead **unlimited and wallet-free**, but idle-gated as an
@@ -277,22 +285,28 @@ add-friend are off. AI games are **practice** — they never count toward a play
statistics. statistics.
### Offline mode ### Offline mode
An installed web PWA signed in with a confirmed email can switch to a deliberate **offline mode** The **web and native apps** play **offline automatically** — there is **no on/off switch**; the
(Settings → Play mode). Offline, the app never touches the network: the header turns blue with an online-only **Telegram and VK mini-apps** never go offline (everything below applies to the web and
*Offline* chip, the lobby lists only the games stored on the device, and online-only surfaces are native apps only). When it cannot reach the
disabled or hidden (the Stats tab; the *random opponent* option in New Game). server the header turns blue with an *Offline* chip and play is confined to games on the device; a
A New Game against the robot creates a device-local **vs_ai** game — it momentary blip is ridden out as a quiet *"Connecting…"* (it never drops you offline), and when the
plays entirely in the browser with no backend. Local games are kept on the device, visible only in connection returns the app goes back online on its own with a brief *back online* toast. Offline, the
offline mode, and never sync to the account. So a game can be created and played with no connection, app never touches the network.
the app quietly preloads the dictionaries for the player's enabled variants while still online, and
(once installed) launches from a precached shell even with no network. Switching **to** offline first
checks that the enabled variants' dictionaries are on the device — if any are missing it fetches them
and waits briefly, and if they cannot be readied it stays online with a short *needs internet* note
(the download keeps running in the background, so a later switch is instant). The mode is
device-scoped and sticky across launches.
Offline, New Game's **with friends** starts a **local pass-and-play** game for 2-4 people sharing one The **lobby stays unified**: your device-local games are active, while your server games are still
device. You first set a **host (leader) password** — a 4-digit PIN entered on a lock-screen-style listed but **greyed out** — visible but un-openable until you are back online (a tap explains why).
Online-only surfaces (the Stats tab) are disabled and invitations are hidden while offline. A New Game
against the robot creates a device-local **vs_ai** game that plays entirely on the device with no
backend; local games are kept on the device and never sync to the account, so a game can be created and
played with no connection. The app quietly preloads the dictionaries for the player's enabled variants
while still online, and (once installed, or on the native app) launches from bundled/precached data even
with no network; if a variant's dictionary is not available offline, creating that game is disabled with
a short note.
New Game's **with friends** offers a choice — **invite a friend** to play online, or a **local
pass-and-play** game for 2-4 people sharing one device; with no network only pass-and-play is available.
(In the online-only Telegram/VK mini-apps *with friends* is the friend invite alone — no pass-and-play.)
In pass-and-play you first set a **host (leader) password** — a 4-digit PIN entered on a lock-screen-style
keypad; until it is set the player rows stay locked. The app then asks whether you are playing too — keypad; until it is set the player rows stay locked. The app then asks whether you are playing too —
if so you take the first seat with your profile name. You name each player (2 to 4; add or remove if so you take the first seat with your profile name. You name each player (2 to 4; add or remove
rows, where a removal asks for the leader password) and may give any seat its **own optional PIN**. rows, where a removal asks for the leader password) and may give any seat its **own optional PIN**.
@@ -305,14 +319,29 @@ pass-and-play game. A game that finishes normally is saved to the offline lobby
deleting any pass-and-play game — running or finished — from the lobby asks for the leader password, deleting any pass-and-play game — running or finished — from the lobby asks for the leader password,
so no one can wipe a game the moment they have moved. so no one can wipe a game the moment they have moved.
The app also **enters offline mode on its own** when it cannot reach the network. On a cold launch Going offline and coming back are both **automatic**. On a cold launch with no connection the app
with no connection it switches to offline for that session; when the device is online but the gateway starts straight in the offline lobby; while it is open, losing the network — airplane mode — turns it
stays silent for a few seconds it asks first — *No connection. Switch to offline mode?*, with offline on its own, and restoring the network returns it online by itself. There is no dialog and no
**Switch** (go offline) or **Wait for network** (keep retrying online). While the app is open, losing switch to remember: a brief drop is ridden out quietly and only a sustained loss turns the chrome
the network — airplane mode — switches to offline automatically, and restoring it returns online by offline, so you are never interrupted for a hiccup.
itself. A self-entered offline is temporary: it reverts to online the moment the network is back, and
the next launch re-checks. A **deliberate** offline — the Settings toggle, or **Switch** in that **Losing the connection while inside an online game** is surfaced on **every platform, including the
dialog — is the player's choice: it is kept, and never undone automatically. Telegram/VK mini-apps** — unlike the offline-play mode above, which is web/native only — because an
online game needs the server for every move. A slim *connection lost* banner appears at the top of the
board and the play area **freezes**: the rack and the move controls disable, and the resign, the
add-friend/block and the chat/dictionary controls are hidden, while a move you had started stays on the
board as a draft. Nothing
is sent; when the connection returns the banner clears, the controls come back and you commit the move
yourself. If you were already in the chat or the dictionary when the drop happened you stay there — chat
becomes read-only (send and nudge disable) and the dictionary keeps checking words against the game's
on-device dictionary.
### Staying up to date
When a new client version is required, the app does not lock you out. On the **native** app a
too-old build shows a notice with **Update** (opens the store) and **Play offline** (dismiss and keep
playing your on-device games); on the web it offers **Update** (reload) instead. A softer,
non-blocking **"update available"** banner appears in the lobby when a newer — but not yet
required — version exists; you can update or dismiss it, and play continues either way.
### Native app (Android) ### Native app (Android)
The game also ships as a standalone **Android app** (first on **RuStore**), a packaged build of the same The game also ships as a standalone **Android app** (first on **RuStore**), a packaged build of the same
@@ -420,9 +449,11 @@ unanswered reply. Guests cannot send feedback (the entry is hidden). A player th
barred from feedback (a role, not a full account block) sees the send control disabled. barred from feedback (a role, not a full account block) sees the send control disabled.
### Telegram support chat ### Telegram support chat
A user can also reach the operators straight from the Telegram bot: anything they send the bot A user can also reach the operators straight from the Telegram bot. The `/support` command replies
other than `/start` — text, a photo, a voice message, a file — is forwarded into the operators' with the support desk's working hours and what to include (a description and, when possible,
private support group, grouped into a per-user thread. The user sees no automatic reply; an screenshots). Anything else they send the bot other than `/start` — text, a photo, a voice message,
a file — is forwarded into the operators' private support group, grouped into a per-user thread. The
user sees no automatic reply to a forwarded message; an
operator answers from that thread and the bot delivers the answer back as an ordinary bot message, operator answers from that thread and the bot delivers the answer back as an ordinary bot message,
so to the user it is a quiet one-to-one conversation. Operators can block a user (the bot then so to the user it is a quiet one-to-one conversation. Operators can block a user (the bot then
silently ignores their messages) or clear a thread's messages. This is independent of the in-app silently ignores their messages) or clear a thread's messages. This is independent of the in-app
@@ -525,7 +556,8 @@ at any time (games already lost stay lost).
Where the bot manages a channel's **linked discussion chat**, everyone may write by default and the Where the bot manages a channel's **linked discussion chat**, everyone may write by default and the
bot **mutes** a player who is **not registered** or is **blocked**, un-muting them once they register bot **mutes** a player who is **not registered** or is **blocked**, un-muting them once they register
or are unblocked. So an unregistered newcomer who comments is muted (the promo bot points them at the or are unblocked. It also clears the automatic pin Telegram puts on each channel post that is
auto-forwarded into the chat, so only deliberately pinned messages stay pinned. So an unregistered newcomer who comments is muted (the promo bot points them at the
game to register, after which the bot restores their voice), and a registered, unblocked player simply game to register, after which the bot restores their voice), and a registered, unblocked player simply
writes. An operator can also **mute a player in the chat only** — a `chat_muted` role on the user card — writes. An operator can also **mute a player in the chat only** — a `chat_muted` role on the user card —
without a full account block; an account block mutes them in the chat regardless. Muting and unmuting without a full account block; an account block mutes them in the chat regardless. Muting and unmuting
+64 -35
View File
@@ -32,12 +32,13 @@ top-1 подсказку, безлимитную проверку слова с
главнее. Страница несёт статическую русскую SEO-«шапку» (title/description, карточка главнее. Страница несёт статическую русскую SEO-«шапку» (title/description, карточка
Open Graph, которую используют превью ссылок в Telegram/VK, канонический адрес Open Graph, которую используют превью ссылок в Telegram/VK, канонический адрес
продакшен-домена, JSON-LD, набор favicon и `robots.txt`), а оболочка SPA помечена продакшен-домена, JSON-LD, набор favicon и `robots.txt`), а оболочка SPA помечена
`noindex` — индексируется только посадочная страница. В подвале — ссылки на **публичную `noindex` — индексируется только посадочная страница. В подвале — ссылки на три **юридических
оферту** (`/offer/`) и рядом на **обратную связь** (тот самый Telegram-бот, который оферта документа** — **пользовательское соглашение** (`/eula/`), **политику конфиденциальности**
указывает как контакт Продавца). Страница оферты (юридический документ, который принимается при (`/privacy/`) и **публичную оферту** (`/offer/`) — и рядом на **обратную связь** (тот самый
покупке) рендерится по запросу из `ui/legal/offer_ru.md`, а её **перечень стоимости** (§4.4) Telegram-бот, который документы указывают как контакт Продавца). Каждый — **двуязычный (RU/EN)** с
формируется из живого каталога товаров, поэтому опубликованные цены всегда совпадают с тем, что переключателем языка и темы, рендерится из исходников `ui/legal/*_{ru,en}.md` сайдкаром-рендерером;
сейчас в продаже — без передеплоя. оферта дополнительно подставляет **перечень стоимости** (§4.4), формируемый из живого каталога
товаров, поэтому опубликованные цены всегда совпадают с тем, что сейчас в продаже — без передеплоя.
В обычном вебе клиент — **устанавливаемое PWA**: незалогиненный игрок видит призыв к установке В обычном вебе клиент — **устанавливаемое PWA**: незалогиненный игрок видит призыв к установке
под формой входа (и внизу «Настроек»), который в один тап ставит приложение на рабочий стол под формой входа (и внизу «Настроек»), который в один тап ставит приложение на рабочий стол
@@ -76,7 +77,10 @@ launch-параметрам VK (их проверяет gateway), и при пе
так как VK не кладёт имя в подписанный запуск). Пока тема в режиме «авто», приложение следует так как VK не кладёт имя в подписанный запуск). Пока тема в режиме «авто», приложение следует
светлой/тёмной схеме VK-клиента, а раскладка обходит нижнюю home-bar VK на мобильных. Тот же экран светлой/тёмной схеме VK-клиента, а раскладка обходит нижнюю home-bar VK на мобильных. Тот же экран
тихого повтора «не удалось тихого повтора «не удалось
загрузить» действует и внутри VK. загрузить» действует и внутри VK. Если открыть ссылку на мини-приложение прямо в обычном браузере —
вход `/vk/` без подписанного запуска VK или `/telegram/` без данных входа Telegram — показывается
компактный экран диагностики, которым можно поделиться, вместо того чтобы продолжить (гостем-однодневкой
на VK или редиректом прочь на Telegram), — чтобы игрок, случайно попавший туда, прислал нам скриншот.
**Email**-вход (в вебе) отправляет на адрес брендированный шестизначный код подтверждения — **Email**-вход (в вебе) отправляет на адрес брендированный шестизначный код подтверждения —
локализованный (ru/en), без картинок; после ввода игрок входит. Новый адрес заводится при первом локализованный (ru/en), без картинок; после ввода игрок входит. Новый адрес заводится при первом
запросе, но становится постоянным аккаунтом только после подтверждения кода — так брошенная, запросе, но становится постоянным аккаунтом только после подтверждения кода — так брошенная,
@@ -232,7 +236,9 @@ e-mail) либо ввод фразы. Активные игры форфейтя
короткий прогрев, — и уходит на сервер, если локальный словарь недоступен. Инструмент проверки слова безлимитный и короткий прогрев, — и уходит на сервер, если локальный словарь недоступен. Инструмент проверки слова безлимитный и
предлагает пожаловаться на любой результат; для найденного слова он также даёт ссылку на предлагает пожаловаться на любой результат; для найденного слова он также даёт ссылку на
внешний справочный словарь (gramota.ru для русских игр, scrabblewordfinder.org для английских), внешний справочный словарь (gramota.ru для русских игр, scrabblewordfinder.org для английских),
чтобы его посмотреть. Подсказки управляются настройками чтобы его посмотреть. В офлайне в онлайн-партии проверка идёт по собственному словарю партии на
устройстве — тот же вердикт, что и на сервере, если этот словарь доступен, иначе показывается
*недоступно офлайн*, — а жалоба и внешняя ссылка, которым нужна сеть, скрываются. Подсказки управляются настройками
партии — разрешены ли они и сколько их у каждого игрока на старте — и расходуют партии — разрешены ли они и сколько их у каждого игрока на старте — и расходуют
личный кошелёк подсказок после исчерпания внутриигрового лимита. Против робота личный кошелёк подсказок после исчерпания внутриигрового лимита. Против робота
(**vs_ai**) подсказки, наоборот, **безлимитны и без кошелька**, но с idle-гейтом как (**vs_ai**) подсказки, наоборот, **безлимитны и без кошелька**, но с idle-гейтом как
@@ -283,22 +289,27 @@ e-mail) либо ввод фразы. Активные игры форфейтя
редкими ходами вопреки плану, затухающими к эндшпилю), а чат, nudge и «добавить в друзья» выключены. Партии с ИИ — это **тренировка**: они не идут в статистику игрока. редкими ходами вопреки плану, затухающими к эндшпилю), а чат, nudge и «добавить в друзья» выключены. Партии с ИИ — это **тренировка**: они не идут в статистику игрока.
### Офлайн-режим ### Офлайн-режим
Установленный веб-PWA со входом по подтверждённой почте может переключиться в осознанный **Веб- и нативное приложения** играют **офлайн автоматически** — никакого переключателя нет; онлайн-только
**офлайн-режим** (Настройки → Режим игры). В офлайне приложение не обращается к сети: шапка синеет с **мини-приложения Telegram и VK** никогда не уходят в офлайн (всё ниже относится только к веб- и нативному
меткой *Офлайн*, лобби показывает только сохранённые на устройстве игры, а сетевые поверхности приложениям). Когда оно не может достучаться
отключены или скрыты (вкладка Статистика; вариант «случайный соперник» в Новой игре). до сервера, шапка синеет с меткой *Офлайн*, а игра ограничивается партиями на устройстве; кратковременный
Новая игра против робота создаёт локальную **vs_ai**-игру — он ходит сбой пережидается как тихое *«Подключение…»* (в офлайн не роняет), а при возврате связи приложение само
целиком в браузере без бэкенда. Локальные игры хранятся на устройстве, видны только в офлайн-режиме и возвращается онлайн с коротким тостом *соединение восстановлено*. В офлайне приложение не обращается к
никогда не синхронизируются с аккаунтом. Чтобы игру можно было создать и сыграть без связи, приложение сети.
заранее, пока ещё онлайн, подгружает словари включённых игроком вариантов и (после установки)
запускается из прекешированного шелла даже без сети. Переключение **в** офлайн сначала проверяет, что
словари включённых вариантов есть на устройстве — если каких-то не хватает, оно их подгружает и недолго
ждёт, а если подготовить их не удаётся, остаётся онлайн с короткой заметкой *нужен интернет* (загрузка
продолжается в фоне, так что следующее переключение мгновенно). Режим привязан к устройству и
сохраняется между запусками.
В офлайне «с друзьями» в Новой игре запускает **локальную игру по очереди (hotseat)** на 24 человек **Лобби остаётся единым**: локальные игры на устройстве активны, а серверные игры по-прежнему видны, но
за одним устройством. Сначала задаётся **пароль ведущего** — 4-значный PIN на клавиатуре в стиле **затемнены** — их видно, но открыть нельзя, пока не вернётесь онлайн (тап поясняет почему). Сетевые
поверхности (вкладка Статистика) отключены, а приглашения в офлайне скрыты. Новая игра против робота
создаёт локальную **vs_ai**-игру, которая идёт целиком на устройстве без бэкенда; локальные игры хранятся
на устройстве и никогда не синхронизируются с аккаунтом, так что игру можно создать и сыграть без связи.
Приложение заранее, пока ещё онлайн, подгружает словари включённых игроком вариантов и (после установки
или в нативном приложении) запускается из вшитых/прекешированных данных даже без сети; если словарь
варианта недоступен офлайн, создание такой игры отключается с короткой заметкой.
«С друзьями» в Новой игре предлагает выбор — **пригласить друга** для игры онлайн или **локальную игру
по очереди (hotseat)** на 2–4 человек за одним устройством; без сети доступна только игра по очереди.
(В онлайн-только мини-приложениях Telegram/VK «с друзьями» — это только приглашение друга, без игры по очереди.)
В игре по очереди сначала задаётся **пароль ведущего** — 4-значный PIN на клавиатуре в стиле
экрана блокировки; пока он не задан, строки игроков заблокированы. Затем приложение спрашивает, экрана блокировки; пока он не задан, строки игроков заблокированы. Затем приложение спрашивает,
играете ли вы сами — если да, вы занимаете первое место под именем из профиля. Вы называете каждого играете ли вы сами — если да, вы занимаете первое место под именем из профиля. Вы называете каждого
игрока (от 2 до 4; строки можно добавлять и удалять, удаление спрашивает пароль ведущего) и можете игрока (от 2 до 4; строки можно добавлять и удалять, удаление спрашивает пароль ведущего) и можете
@@ -312,14 +323,28 @@ e-mail) либо ввод фразы. Активные игры форфейтя
идущей или завершённой — из лобби спрашивает пароль ведущего, чтобы никто не стёр партию сразу после идущей или завершённой — из лобби спрашивает пароль ведущего, чтобы никто не стёр партию сразу после
своего хода. своего хода.
Приложение также **само включает офлайн-режим**, когда не может достучаться до сети. При холодном Уход в офлайн и возврат — оба **автоматические**. При холодном запуске без связи приложение сразу
запуске без связи оно переходит в офлайн на текущую сессию; когда устройство онлайн, но шлюз молчит стартует в офлайн-лобби; пока оно открыто, потеря сети — режим полёта — сама уводит в офлайн, а
несколько секунд, оно сперва спрашивает — *Нет связи. Включить офлайн-режим?*, с выбором **Включить** восстановление сети само возвращает онлайн. Ни диалога, ни переключателя, который надо помнить:
(уйти в офлайн) или **Ждать сеть** (продолжить попытки онлайн). Пока приложение открыто, потеря сети — кратковременный сбой пережидается тихо, и только устойчивая потеря переводит интерфейс в офлайн, так что
режим полёта — переключает в офлайн автоматически, а её восстановление само возвращает онлайн. вас не прерывают из-за короткой икоты.
Самостоятельно включённый офлайн временный: он возвращается в онлайн, как только сеть появилась, и
следующий запуск проверяет заново. **Осознанный** офлайн — тумблер в Настройках или **Включить** в том **Потеря связи внутри онлайн-партии** показывается на **всех платформах, включая мини-приложения
диалоге — это выбор игрока: он сохраняется и никогда не отменяется автоматически. Telegram/VK** — в отличие от офлайн-режима игры выше, который только для веба и нативного приложения, —
потому что онлайн-партии сервер нужен на каждый ход. Сверху доски появляется тонкий баннер *связь
потеряна*, и игровая область **замораживается**: рэк и ходовые кнопки отключаются, а «сдаться»,
«в друзья»/«заблокировать» и вход в чат/словарь скрываются; начатый ход остаётся на доске черновиком.
Ничего не отправляется;
когда связь возвращается, баннер исчезает, кнопки оживают, и ход отправляешь ты сам. Если связь упала,
когда ты уже был в чате или словаре, ты там и остаёшься — чат становится «только для чтения» (отправка
и nudge отключаются), а словарь продолжает проверять слова по словарю партии на устройстве.
### Актуальная версия
Когда требуется новая версия клиента, приложение не блокирует вас наглухо. В **нативном** приложении
слишком старая сборка показывает уведомление с **Обновить** (открывает магазин) и **Играть офлайн**
(закрыть и продолжить играть на устройстве); в вебе вместо этого предлагается **Обновить** (перезагрузка).
Более мягкий, ненавязчивый баннер **«Доступно обновление»** появляется в лобби, когда есть новее — но пока
не обязательная — версия; его можно обновить или закрыть, игра продолжается в любом случае.
### Нативное приложение (Android) ### Нативное приложение (Android)
Игра также выходит как отдельное **приложение для Android** (сначала в **RuStore**) — упакованная сборка Игра также выходит как отдельное **приложение для Android** (сначала в **RuStore**) — упакованная сборка
@@ -432,9 +457,11 @@ Telegram. На сенсорном устройстве в настройках
обратную связь (роль, а не полная блокировка аккаунта), видит кнопку отправки недоступной. обратную связь (роль, а не полная блокировка аккаунта), видит кнопку отправки недоступной.
### Чат поддержки в Telegram ### Чат поддержки в Telegram
С операторами можно поговорить и прямо из Telegram-бота: всё, что пользователь присылает боту, С операторами можно поговорить и прямо из Telegram-бота. Команда `/support` отвечает графиком работы
кроме `/start` — текст, фото, голосовое, файл, — пересылается в закрытую группу поддержки операторов поддержки и напоминанием, что приложить (подробное описание и, по возможности, скриншоты). Всё
и собирается в отдельную ветку на пользователя. Пользователь не получает автоответа; оператор остальное, что пользователь присылает боту, кроме `/start` — текст, фото, голосовое, файл, —
пересылается в закрытую группу поддержки операторов и собирается в отдельную ветку на пользователя.
Пользователь не получает автоответа на пересланное сообщение; оператор
отвечает из этой ветки, и бот доставляет ответ обратно обычным сообщением — для пользователя это отвечает из этой ветки, и бот доставляет ответ обратно обычным сообщением — для пользователя это
тихий диалог один на один. Операторы могут заблокировать пользователя (тогда бот молча игнорирует тихий диалог один на один. Операторы могут заблокировать пользователя (тогда бот молча игнорирует
его сообщения) или очистить сообщения ветки. Это независимо от встроенной «Обратной связи» выше — его сообщения) или очистить сообщения ветки. Это независимо от встроенной «Обратной связи» выше —
@@ -540,7 +567,9 @@ high-rate флага. С карточки пользователя операт
Там, где бот ведёт **привязанный к каналу чат-обсуждение**, по умолчанию писать может каждый, а бот Там, где бот ведёт **привязанный к каналу чат-обсуждение**, по умолчанию писать может каждый, а бот
**глушит** игрока, который **не зарегистрирован** или **заблокирован**, и снимает мьют, как только тот **глушит** игрока, который **не зарегистрирован** или **заблокирован**, и снимает мьют, как только тот
зарегистрируется или будет разблокирован. То есть незарегистрированного новичка, написавшего в чат, зарегистрируется или будет разблокирован. Ещё бот убирает автоматический пин, который Telegram ставит
на каждый пост канала, авто-форварднутый в чат, — закреплёнными остаются только намеренно закреплённые
сообщения. То есть незарегистрированного новичка, написавшего в чат,
бот глушит (промо-бот направляет его в игру зарегистрироваться, после чего бот возвращает голос), а бот глушит (промо-бот направляет его в игру зарегистрироваться, после чего бот возвращает голос), а
зарегистрированный незаблокированный игрок просто пишет. Оператор также может **замьютить игрока только зарегистрированный незаблокированный игрок просто пишет. Оператор также может **замьютить игрока только
в чате** — роль `chat_muted` на карточке пользователя — без полной блокировки аккаунта; блокировка в чате** — роль `chat_muted` на карточке пользователя — без полной блокировки аккаунта; блокировка
+90
View File
@@ -0,0 +1,90 @@
# Icon & logo asset map
> The whole shipped icon set is sliced from **one master**. This file is the per-platform
> **target map** (which file goes where, at what size). How the mark itself is constructed
> — proportions, palette, wood grain, the light/shadow gradients, the «Э» + ✻ placement,
> all in fractions of the side — is specified in [`ICON_BRANDBOOK.md`](ICON_BRANDBOOK.md).
> The reference generator, the pinned Spectral font and the committed masters live in
> [`../assets/icons/brand/`](../assets/icons/brand/).
## The master (one source, six layers)
`brand/build-icon.mjs` emits any `--variant light|dark` in any `--layer`:
| Layer | What | Feeds |
|---|---|---|
| `full` | rounded tile + master mark | `favicon.svg` |
| `flat` | square tile + master mark (OS rounds) | apple-touch, PWA "any", VK, TG, store |
| `background` | square tile only, full-bleed | Android adaptive background |
| `foreground` | mark only, transparent, re-placed for the round mask | Android adaptive foreground |
| `maskable` | square tile + foreground-placed mark | PWA `maskable` |
| `monochrome` | foreground-placed silhouette, flat colour | Android 13+ themed layer |
`brand/build-set.mjs` renders every raster below; `brand/build-android-res.mjs` renders
the Android launcher resources.
## Generated targets
### Web — `ui/public/` (via `brand/build-set.mjs`)
| File | Size | Note |
|---|---|---|
| `favicon.svg` | vector | **light + dark in one file** via `prefers-color-scheme` |
| `favicon.ico` | 32×32 | PNG-in-ICO; answers the blind `/favicon.ico` probe |
| `apple-touch-icon.png` | 180×180 | opaque full-bleed (iOS masks its own corners) |
| `icon-192.png` / `icon-512.png` | 192 / 512 | PWA `any`, light |
| `icon-192-dark.png` / `icon-512-dark.png` | 192 / 512 | dark variants (generated; see PWA note) |
| `icon-maskable-512.png` | 512×512 | PWA `maskable`, light |
| `icon-maskable-512-dark.png` | 512×512 | dark variant (generated; see PWA note) |
| `og-image.png` | 1200×630 | board-green card: master tile + «Эрудит» / Игра в слова |
### PWA — `ui/public/manifest.webmanifest`
Referenced icons are the **light** set (`icon-192`, `icon-512` = `any`;
`icon-maskable-512` = `maskable`). The manifest has **no reliable way to pick an icon by
colour scheme**, so the dark PNGs are **generated but not referenced** — theming the
in-browser tab is handled by `favicon.svg` instead. Wire the dark files in only if a
concrete installer target is known to honour them.
### Android — `ui/android/app/src/main/res/` (via `brand/build-android-res.mjs`)
Capacitor layer inputs are `ui/assets/{icon,icon-foreground,icon-background}.png`. The
resources are **hand-generated from the layers**, not `capacitor-assets` — our layers
already fill the 108 dp canvas (foreground inside the 61 % safe zone, background
full-bleed), so they must be placed with **no inset**, and we add the **monochrome**
themed layer, neither of which `capacitor-assets` does. Per density mdpi→xxxhdpi (+ldpi):
- `mipmap-*/ic_launcher.png` / `ic_launcher_round.png` — legacy square / round (composite)
- `mipmap-*/ic_launcher_foreground.png` + `ic_launcher_background.png` — adaptive layers
- `mipmap-*/ic_launcher_monochrome.png` — themed layer
- `mipmap-anydpi-v26/ic_launcher.xml` + `ic_launcher_round.xml`**no `<inset>`**, with
`<background>` + `<foreground>` + `<monochrome>` (committed by hand)
### iOS / iPhone / iPad — FUTURE
No iOS shell today. When one exists, `flat` (opaque, no alpha, no rounded corners; the OS
masks) drives the asset catalog — a single 1024×1024 marketing icon (Xcode derives the
rest), plus light/dark/tinted where the catalog supports it.
### VK / Telegram / store — manual upload (`brand/{vk,tg,store}/`)
Square, **no rounding** (each platform crops/rounds itself), light `flat`:
| Folder | Files | Use |
|---|---|---|
| `vk/` | `vk-576/278/150/32.png` | VK community / app icons |
| `tg/` | `tg-512.png` | Telegram bot avatar + Mini App icon (512, square; TG crops circular) |
| `tg/` | `tg-demo-640x360.png` | BotFather `/newapp` demo banner (tile + wordmark) |
| `store/` | `rustore-512.png` | RuStore app icon |
| RuStore | screenshots / feature graphic | per the RuStore listing spec (uploaded by hand) |
| Google Play (future) | icon / feature graphic | 512×512 / 1024×500 |
| App Store (future) | marketing icon / screenshots | 1024×1024 / per device class |
## Regenerate
```sh
cd assets/icons/brand
npm i # opentype.js (rasterisation reuses the ui package's chromium)
node build-set.mjs # web (ui/public) + Capacitor layers (ui/assets) + vk/ tg/ store/ + og
node build-android-res.mjs # Android launcher resources (all densities + monochrome)
```
+166
View File
@@ -0,0 +1,166 @@
# Erudit app-icon — brand book
The single source of truth for **how the Erudit icon is constructed**. It is written to
be reproducible *from words alone*: a designer with this document and the Spectral font
can rebuild the icon exactly, at any size, without pixel-matching anything.
Two rules make that possible:
1. **The icon is a square.** Every size and position below is a **fraction of the
square's side** (equivalently, a percentage). Nothing is in pixels. Draw the square
at whatever resolution you need and scale each value to it.
2. **The origin is the top-left corner.** `x` grows right, `y` grows down. So "center
`48.1% / 49.2%`" means a point `0.481·side` from the left and `0.492·side` from the top.
The companion generator in [`../assets/icons/brand/`](../assets/icons/brand/) is the
machine version of this exact spec (`build-icon.mjs`, where each constant is the same
fraction printed here). The `docs/ICONS.md` map covers the *opposite* concern — which
files/sizes we slice this master into for each platform.
## Anatomy
![Construction scheme](../assets/icons/brand/icon-construction.png)
Bottom-to-top the icon is six layers: **tile → wood grain → light sheen → shadow →
letter «Э» → star ✻**. The letter and star are always drawn last, at full ink colour;
the grain and the two gradients only ever touch the background.
## Palette
The icon ships as a **light** and a **dark** variant (a matched pair — the light tile
carries dark ink, the dark tile carries light ink). Each variant uses four colours.
| Role | Light | Dark |
|------|-------|------|
| **Tile** — the wooden body | `#e6b053` | `#4a3316` |
| **Ink** — the «Э» and the ✻ | `#5a3a12` | `#f2d9a0` |
| **Grain** — the vertical stripes | `#d8a54e` | `#432e14` |
| **Sheen** — diagonal light | white, α 0 → 15 % | white, α 0 → 15 % |
| **Shadow** — diagonal dark | black, α 15 → 0 % | black, α 15 → 0 % |
The grain colour is intentionally close to the tile — a hair darker — so the stripes
read as wood texture, not as stripes.
## 1 — Tile
A **square with rounded corners**, corner radius **17 %** of the side, filled with the
**Tile** colour. This is the whole background; there is no separate outer frame or
border. Corners are baked at 17 %; platform masks (iOS/Android) may round further on top.
## 2 — Wood grain
Subtle vertical planks over the tile:
- Divide the width into **6 equal columns** (each `1/6` of the side wide).
- On the **right edge of each column**, draw one **vertical stripe**, width **`1/32` of
the side** (≈ 3.1 %), running the full height.
- Move the **whole set of six stripes left** by **`1/16` of the side** (= two stripe
widths).
- **Tilt every stripe 4°** (clockwise, about its own centre).
- Draw the stripes **past the top and bottom edges** so the tilt leaves no empty corners,
then **clip the whole set to the tile** (so the rounded corners stay clean).
- Colour: **Grain**.
## 3 — Light sheen · 4 — Shadow
Two linear gradients along the **same diagonal**, from the **bottom-left corner to the
top-right corner**, each clipped to the tile and sitting **above the grain, below the
letter**:
- **Sheen** — white. Fully transparent at the bottom-left, rising to **15 % opacity** at
the top-right. Brightens the top-right.
- **Shadow** — black. **15 % opacity** at the bottom-left, fading to transparent at the
top-right. Deepens the bottom-left.
Together they give the tile a lit-from-the-top-right, resting-in-shadow-at-the-bottom-left
sense of depth. Both percentages are a tuning knob (`--sheen`, `--shade`).
## 5 — The letter «Э»
- Typeface: **Spectral, Bold** (SIL OFL; pinned in the brand folder).
- Glyph: the Cyrillic capital **«Э»** (U+042D).
- Size: scale the glyph so its **ink bounding box is 60.5 % of the side tall** (its width
then follows the font — about 53.2 %).
- Position: place the **center of that bounding box** at **48.1 % / 49.2 %** (just up and
left of the icon center).
- Colour: **Ink**.
## 6 — The star ✻
A **six-spoke teardrop asterisk** (the "score" mark that replaces a tile's point number):
- Each spoke tapers to a **point at the center** and ends in a **round bulb**; a central
disc equal to a bulb fuses the six spokes.
- Outer diameter: **17.3 % of the side**.
- Bulb radius (and the central disc): **22 % of the star's radius**.
- Position: center at **78.8 % / 78.8 %** — tucked to the lower-right of the «Э», like a
subscript, with a clear gap from the letter.
- Colour: **Ink**.
Spectral has no ✻ glyph, so the star is **drawn geometrically**, not set as type. The exact
construction is in `build-icon.mjs` (`starPath`).
## Composition intent
The «Э» and the ✻ are treated as **one group**. Its combined bounding box measures
about **65.9 % × 68.6 %** of the side, and it is placed so its **bottom-right corner sits
at 87.5 % / 87.5 %** of the icon (the lower-right corner of an inner `12.5 % … 87.5 %`
safe square). That is what pushes the composition down-and-right and leaves the star room
in the corner. When re-deriving positions, this is the anchor to preserve.
> This bottom-right bias suits a **full-bleed** icon (iOS / PWA / favicon, which show
> almost the whole square). Android crops more, so it uses a **separate two-layer build**
> — see the next section. The master itself is never moved for Android.
## Android adaptive icon (two layers)
Android icons are **108×108 dp** and the OS applies its own mask (circle, squircle,
rounded square…). Only the **inner 66 dp = 61 % of the side, centred** is guaranteed
visible under every mask; the outer ~1/6 ring is used for the mask and motion effects.
So Android is **not** the full-bleed master — it is built as two layers:
- **Background** — the tile with wood grain and the light/shadow gradients, **full-bleed
and square** (no rounded corners; the OS supplies the shape). It fills the whole 108 dp
layer, so the sacrificial outer ring is just more wood.
- **Foreground** — **only the «Э» + ✻**, transparent, **re-placed** to sit safely inside
the mask:
- «Э»: box height **53.9 %**, box center **52.3 % / 49.5 %**.
- ✻: outer diameter **13.2 %** (smaller than the master's), center **76.3 % / 72.7 %**,
tucked closer to the letter.
- The mark is centred, then nudged **right 8 % / down 3 %** so it reads optically
centred under the round mask, and the whole group fits within the **61 % safe zone**.
Everything else (palette, grain, gradients, star construction) is identical to the master.
## Variants in use
The **light** variant is the primary icon (launcher, favicon, apple-touch, PWA). The
**dark** variant is shipped in addition wherever the platform can pick by appearance
(e.g. an SVG favicon via `prefers-color-scheme`). Where a platform allows only one icon,
use **light**.
## Reproduce
```sh
cd assets/icons/brand
npm i opentype.js
# full master (iOS / PWA / favicon), either variant:
node build-icon.mjs --variant light --layer full > icon-light.svg
# Android adaptive layers:
node build-icon.mjs --variant light --layer background > icon-light-android-background.svg
node build-icon.mjs --variant light --layer foreground > icon-light-android-foreground.svg
# the construction figure:
node build-icon.mjs --variant light --scheme > icon-construction.svg
node render-png.mjs icon-light.svg icon-light.png 1024 # any size
```
`--layer` is `full` (default), `background`, or `foreground`. All outputs are committed
alongside the generator in [`../assets/icons/brand/`](../assets/icons/brand/).
## Masters
| | Light | Dark |
|-|-------|------|
| **Full** (iOS / PWA / favicon) | ![light](../assets/icons/brand/icon-light.png) | ![dark](../assets/icons/brand/icon-dark.png) |
| **Android background** | ![lbg](../assets/icons/brand/icon-light-android-background.png) | ![dbg](../assets/icons/brand/icon-dark-android-background.png) |
| **Android foreground** | ![lfg](../assets/icons/brand/icon-light-android-foreground.png) | ![dfg](../assets/icons/brand/icon-dark-android-foreground.png) |
+3 -1
View File
@@ -201,7 +201,9 @@ web+PWA / native Android+iOS через Capacitor). Владелец (самоз
начисления): Фишки, подсказки, дни-без-рекламы, участие-в-турнире. **Продукт = набор начисления): Фишки, подсказки, дни-без-рекламы, участие-в-турнире. **Продукт = набор
атомов + цена** (по одной ценности или комбо). «Пакет Фишек» — цена **per-метод** атомов + цена** (по одной ценности или комбо). «Пакет Фишек» — цена **per-метод**
(мультивалютная: Голоса/Stars/руб — один продукт, D2). «Ценность за Фишки» — цена в (мультивалютная: Голоса/Stars/руб — один продукт, D2). «Ценность за Фишки» — цена в
Фишках (единая). Курсы покупки Фишек и Фишки за rewarded — тоже в каталоге. Фишках (единая). Курсы покупки Фишек и Фишки за rewarded — тоже в каталоге. (Ставка
rewarded + дневной/часовой потолки — строка общего конфига `payments.config`, правится в
секции «Rewarded ads» на странице каталога админки; это не продаваемый продукт-атом.)
- **D33. Стекинг «без рекламы»:** `paid_until[origin] += срок` от `max(now, текущий - **D33. Стекинг «без рекламы»:** `paid_until[origin] += срок` от `max(now, текущий
конец)` (остаток не теряется, «плюсуются» как в документе). «Навсегда» — отдельный конец)` (остаток не теряется, «плюсуются» как в документе). «Навсегда» — отдельный
вечный флаг (перекрывает сроки). Бонусы «(+50)» — маркетинговая пометка владельца; вечный флаг (перекрывает сроки). Бонусы «(+50)» — маркетинговая пометка владельца;
+22 -13
View File
@@ -17,14 +17,17 @@ tests or touching CI.
- **UI** — Vitest (unit) + Playwright - **UI** — Vitest (unit) + Playwright
(e2e), mirroring the chosen plain-Svelte + Vite toolchain. Vitest covers (e2e), mirroring the chosen plain-Svelte + Vite toolchain. Vitest covers
the FlatBuffers codecs (friend list, invitation, stats), the win-rate the FlatBuffers codecs (friend list, invitation, stats), the win-rate
derivation and the GCG share/copy/download choice, plus Playwright specs against the derivation, the GCG share/copy/download choice and the **net-state reducer**
(`netstate.test.ts` — every transition of the connectivity/version machine, the anti-flap
hysteresis, the two-tier version decision and all 12 offline edge cases as pure assertions),
plus Playwright specs against the
mock for the friends screen (code issue/redeem, accept a request), the lobby mock for the friends screen (code issue/redeem, accept a request), the lobby
invitations section, the stats screen, profile editing, and the export chooser's invitations section, the stats screen, profile editing, and the export chooser's
finished-only visibility + its signed-URL download flow (route-intercepted). The finished-only visibility + its signed-URL download flow (route-intercepted). The
**offline mode** spec (`e2e/offline.spec.ts`) plays a full device-local `vs_ai` game **offline mode** spec (`e2e/offline.spec.ts`) plays a full device-local `vs_ai` game
end-to-end: it forces the installed-PWA display mode, enters offline through the end-to-end: offline is **implicit** now (no toggle), so it drives the mock's `__net` hook to
Settings toggle (whose readiness check fetches the enabled variants' dawgs), then creates auto-detect offline (asserting the toast, the greyed-from-cache server games and the disabled
and plays a local game with a **pinned bag seed** (`window.__mock.setLocalSeed`, so the Stats tab), self-heals back online, then creates and plays a local game with a **pinned bag seed** (`window.__mock.setLocalSeed`, so the
rack is deterministic and the human can tap out a precomputed opening), asserting the rack is deterministic and the human can tap out a precomputed opening), asserting the
robot's real reply and the IndexedDB replay after a reload. A local game needs a real robot's real reply and the IndexedDB replay after a reload. A local game needs a real
dictionary, so the mock's `fetchDict` serves the per-variant dawgs from the preview dictionary, so the mock's `fetchDict` serves the per-variant dawgs from the preview
@@ -36,15 +39,21 @@ tests or touching CI.
`Capacitor.getPlatform` stub is clobbered by the core shim), then asserts a no-session cold boot lands `Capacitor.getPlatform` stub is clobbered by the core shim), then asserts a no-session cold boot lands
in the **offline guest lobby** (not `/login`), plays a local vs_ai move from the **bundled** dawg tier in the **offline guest lobby** (not `/login`), plays a local vs_ai move from the **bundled** dawg tier
(`playwright.config.ts` bundles the dicts into `dist-e2e/dict/`), starts a hotseat game, and drives (`playwright.config.ts` bundles the dicts into `dist-e2e/dict/`), starts a hotseat game, and drives
`window.__native.reconcile()` to prove online lights up and Profile hides the Telegram/VK link buttons. `window.__native.reconcile()` to prove online lights up — the device-local game stays listed in the
The **update overlay** spec (`e2e/update.spec.ts`) drives the `__update` hook to prove the terminal **unified lobby** — and Profile hides the Telegram/VK link buttons.
update cover; `retry.test.ts` covers the `FailedPrecondition → update_required` mapping. The **version-gate** spec (`e2e/update.spec.ts`) drives the `__update` hook to prove both tiers — the
- **Client-version gate** (Go) — `gateway/internal/clientver` unit-tests the `v?MAJOR.MINOR.PATCH` parse hard *Update / Play offline* notice (and that "Play offline" drops into the offline lobby) and the soft
(with/without `v`, a `-N-gSHA` suffix, `dev`/empty ⇒ !ok) and ordering; `connectsrv`'s server tests dismissable *update available* nudge in the lobby; `retry.test.ts` covers the `FailedPrecondition →
assert a too-old `Execute` returns `result_code = "update_required"` (and the op handler never ran), a update_required` mapping.
too-old `Subscribe` returns `FailedPrecondition`, and an absent header / empty min / unparseable header - **Client-version gate** (Go, two tiers) — `gateway/internal/clientver` unit-tests the `v?MAJOR.MINOR.PATCH`
/ equal version all pass (fail-open); `config` tests reject a non-empty, unparseable parse (with/without `v`, a `-N-gSHA` suffix, `dev`/empty ⇒ !ok) and ordering. `connectsrv`'s server tests
`GATEWAY_MIN_CLIENT_VERSION`. assert the **hard** tier — a too-old `Execute` returns `result_code = "update_required"` (and the op
handler never ran), a too-old `Subscribe` returns `FailedPrecondition`, and an absent header / empty min /
unparseable header / equal version all pass (fail-open) — and the **soft** tier (`TestExecuteUpdateRecommended`):
a served build in `min ≤ v < recommended` carries the `X-Update-Recommended: 1` response header, while a
too-old, up-to-date, absent/garbled, or dormant-tier request does not. `config` tests reject a non-empty
unparseable `GATEWAY_MIN_CLIENT_VERSION` / `GATEWAY_RECOMMENDED_CLIENT_VERSION`, and a recommended below the
minimum.
- **Render sidecar** — `renderer/` (Node + skia-canvas executing the shared - **Render sidecar** — `renderer/` (Node + skia-canvas executing the shared
`ui/src/lib/gameimage.ts`) carries a `node --test` smoke: the committed fixture (a `ui/src/lib/gameimage.ts`) carries a `node --test` smoke: the committed fixture (a
real self-played 35-move game) must rasterize to a plausible PNG real self-played 35-move game) must rasterize to a plausible PNG
+8 -3
View File
@@ -42,10 +42,15 @@ ENV VITE_TELEGRAM_BOT_ID=$VITE_TELEGRAM_BOT_ID \
VITE_APP_VERSION=$VITE_APP_VERSION \ VITE_APP_VERSION=$VITE_APP_VERSION \
VITE_ADS_STUB=$VITE_ADS_STUB VITE_ADS_STUB=$VITE_ADS_STUB
# Install with the lockfile first (the workspace file carries pnpm's build-script # Install with the lockfile first, then build. Committed src/gen/ means no codegen here.
# approval for esbuild), then build. Committed src/gen/ means no codegen here. # --ignore-scripts: the Vite build needs no dependency build script — esbuild's platform binary
# ships as an optional dependency (only its CLI-shim postinstall is skipped, which Vite does not use),
# and core-js-bundle's prebuilt file is read directly. It notably skips sharp's native build (a
# `pnpm android:assets` tool via @capacitor/assets, unused here), which would otherwise fail on this
# Alpine/musl stage with no prebuilt binary and no compiler. The workspace allowBuilds stay for local
# installs (where android:assets does need sharp built).
COPY ui/package.json ui/pnpm-lock.yaml ui/pnpm-workspace.yaml ./ COPY ui/package.json ui/pnpm-lock.yaml ui/pnpm-workspace.yaml ./
RUN pnpm install --frozen-lockfile RUN pnpm install --frozen-lockfile --ignore-scripts
COPY ui ./ COPY ui ./
RUN pnpm build RUN pnpm build
+2 -1
View File
@@ -121,7 +121,8 @@ web code exchange via `internal/vkid`, and both forward the trusted `external_id
Rate-limit defaults (built-in): public 30/min·IP (burst 10), authenticated Rate-limit defaults (built-in): public 30/min·IP (burst 10), authenticated
300/min·user (burst 80, sized for multi-device play), admin 300/min·user (burst 80, sized for multi-device play), admin
60/min·IP (burst 20, guarding the `/_gm` mount ahead of its Basic-Auth), 60/min·IP (burst 20, guarding the `/_gm` mount ahead of its Basic-Auth),
email-code 5/10 min·IP (burst 2). email-code 5/10 min·IP (burst 4, covering request + a mistyped code + the
correct one; defence-in-depth over the backend's per-code 5-attempt/15-min cap).
Every rejection increments `gateway_rate_limited_total{class}` Every rejection increments `gateway_rate_limited_total{class}`
(`user`/`public`/`email`/`admin`) and logs one Debug line; a reporter drains the (`user`/`public`/`email`/`admin`) and logs one Debug line; a reporter drains the
+18 -17
View File
@@ -221,23 +221,24 @@ func run(ctx context.Context, cfg config.Config, logger *zap.Logger) error {
} }
registry := transcode.NewRegistry(backend, validator, regOpts...) registry := transcode.NewRegistry(backend, validator, regOpts...)
edge := connectsrv.NewServer(connectsrv.Deps{ edge := connectsrv.NewServer(connectsrv.Deps{
Registry: registry, Registry: registry,
Sessions: sessions, Sessions: sessions,
Backend: backend, Backend: backend,
Limiter: limiter, Limiter: limiter,
Tracker: tracker, Tracker: tracker,
Banlist: banlist, Banlist: banlist,
Blocklist: blocklist, Blocklist: blocklist,
Honeytoken: cfg.Abuse.Honeytoken, Honeytoken: cfg.Abuse.Honeytoken,
VKAppSecret: cfg.VKAppSecret, VKAppSecret: cfg.VKAppSecret,
Hub: hub, Hub: hub,
RateLimit: cfg.RateLimit, RateLimit: cfg.RateLimit,
Heartbeat: cfg.PushHeartbeatInterval, Heartbeat: cfg.PushHeartbeatInterval,
Logger: logger, Logger: logger,
AdminProxy: adminProxy, AdminProxy: adminProxy,
Meter: tel.MeterProvider().Meter("scrabble/gateway/edge"), Meter: tel.MeterProvider().Meter("scrabble/gateway/edge"),
MaxBodyBytes: cfg.MaxBodyBytes, MaxBodyBytes: cfg.MaxBodyBytes,
MinClientVersion: cfg.MinClientVersion, MinClientVersion: cfg.MinClientVersion,
RecommendedClientVersion: cfg.RecommendedClientVersion,
}) })
// Bridge the backend push stream into the fan-out hub (and the out-of-app // Bridge the backend push stream into the fan-out hub (and the out-of-app
+33 -10
View File
@@ -60,6 +60,11 @@ type Config struct {
// "update required" signal before its payload is decoded. Empty leaves the gate dormant // "update required" signal before its payload is decoded. Empty leaves the gate dormant
// (every client is served) — the default for web-only deployments. // (every client is served) — the default for web-only deployments.
MinClientVersion string MinClientVersion string
// RecommendedClientVersion, when non-empty, is the version below which a served client is nudged
// to update — a non-blocking "update available" signal (the X-Update-Recommended response header)
// on gated responses; the call still succeeds. It must be at least MinClientVersion. Empty leaves
// the soft tier off (the default); it does not affect the hard MinClientVersion gate.
RecommendedClientVersion string
// RateLimit configures the in-memory anti-abuse limiter. // RateLimit configures the in-memory anti-abuse limiter.
RateLimit RateLimitConfig RateLimit RateLimitConfig
// Abuse configures the temporary IP ban and the honeytoken (prod-only). // Abuse configures the temporary IP ban and the honeytoken (prod-only).
@@ -160,7 +165,12 @@ func DefaultRateLimit() RateLimitConfig {
// because multi-device play tripped the old 120/40. // because multi-device play tripped the old 120/40.
UserPerMinute: 300, UserBurst: 80, UserPerMinute: 300, UserBurst: 80,
AdminPerMinute: 60, AdminBurst: 20, AdminPerMinute: 60, AdminBurst: 20,
EmailPer10Min: 5, EmailBurst: 2, // Email-code path (per IP), defence-in-depth over the backend's own per-code guards
// (a 5-attempt cap + 15-min TTL + per-recipient send throttle). Burst 4 so the honest flow
// — request a code, mistype once or twice, then enter the right one — is not throttled
// mid-login: a request + wrong-code + right-code sequence exhausted the old burst of 2 and
// tripped the limit on the correct code, which the client mis-read as going offline.
EmailPer10Min: 5, EmailBurst: 4,
} }
} }
@@ -232,15 +242,16 @@ func (c VKIDConfig) Enabled() bool {
func Load() (Config, error) { func Load() (Config, error) {
var err error var err error
c := Config{ c := Config{
HTTPAddr: envOr("GATEWAY_HTTP_ADDR", defaultHTTPAddr), HTTPAddr: envOr("GATEWAY_HTTP_ADDR", defaultHTTPAddr),
LogLevel: envOr("GATEWAY_LOG_LEVEL", defaultLogLevel), LogLevel: envOr("GATEWAY_LOG_LEVEL", defaultLogLevel),
BackendHTTPURL: envOr("GATEWAY_BACKEND_HTTP_URL", defaultBackendHTTPURL), BackendHTTPURL: envOr("GATEWAY_BACKEND_HTTP_URL", defaultBackendHTTPURL),
BackendGRPCAddr: envOr("GATEWAY_BACKEND_GRPC_ADDR", defaultBackendGRPCAddr), BackendGRPCAddr: envOr("GATEWAY_BACKEND_GRPC_ADDR", defaultBackendGRPCAddr),
AdminUser: os.Getenv("GATEWAY_ADMIN_USER"), AdminUser: os.Getenv("GATEWAY_ADMIN_USER"),
AdminPassword: os.Getenv("GATEWAY_ADMIN_PASSWORD"), AdminPassword: os.Getenv("GATEWAY_ADMIN_PASSWORD"),
ValidatorAddr: os.Getenv("GATEWAY_VALIDATOR_ADDR"), ValidatorAddr: os.Getenv("GATEWAY_VALIDATOR_ADDR"),
VKAppSecret: os.Getenv("GATEWAY_VK_APP_SECRET"), VKAppSecret: os.Getenv("GATEWAY_VK_APP_SECRET"),
MinClientVersion: os.Getenv("GATEWAY_MIN_CLIENT_VERSION"), MinClientVersion: os.Getenv("GATEWAY_MIN_CLIENT_VERSION"),
RecommendedClientVersion: os.Getenv("GATEWAY_RECOMMENDED_CLIENT_VERSION"),
VKID: VKIDConfig{ VKID: VKIDConfig{
AppID: os.Getenv("GATEWAY_VK_ID_APP_ID"), AppID: os.Getenv("GATEWAY_VK_ID_APP_ID"),
ClientSecret: os.Getenv("GATEWAY_VK_ID_CLIENT_SECRET"), ClientSecret: os.Getenv("GATEWAY_VK_ID_CLIENT_SECRET"),
@@ -344,6 +355,18 @@ func (c Config) validate() error {
return fmt.Errorf("config: GATEWAY_MIN_CLIENT_VERSION %q is not a MAJOR.MINOR.PATCH version", c.MinClientVersion) return fmt.Errorf("config: GATEWAY_MIN_CLIENT_VERSION %q is not a MAJOR.MINOR.PATCH version", c.MinClientVersion)
} }
} }
if c.RecommendedClientVersion != "" {
rec, ok := clientver.Parse(c.RecommendedClientVersion)
if !ok {
return fmt.Errorf("config: GATEWAY_RECOMMENDED_CLIENT_VERSION %q is not a MAJOR.MINOR.PATCH version", c.RecommendedClientVersion)
}
// The soft tier must sit at or above the hard minimum: a recommended below the minimum is a
// misconfiguration (every client below min is already turned away). An empty/unparseable
// minimum imposes no lower bound, so the recommended may stand alone.
if min, ok := clientver.Parse(c.MinClientVersion); ok && clientver.Less(rec, min) {
return fmt.Errorf("config: GATEWAY_RECOMMENDED_CLIENT_VERSION %q must be >= GATEWAY_MIN_CLIENT_VERSION %q", c.RecommendedClientVersion, c.MinClientVersion)
}
}
if c.Abuse.BanEnabled && (c.Abuse.BanThreshold <= 0 || c.Abuse.BanWindow <= 0 || c.Abuse.BanDuration <= 0) { if c.Abuse.BanEnabled && (c.Abuse.BanThreshold <= 0 || c.Abuse.BanWindow <= 0 || c.Abuse.BanDuration <= 0) {
return fmt.Errorf("config: GATEWAY_ABUSE_BAN_THRESHOLD/_WINDOW/_DURATION must be positive when GATEWAY_ABUSE_BAN_ENABLED") return fmt.Errorf("config: GATEWAY_ABUSE_BAN_THRESHOLD/_WINDOW/_DURATION must be positive when GATEWAY_ABUSE_BAN_ENABLED")
} }
+36
View File
@@ -70,6 +70,42 @@ func TestLoadMinClientVersion(t *testing.T) {
} }
} }
// TestLoadRecommendedClientVersion verifies the soft-tier config: dormant (empty) by default, a
// parseable version accepted (standing alone with no minimum, or at/above one), an unparseable one
// rejected, and a recommended below the minimum rejected.
func TestLoadRecommendedClientVersion(t *testing.T) {
c, err := Load()
if err != nil {
t.Fatalf("Load: %v", err)
}
if c.RecommendedClientVersion != "" {
t.Errorf("RecommendedClientVersion = %q, want empty (soft tier dormant)", c.RecommendedClientVersion)
}
// Stands alone with no minimum configured.
t.Setenv("GATEWAY_RECOMMENDED_CLIENT_VERSION", "v1.20.0")
if c, err = Load(); err != nil {
t.Fatalf("Load with a valid recommended version (no min): %v", err)
}
if c.RecommendedClientVersion != "v1.20.0" {
t.Errorf("RecommendedClientVersion = %q, want %q", c.RecommendedClientVersion, "v1.20.0")
}
// At or above the minimum is accepted.
t.Setenv("GATEWAY_MIN_CLIENT_VERSION", "v1.16.0")
if _, err = Load(); err != nil {
t.Fatalf("Load with recommended >= min: %v", err)
}
// Below the minimum is rejected.
t.Setenv("GATEWAY_RECOMMENDED_CLIENT_VERSION", "v1.10.0")
if _, err := Load(); err == nil {
t.Fatal("Load: expected an error for a recommended version below the minimum, got nil")
}
// Unparseable is rejected.
t.Setenv("GATEWAY_RECOMMENDED_CLIENT_VERSION", "dev")
if _, err := Load(); err == nil {
t.Fatal("Load: expected an error for an unparseable GATEWAY_RECOMMENDED_CLIENT_VERSION, got nil")
}
}
// TestLoadAbuseDefaults verifies the anti-abuse ban defaults: disabled (prod-only), // TestLoadAbuseDefaults verifies the anti-abuse ban defaults: disabled (prod-only),
// the agreed thresholds, and no honeytoken. // the agreed thresholds, and no honeytoken.
func TestLoadAbuseDefaults(t *testing.T) { func TestLoadAbuseDefaults(t *testing.T) {
+61 -4
View File
@@ -51,10 +51,15 @@ const honeypotHeader = "X-Scrabble-Honeypot"
// the edge can turn away a build too old to speak the current wire contract, before it decodes // the edge can turn away a build too old to speak the current wire contract, before it decodes
// the payload. resultUpdateRequired is the stable envelope result_code — with the Subscribe // the payload. resultUpdateRequired is the stable envelope result_code — with the Subscribe
// counterpart connect.CodeFailedPrecondition — that any build, however old, recognises as // counterpart connect.CodeFailedPrecondition — that any build, however old, recognises as
// "you must update". Both are part of the frozen wire contract (docs/ARCHITECTURE.md §2). // "you must update". updateRecommendedHeader is the additive, non-blocking soft-tier signal: set
// on a served Execute response when the client is at or above the hard minimum but below the
// recommended version, it nudges an update without failing the call. Headers are the
// version-tolerant layer, so an old client simply ignores it. All part of the frozen wire
// contract (docs/ARCHITECTURE.md §2).
const ( const (
clientVersionHeader = "X-Client-Version" clientVersionHeader = "X-Client-Version"
resultUpdateRequired = "update_required" resultUpdateRequired = "update_required"
updateRecommendedHeader = "X-Update-Recommended"
) )
// Limiter classes, the `class` attribute of gateway_rate_limited_total and the // Limiter classes, the `class` attribute of gateway_rate_limited_total and the
@@ -104,6 +109,12 @@ type Server struct {
minClient clientver.Version minClient clientver.Version
gateOn bool gateOn bool
// recClient is the version below which a served client is nudged to update (the soft tier);
// recOn is false when the soft tier is dormant (GATEWAY_RECOMMENDED_CLIENT_VERSION empty or
// unparseable). It is independent of the hard gate.
recClient clientver.Version
recOn bool
publicPolicy ratelimit.Policy publicPolicy ratelimit.Policy
userPolicy ratelimit.Policy userPolicy ratelimit.Policy
emailPolicy ratelimit.Policy emailPolicy ratelimit.Policy
@@ -148,6 +159,9 @@ type Deps struct {
// older X-Client-Version is turned away with "update required". Empty or unparseable leaves // older X-Client-Version is turned away with "update required". Empty or unparseable leaves
// the gate dormant. // the gate dormant.
MinClientVersion string MinClientVersion string
// RecommendedClientVersion is the version below which a served client is nudged to update (the
// non-blocking X-Update-Recommended header). Empty or unparseable leaves the soft tier dormant.
RecommendedClientVersion string
} }
// NewServer constructs the edge service. // NewServer constructs the edge service.
@@ -192,6 +206,18 @@ func NewServer(d Deps) *Server {
log.Warn("ignoring unparseable MinClientVersion; client-version gate disabled", zap.String("value", d.MinClientVersion)) log.Warn("ignoring unparseable MinClientVersion; client-version gate disabled", zap.String("value", d.MinClientVersion))
} }
} }
// Parse the recommended (soft-tier) version once, the same way. Config.validate already rejects an
// unparseable value or one below the minimum, so the warn branch only guards a direct (test)
// construction; an empty value leaves the soft tier dormant.
var recClient clientver.Version
recOn := false
if d.RecommendedClientVersion != "" {
if v, ok := clientver.Parse(d.RecommendedClientVersion); ok {
recClient, recOn = v, true
} else {
log.Warn("ignoring unparseable RecommendedClientVersion; update-recommended tier disabled", zap.String("value", d.RecommendedClientVersion))
}
}
return &Server{ return &Server{
registry: d.Registry, registry: d.Registry,
sessions: d.Sessions, sessions: d.Sessions,
@@ -210,6 +236,8 @@ func NewServer(d Deps) *Server {
maxBodyBytes: maxBody, maxBodyBytes: maxBody,
minClient: minClient, minClient: minClient,
gateOn: gateOn, gateOn: gateOn,
recClient: recClient,
recOn: recOn,
publicPolicy: ratelimit.PerMinute(rl.PublicPerMinute, rl.PublicBurst), publicPolicy: ratelimit.PerMinute(rl.PublicPerMinute, rl.PublicBurst),
userPolicy: ratelimit.PerMinute(rl.UserPerMinute, rl.UserBurst), userPolicy: ratelimit.PerMinute(rl.UserPerMinute, rl.UserBurst),
emailPolicy: ratelimit.Per(rl.EmailPer10Min, 10*time.Minute, rl.EmailBurst), emailPolicy: ratelimit.Per(rl.EmailPer10Min, 10*time.Minute, rl.EmailBurst),
@@ -335,15 +363,44 @@ func (s *Server) clientTooOld(header string) bool {
return clientver.Less(v, s.minClient) return clientver.Less(v, s.minClient)
} }
// clientUpdateRecommended reports whether the X-Client-Version header names a version in the soft
// band — at or above the hard minimum but below the recommended version — so a served response can
// carry the non-blocking X-Update-Recommended nudge. It fails open exactly like clientTooOld: a
// dormant soft tier, or an absent or unparseable header, returns false. A client below the minimum is
// turned away by the hard gate and is never nudged, so it is excluded here too (a dormant hard gate
// leaves minClient at the zero version, which no real version is below, so the band is simply
// "below recommended").
func (s *Server) clientUpdateRecommended(header string) bool {
if !s.recOn {
return false
}
v, ok := clientver.Parse(header)
if !ok {
return false
}
return !clientver.Less(v, s.minClient) && clientver.Less(v, s.recClient)
}
// Execute runs one unary operation. Domain failures are returned in the envelope // Execute runs one unary operation. Domain failures are returned in the envelope
// (result_code != "ok", HTTP 200); only edge failures (rate limit, missing // (result_code != "ok", HTTP 200); only edge failures (rate limit, missing
// session, unknown type, internal) become Connect errors. // session, unknown type, internal) become Connect errors.
func (s *Server) Execute(ctx context.Context, req *connect.Request[edgev1.ExecuteRequest]) (*connect.Response[edgev1.ExecuteResponse], error) { func (s *Server) Execute(ctx context.Context, req *connect.Request[edgev1.ExecuteRequest]) (resp *connect.Response[edgev1.ExecuteResponse], err error) {
start := time.Now() start := time.Now()
msgType := req.Msg.GetMessageType() msgType := req.Msg.GetMessageType()
result := "internal" result := "internal"
defer func() { s.metrics.recordEdge(ctx, msgType, result, start) }() defer func() { s.metrics.recordEdge(ctx, msgType, result, start) }()
// The soft-tier nudge rides a response header on any served response (the call still succeeds), so a
// client at or above the hard minimum but below the recommended version is told an update is
// available without being interrupted. A too-old client (turned away below) or a missing/garbled
// version yields no nudge.
recommend := s.clientUpdateRecommended(req.Header().Get(clientVersionHeader))
defer func() {
if recommend && resp != nil {
resp.Header().Set(updateRecommendedHeader, "1")
}
}()
// The version gate rides the outermost stable layer (an HTTP header) and is checked before // The version gate rides the outermost stable layer (an HTTP header) and is checked before
// the payload is decoded, so a too-old client makes zero successful calls but sees the // the payload is decoded, so a too-old client makes zero successful calls but sees the
// recognizable update_required envelope rather than a decode crash (docs/ARCHITECTURE.md §2). // recognizable update_required envelope rather than a decode crash (docs/ARCHITECTURE.md §2).
@@ -53,6 +53,33 @@ func newEdgeMin(t *testing.T, minVersion string, backendHandler http.HandlerFunc
} }
} }
// newEdgeVersions is newEdgeMin with the soft-tier recommended version also armed (empty leaves it off).
func newEdgeVersions(t *testing.T, minVersion, recommendedVersion string, backendHandler http.HandlerFunc) (edgev1connect.GatewayClient, func()) {
t.Helper()
backendSrv := httptest.NewServer(backendHandler)
backend, err := backendclient.New(backendSrv.URL, "localhost:9090", 2*time.Second)
if err != nil {
t.Fatalf("backendclient: %v", err)
}
edge := connectsrv.NewServer(connectsrv.Deps{
Registry: transcode.NewRegistry(backend, nil),
Sessions: session.NewCache(backend, time.Minute, 100),
Limiter: ratelimit.New(),
Hub: push.NewHub(0),
RateLimit: config.DefaultRateLimit(),
Heartbeat: 15 * time.Second,
MinClientVersion: minVersion,
RecommendedClientVersion: recommendedVersion,
})
edgeSrv := httptest.NewServer(edge.HTTPHandler())
client := edgev1connect.NewGatewayClient(http.DefaultClient, edgeSrv.URL)
return client, func() {
edgeSrv.Close()
_ = backend.Close()
backendSrv.Close()
}
}
func TestExecuteGuestAuthOK(t *testing.T) { func TestExecuteGuestAuthOK(t *testing.T) {
client, cleanup := newEdge(t, func(w http.ResponseWriter, r *http.Request) { client, cleanup := newEdge(t, func(w http.ResponseWriter, r *http.Request) {
_, _ = w.Write([]byte(`{"token":"tok","user_id":"u-1","is_guest":true,"display_name":"Guest"}`)) _, _ = w.Write([]byte(`{"token":"tok","user_id":"u-1","is_guest":true,"display_name":"Guest"}`))
@@ -156,6 +183,54 @@ func TestExecuteVersionGate(t *testing.T) {
} }
} }
// TestExecuteUpdateRecommended verifies the soft-tier nudge on the unary path: a served client at or
// above the hard minimum but below the recommended version gets the X-Update-Recommended response
// header (the call still succeeds); a too-old (hard-gated), up-to-date, absent, or unparseable version
// and a dormant soft tier get no header.
func TestExecuteUpdateRecommended(t *testing.T) {
backend := func(w http.ResponseWriter, _ *http.Request) {
_, _ = w.Write([]byte(`{"token":"tok","user_id":"u-1","is_guest":true,"display_name":"Guest"}`))
}
tests := []struct {
name string
min string
rec string
header string
wantResult string
wantHeader bool
}{
{"soft band is nudged", "v1.16.0", "v1.20.0", "v1.17.0", "ok", true},
{"at the minimum is nudged", "v1.16.0", "v1.20.0", "v1.16.0", "ok", true},
{"at the recommended is not nudged", "v1.16.0", "v1.20.0", "v1.20.0", "ok", false},
{"newer than recommended is not nudged", "v1.16.0", "v1.20.0", "v2.0.0", "ok", false},
{"too old is gated, not nudged", "v1.16.0", "v1.20.0", "v1.0.0", "update_required", false},
{"absent header, no nudge", "v1.16.0", "v1.20.0", "", "ok", false},
{"unparseable header, no nudge", "v1.16.0", "v1.20.0", "dev", "ok", false},
{"recommended alone (no min) nudges below it", "", "v1.20.0", "v1.0.0", "ok", true},
{"soft tier dormant, no nudge", "v1.16.0", "", "v1.0.0", "update_required", false},
}
for _, tc := range tests {
t.Run(tc.name, func(t *testing.T) {
client, cleanup := newEdgeVersions(t, tc.min, tc.rec, backend)
defer cleanup()
req := connect.NewRequest(&edgev1.ExecuteRequest{MessageType: transcode.MsgAuthGuest, RequestId: "rq"})
if tc.header != "" {
req.Header().Set("X-Client-Version", tc.header)
}
resp, err := client.Execute(context.Background(), req)
if err != nil {
t.Fatalf("execute: %v", err)
}
if got := resp.Msg.GetResultCode(); got != tc.wantResult {
t.Fatalf("result_code = %q, want %q", got, tc.wantResult)
}
if got := resp.Header().Get("X-Update-Recommended") == "1"; got != tc.wantHeader {
t.Fatalf("X-Update-Recommended present = %v, want %v", got, tc.wantHeader)
}
})
}
}
// TestSubscribeVersionGate verifies the streaming path: a too-old client is refused with // TestSubscribeVersionGate verifies the streaming path: a too-old client is refused with
// FailedPrecondition, while an equal or absent version is admitted and proceeds to auth (which // FailedPrecondition, while an equal or absent version is admitted and proceeds to auth (which
// fails Unauthenticated with no session, proving it passed the version gate). // fails Unauthenticated with no session, proving it passed the version gate).
@@ -4,6 +4,7 @@ import (
"testing" "testing"
"time" "time"
"scrabble/gateway/internal/config"
"scrabble/gateway/internal/ratelimit" "scrabble/gateway/internal/ratelimit"
) )
@@ -44,3 +45,20 @@ func TestPerWindow(t *testing.T) {
t.Fatalf("per-window burst = %v, want [true true false]", got) t.Fatalf("per-window burst = %v, want [true true false]", got)
} }
} }
// TestEmailBurstAllowsHonestLoginFlow guards the default email-code burst against being
// tightened back below the honest login sequence: requesting a code, submitting one wrong
// code, then the correct one are three immediate email-class events from one IP, and all
// must pass. A burst of 2 denied the correct-code login, which the client mis-read as going
// offline and could not recover from on the session-less login screen. This is defence in
// depth over the backend's own per-code attempt cap and code TTL.
func TestEmailBurstAllowsHonestLoginFlow(t *testing.T) {
rl := config.DefaultRateLimit()
p := ratelimit.Per(rl.EmailPer10Min, 10*time.Minute, rl.EmailBurst)
l := ratelimit.New()
for i, want := range []bool{true, true, true} { // request code, wrong code, right code
if got := l.Allow("email:198.51.100.7", p); got != want {
t.Fatalf("honest email event %d allowed = %v, want %v", i+1, got, want)
}
}
}
+18 -4
View File
@@ -9,8 +9,9 @@ it. See [`docs/ARCHITECTURE.md`](../../docs/ARCHITECTURE.md) §1/§3/§10/§12/
validation gRPC API the gateway calls during Telegram auth, on the trusted internal validation gRPC API the gateway calls during Telegram auth, on the trusted internal
network with no VPN. Because it needs no Telegram reachability, **game login stays up network with no VPN. Because it needs no Telegram reachability, **game login stays up
even when the bot or the bot-link is down**. even when the bot or the bot-link is down**.
- **`cmd/bot`** (remote) — runs the Bot API long-poll (Mini App launch + `/start` - **`cmd/bot`** (remote) — runs the Bot API long-poll (Mini App launch, `/start`
deep-links) and `sendMessage`, the only component reaching the Telegram Bot API. It deep-links, the `/support` info reply) and `sendMessage`, the only component reaching
the Telegram Bot API. It
holds **no inbound port**: it dials the gateway over a reverse **mTLS bot-link** and holds **no inbound port**: it dials the gateway over a reverse **mTLS bot-link** and
executes the send commands the gateway pushes, so its egress can run on a host with executes the send commands the gateway pushes, so its egress can run on a host with
native Telegram access (a VPN sidecar in the test contour, a separate host in prod). native Telegram access (a VPN sidecar in the test contour, a separate host in prod).
@@ -49,6 +50,13 @@ Telegram identity to an account from a browser. Both map a rejection to gRPC
chat" — rather than a dangling "@"). This is otherwise **self-contained** chat" — rather than a dangling "@"). This is otherwise **self-contained**
— the bot never calls back into the game, so `/start` onboarding works even when the game — the bot never calls back into the game, so `/start` onboarding works even when the game
is down. is down.
- **Support command.** `/support` replies with a fixed support-desk info message — the
operators' working hours and what to include (a description and, when possible,
screenshots). Like `/start` it answers only in a private chat and is **Russian or
English** by the sender's reported language, and it is listed in the bot's command menu
(localized, Russian/English). It is a dedicated command handler, so it intercepts
`/support` before the support relay below — the command line itself is not forwarded to
operators, while the user's following description still is.
- **Moderated-chat gating.** When `TELEGRAM_CHAT_ID` names a channel's linked discussion - **Moderated-chat gating.** When `TELEGRAM_CHAT_ID` names a channel's linked discussion
group, the bot gates who may write there. The group **allows sending by default** (a group, the bot gates who may write there. The group **allows sending by default** (a
human setting) and the bot only **restricts** — Telegram intersects the chat default with human setting) and the bot only **restricts** — Telegram intersects the chat default with
@@ -62,7 +70,12 @@ Telegram identity to an account from a browser. Both map a rejection to gRPC
the bot applies it — but only to a member currently in the chat (it probes one user with the bot applies it — but only to a member currently in the chat (it probes one user with
`getChatMember`, since bots cannot list members). The bot must be an **administrator** there `getChatMember`, since bots cannot list members). The bot must be an **administrator** there
with the **"Ban users"** right (the Bot API `can_restrict_members`), and it subscribes to with the **"Ban users"** right (the Bot API `can_restrict_members`), and it subscribes to
`chat_member` updates, which Telegram delivers only to a chat admin. `chat_member` updates, which Telegram delivers only to a chat admin. The bot also **removes
the automatic pin** Telegram places on every channel post auto-forwarded into this linked
chat (the `Message.is_automatic_forward` marker): it unpins only that one message
(`unpinChatMessage` by id), so a pin set by a human admin — or by the bot for another
message — is never touched. This needs the **pin-messages** right (`can_pin_messages`) in
addition to "Ban users"; a startup self-check warns when it is missing.
- **Support relay (optional).** When `TELEGRAM_SUPPORT_CHAT_ID` names a private **forum - **Support relay (optional).** When `TELEGRAM_SUPPORT_CHAT_ID` names a private **forum
supergroup**, the bot runs a direct support channel for users who message it. A user's first supergroup**, the bot runs a direct support channel for users who message it. A user's first
non-`/start` message opens a dedicated **forum topic** whose first message is an info card (the non-`/start` message opens a dedicated **forum topic** whose first message is an info card (the
@@ -76,7 +89,8 @@ Telegram identity to an account from a browser. Both map a rejection to gRPC
message. State (user→topic map, block list, relayed ids) is a JSON file under message. State (user→topic map, block list, relayed ids) is a JSON file under
`TELEGRAM_SUPPORT_STATE_DIR` on a persistent volume — the bot host has no database. The bot must `TELEGRAM_SUPPORT_STATE_DIR` on a persistent volume — the bot host has no database. The bot must
be an **administrator** in the group with the **manage-topics** and **delete-messages** rights. be an **administrator** in the group with the **manage-topics** and **delete-messages** rights.
The relay is bot-local (no backend, no bot-link) and the user gets no automatic reply. The relay is bot-local (no backend, no bot-link) and a forwarded message gets no automatic
reply (the `/support` command above aside).
- **Promo bot (optional).** When `TELEGRAM_PROMO_BOT_TOKEN` is set, the container also - **Promo bot (optional).** When `TELEGRAM_PROMO_BOT_TOKEN` is set, the container also
runs a **second, standalone** bot whose only job is to answer `/start` with a localized runs a **second, standalone** bot whose only job is to answer `/start` with a localized
message and a button that opens the **main** bot's Mini App. The button is a **URL** to message and a button that opens the **main** bot's Mini App. The button is a **URL** to
+51 -2
View File
@@ -1,6 +1,7 @@
// Package bot wraps the Telegram Bot API client (github.com/go-telegram/bot): it // Package bot wraps the Telegram Bot API client (github.com/go-telegram/bot): it
// runs the long-poll update loop — replying to /start (with an optional deep-link // runs the long-poll update loop — replying to /start (with an optional deep-link
// payload) and any other message with a Mini App launch button — and sends the // payload), to /support with the support-desk info message, and to any other message
// with a Mini App launch button — and sends the
// notification and admin messages the connector requests. The bot token lives only // notification and admin messages the connector requests. The bot token lives only
// in this process. // in this process.
package bot package bot
@@ -136,6 +137,7 @@ func New(cfg Config, log *zap.Logger) (*Bot, error) {
opts := []tgbot.Option{ opts := []tgbot.Option{
tgbot.WithDefaultHandler(t.handleUpdate), tgbot.WithDefaultHandler(t.handleUpdate),
tgbot.WithMessageTextHandler("/start", tgbot.MatchTypePrefix, t.handleStart), tgbot.WithMessageTextHandler("/start", tgbot.MatchTypePrefix, t.handleStart),
tgbot.WithMessageTextHandler("/support", tgbot.MatchTypePrefix, t.handleSupport),
} }
if t.supportEnabled() { if t.supportEnabled() {
t.supportLocks = newKeyedMutex() t.supportLocks = newKeyedMutex()
@@ -186,11 +188,26 @@ func New(cfg Config, log *zap.Logger) (*Bot, error) {
// Run sets the bot commands and the Mini App menu button, then blocks on the // Run sets the bot commands and the Mini App menu button, then blocks on the
// long-poll update loop until ctx is cancelled. // long-poll update loop until ctx is cancelled.
func (t *Bot) Run(ctx context.Context) { func (t *Bot) Run(ctx context.Context) {
// Command menu: the default (fallback) list is English; a Russian-scoped list
// localises the labels for ru clients. A language scope replaces the whole list, so
// it repeats /start with its own Russian label.
if _, err := t.api.SetMyCommands(ctx, &tgbot.SetMyCommandsParams{ if _, err := t.api.SetMyCommands(ctx, &tgbot.SetMyCommandsParams{
Commands: []models.BotCommand{{Command: "start", Description: "Open Scrabble"}}, Commands: []models.BotCommand{
{Command: "start", Description: "Play Scrabble"},
{Command: "support", Description: "Contact the administration"},
},
}); err != nil { }); err != nil {
t.log.Warn("set commands failed", zap.Error(err)) t.log.Warn("set commands failed", zap.Error(err))
} }
if _, err := t.api.SetMyCommands(ctx, &tgbot.SetMyCommandsParams{
LanguageCode: "ru",
Commands: []models.BotCommand{
{Command: "start", Description: "Играть в «Эрудита»"},
{Command: "support", Description: "Связаться с администрацией"},
},
}); err != nil {
t.log.Warn("set ru commands failed", zap.Error(err))
}
if _, err := t.api.SetChatMenuButton(ctx, &tgbot.SetChatMenuButtonParams{ if _, err := t.api.SetChatMenuButton(ctx, &tgbot.SetChatMenuButtonParams{
MenuButton: models.MenuButtonWebApp{ MenuButton: models.MenuButtonWebApp{
Type: models.MenuButtonTypeWebApp, Type: models.MenuButtonTypeWebApp,
@@ -268,6 +285,12 @@ func (t *Bot) logChatAdminStatus(ctx context.Context) {
return return
} }
t.log.Info("chat gating ready: bot is an admin with the restrict-members right", zap.Int64("chat_id", t.chatID)) t.log.Info("chat gating ready: bot is an admin with the restrict-members right", zap.Int64("chat_id", t.chatID))
// The same moderated chat also gets the linked channel's auto-forwarded posts un-pinned,
// which needs the pin-messages right; warn (separately from gating) when it is missing.
if !m.Administrator.CanPinMessages {
t.log.Warn("auto-unpin of linked channel posts WILL NOT WORK: the bot lacks the pin-messages right",
zap.Int64("chat_id", t.chatID))
}
} }
// Notify sends a notification message with a Mini App launch button that opens the // Notify sends a notification message with a Mini App launch button that opens the
@@ -388,6 +411,14 @@ func (t *Bot) handleUpdate(ctx context.Context, api *tgbot.Bot, update *models.U
t.handleSuccessfulPayment(ctx, update.Message) t.handleSuccessfulPayment(ctx, update.Message)
return return
} }
// A channel post automatically forwarded into the moderated discussion chat is
// auto-pinned by Telegram (a non-disableable behaviour). Unpin that one message so only
// deliberate pins remain; it targets this exact message id, so a pin set by a human admin
// — or by the bot for another message — is never touched.
if m := update.Message; m != nil && m.IsAutomaticForward && t.chatID != 0 && m.Chat.ID == t.chatID {
t.handleLinkedChannelPin(ctx, m)
return
}
// Support relay (when enabled): a non-/start message — /start has its own handler — // Support relay (when enabled): a non-/start message — /start has its own handler —
// is either an operator's reply in the support chat or a user's direct message to // is either an operator's reply in the support chat or a user's direct message to
// relay. Everything else falls through to the Mini App launch reply. // relay. Everything else falls through to the Mini App launch reply.
@@ -404,6 +435,24 @@ func (t *Bot) handleUpdate(ctx context.Context, api *tgbot.Bot, update *models.U
t.handleStart(ctx, api, update) t.handleStart(ctx, api, update)
} }
// handleLinkedChannelPin removes Telegram's automatic pin from a linked channel's post
// that was auto-forwarded into the moderated discussion chat. It unpins only that exact
// message (by id), so a pin set by a human admin — or by the bot for another message — is
// left untouched. It needs the bot to hold the pin-messages right in the chat; a missing
// right surfaces as a warning (the unpin then no-ops), not a crash.
func (t *Bot) handleLinkedChannelPin(ctx context.Context, m *models.Message) {
if _, err := t.api.UnpinChatMessage(ctx, &tgbot.UnpinChatMessageParams{
ChatID: m.Chat.ID,
MessageID: m.ID,
}); err != nil {
t.log.Warn("could not unpin an auto-forwarded channel post; does the bot have the pin-messages right?",
zap.Int64("chat_id", m.Chat.ID), zap.Int("message_id", m.ID), zap.Error(err))
return
}
t.log.Debug("unpinned an auto-forwarded channel post",
zap.Int64("chat_id", m.Chat.ID), zap.Int("message_id", m.ID))
}
// SetEligibilityResolver wires the chat-eligibility resolver after construction (the // SetEligibilityResolver wires the chat-eligibility resolver after construction (the
// bot-link client backing it is built after the bot). // bot-link client backing it is built after the bot).
func (t *Bot) SetEligibilityResolver(resolve EligibilityResolver) { func (t *Bot) SetEligibilityResolver(resolve EligibilityResolver) {
+53 -1
View File
@@ -19,10 +19,11 @@ const (
) )
// chatAPI is a fake Bot API for the chat-gating tests: it answers getMe, returns a // chatAPI is a fake Bot API for the chat-gating tests: it answers getMe, returns a
// scripted getChatMember status, and records restrictChatMember calls. // scripted getChatMember status, and records restrictChatMember and unpinChatMessage calls.
type chatAPI struct { type chatAPI struct {
memberStatus string // the status getChatMember reports (default "left") memberStatus string // the status getChatMember reports (default "left")
restricts []restrictCall restricts []restrictCall
unpins []unpinCall
} }
type restrictCall struct { type restrictCall struct {
@@ -30,6 +31,13 @@ type restrictCall struct {
canSend bool // can_send_messages in the applied permissions canSend bool // can_send_messages in the applied permissions
} }
// unpinCall records a single unpinChatMessage request (raw form values, mirroring the
// restrictCall style).
type unpinCall struct {
chatID string
messageID string
}
func (a *chatAPI) ServeHTTP(w http.ResponseWriter, r *http.Request) { func (a *chatAPI) ServeHTTP(w http.ResponseWriter, r *http.Request) {
switch { switch {
case strings.HasSuffix(r.URL.Path, "/getMe"): case strings.HasSuffix(r.URL.Path, "/getMe"):
@@ -47,6 +55,9 @@ func (a *chatAPI) ServeHTTP(w http.ResponseWriter, r *http.Request) {
_ = json.Unmarshal([]byte(r.FormValue("permissions")), &perms) _ = json.Unmarshal([]byte(r.FormValue("permissions")), &perms)
a.restricts = append(a.restricts, restrictCall{userID: r.FormValue("user_id"), canSend: perms.CanSendMessages}) a.restricts = append(a.restricts, restrictCall{userID: r.FormValue("user_id"), canSend: perms.CanSendMessages})
io.WriteString(w, `{"ok":true,"result":true}`) io.WriteString(w, `{"ok":true,"result":true}`)
case strings.HasSuffix(r.URL.Path, "/unpinChatMessage"):
a.unpins = append(a.unpins, unpinCall{chatID: r.FormValue("chat_id"), messageID: r.FormValue("message_id")})
io.WriteString(w, `{"ok":true,"result":true}`)
default: default:
io.WriteString(w, `{"ok":true,"result":true}`) io.WriteString(w, `{"ok":true,"result":true}`)
} }
@@ -237,3 +248,44 @@ func TestApplyChatGateSkipsAbsentAndAdmin(t *testing.T) {
}) })
} }
} }
// autoForwardUpdate builds a message update as it lands in the discussion chat: a post in
// chat chatID with id msgID and is_automatic_forward set to auto.
func autoForwardUpdate(chatID int64, msgID int, auto bool) *models.Update {
return &models.Update{Message: &models.Message{
ID: msgID,
Chat: models.Chat{ID: chatID, Type: models.ChatTypeSupergroup},
IsAutomaticForward: auto,
}}
}
func TestAutoForwardUnpinned(t *testing.T) {
// A channel post auto-forwarded into the moderated chat is unpinned by its exact id.
api := &chatAPI{}
b := newChatBot(t, api)
b.handleUpdate(context.Background(), b.api, autoForwardUpdate(testChatID, 42, true))
if len(api.unpins) != 1 || api.unpins[0].chatID != "555" || api.unpins[0].messageID != "42" {
t.Fatalf("unpins = %+v, want one {555, 42}", api.unpins)
}
}
func TestNonAutoForwardNotUnpinned(t *testing.T) {
// A normal message in the moderated chat (e.g. another admin's pinned message) is never
// unpinned — the is_automatic_forward filter is what guards every other pin.
api := &chatAPI{}
b := newChatBot(t, api)
b.handleUpdate(context.Background(), b.api, autoForwardUpdate(testChatID, 42, false))
if len(api.unpins) != 0 {
t.Fatalf("unpins = %+v, want none for a non-auto-forward message", api.unpins)
}
}
func TestAutoForwardOtherChatIgnored(t *testing.T) {
// An auto-forward in some other chat (not the configured moderated one) is ignored.
api := &chatAPI{}
b := newChatBot(t, api)
b.handleUpdate(context.Background(), b.api, autoForwardUpdate(999, 42, true))
if len(api.unpins) != 0 {
t.Fatalf("unpins = %+v, want none for a foreign chat", api.unpins)
}
}
@@ -0,0 +1,63 @@
package bot
import (
"context"
"strings"
tgbot "github.com/go-telegram/bot"
"github.com/go-telegram/bot/models"
"go.uber.org/zap"
)
// The /support command replies with a fixed support-desk info message — the operators'
// working hours and what to include (a description and, when possible, screenshots). It
// is a dedicated command handler (registered like /start), so it intercepts "/support"
// before the support relay: the command line itself is not forwarded into an operator
// topic, while the user's following description still is.
// handleSupport replies to /support with the localized support-desk info message. Like
// handleStart it answers only in a private chat — in the moderated group the bot never
// chats — and it sends a plain text message with no launch button.
func (t *Bot) handleSupport(ctx context.Context, api *tgbot.Bot, update *models.Update) {
if update.Message == nil {
return
}
if update.Message.Chat.Type != models.ChatTypePrivate {
return
}
// The sender's Telegram language rides on the message itself (Message.from.language_code);
// fall back to English when it is absent, matching startText.
lang := ""
if update.Message.From != nil {
lang = update.Message.From.LanguageCode
}
if _, err := api.SendMessage(ctx, &tgbot.SendMessageParams{
ChatID: update.Message.Chat.ID,
Text: supportText(lang),
}); err != nil {
t.log.Warn("reply to support failed", zap.Error(err))
}
}
// supportText returns the localized /support reply. Russian is used when lang (the IETF
// language tag the Telegram client reports on the message's sender) starts with "ru",
// English otherwise and when it is absent — mirroring startText's language choice.
func supportText(lang string) string {
if strings.HasPrefix(strings.ToLower(lang), "ru") {
return ruSupport
}
return enSupport
}
// ruSupport and enSupport are the Russian and English /support replies; the English one
// is the fallback for any non-Russian or missing sender language.
const (
ruSupport = "Поддержка «Эрудита» на связи с 08:00 до 18:00 (по времени UTC). " +
"Если Вы столкнулись с проблемой, подробно опишите её и добавьте, по возможности, скриншоты. " +
"Также будем рады выслушать Ваши пожелания и предложения по функционалу игры. " +
"Ответим в самое ближайшее время."
enSupport = "“Erudite” support is available from 08:00 to 18:00 (UTC). " +
"If you have run into a problem, please describe it in detail and attach screenshots if you can. " +
"We would also be glad to hear your wishes and suggestions about the game's features. " +
"We will reply as soon as possible."
)
@@ -0,0 +1,51 @@
package bot
import (
"context"
"strings"
"testing"
"github.com/go-telegram/bot/models"
)
func TestHandleSupportRepliesPrivateOnly(t *testing.T) {
t.Run("english by default", func(t *testing.T) {
api := &fakeBotAPI{}
b := newTestBot(t, api)
b.handleSupport(context.Background(), b.api, &models.Update{Message: &models.Message{
Chat: models.Chat{ID: 42, Type: models.ChatTypePrivate}, Text: "/support",
}})
if api.chatID != "42" {
t.Errorf("chat_id = %q, want 42", api.chatID)
}
// No reported language -> English support reply.
if !strings.Contains(api.text, "support is available") {
t.Errorf("text = %q, want the English support reply", api.text)
}
// A plain info message carries no launch button.
if api.replyMarkup != "" {
t.Errorf("reply_markup = %q, want none", api.replyMarkup)
}
})
t.Run("russian for a ru sender", func(t *testing.T) {
api := &fakeBotAPI{}
b := newTestBot(t, api)
b.handleSupport(context.Background(), b.api, &models.Update{Message: &models.Message{
Chat: models.Chat{ID: 42, Type: models.ChatTypePrivate}, Text: "/support",
From: &models.User{ID: 7, LanguageCode: "ru"},
}})
if !strings.Contains(api.text, "Поддержка «Эрудита»") {
t.Errorf("text = %q, want the Russian support reply", api.text)
}
})
t.Run("group ignored", func(t *testing.T) {
api := &fakeBotAPI{}
b := newTestBot(t, api)
b.handleSupport(context.Background(), b.api, &models.Update{Message: &models.Message{
Chat: models.Chat{ID: -100, Type: models.ChatTypeSupergroup}, Text: "/support",
}})
if api.chatID != "" {
t.Errorf("group /support got a reply (chat=%q); the bot never chats in the group", api.chatID)
}
})
}
@@ -169,7 +169,7 @@ func TestExecutorChatGateInvalidExternalID(t *testing.T) {
} }
func TestExecutorCreateInvoice(t *testing.T) { func TestExecutorCreateInvoice(t *testing.T) {
sender := &fakeSender{invoiceLink: "https://t.me/$abc"} sender := &fakeSender{invoiceLink: "https://telegram.me/$abc"}
exec := NewExecutor(sender, 0, nil) exec := NewExecutor(sender, 0, nil)
cmd := &botlinkv1.Command{Payload: &botlinkv1.Command_CreateInvoice{CreateInvoice: &botlinkv1.CreateInvoiceCommand{ cmd := &botlinkv1.Command{Payload: &botlinkv1.Command_CreateInvoice{CreateInvoice: &botlinkv1.CreateInvoiceCommand{
Title: "50 chips", Description: "50 chips", Payload: "order-1", Amount: 40, Title: "50 chips", Description: "50 chips", Payload: "order-1", Amount: 40,
@@ -178,7 +178,7 @@ func TestExecutorCreateInvoice(t *testing.T) {
if err != nil { if err != nil {
t.Fatalf("handle: %v", err) t.Fatalf("handle: %v", err)
} }
if !delivered || result != "https://t.me/$abc" { if !delivered || result != "https://telegram.me/$abc" {
t.Errorf("create invoice = %v / %q, want true / the link", delivered, result) t.Errorf("create invoice = %v / %q, want true / the link", delivered, result)
} }
if len(sender.invoice) != 1 || sender.invoice[0].payload != "order-1" || sender.invoice[0].amount != 40 { if len(sender.invoice) != 1 || sender.invoice[0].payload != "order-1" || sender.invoice[0].amount != 40 {
+1 -1
View File
@@ -66,7 +66,7 @@ type BotConfig struct {
// bot's message text (TELEGRAM_BOT_USERNAME; required when the promo bot runs). // bot's message text (TELEGRAM_BOT_USERNAME; required when the promo bot runs).
BotUsername string BotUsername string
// BotLinkURL is the main bot's Mini App direct link — the same value the UI builds // BotLinkURL is the main bot's Mini App direct link — the same value the UI builds
// share links from (VITE_TELEGRAM_LINK), e.g. https://t.me/<bot>/<app>. The promo // share links from (VITE_TELEGRAM_LINK), e.g. https://telegram.me/<bot>/<app>. The promo
// button appends ?startapp=<payload> to it (TELEGRAM_BOT_LINK; required when the // button appends ?startapp=<payload> to it (TELEGRAM_BOT_LINK; required when the
// promo bot runs). It is distinct from the BotLink mTLS dial config below. // promo bot runs). It is distinct from the BotLink mTLS dial config below.
BotLinkURL string BotLinkURL string
@@ -112,7 +112,7 @@ func TestLoadBotChatAndPromo(t *testing.T) {
t.Setenv("TELEGRAM_CHAT_ID", "-100222") t.Setenv("TELEGRAM_CHAT_ID", "-100222")
t.Setenv("TELEGRAM_PROMO_BOT_TOKEN", "promo-token") t.Setenv("TELEGRAM_PROMO_BOT_TOKEN", "promo-token")
t.Setenv("TELEGRAM_BOT_USERNAME", "@ScrabbleBot") t.Setenv("TELEGRAM_BOT_USERNAME", "@ScrabbleBot")
t.Setenv("TELEGRAM_BOT_LINK", "https://t.me/ScrabbleBot/app") t.Setenv("TELEGRAM_BOT_LINK", "https://telegram.me/ScrabbleBot/app")
c, err := LoadBot() c, err := LoadBot()
if err != nil { if err != nil {
t.Fatalf("LoadBot: %v", err) t.Fatalf("LoadBot: %v", err)
@@ -126,7 +126,7 @@ func TestLoadBotChatAndPromo(t *testing.T) {
if c.BotUsername != "ScrabbleBot" { if c.BotUsername != "ScrabbleBot" {
t.Errorf("BotUsername = %q, want the leading @ stripped", c.BotUsername) t.Errorf("BotUsername = %q, want the leading @ stripped", c.BotUsername)
} }
if c.BotLinkURL != "https://t.me/ScrabbleBot/app" { if c.BotLinkURL != "https://telegram.me/ScrabbleBot/app" {
t.Errorf("BotLinkURL = %q", c.BotLinkURL) t.Errorf("BotLinkURL = %q", c.BotLinkURL)
} }
}) })
@@ -13,7 +13,7 @@ import (
) )
func TestPromoTextLocalization(t *testing.T) { func TestPromoTextLocalization(t *testing.T) {
const url = "https://t.me/bot/app?startapp=verudit_ru-scrabble_en" const url = "https://telegram.me/bot/app?startapp=verudit_ru-scrabble_en"
// The @username is rendered as a clickable deep link (the same target as the button), // The @username is rendered as a clickable deep link (the same target as the button),
// not a plain mention, so tapping it opens the seeded Mini App. // not a plain mention, so tapping it opens the seeded Mini App.
wantLink := `<a href="` + url + `">@ScrabbleBot</a>` wantLink := `<a href="` + url + `">@ScrabbleBot</a>`
@@ -41,17 +41,17 @@ func TestPromoTextLocalization(t *testing.T) {
} }
func TestLaunchURLAppendsStartapp(t *testing.T) { func TestLaunchURLAppendsStartapp(t *testing.T) {
b := &Bot{linkURL: "https://t.me/bot/app"} b := &Bot{linkURL: "https://telegram.me/bot/app"}
if got := b.launchURL(""); got != "https://t.me/bot/app" { if got := b.launchURL(""); got != "https://telegram.me/bot/app" {
t.Errorf("empty payload = %q, want the base link unchanged", got) t.Errorf("empty payload = %q, want the base link unchanged", got)
} }
if got := b.launchURL("g123"); got != "https://t.me/bot/app?startapp=g123" { if got := b.launchURL("g123"); got != "https://telegram.me/bot/app?startapp=g123" {
t.Errorf("launchURL = %q, want startapp=g123 appended", got) t.Errorf("launchURL = %q, want startapp=g123 appended", got)
} }
} }
func TestLaunchMarkupIsURLButton(t *testing.T) { func TestLaunchMarkupIsURLButton(t *testing.T) {
b := &Bot{linkURL: "https://t.me/bot/app"} b := &Bot{linkURL: "https://telegram.me/bot/app"}
btn := b.launchMarkup("Play", "f99").InlineKeyboard[0][0] btn := b.launchMarkup("Play", "f99").InlineKeyboard[0][0]
if btn.WebApp != nil { if btn.WebApp != nil {
t.Error("the promo button must not be a web_app button (it would sign initData with the promo token, which the main bot rejects)") t.Error("the promo button must not be a web_app button (it would sign initData with the promo token, which the main bot rejects)")
@@ -84,7 +84,7 @@ func TestHandleStartReplies(t *testing.T) {
api := &fakeAPI{} api := &fakeAPI{}
srv := httptest.NewServer(api) srv := httptest.NewServer(api)
t.Cleanup(srv.Close) t.Cleanup(srv.Close)
b, err := New(Config{Token: "1:2", APIBaseURL: srv.URL, BotUsername: "ScrabbleBot", BotLinkURL: "https://t.me/bot/app"}, zap.NewNop()) b, err := New(Config{Token: "1:2", APIBaseURL: srv.URL, BotUsername: "ScrabbleBot", BotLinkURL: "https://telegram.me/bot/app"}, zap.NewNop())
if err != nil { if err != nil {
t.Fatalf("new: %v", err) t.Fatalf("new: %v", err)
} }
@@ -98,7 +98,7 @@ func TestHandleStartReplies(t *testing.T) {
if api.chatID != "42" { if api.chatID != "42" {
t.Errorf("chat_id = %q, want 42", api.chatID) t.Errorf("chat_id = %q, want 42", api.chatID)
} }
if !strings.Contains(api.text, `<a href="https://t.me/bot/app?startapp=f99">@ScrabbleBot</a>`) { if !strings.Contains(api.text, `<a href="https://telegram.me/bot/app?startapp=f99">@ScrabbleBot</a>`) {
t.Errorf("text = %q, want the @mention linked to the startapp deep link", api.text) t.Errorf("text = %q, want the @mention linked to the startapp deep link", api.text)
} }
if strings.Contains(api.replyMarkup, "web_app") { if strings.Contains(api.replyMarkup, "web_app") {
@@ -113,7 +113,7 @@ func TestHandleStartIgnoresGroup(t *testing.T) {
api := &fakeAPI{} api := &fakeAPI{}
srv := httptest.NewServer(api) srv := httptest.NewServer(api)
t.Cleanup(srv.Close) t.Cleanup(srv.Close)
b, err := New(Config{Token: "1:2", APIBaseURL: srv.URL, BotUsername: "B", BotLinkURL: "https://t.me/b/a"}, zap.NewNop()) b, err := New(Config{Token: "1:2", APIBaseURL: srv.URL, BotUsername: "B", BotLinkURL: "https://telegram.me/b/a"}, zap.NewNop())
if err != nil { if err != nil {
t.Fatalf("new: %v", err) t.Fatalf("new: %v", err)
} }
+5 -4
View File
@@ -23,10 +23,11 @@ ENV APP_VERSION=${VERSION}
WORKDIR /app WORKDIR /app
COPY --from=build /src/renderer/node_modules ./node_modules COPY --from=build /src/renderer/node_modules ./node_modules
COPY --from=build /src/renderer/dist ./dist COPY --from=build /src/renderer/dist ./dist
COPY renderer/src/server.mjs renderer/src/render.mjs renderer/src/offer.mjs ./src/ COPY renderer/src/server.mjs renderer/src/render.mjs renderer/src/offer.mjs renderer/src/legal.mjs ./src/
# The public-offer prose (GET /offer/): the owner-edited markdown, read once at boot. The dynamic # The public legal pages: the owner-edited markdown, read once at boot. GET /offer/ also splices in
# price list is fetched from the backend at request time and spliced in. # the live price list fetched from the backend at request time; GET /privacy/ and GET /eula/ are
COPY ui/legal/offer_ru.md ./legal/offer_ru.md # static (no dynamic data).
COPY ui/legal/offer_ru.md ui/legal/offer_en.md ui/legal/privacy_ru.md ui/legal/privacy_en.md ui/legal/eula_ru.md ui/legal/eula_en.md ./legal/
USER node USER node
EXPOSE 8090 EXPOSE 8090
CMD ["node", "src/server.mjs"] CMD ["node", "src/server.mjs"]
+21 -9
View File
@@ -1,10 +1,11 @@
# renderer — the render sidecar (finished-game image + public offer page) # renderer — the render sidecar (finished-game image + public legal pages)
An internal Node service that runs shared `ui/src/lib` renderers server-side — bundled verbatim at An internal Node service that runs shared `ui/src/lib` renderers server-side — bundled verbatim at
image build time (`src/entry.ts` → esbuild → `dist/gameimage.mjs`) so there is one renderer and no image build time (`src/entry.ts` → esbuild → `dist/gameimage.mjs`) so there is one renderer and no
drift from the browser. It serves two surfaces: the finished-game export **PNG** (on drift from the browser. It serves two surfaces: the finished-game export **PNG** (on
[skia-canvas](https://github.com/samizdatco/skia-canvas), pixel-identical to the design the owner [skia-canvas](https://github.com/samizdatco/skia-canvas), pixel-identical to the design the owner
signed off) and the public **offer page**. signed off) and the public **legal pages** (the offer, the privacy policy and the EULA), each
bilingual (RU + EN) with a language + theme switcher.
## Interface ## Interface
@@ -16,9 +17,16 @@ signed off) and the public **offer page**.
- `GET /offer/` — the public offer page as `text/html`: the owner-edited `ui/legal/offer_ru.md` - `GET /offer/` — the public offer page as `text/html`: the owner-edited `ui/legal/offer_ru.md`
(baked into the image, read at boot) with the live catalog **price list** (§4.4) fetched as (baked into the image, read at boot) with the live catalog **price list** (§4.4) fetched as
markdown from the backend's internal `/api/v1/internal/offer/pricing` (`RENDERER_BACKEND_URL`) markdown from the backend's internal `/api/v1/internal/offer/pricing` (`RENDERER_BACKEND_URL`)
and spliced in at the `<#pricing_template#>` marker, then rendered by the shared and spliced in at the `<#pricing_template#>` marker of both language sources (`offer_ru.md` /
`ui/src/lib/offer.ts`. `GET /offer` → 301 `/offer/`. **The only edge-exposed route** — caddy `offer_en.md`), then rendered as one bilingual page by the shared `ui/src/lib/offer.ts`
routes `/offer/` here; a backend outage yields a 502, never a stale price list. `renderLegalHtml` (the English view transliterates the Russian product names client-side).
`GET /offer` → 301 `/offer/`. Caddy routes `/offer/` here; a backend outage yields a 502, never a
stale price list.
- `GET /privacy/` — the privacy policy, and `GET /eula/` the end-user licence agreement, both as
`text/html`: the owner-edited `ui/legal/{privacy,eula}_{ru,en}.md` (baked in, read at boot),
rendered once at boot as one bilingual page by the same shared `renderLegalHtml` and served from
cache. **Static** — no backend fetch. `GET /privacy` / `GET /eula` → 301 to the trailing-slash
form. Caddy routes these here too.
- `GET /healthz` — liveness (the compose healthcheck the backend's `depends_on` gates on). - `GET /healthz` — liveness (the compose healthcheck the backend's `depends_on` gates on).
The service renders and nothing else: for the PNG, authentication, the participant check and the The service renders and nothing else: for the PNG, authentication, the participant check and the
@@ -32,10 +40,14 @@ page.
```sh ```sh
pnpm install # skia-canvas + esbuild MUST stay approved in pnpm-workspace.yaml pnpm install # skia-canvas + esbuild MUST stay approved in pnpm-workspace.yaml
# (allowBuilds) or the native binary is silently never fetched # (allowBuilds) or the native binary is silently never fetched
pnpm test # bundles, then node --test (PNG smoke + offer splice) pnpm test # bundles, then node --test (PNG smoke + offer/legal render)
# local run on :8090 (RENDERER_PORT overrides). For /offer/, point at the offer source and a # local run on :8090 (RENDERER_PORT overrides). The server reads all three legal sources at boot, so
# reachable backend (else the boot read / the price fetch fail): # point each at its ui/legal source (the baked paths do not exist in a local checkout); /offer/ also
RENDERER_OFFER_MD=../ui/legal/offer_ru.md RENDERER_BACKEND_URL=http://localhost:8080 node src/server.mjs # needs a reachable backend for the price fetch:
RENDERER_OFFER_MD=../ui/legal/offer_ru.md RENDERER_OFFER_EN_MD=../ui/legal/offer_en.md \
RENDERER_PRIVACY_MD=../ui/legal/privacy_ru.md RENDERER_PRIVACY_EN_MD=../ui/legal/privacy_en.md \
RENDERER_EULA_MD=../ui/legal/eula_ru.md RENDERER_EULA_EN_MD=../ui/legal/eula_en.md \
RENDERER_BACKEND_URL=http://localhost:8080 node src/server.mjs
``` ```
The runtime image (`renderer/Dockerfile`, `node:22-slim`) bakes in Liberation Sans (the The runtime image (`renderer/Dockerfile`, `node:22-slim`) bakes in Liberation Sans (the
+4 -3
View File
@@ -2,8 +2,9 @@
// browser build unit-tests — so the sidecar runs them on the server. Bundled by `pnpm run bundle` // browser build unit-tests — so the sidecar runs them on the server. Bundled by `pnpm run bundle`
// into dist/gameimage.mjs (type erasure only; the ui project type-checks the source): // into dist/gameimage.mjs (type erasure only; the ui project type-checks the source):
// - drawGameImage + setAlphabet: the finished-game PNG export (POST /render). // - drawGameImage + setAlphabet: the finished-game PNG export (POST /render).
// - renderOfferHtml: the public-offer page (GET /offer/) — the same renderer the landing build // - renderOfferHtml / renderLegalHtml: the public legal pages — GET /offer/ (with the live
// used to invoke, now server-side so the live catalog price list can be spliced in. // catalog price list spliced in), GET /privacy/ and GET /eula/ (static) — server-side so one
// renderer serves them all.
export { drawGameImage, type RenderOptions } from '../../ui/src/lib/gameimage'; export { drawGameImage, type RenderOptions } from '../../ui/src/lib/gameimage';
export { setAlphabet } from '../../ui/src/lib/alphabet'; export { setAlphabet } from '../../ui/src/lib/alphabet';
export { renderOfferHtml } from '../../ui/src/lib/offer'; export { renderOfferHtml, renderLegalHtml } from '../../ui/src/lib/offer';
+24
View File
@@ -0,0 +1,24 @@
// The public legal pages (GET /privacy/, GET /eula/): the owner-edited RU + EN markdown under
// ui/legal/, rendered by the SAME shared renderLegalHtml the offer uses (bundled from ui/src/lib/offer)
// into one bilingual page with a language + theme switcher. Unlike the offer, these carry no dynamic
// data — no backend fetch, no marker splice. Kept out of server.mjs so the per-page title/canonical
// presets are unit-testable without HTTP (test/legal.test.mjs).
import { renderLegalHtml } from '../dist/gameimage.mjs';
// renderPrivacy renders the privacy-policy markdown (RU + EN) as the standalone /privacy/ page.
export function renderPrivacy(ruMd, enMd) {
return renderLegalHtml({
ru: { md: ruMd, title: 'Политика конфиденциальности — Эрудит' },
en: { md: enMd, title: 'Privacy Policy — Erudit' },
canonical: 'https://erudit-game.ru/privacy/',
});
}
// renderEula renders the end-user licence agreement markdown (RU + EN) as the standalone /eula/ page.
export function renderEula(ruMd, enMd) {
return renderLegalHtml({
ru: { md: ruMd, title: 'Пользовательское соглашение — Эрудит' },
en: { md: enMd, title: 'Terms of Use — Erudit' },
canonical: 'https://erudit-game.ru/eula/',
});
}
+13 -9
View File
@@ -1,17 +1,21 @@
// The public-offer page renderer: splices the live catalog price list into the owner-edited offer // The public-offer page renderer: splices the live catalog price list into BOTH language sources of
// markdown and renders it with the SAME renderOfferHtml the landing build used to invoke (bundled // the owner-edited offer markdown at the pricing marker, and renders the bilingual /offer/ page with
// from ui/src/lib/offer). Kept out of server.mjs so the substitution is unit-testable without HTTP. // the SAME renderOfferHtml the browser build uses (bundled from ui/src/lib/offer). The English view
// transliterates the Russian product names client-side (renderLegalHtml). Kept out of server.mjs so
// the substitution is unit-testable without HTTP.
import { renderOfferHtml } from '../dist/gameimage.mjs'; import { renderOfferHtml } from '../dist/gameimage.mjs';
// pricingMarker is the token ui/legal/offer_ru.md carries at §4.4 where the price list belongs. It // pricingMarker is the token ui/legal/offer_{ru,en}.md carry at §4.4 where the price list belongs. It
// mirrors the backend marker (backend/internal/payments/offer.go) and is replaced with the fetched // mirrors the backend marker (backend/internal/payments/offer.go) and is replaced with the fetched
// tables before rendering. // tables before rendering.
export const pricingMarker = '<#pricing_template#>'; export const pricingMarker = '<#pricing_template#>';
// renderOffer returns the standalone /offer/ HTML: the offer markdown with the pricing marker // renderOffer returns the standalone /offer/ HTML: the RU + EN offer markdown with the pricing marker
// replaced by pricingTables (the two markdown tables the backend projects from the active catalog), // replaced by pricingTables (the two markdown tables the backend projects from the active catalog),
// rendered to a self-contained document. The replacement is literal (a function replacer) so a "$" // rendered to a self-contained bilingual document. The replacement is literal (a function replacer)
// in a product title is never read as a replacement pattern; a missing marker leaves the text as is. // so a "$" in a product title is never read as a replacement pattern; a missing marker leaves the
export function renderOffer(offerMarkdown, pricingTables) { // text as is.
return renderOfferHtml(offerMarkdown.replace(pricingMarker, () => pricingTables)); export function renderOffer(ruMarkdown, enMarkdown, pricingTables) {
const splice = (md) => md.replace(pricingMarker, () => pricingTables);
return renderOfferHtml(splice(ruMarkdown), splice(enMarkdown));
} }
+44 -13
View File
@@ -2,29 +2,44 @@
// renderers (bundled from ui/src/lib at image build time). It serves two surfaces: // renderers (bundled from ui/src/lib at image build time). It serves two surfaces:
// //
// POST /render {game, moves, alphabet, labels, dateLocale, hostname, scale?} → image/png // POST /render {game, moves, alphabet, labels, dateLocale, hostname, scale?} → image/png
// GET /offer/ → text/html (the public offer page: ui/legal/offer_ru.md with the live catalog // GET /offer/ → text/html (the public offer page: ui/legal/offer_ru.md with the live catalog
// price list spliced in — the only edge-exposed route; caddy routes /offer/ here) // price list spliced in; caddy routes /offer/ here)
// GET /offer → 301 /offer/ // GET /privacy/ → text/html (the privacy policy: ui/legal/privacy_ru.md, static)
// GET /healthz → 200 // GET /eula/ → text/html (the EULA: ui/legal/eula_ru.md, static)
// GET /offer, /privacy, /eula → 301 to the trailing-slash form
// GET /healthz → 200
// //
// /render is internal-only (the backend calls it; authentication, participant checks and the signed // /render is internal-only (the backend calls it; authentication, participant checks and the signed
// public URL live in the backend). /offer/ is reachable from the edge but read-only and unprivileged // public URL live in the backend). The legal pages (/offer/, /privacy/, /eula/) are reachable from the
// — it fetches the price list from the backend's internal endpoint and renders the committed offer // edge but read-only and unprivileged: /offer/ splices the price list fetched from the backend's
// markdown; no user input reaches it. // internal endpoint into the committed offer markdown; /privacy/ and /eula/ are static committed
// markdown. No user input reaches any of them.
import { createServer } from 'node:http'; import { createServer } from 'node:http';
import { readFileSync } from 'node:fs'; import { readFileSync } from 'node:fs';
import { renderRequest } from './render.mjs'; import { renderRequest } from './render.mjs';
import { renderOffer } from './offer.mjs'; import { renderOffer } from './offer.mjs';
import { renderPrivacy, renderEula } from './legal.mjs';
const PORT = Number(process.env.RENDERER_PORT || 8090); const PORT = Number(process.env.RENDERER_PORT || 8090);
// A render request is a finished game's journal — generously capped. // A render request is a finished game's journal — generously capped.
const MAX_BODY = 2 * 1024 * 1024; const MAX_BODY = 2 * 1024 * 1024;
// The offer prose is baked into the image (renderer/Dockerfile) and read once at boot; only the // Each legal page is bilingual (RU + EN); both markdown sources are baked into the image
// price list is dynamic (fetched per request). The backend endpoint is internal (off the edge // (renderer/Dockerfile) and read once at boot. The offer's price list is the only dynamic part
// allow-list) and serves the tables from its in-memory cache. RENDERER_OFFER_MD overrides the baked // (fetched per request from the backend's internal endpoint and spliced in); privacy + eula carry no
// path for a local run (point it at ../ui/legal/offer_ru.md). // dynamic data. The RENDERER_*_MD / RENDERER_*_EN_MD env vars override the baked paths for a local run.
const OFFER_MD = readFileSync(process.env.RENDERER_OFFER_MD || new URL('../legal/offer_ru.md', import.meta.url), 'utf8'); const rd = (p) => readFileSync(p, 'utf8');
const OFFER_MD = rd(process.env.RENDERER_OFFER_MD || new URL('../legal/offer_ru.md', import.meta.url));
const OFFER_EN_MD = rd(process.env.RENDERER_OFFER_EN_MD || new URL('../legal/offer_en.md', import.meta.url));
const PRIVACY_MD = rd(process.env.RENDERER_PRIVACY_MD || new URL('../legal/privacy_ru.md', import.meta.url));
const PRIVACY_EN_MD = rd(process.env.RENDERER_PRIVACY_EN_MD || new URL('../legal/privacy_en.md', import.meta.url));
const EULA_MD = rd(process.env.RENDERER_EULA_MD || new URL('../legal/eula_ru.md', import.meta.url));
const EULA_EN_MD = rd(process.env.RENDERER_EULA_EN_MD || new URL('../legal/eula_en.md', import.meta.url));
// Render the static legal pages once at boot and serve the cached HTML: re-parsing the large EULA on
// every request is slow enough under deploy-time host contention (the renderer is CPU/memory-capped)
// to flake the contour probe. The offer stays per-request because it splices in the live price list.
const PRIVACY_HTML = renderPrivacy(PRIVACY_MD, PRIVACY_EN_MD);
const EULA_HTML = renderEula(EULA_MD, EULA_EN_MD);
const OFFER_PRICING_URL = const OFFER_PRICING_URL =
(process.env.RENDERER_BACKEND_URL || 'http://backend:8080') + '/api/v1/internal/offer/pricing'; (process.env.RENDERER_BACKEND_URL || 'http://backend:8080') + '/api/v1/internal/offer/pricing';
@@ -68,12 +83,28 @@ const server = createServer(async (req, res) => {
return; return;
} }
if (req.method === 'GET' && path === '/offer/') { if (req.method === 'GET' && path === '/offer/') {
const html = renderOffer(OFFER_MD, await fetchOfferPricing()); const html = renderOffer(OFFER_MD, OFFER_EN_MD, await fetchOfferPricing());
res res
.writeHead(200, { 'content-type': 'text/html; charset=utf-8', 'cache-control': 'no-cache' }) .writeHead(200, { 'content-type': 'text/html; charset=utf-8', 'cache-control': 'no-cache' })
.end(html); .end(html);
return; return;
} }
if (req.method === 'GET' && (path === '/privacy' || path === '/eula')) {
res.writeHead(301, { location: path + '/' }).end();
return;
}
if (req.method === 'GET' && path === '/privacy/') {
res
.writeHead(200, { 'content-type': 'text/html; charset=utf-8', 'cache-control': 'no-cache' })
.end(PRIVACY_HTML);
return;
}
if (req.method === 'GET' && path === '/eula/') {
res
.writeHead(200, { 'content-type': 'text/html; charset=utf-8', 'cache-control': 'no-cache' })
.end(EULA_HTML);
return;
}
if (req.method === 'POST' && path === '/render') { if (req.method === 'POST' && path === '/render') {
const png = await renderRequest(JSON.parse(await readBody(req))); const png = await renderRequest(JSON.parse(await readBody(req)));
res.writeHead(200, { 'content-type': 'image/png', 'content-length': png.length }).end(png); res.writeHead(200, { 'content-type': 'image/png', 'content-length': png.length }).end(png);
+27
View File
@@ -0,0 +1,27 @@
// Unit test for the static legal pages: renderPrivacy / renderEula wrap the owner-edited RU + EN
// markdown in the shared bilingual legal-page chrome (bundled renderLegalHtml) with the right title +
// canonical URL and no dynamic data. The prose rendering itself is unit-tested in ui/ (offer.test.ts);
// here we assert the per-page presets and that both languages reach the document.
import test from 'node:test';
import assert from 'node:assert/strict';
import { renderPrivacy, renderEula } from '../src/legal.mjs';
test('renderPrivacy wraps the RU + EN markdown in the privacy-page chrome', () => {
const html = renderPrivacy(
'# Политика конфиденциальности\n\n**1.1.** ИНН 290210610742.',
'# Privacy Policy\n\n**1.1.** TIN 290210610742.',
);
assert.ok(html.includes('<!doctype html>'), 'a standalone document');
assert.ok(html.includes('<title>Политика конфиденциальности — Эрудит</title>'), 'the privacy title');
assert.ok(html.includes('data-title-en="Privacy Policy — Erudit"'), 'the English title for the toggle');
assert.ok(html.includes('href="https://erudit-game.ru/privacy/"'), 'the privacy canonical URL');
assert.ok(html.includes('290210610742'), 'the prose reached the document');
assert.ok(html.includes('<main data-lang="en" hidden>'), 'the English body is present, hidden by default');
});
test('renderEula wraps the RU + EN markdown in the EULA-page chrome', () => {
const html = renderEula('# Лицензионное соглашение\n\nтекст', '# Terms of Use\n\ntext');
assert.ok(html.includes('<title>Пользовательское соглашение — Эрудит</title>'), 'the EULA title');
assert.ok(html.includes('data-title-en="Terms of Use — Erudit"'), 'the English title for the toggle');
assert.ok(html.includes('href="https://erudit-game.ru/eula/"'), 'the EULA canonical URL');
});
+13 -11
View File
@@ -1,7 +1,7 @@
// Unit test for the public-offer page assembly: renderOffer splices the backend's price-list // Unit test for the public-offer page assembly: renderOffer splices the backend's price-list markdown
// markdown into the offer at the pricing marker and renders it with the shared renderOfferHtml // into BOTH language sources of the offer at the pricing marker and renders them with the shared
// (bundled from ui/src/lib/offer). The prose rendering itself is unit-tested in ui/ (offer.test.ts); // renderOfferHtml (bundled from ui/src/lib/offer). The prose rendering itself is unit-tested in ui/
// here we assert the substitution and that the tables reach the rendered HTML. // (offer.test.ts); here we assert the substitution reaches both languages and the tables render.
import test from 'node:test'; import test from 'node:test';
import assert from 'node:assert/strict'; import assert from 'node:assert/strict';
import { renderOffer, pricingMarker } from '../src/offer.mjs'; import { renderOffer, pricingMarker } from '../src/offer.mjs';
@@ -11,19 +11,21 @@ const tables =
'| --- | --- | --- | --- |\n' + '| --- | --- | --- | --- |\n' +
'| 50 «Фишек» | 200.00 | 30 | 100 |'; '| 50 «Фишек» | 200.00 | 30 | 100 |';
test('splices the price list into the offer and renders the tables to HTML', () => { test('splices the price list into both language sources and renders the tables', () => {
const md = `# Публичная оферта\n\n**4.4.** Стоимость Товаров:\n\n${pricingMarker}\n`; const ru = `# Публичная оферта\n\n**4.4.** Стоимость Товаров:\n\n${pricingMarker}\n`;
const html = renderOffer(md, tables); const en = `# Public offer\n\n**4.4.** Cost of the Goods:\n\n${pricingMarker}\n`;
// The marker is gone and the projected table reached the document as a real HTML table. const html = renderOffer(ru, en, tables);
assert.ok(!html.includes(pricingMarker), 'pricing marker must be substituted'); // The marker is gone from both languages and the projected table reached the document.
assert.ok(!html.includes(pricingMarker), 'the pricing marker must be substituted in both languages');
assert.ok(html.includes('<table>'), 'the price list must render as an HTML table'); assert.ok(html.includes('<table>'), 'the price list must render as an HTML table');
assert.ok(html.includes('<td>200.00</td>'), 'a rouble price cell must be present'); assert.ok(html.includes('<td>200.00</td>'), 'a rouble price cell must be present');
assert.ok(html.includes('50 «Фишек»'), 'the product title must be present'); assert.ok(html.includes('50 «Фишек»'), 'the product title must be present');
// The offer chrome from the shared renderer is intact. assert.ok(html.includes('<h1>Публичная оферта</h1>'), 'the Russian heading rendered');
assert.ok(html.includes('<h1>Public offer</h1>'), 'the English heading rendered');
assert.ok(html.includes('<!doctype html>')); assert.ok(html.includes('<!doctype html>'));
}); });
test('a "$" in a title is substituted literally, not as a replacement pattern', () => { test('a "$" in a title is substituted literally, not as a replacement pattern', () => {
const html = renderOffer(`x ${pricingMarker} y`, '| $5 pack | 1 |'); const html = renderOffer(`ru ${pricingMarker}`, `en ${pricingMarker}`, '| $5 pack | 1 |');
assert.ok(html.includes('$5 pack'), 'a "$" in the tables must survive substitution'); assert.ok(html.includes('$5 pack'), 'a "$" in the tables must survive substitution');
}); });
+2 -2
View File
@@ -29,13 +29,13 @@ pnpm codegen # regenerate src/gen from edge.proto + scrabble.fbs (dev-time)
gateway origin for a packaged (non-proxied) build. `VITE_TELEGRAM_BOT_ID` gateway origin for a packaged (non-proxied) build. `VITE_TELEGRAM_BOT_ID`
enables the "Link Telegram" web sign-in (the Login Widget) — inert until the site enables the "Link Telegram" web sign-in (the Login Widget) — inert until the site
domain is registered with BotFather (`/setdomain`); `VITE_TELEGRAM_LINK` is the domain is registered with BotFather (`/setdomain`); `VITE_TELEGRAM_LINK` is the
friend-invite Mini App link for the single bot (full URL `https://t.me/<bot>/<app>`). friend-invite Mini App link for the single bot (full URL `https://telegram.me/<bot>/<app>`).
`VITE_TELEGRAM_GAME_CHANNEL_NAME` is the "Play in Telegram" link shown on the landing page. `VITE_TELEGRAM_GAME_CHANNEL_NAME` is the "Play in Telegram" link shown on the landing page.
The **native (Capacitor) build** additionally reads `VITE_DICT_VERSION` (the bundled-dictionary version The **native (Capacitor) build** additionally reads `VITE_DICT_VERSION` (the bundled-dictionary version
the offline path requests, matching the packaged DAWGs), `VITE_PAYMENTS_DISABLED=1` (hide in-app purchases the offline path requests, matching the packaged DAWGs), `VITE_PAYMENTS_DISABLED=1` (hide in-app purchases
in the RuStore MVP), `VITE_RUSTORE_URL` (the update overlay's store target; empty until published) and in the RuStore MVP), `VITE_RUSTORE_URL` (the update overlay's store target; empty until published) and
`VITE_APP_VERSION` (`git describe --tags` → the `X-Client-Version` gate header) — all wired by `VITE_APP_VERSION` (`git describe --tags` → the `X-Client-Version` gate header) — all wired by
`.gitea/workflows/android-build.yaml`; see `ANDROID_PLAN.md`. `.gitea/workflows/android-build.yaml`.
The build has **two entries**: the game SPA (`index.html`, served at `/app/` and The build has **two entries**: the game SPA (`index.html`, served at `/app/` and
`/telegram/`) and a lightweight landing page (`landing.html`, served at `/`). `/telegram/`) and a lightweight landing page (`landing.html`, served at `/`).
+1 -1
View File
@@ -53,7 +53,7 @@ captures/
.idea/navEditor.xml .idea/navEditor.xml
# Keystore files # Keystore files
# Release signing material must never be committed (see ANDROID_PLAN.md — keystore loss/leak risk). # Release signing material must never be committed (keystore loss/leak risk).
*.jks *.jks
*.keystore *.keystore
+1
View File
@@ -10,6 +10,7 @@ android {
apply from: "../capacitor-cordova-android-plugins/cordova.variables.gradle" apply from: "../capacitor-cordova-android-plugins/cordova.variables.gradle"
dependencies { dependencies {
implementation project(':capacitor-app') implementation project(':capacitor-app')
implementation project(':capacitor-network')
} }
@@ -1,9 +1,6 @@
<?xml version="1.0" encoding="utf-8"?> <?xml version="1.0" encoding="utf-8"?>
<adaptive-icon xmlns:android="http://schemas.android.com/apk/res/android"> <adaptive-icon xmlns:android="http://schemas.android.com/apk/res/android">
<background> <background android:drawable="@mipmap/ic_launcher_background" />
<inset android:drawable="@mipmap/ic_launcher_background" android:inset="16.7%" /> <foreground android:drawable="@mipmap/ic_launcher_foreground" />
</background> <monochrome android:drawable="@mipmap/ic_launcher_monochrome" />
<foreground>
<inset android:drawable="@mipmap/ic_launcher_foreground" android:inset="16.7%" />
</foreground>
</adaptive-icon> </adaptive-icon>
@@ -1,9 +1,6 @@
<?xml version="1.0" encoding="utf-8"?> <?xml version="1.0" encoding="utf-8"?>
<adaptive-icon xmlns:android="http://schemas.android.com/apk/res/android"> <adaptive-icon xmlns:android="http://schemas.android.com/apk/res/android">
<background> <background android:drawable="@mipmap/ic_launcher_background" />
<inset android:drawable="@mipmap/ic_launcher_background" android:inset="16.7%" /> <foreground android:drawable="@mipmap/ic_launcher_foreground" />
</background> <monochrome android:drawable="@mipmap/ic_launcher_monochrome" />
<foreground>
<inset android:drawable="@mipmap/ic_launcher_foreground" android:inset="16.7%" />
</foreground>
</adaptive-icon> </adaptive-icon>
Binary file not shown.

Before

Width:  |  Height:  |  Size: 2.2 KiB

After

Width:  |  Height:  |  Size: 4.4 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 913 B

After

Width:  |  Height:  |  Size: 10 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 5.5 KiB

After

Width:  |  Height:  |  Size: 4.7 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 3.3 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 3.2 KiB

After

Width:  |  Height:  |  Size: 5.7 KiB

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