Compare commits

..

67 Commits

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

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

Rename VITE_STORE_URL -> VITE_RUSTORE_URL (empty until publish).

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-12 01:34:25 +02:00
developer 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
246 changed files with 10938 additions and 1445 deletions
+68 -3
View File
@@ -28,6 +28,57 @@ on it**, and prefer the authoritative docs where they overlap. Add to this file
- **pnpm corepack pre-flight flakes.** `pnpm exec` / `pnpm check` occasionally abort on a corepack - **pnpm corepack pre-flight flakes.** `pnpm exec` / `pnpm check` occasionally abort on a corepack
pre-flight. Dodge it by invoking the tool directly: `node_modules/.bin/<tool>`. pre-flight. Dodge it by invoking the tool directly: `node_modules/.bin/<tool>`.
## Native Android build (Capacitor)
The native Android app is a Capacitor 8 wrapper of the `ui` SPA, scaffolded under
`ui/` (a Node project outside `go.work`). Hard-won bring-up facts — verify against current code:
- **Capacitor 8 needs JDK 21, not 17.** `@capacitor/android` compiles at `VERSION_21`; a JDK 17 Gradle
run dies with `error: invalid source release: 21`. Install sudo-free via the Homebrew **formula**
`brew install openjdk@21` (the `temurin@21` **cask** wants sudo for a system `.pkg`, unusable
non-interactively) + a user symlink into `~/Library/Java/JavaVirtualMachines/` so
`/usr/libexec/java_home -v 21` finds it. JDK 17 stays fine for `sdkmanager`/`avdmanager`.
- **Cap 8 SDK pins:** compileSdk/targetSdk **36**, minSdk **24**, Gradle **8.14.3**, AGP **8.13.0**
(`ui/android/variables.gradle`). Install `platforms;android-36` — Android Studio's newer
`android-36.1` does NOT satisfy compileSdk 36.
- **SDK is Android Studio's** at `~/Library/Android/sdk` (no `cmdline-tools` by default → `brew install
--cask android-commandlinetools`, then `sdkmanager`/`avdmanager --sdk_root=$HOME/Library/Android/sdk`).
Gradle finds it via **`ANDROID_HOME`** (`local.properties` is gitignored, machine-specific).
- **Build recipe:** `cd ui && pnpm build && node_modules/.bin/cap sync android`; then `cd ui/android &&
ANDROID_HOME=~/Library/Android/sdk JAVA_HOME=$(/usr/libexec/java_home -v 21) ./gradlew assembleDebug`
→ `ui/android/app/build/outputs/apk/debug/app-debug.apk`. Prefer the local `ui/node_modules/.bin/cap`
(dodges the corepack flake).
- **Emulator smoke:** existing AVDs `Pixel_10` / `Pixel_4_Android_10_API_29` / `Pixel_Android_9`;
`emulator -avd <name>`, `adb install -r <apk>`, `adb shell monkey -p ru.eruditgame.app -c
android.intent.category.LAUNCHER 1`, `adb exec-out screencap -p > x.png`. The boot wait needs `sleep`
→ run it as a **background** Bash task (foreground `sleep` is blocked). Browsers/WebView are cached, so a
full offline vs_ai turn is drivable via `adb shell input tap` (tap through the first-run coachmark tour —
each tap advances one step — then Hint places a suggested word; commit → the robot replies).
- **Android 15+ edge-to-edge safe-area (WebView-version-dependent, bit me on API 37).** targetSdk 36 forces
edge-to-edge — the WebView draws behind the status bar (top) and the gesture-nav home indicator (bottom).
On Android **WebView < 140**, `env(safe-area-inset-*)` wrongly reports **0**, so chrome relying on it draws
under the bars and is untappable: the top nav under the clock, and the game's bottom action bar's **centre**
button (Hint) under the home-indicator pill (side buttons still work; `navigation_mode`=2 is gesture nav).
Fix is CSS-only, in TWO parts: (1) Capacitor 8's **SystemBars** plugin (built into `@capacitor/core`,
`insetsHandling:'css'` default — no dep, no config) injects correct `--safe-area-inset-*`; consume them as
`--tg-safe-*: var(--safe-area-inset-*, env(safe-area-inset-*, 0px))` (`ui/src/app.css`) — fixes any consumer
that ALREADY applies the token (the bottom bars: `Game.svelte`/`Screen.svelte` `--tg-safe-bottom`).
(2) But a consumer that never applied the top inset on the native path is NOT fixed by the token alone — the
**header's** top inset was Telegram-fullscreen-scoped only, so the native header sat under the status bar on
EVERY WebView; it needed its own `.bar { padding-top: calc(var(--safe-area-inset-top, 0px) + 5px) }`
(`Header.svelte`, native-only via the plugin var; tg-fullscreen still overrides via specificity). **Measure
per element, don't eyeball** — a centred title at y=11 under a 54px bar reads as "fine" in a screenshot but
is overlapping; an emulator WebView auto-updated to ≥140 (Chrome 149) also hides the `env()`=0 half (so the
BOTTOM looks fine there while the user's < 140 device overlaps). **Inspect a live debug WebView** over CDP:
`adb forward tcp:9222 localabstract:$(adb shell cat /proc/net/unix | grep -o 'webview_devtools_remote_[0-9]*'
| head -1)`, then Playwright `chromium.connectOverCDP('http://localhost:9222')` → `page.evaluate` (read each
element's `getBoundingClientRect().top`, and `--safe-area-inset-*` vs `env(...)`).
- **`sharp` is whitelisted** in `ui/pnpm-workspace.yaml` (`allowBuilds: sharp: true`) — `@capacitor/assets`
uses it for `pnpm android:assets` (launcher icon/splash); else pnpm 11 raises `ERR_PNPM_IGNORED_BUILDS`.
- **Bundled offline dicts come from the `scrabble-dictionary` release** (`scrabble-dawg-<DICT_VERSION>.tar.gz`,
keyed on the `DICT_VERSION` Gitea var — the same source the backend image + CI `curl`), NOT
`scrabble-solver/dawg` (those are the solver's pinned test fixtures).
## Wire / schema evolution (client ↔ gateway ↔ backend) ## Wire / schema evolution (client ↔ gateway ↔ backend)
- **FBS is additive-only.** Add **trailing** fields ("added trailing — backward-compatible"). Never - **FBS is additive-only.** Add **trailing** fields ("added trailing — backward-compatible"). Never
@@ -84,6 +135,20 @@ on it**, and prefer the authoritative docs where they overlap. Add to this file
eyeball per-variant tiles. eyeball per-variant tiles.
- **Durable Playwright MCP servers** (chromium + webkit) exist for UI inspection (there's a - **Durable Playwright MCP servers** (chromium + webkit) exist for UI inspection (there's a
plugin-config gotcha in wiring them). plugin-config gotcha in wiring them).
- **The `playwright test` runner can't *fetch* browsers in this sandbox** — `playwright install` dies
with `EBADF` against the Playwright CDN (blocked network), even with the sandbox off. Run the e2e in
**CI** (the `ui` job installs chromium+webkit), or drive a state live through the **Playwright MCP**
browser against a local `vite --mode mock` server for visual verification. **But if chromium/webkit are
already cached** in `~/Library/Caches/ms-playwright/`, `playwright test` runs locally fine (only the CDN
fetch is blocked) — the native offline-first e2e was run green locally this way (chromium + webkit),
its dawgs from the sibling `../../scrabble-solver/dawg` via the webServer's `bundle-dicts.mjs` fallback.
- **A native (Capacitor) e2e must inject `window.androidBridge`, NOT `window.Capacitor.getPlatform`.**
`@capacitor/core` (pulled in during boot by `initNativeShell`'s dynamic `@capacitor/app` import)
**replaces** any pre-set `window.Capacitor` with its own shim and derives the platform from
`window.androidBridge` (android) / `window.webkit.messageHandlers` (ios) — so a bare injected
`Capacitor.getPlatform: () => 'android'` is clobbered to `web` and the boot falls to `/login`. Inject
`window.androidBridge = { postMessage(){} }` in an `addInitScript` (see `e2e/native.spec.ts` `simulateNative`);
`initNativeShell` is written to tolerate the stub bridge (`try/catch` round the `@capacitor/app` addListener).
- **`docker run -p ...` boot tests fail from the shell** (published ports unreachable in this env). - **`docker run -p ...` boot tests fail from the shell** (published ports unreachable in this env).
Use **testcontainers** for container-backed tests. Use **testcontainers** for container-backed tests.
- **Distroless images run as UID 65532 (nonroot).** Bind-mounted TLS keys must be **0644** (not 0600) - **Distroless images run as UID 65532 (nonroot).** Bind-mounted TLS keys must be **0644** (not 0600)
@@ -169,9 +234,9 @@ on it**, and prefer the authoritative docs where they overlap. Add to this file
`--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).
+172
View File
@@ -0,0 +1,172 @@
# Manual signed-APK build for the standalone Android app (RuStore). Runs ONLY from master, ONLY on
# workflow_dispatch with confirm=build — the same deliberate-manual shape as prod-deploy.yaml, never on
# a PR. It builds the native-flavoured SPA, bundles the offline dictionaries into the APK assets, and
# assembles a release APK, uploaded as a run artifact (RuStore upload stays manual for the MVP).
#
# Signing degrades gracefully: with the ANDROID_KEYSTORE_* secrets present the APK is signed; without
# them build.gradle produces an UNSIGNED release APK (so a dry run still proves the whole pipeline).
# The keystore is a publication prerequisite — see deploy/README.md (Android build/release runbook).
# The toolchain is self-provisioned here (JDK 21 + a cached Android SDK), so the runner host needs
# nothing pre-installed.
name: android-build
run-name: "android build ${{ github.sha }}"
on:
workflow_dispatch:
inputs:
confirm:
description: 'Type "build" to confirm an APK build from master.'
required: true
default: ""
permissions:
contents: read
env:
NO_COLOR: "1"
# The dictionary release, one source of truth (same Gitea variable the backend image + CI use). It
# both fetches the DAWGs and labels the bundled files (VITE_DICT_VERSION must equal __DICT_VERSION__).
DICT_VERSION: ${{ vars.DICT_VERSION }}
VITE_DICT_VERSION: ${{ vars.DICT_VERSION }}
# Hide in-app purchases in the RuStore MVP (RuStore, not Google Play — VITE_GP_BUILD stays unset).
VITE_PAYMENTS_DISABLED: "1"
# The update overlay's store target; empty until publication (the button no-ops, and the version gate
# is dormant in the MVP so it never fires). Set the ANDROID_RUSTORE_URL variable when the app is live.
VITE_RUSTORE_URL: ${{ vars.ANDROID_RUSTORE_URL }}
# Host-executor runner: the Android SDK is pre-installed on the host. Override ANDROID_SDK_DIR if it
# lives elsewhere (the default matches deploy/README.md's install path).
ANDROID_HOME: ${{ vars.ANDROID_SDK_DIR || '/opt/android-sdk' }}
ANDROID_SDK_ROOT: ${{ vars.ANDROID_SDK_DIR || '/opt/android-sdk' }}
jobs:
build:
if: ${{ github.ref == 'refs/heads/master' && inputs.confirm == 'build' }}
runs-on: ubuntu-latest
defaults:
run:
shell: bash
steps:
- uses: actions/checkout@v4
with:
fetch-depth: 0
# Host-executor runner: the Android SDK is pre-installed on the host (JDK 21 comes from setup-java
# below). Fail fast + legibly if the runner user cannot read/execute it or a needed package is
# missing — this doubles as the runner-access check (deploy/README.md).
- name: Verify the host Android SDK
run: |
sm="$ANDROID_HOME/cmdline-tools/latest/bin/sdkmanager"
if [ ! -x "$sm" ]; then
echo "::error::sdkmanager not found/executable at $sm — set the ANDROID_SDK_DIR variable if the SDK lives elsewhere, or grant the runner user read+exec: sudo chmod -R a+rX \"$ANDROID_HOME\""; exit 1
fi
for pkg in "platforms/android-36" "build-tools"; do
if [ ! -d "$ANDROID_HOME/$pkg" ]; then
echo "::error::missing $ANDROID_HOME/$pkg — run: \"$sm\" 'platforms;android-36' 'build-tools;36.0.0'"; exit 1
fi
done
echo "Android SDK OK at $ANDROID_HOME"; "$sm" --version
- name: Compute version + native build env
id: prep
env:
PUBLIC_BASE_URL: ${{ vars.PROD_PUBLIC_BASE_URL }}
run: |
# A store release must sit on an exact vMAJOR.MINOR.PATCH tag (G tags before dispatch) so the
# versionCode is deterministic and strictly increasing across uploads. Refuse anything else
# rather than derive a versionCode from a "-N-gSHA" describe.
desc="$(git describe --tags --exact-match 2>/dev/null || true)"
case "$desc" in
v[0-9]*.[0-9]*.[0-9]*) ;;
*) echo "::error::HEAD is not on a clean vX.Y.Z tag (git describe --exact-match = '${desc:-none}'); tag the release first"; exit 1 ;;
esac
v="${desc#v}"
IFS=. read -r MA MI PA <<< "$v"
# 10# forces base-10 so a zero-padded part is never read as octal.
code=$(( 10#$MA * 1000000 + 10#$MI * 1000 + 10#$PA ))
# The native SPA talks to the production origin (reuse the prod public base URL); strip any
# trailing slash so the Connect endpoint never doubles it.
gateway="${PUBLIC_BASE_URL%/}"
{
echo "tag=$desc"
echo "name=$v"
echo "code=$code"
echo "gateway=$gateway"
} >> "$GITHUB_OUTPUT"
echo "release $desc -> versionName $v versionCode $code, gateway $gateway"
# Same release + fetch as the Go/UI jobs in ci.yaml — the bundled dicts come from this tarball,
# NOT the scrabble-solver sibling (ui is a Node project outside go.work).
- name: Fetch dictionary DAWGs
run: |
mkdir -p "${GITHUB_WORKSPACE}/dawg"
curl -fsSL -o /tmp/dawg.tar.gz "https://gitea.iliadenisov.ru/developer/scrabble-dictionary/releases/download/${DICT_VERSION}/scrabble-dawg-${DICT_VERSION}.tar.gz"
tar xzf /tmp/dawg.tar.gz -C "${GITHUB_WORKSPACE}/dawg"
ls -la "${GITHUB_WORKSPACE}/dawg"
- name: Set up Node
uses: actions/setup-node@v4
with:
node-version: 22
- name: Install pnpm
run: npm install -g pnpm@11.0.9
- name: Install deps
working-directory: ui
run: pnpm install --frozen-lockfile
- name: Build the SPA (native flavour)
working-directory: ui
env:
VITE_GATEWAY_URL: ${{ steps.prep.outputs.gateway }}
VITE_APP_VERSION: ${{ steps.prep.outputs.tag }}
run: pnpm run build
# Copy the release DAWGs into dist/dict/<variant>@<version>.dawg for the offline-first bundled tier
# (after the build, before cap sync copies dist/ into the native assets).
- name: Bundle the dictionaries
working-directory: ui
env:
DICT_DIR: ${{ github.workspace }}/dawg
run: node scripts/bundle-dicts.mjs
- name: Set up JDK 21
uses: actions/setup-java@v4
with:
distribution: temurin
java-version: "21"
# Local cap binary (dodges the corepack pre-flight flake); syncs dist/ (incl. dict/) + native deps.
- name: Sync the native project
working-directory: ui
run: node_modules/.bin/cap sync android
- name: Decode the release keystore
id: keystore
env:
ANDROID_KEYSTORE_BASE64: ${{ secrets.ANDROID_KEYSTORE_BASE64 }}
run: |
if [ -n "$ANDROID_KEYSTORE_BASE64" ]; then
printf '%s' "$ANDROID_KEYSTORE_BASE64" | base64 -d > "${GITHUB_WORKSPACE}/release.jks"
echo "file=${GITHUB_WORKSPACE}/release.jks" >> "$GITHUB_OUTPUT"
echo "keystore decoded -> signed release build"
else
echo "file=" >> "$GITHUB_OUTPUT"
echo "::warning::ANDROID_KEYSTORE_BASE64 is not set — building an UNSIGNED release APK (not installable/publishable)"
fi
- name: Assemble the release APK
working-directory: ui/android
env:
ANDROID_KEYSTORE_FILE: ${{ steps.keystore.outputs.file }}
ANDROID_KEYSTORE_PASSWORD: ${{ secrets.ANDROID_KEYSTORE_PASSWORD }}
ANDROID_KEY_ALIAS: ${{ secrets.ANDROID_KEY_ALIAS }}
ANDROID_KEY_PASSWORD: ${{ secrets.ANDROID_KEY_PASSWORD }}
run: ./gradlew assembleRelease -PversionCode=${{ steps.prep.outputs.code }} -PversionName=${{ steps.prep.outputs.name }} --console=plain
- name: Upload the APK artifact
uses: actions/upload-artifact@v4
with:
name: erudit-${{ steps.prep.outputs.name }}-apk
path: ui/android/app/build/outputs/apk/release/*.apk
if-no-files-found: error
+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 }}
+745 -85
View File
@@ -45,6 +45,7 @@ installs away cleanly, and (c) opens offline-first as a soft guest with local pl
| Payments in MVP | Hidden (deferred; reuse the distribution flag) | | Payments in MVP | Hidden (deferred; reuse the distribution flag) |
| appId (permanent) | **`ru.eruditgame.app`** | | appId (permanent) | **`ru.eruditgame.app`** |
| App display name | **`Эрудит`** (Cyrillic) | | App display name | **`Эрудит`** (Cyrillic) |
| Toolchain (locked) | **Capacitor 8** (`@capacitor/*` `^8`); **compileSdk/targetSdk 36, minSdk 24**; **JDK 21** (required — `@capacitor/android` compiles at Java 21; AGP 8.13 / Gradle 8.14.3); Android SDK `platforms;android-36` + `build-tools;36.x` |
**Conventions:** all code/comments/commits/docs in English. Do NOT put stage/phase numbers in code, **Conventions:** all code/comments/commits/docs in English. Do NOT put stage/phase numbers in code,
commits, or PR titles. Bake design into the main docs (`docs/ARCHITECTURE.md` etc.) — this repo commits, or PR titles. Bake design into the main docs (`docs/ARCHITECTURE.md` etc.) — this repo
@@ -57,23 +58,236 @@ client-only (no server change).
--- ---
## Progress (as-built)
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
under `ui/android/` (tracked), `@capacitor/*` deps + `capacitor.config.ts`, hardware Back in
`ui/src/lib/native.ts` wired from `App.svelte`. `pnpm build``cap sync``./gradlew assembleDebug`
builds a debug APK that installs and **cold-launches to the Login screen** on the Pixel_10 emulator.
`svelte-check` 0 errors, `vitest` 584 passed. Build recipe + toolchain gotchas live in
`.claude/CLAUDE.md` → "Native Android build (Capacitor)".
**Launcher icon:** the temporary placeholder was **superseded by the icon rebrand** — the launcher
now uses the two-layer adaptive icon (background + foreground + monochrome) generated from the brand
master; see the RESUME block (item 1), `docs/ICON_BRANDBOOK.md` and `docs/ICONS.md`.
- **B. Native web-build correctness — ✅ DONE & verified (2026-07-12).** New `ui/src/lib/origin.ts`
`gatewayOrigin()`; the two `location.origin` landmines fixed (`game/Game.svelte` export/share URL,
`screens/Wallet.svelte` site-root) — `transport.ts:32` already resolves via `VITE_GATEWAY_URL`, left
as-is. Service worker skipped on the native channel (`lib/pwa.svelte.ts`). Payments hidden in the MVP
via new `purchasesHidden()` (`lib/distribution.ts`: folds `VITE_PAYMENTS_DISABLED` + the GP flag, plus
a `?nopay` mock force); the `Wallet.svelte` buy tab shows a neutral pointer-free note
(`wallet.purchasesSoon`) for the RuStore MVP, RuStore stub kept GP-only. Native env types in
`vite-env.d.ts`. `svelte-check` 0 errors, `vitest` 590 passed, web + native `vite build` clean;
`?nopay`/`?gp` wallet states verified live via the Playwright MCP browser (the e2e runner can't fetch
browsers in this sandbox — the states are covered by `e2e/wallet.spec.ts`, which CI runs).
- **C. Client-version gate — ✅ DONE & verified (2026-07-12).** Server: new `gateway/internal/clientver`
(parse + compare), `GATEWAY_MIN_CLIENT_VERSION` config (empty ⇒ dormant, validated at load), and the
gate in `connectsrv``Execute` returns `result_code="update_required"` before the registry lookup,
`Subscribe` returns `FailedPrecondition`; fail-open on an absent/garbled header. Client:
`X-Client-Version` on every call (`transport.ts headers()`), a terminal `update.svelte.ts` store +
`UpdateOverlay.svelte` (native → `VITE_RUSTORE_URL`, web → reload), `retry.ts` maps
`FailedPrecondition → update_required`, the `__update` mock hook. `gofmt`/`vet` clean, Go
`clientver`/config/`connectsrv` tests green, `svelte-check` 0, `vitest` 591, e2e 232 (incl.
`update.spec.ts`), client build clean. Silent reconciliation seam deferred to D (owner); in-app
store-update SDK noted Out of scope.
- **D. Offline-first — ✅ CODE-COMPLETE, e2e-verified & on-device-smoke-verified (D.1D.6 done, 2026-07-12).**
The offline path is proven on-device (Pixel_10 / API 37, airplane mode): cold-boot → offline guest lobby, a
full local vs_ai turn (bundled dawg — Hint placed "FEZ", the robot replied "NEEDFIRE"), New Game offering
both modes; the smoke also surfaced + fixed a native-chrome edge-to-edge safe-area bug (below). Remaining for
D: the **reconcile-online leg on-device** (deferred — it hits prod, minting a guest) and the **deferred
local-game-visibility decision** (below). D.5 is also covered by `e2e/native.spec.ts` (vs_ai move + hotseat).
- **D.1 + D.2 (foundations) — ✅ DONE & committed `bcd5a1d` (2026-07-12).** `ui/scripts/bundle-dicts.mjs`
(release DAWGs → `dist/dict/<variant>@<version>.dawg`, keyed on `VITE_DICT_VERSION`, `OUT_DIR` override
for the e2e); the `dict/loader.ts` **bundled tier** (between IndexedDB and network, **native-gated**
see the §D.1 correction); `__DICT_VERSION__` vite define + declaration; `lib/localguest.ts` (persisted
device-local guest id, no DB row) + `common.guest` i18n; `NewGame.svelte` offline vs_ai/hotseat creates
fall back to `__DICT_VERSION__` and `localGuestId()`/`t('common.guest')` when there is no session.
- **D.3 (cold boot) + D.4 (reconciliation + silent seam) — ✅ DONE & verified (2026-07-12).** The native
no-session cold boot lands in the offline lobby as a device-local guest (`app.svelte.ts` bootstrap
`else if (native)` branch — `localGuestId()` + `setOfflineMode(true,false)` + `scheduleRecovery(0)`);
lazy `reconcileServerGuest()` mints + adopts a server guest and clears the auto-offline when the gateway
is reachable (kicked at boot + by the recovery poll + the online event). Silent seam:
`transport.ts` `exec` gained `{ silent, allowOffline }` (suppress the update overlay **and** bypass the
kill switch), plus `authGuestSilent(locale)` on the `GatewayClient` interface, the real transport and the
mock. `native.ts` `initNativeShell` made bridge-tolerant. New `e2e/native.spec.ts` (Capacitor injected;
boot→offline-lobby, local vs_ai move, hotseat start, reconcile→online) + `playwright.config.ts` bundles
the dawgs into `dist-e2e/dict/`. `svelte-check` 0, `vitest` 591, web + native builds clean; e2e runs in CI.
See §D.3/§D.4 for the as-built corrections (shouldBootOffline **not** changed; `allowOffline` +
checkReachable-token findings; the `__native.reconcile` e2e hook).
- **D.6 (Profile tg/vk hide on native) — ✅ DONE & verified (2026-07-12).** `Profile.svelte` gates
`telegramLinkable`/`vkLinkable` on `!nativeShell` (`clientChannel()` android/ios), so the Telegram + VK
LINK buttons are hidden on the native build; email + account management (incl. any existing link's UNLINK,
a redirect-free gateway call) stay. Covered by the `e2e/native.spec.ts` reconcile test (Profile → no Link
Telegram / Link VK buttons, email present).
- **Native chrome: Android 15+ edge-to-edge safe-area — ✅ FIXED & on-device-verified (2026-07-12, owner-confirmed).**
targetSdk 36 forces edge-to-edge, so the WebView draws behind the system bars; the app chrome overlapped the
status bar (top nav, untappable) and the gesture-nav home indicator (the game's centre Hint button — side
buttons fine). Two-part CSS fix consuming the SystemBars core plugin's injected `--safe-area-inset-*`:
the **bottom** via the `--tg-safe-*` token (`app.css`), the **header top** via a native-only `.bar` rule
(`Header.svelte` — its top inset had been Telegram-fullscreen-scoped only, so the token alone didn't reach
it). No dep/config (SystemBars ships in `@capacitor/core` v8). Verified on Pixel_10 / API 37 by live-WebView
CDP measurement (header title y=11→65, `--safe-area-inset-*` = 54/24px) **and** owner re-test on their device.
Full gotcha + CDP recipe in `.claude/CLAUDE.md`. Commits `4a0689a` (bottom) + `49c5379` (top).
- **DEFERRED DECISION (owner-agreed 2026-07-12) — native local-game visibility.** The online lobby lists
only server games (`Lobby.svelte:42/54`), so a native guest's **device-local vs_ai/hotseat games hide from
the lobby once reconciled online** (the pre-existing offline↔online split). For native this IS the primary
flow, so the behaviour **must change** — a native guest should still see / resume their local games when
online. Owner agreed it needs changing but wants to design it **at the very end of the Android work (before
release — see §G)**; out of D.3/D.4 scope, tracked here so it is not lost.
- **E. CI — signed APK artifact — ✅ CODE-COMPLETE & locally-proven (2026-07-12).** New manual workflow
`.gitea/workflows/android-build.yaml` (`workflow_dispatch` + `confirm=build`, `if` gated to `master`
mirrors `prod-deploy.yaml`; never on a PR) builds the native-flavoured SPA, bundles the offline dicts, and
assembles a release APK uploaded as a run artifact. Like `prod-deploy`, it is **provable only by a dispatch
from `master`** (that is §G), so E was verified as far as possible now: locally via `assembleDebug` + a
**keyless** `assembleRelease` (→ `app-release-unsigned.apk`, `versionCode 1017000` / `versionName 1.17.0`
for a `v1.17.0` tag — confirmed in `output-metadata.json`), `svelte-check` 0, YAML structure checked.
As-built:
- **`ui/android/app/build.gradle`:** `versionCode`/`versionName` read `-PversionCode`/`-PversionName` (defaults
keep local `assembleDebug` building). **CORRECTION vs the plan snippet — use `=` assignment**
(`versionCode = (…).toInteger()`), NOT the command form `versionCode (…).toInteger()`: the latter binds
`.toInteger()` to the DSL setter's null return (`> Value is null`). A guarded `signingConfigs.release` reads
the keystore from env and is attached **only when the keystore file exists** — so **no keystore ⇒ an UNSIGNED
release APK, not a failure** (a dispatch proves the pipeline before the keystore exists).
- **Toolchain:** the **CI runner is a host-executor on a remote Debian host**. JDK 21 comes from
`actions/setup-java`; the **Android SDK is host-provisioned** (pre-installed at `ANDROID_SDK_DIR`, default
`/opt/android-sdk`), with a fail-fast **Verify the host Android SDK** pre-flight that also checks the `runner`
user's read/exec access (`platforms;android-36` + `build-tools`). Unit tests need no new tooling (they ride
the existing `unit`/`ui` jobs).
- **Env:** `VITE_GATEWAY_URL` reuses `vars.PROD_PUBLIC_BASE_URL` (trailing slash stripped); `VITE_DICT_VERSION` /
`DICT_VERSION` = the `DICT_VERSION` var; `VITE_PAYMENTS_DISABLED=1`. **`VITE_STORE_URL` was renamed to
`VITE_RUSTORE_URL`** (owner) — left **empty** via the (unset) `vars.ANDROID_RUSTORE_URL`, so the update button
no-ops until publication (`UpdateOverlay.svelte` already guards `if (url)`; the gate is dormant in the MVP).
- **versionCode from an exact tag:** the workflow refuses anything but a clean `git describe --exact-match`
`vX.Y.Z` (deterministic + monotonic store versionCode); §G tags before dispatch.
- **Delivery:** `actions/upload-artifact@v4` (assumes Gitea 1.26 artifact support — verify at the §G dispatch).
Secrets to set before a **signed** build (§G / publication): `ANDROID_KEYSTORE_BASE64`,
`ANDROID_KEYSTORE_PASSWORD`, `ANDROID_KEY_ALIAS`, `ANDROID_KEY_PASSWORD`.
- **F. Docs — ✅ DONE (2026-07-12).** Baked the Android work into the live docs: `docs/ARCHITECTURE.md` §2
(the client-version gate + the frozen wire contract + the gate × offline rule), §3 (the local-guest /
server-guest / reconciliation identity model) and §13 (the native Capacitor build — bundle model, bundled
dicts, `versionCode` scheme, `file://` origin, RuStore); `docs/FUNCTIONAL.md` (+ `_ru` mirror) a new **Native
app (Android)** domain (offline-first guest launch, email soft-registration, no TG/VK login, hidden
purchases, update-required); `docs/TESTING.md` (the `clientver`/gate Go tests, the `native`/`update` e2e +
the `retry` mapping, and the manual **on-device Android smoke checklist**); `deploy/README.md` (the
`GATEWAY_MIN_CLIENT_VERSION` var + the wire-break bump discipline + the Android build/release runbook —
keystore, secrets, dispatch, RuStore upload); `ui/README.md` (the native `VITE_*` build vars). Every
referenced test file was confirmed to exist.
- **G — pending; gated on the offline-model redesign (was G-step-0).** The deferred native
local-game-visibility decision grew into an owner-approved cross-cutting change (web + native + a small
additive backend/wire bit): remove the explicit offline toggle → one `netState` machine, unify the lobby,
two-tier version gate. **Designed + staged (O1O7)** in the "Offline-model redesign" section below.
**➡ Next actionable work: implement O1** (the pure `netState` reducer, test-first) via `stage-implementation`,
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.
Icon rebrand — **DONE** (was "one master → every icon"): a single Spectral «Э» master now drives web
(favicon light+dark / PWA any + maskable / apple-touch / og), the Capacitor layers and the Android
adaptive res (no inset, + monochrome), plus VK/TG/store art. Specced in `docs/ICON_BRANDBOOK.md`,
mapped in `docs/ICONS.md`, generated from `assets/icons/brand/`. `~/.claude/bin/gitea-ci-watch.py` is present.
RuStore publication prerequisites gate release only.
---
## Prerequisites to start DEVELOPMENT (on the other device) ## Prerequisites to start DEVELOPMENT (on the other device)
Have all of these before writing code: Have all of these before writing code:
- **JDK 17** (Temurin/OpenJDK). - **JDK 21** (Temurin/OpenJDK)**required** by Capacitor 8: `@capacitor/android` sets
`sourceCompatibility/targetCompatibility = VERSION_21`, so a JDK 17 Gradle run fails with
`invalid source release: 21`. (Homebrew: `brew install openjdk@21`.)
- **Android SDK** — Android Studio (recommended: bundles the SDK manager + AVD emulator) *or* - **Android SDK** — Android Studio (recommended: bundles the SDK manager + AVD emulator) *or*
cmdline-tools. Install: `platform-tools`, `platforms;android-34`, `build-tools;34.0.0`. cmdline-tools. Capacitor 8 targets **compileSdk/targetSdk 36, minSdk 24**, so install:
`platform-tools`, `platforms;android-36`, `build-tools;36.0.0` (a newer build-tools such as
`36.1.0` is also accepted — AGP treats it as a minimum). Gradle 8.14.3 / AGP 8.13 arrive via the
wrapper `cap add android` generates.
- **Node 20+ and pnpm** (via corepack), matching the repo's `ui` toolchain. - **Node 20+ and pnpm** (via corepack), matching the repo's `ui` toolchain.
- **Git**, plus this repo's Gitea access. If this device will push/PR, set up the `tea` CLI login - **Git**, plus this repo's Gitea access. If this device will push/PR, set up the `tea` CLI login
and the CI watcher exactly as the owner's global setup (`~/.claude` tooling: and the CI watcher exactly as the owner's global setup (`~/.claude` tooling:
`python3 ~/.claude/bin/gitea-ci-watch.py`, `tea` against `gitea.lan`). `python3 ~/.claude/bin/gitea-ci-watch.py`, `tea` against `gitea.lan`).
- **Both repositories side by side** per `go.work`: clone `developer/scrabble-game` **and** the - **Both repositories side by side** per `go.work`: clone `developer/scrabble-game` **and** the
sibling `scrabble-solver` next to it. The sibling holds the committed dictionaries sibling `scrabble-solver` next to it — the solver **library** the backend/CI consume via the
(`scrabble-solver/dawg/{ru_scrabble,ru_erudit,en_sowpods}.dawg`) that offline-first bundles, and `go.work` replace. **The bundled dictionaries do NOT come from the solver.** The versioned,
the solver library the backend/CI use. production dictionary set is published by `developer/scrabble-dictionary` as a **release artifact**
- **A test target**: a physical Android device with USB debugging **or** an emulator AVD (e.g. Pixel, `scrabble-dawg-<DICT_VERSION>.tar.gz` (one semver label per set); `backend/Dockerfile` and every CI
API 34). job already `curl` exactly that tarball, keyed on the shared Gitea variable `DICT_VERSION`.
Offline-first bundles the DAWGs from that same release (see D.1 / E), so the Android build needs
`DICT_VERSION` + network access to the release, **not** `scrabble-solver`. (`scrabble-solver/dawg/*.dawg`
are the solver's committed test fixtures — byte-identical to a build but pinned to the solver
commit, not the versioned production set.)
- **A test target**: a physical Android device with USB debugging **or** an emulator AVD (e.g. a
Pixel AVD; any recent API image).
- **No backend needed to build the APK** — it points at production `erudit-game.ru`. A local gateway - **No backend needed to build the APK** — it points at production `erudit-game.ru`. A local gateway
is only needed to exercise the version gate locally (optional). is only needed to exercise the version gate locally (optional).
@@ -152,7 +366,7 @@ it and stay a local guest (do not overlay). Implementation seam: the reconciliat
Ordered so each part is independently verifiable. The MVP now includes offline-first, so the sequence Ordered so each part is independently verifiable. The MVP now includes offline-first, so the sequence
is: scaffold → native-correct → gate → offline-first → CI → docs → release. is: scaffold → native-correct → gate → offline-first → CI → docs → release.
### A. Capacitor scaffolding ### A. Capacitor scaffolding — ✅ DONE
- `ui/package.json`: add deps `@capacitor/core`, `@capacitor/android`, `@capacitor/app`; devDeps - `ui/package.json`: add deps `@capacitor/core`, `@capacitor/android`, `@capacitor/app`; devDeps
`@capacitor/cli`, `@capacitor/assets`. Scripts: `"cap:sync": "cap sync android"`, `@capacitor/cli`, `@capacitor/assets`. Scripts: `"cap:sync": "cap sync android"`,
@@ -190,13 +404,13 @@ 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.
### B. Native web-build correctness (file:// origin) ### B. Native web-build correctness (file:// origin) — ✅ DONE
1. **One origin helper** — new `ui/src/lib/origin.ts`: 1. **One origin helper** — new `ui/src/lib/origin.ts`:
```ts ```ts
@@ -228,15 +442,20 @@ is: scaffold → native-correct → gate → offline-first → CI → docs → r
} }
``` ```
Switch the wallet/purchase consumers from `isGooglePlayBuild()` to `purchasesHidden()` where they Switch the wallet/purchase consumers from `isGooglePlayBuild()` to `purchasesHidden()` where they
hide the buy actions (grep `isGooglePlayBuild` under `ui/src`). Keep the "go to RuStore" stub hide the buy actions (the sole consumer is `Wallet.svelte`). Keep the "go to RuStore" stub
`isGooglePlayBuild()`-only. Add `VITE_PAYMENTS_DISABLED?: string`, `VITE_STORE_URL?: string`, `isGooglePlayBuild()`-only. **As built (owner decision):** in the RuStore MVP (`purchasesHidden() &&
!isGooglePlayBuild()`) the buy tab shows a neutral, pointer-free note — new i18n key
`wallet.purchasesSoon`, `data-testid="purchases-hidden"` — not an empty tab and not a store link; the
same note can replace the RuStore stub in the later Google Play anti-steering variant.
`purchasesHidden()` also carries a mock-only `?nopay` force (mirrors `?gp`) so the e2e drives the
state without a separate build. Add `VITE_PAYMENTS_DISABLED?: string`, `VITE_RUSTORE_URL?: string`,
`VITE_DICT_VERSION?: string` to `ui/src/vite-env.d.ts`. `VITE_DICT_VERSION?: string` to `ui/src/vite-env.d.ts`.
4. **Native env matrix** — see Build & env. 4. **Native env matrix** — see Build & env.
**Done when:** a native-flavoured build reaches the gateway, signs in as guest, plays a move, and the **Done when:** a native-flavoured build reaches the gateway, signs in as guest, plays a move, and the
purchase actions are absent; `pnpm check` + `pnpm test:unit` pass. purchase actions are absent; `pnpm check` + `pnpm test:unit` pass.
### C. The client-version gate ### C. The client-version gate — ✅ DONE
#### C1. Backend (gateway) #### C1. Backend (gateway)
@@ -313,96 +532,194 @@ purchase actions are absent; `pnpm check` + `pnpm test:unit` pass.
before the throw. before the throw.
- catch (after `toGatewayError`, line 73) and the `subscribe()` catch: if the code is - catch (after `toGatewayError`, line 73) and the `subscribe()` catch: if the code is
`UPDATE_REQUIRED`, call `reportUpdateRequired()` before rethrowing/`onError`. `UPDATE_REQUIRED`, call `reportUpdateRequired()` before rethrowing/`onError`.
- **Reconciliation exception:** provide a variant used by background guest-reconciliation that does - **Reconciliation exception — deferred to D (owner decision).** The silent update-path variant
NOT call `reportUpdateRequired()` (it swallows the code and stays offline). Simplest: a boolean (swallows `update_required` without raising the overlay) has no caller until D.4, so it is added
on `exec`/a dedicated `authGuestSilent` path guarded by a flag; the executing session picks the there with its caller rather than speculatively in C. In C every foreground call that gets
seam. Foreground `auth.*` keep the overlay behaviour. `update_required` raises the overlay; offline play never trips it (the network kill switch).
- **`ui/src/lib/retry.ts` `toGatewayError()`**: add - **`ui/src/lib/retry.ts` `toGatewayError()`**: add
`case Code.FailedPrecondition: return new GatewayError('update_required', e.message);`. `case Code.FailedPrecondition: return new GatewayError('update_required', e.message);`.
- **`ui/src/components/UpdateOverlay.svelte`** (new, mirror `MaintenanceOverlay.svelte`): non-dismissable; - **`ui/src/components/UpdateOverlay.svelte`** (new, mirror `MaintenanceOverlay.svelte`): non-dismissable;
shown when `updateRequired.active`; one button — native (`clientChannel()` android/ios) → shown when `updateRequired.active`; one button — native (`clientChannel()` android/ios) →
`window.open(import.meta.env.VITE_STORE_URL, '_system')`; web → `location.reload()`. i18n keys `window.open(import.meta.env.VITE_RUSTORE_URL, '_system')`; web → `location.reload()`. i18n keys
`update.title/body/action` (add siblings to the maintenance keys). `update.title/body/action` (add siblings to the maintenance keys).
- **`ui/src/App.svelte`**: import + place `<UpdateOverlay />` right after `<MaintenanceOverlay />` (line 139). - **`ui/src/App.svelte`**: import + place `<UpdateOverlay />` right after `<MaintenanceOverlay />` (line 139).
- **Mock e2e hook** — `ui/src/lib/gateway.ts` mock branch, next to `__maint`: `__update = { on: reportUpdateRequired }`. - **Mock e2e hook** — `ui/src/lib/gateway.ts` mock branch, next to `__maint`: `__update = { on: reportUpdateRequired }`.
- **Tests:** `update.svelte.test.ts`; extend `retry.test.ts` (`FailedPrecondition → 'update_required'`); - **Tests:** extend `retry.test.ts` (`FailedPrecondition → 'update_required'`) and drive the overlay via
Playwright `update.spec.ts` driving `__update.on()`. Playwright `update.spec.ts` (`__update.on()`). The `update.svelte.ts` store is a `$state` rune module,
which this project's plugin-less `vitest` cannot import (`$state is not defined`); like every other
`*.svelte.ts` store it is covered by the e2e, not a unit test.
**Done when:** Go `clientver` + gate tests pass; `pnpm check`/`test:unit`/`test:e2e` pass; a local **Done when:** Go `clientver` + gate tests pass; `pnpm check`/`test:unit`/`test:e2e` pass; a local
gateway with `GATEWAY_MIN_CLIENT_VERSION=v99.0.0` shows the overlay, unset ⇒ unchanged. gateway with `GATEWAY_MIN_CLIENT_VERSION=v99.0.0` shows the overlay, unset ⇒ unchanged.
### D. Offline-first (bundled dicts + local guest + cold boot + reconciliation) ### D. Offline-first (bundled dicts + local guest + cold boot + reconciliation) — ✅ CODE-COMPLETE
Both offline modes already exist and are gated on `offlineMode.active`: local vs_ai and 2-4-player Both offline modes already exist and are gated on `offlineMode.active`: local vs_ai and 2-4-player
hotseat (pass-and-play with a host PIN referee) — see `ui/src/lib/localgame/source.hotseat.test.ts` hotseat (pass-and-play with a host PIN referee) — see `ui/src/lib/localgame/source.hotseat.test.ts`
and `ui/src/screens/NewGame.svelte:177-431`. This phase makes them reachable on a **cold first launch and `ui/src/screens/NewGame.svelte:177-431`. This phase makes them reachable on a **cold first launch
with no network**. with no network**.
1. **Bundle dictionaries in the APK.** **Status:** D.1D.6 are **done & verified** (foundations committed `bcd5a1d`; boot + reconciliation +
- New build step `ui/scripts/bundle-dicts.mjs`: copy `../scrabble-solver/dawg/{ru_scrabble,ru_erudit,en_sowpods}.dawg` Profile + the Android edge-to-edge safe-area fix committed on `feature/android-native`, 2026-07-12), with
→ `ui/dist/dict/<variant>@<DICT_VERSION>.dawg` (dictKey naming). Run only in the native pipeline `e2e/native.spec.ts` green (chromium + webkit) AND an **on-device emulator smoke** (Pixel_10 / API 37,
(after `pnpm build`, before `cap sync`). Web builds stay slim. airplane mode): cold-boot → offline guest lobby, offline vs_ai played end-to-end (bundled dawg — Hint
- Vite `define`: add `__DICT_VERSION__` from `VITE_DICT_VERSION` (mirrors `__APP_VERSION__`); placed "FEZ", the robot replied "NEEDFIRE"), New Game offers both modes. The smoke surfaced a native-chrome
declare it in `vite-env.d.ts`. bug on **Android 15+ edge-to-edge** (WebView < 140 reports `env(safe-area-inset-*)`=0 → the top nav draws
- **Loader tier** — `ui/src/lib/dict/loader.ts` `load()`: between the IndexedDB tier (line 73-85) under the status bar and the game's centre Hint button under the gesture-nav home indicator) — **fixed** by
and the network tier (line 87), add a **bundled tier** that `fetch('./dict/'+key+'.dawg')` consuming the SystemBars core plugin's injected `--safe-area-inset-*`: the bottom via the `--tg-safe-*` token
(relative — served from app assets on native, 404 on web → falls through). On a hit, build the (`app.css`), and the header's top inset via a native-only `.bar` rule (`Header.svelte` — its top inset was
`Dawg`, cache in memory, seed IndexedDB (`idbPutDawg`), and return. This is the only change that Telegram-fullscreen-scoped only, so the token alone did not reach it). No dep/config (the plugin ships in
lets a never-online device obtain a dictionary. `@capacitor/core` v8); full story in `.claude/CLAUDE.md`. Remaining for D: the reconcile-online leg
- Offline local-game creation must request the **bundled version**: where `NewGame.svelte` on-device (hits prod — a guest mint) and the deferred local-game-visibility decision (§G). **Verify every
offline-create and `localgame` resolve the dict version, use `__DICT_VERSION__` so the requested line ref below against current code.**
`(variant, version)` matches the bundled file (grep the offline `localSource.create` calls in
`NewGame.svelte:83-96,289-296` and the dict-version resolution in `gamesource.ts`/`localgame`).
2. **Local-guest identity** — new `ui/src/lib/localguest.ts`: a device-local id + default display
name (e.g. localized "Гость"), persisted (IndexedDB `scrabble` DB / localStorage). Used as the
human seat's account id in a local vs_ai game when there is no server session (today that id comes
from the session — see `localgame/source.list.test.ts:64`). Hotseat seats stay independent local
identities (already so).
3. **Cold offline-first boot** — `ui/src/lib/offline.ts` + `ui/src/lib/app.svelte.ts`:
- Extend `shouldBootOffline` (offline.ts:85) so that on a **native** channel, no session may still
boot offline as the local guest (dicts are bundled). Keep the current web rule intact (web still
needs a prior online session).
- In `bootstrap()` (app.svelte.ts): when native and (no cached session) and (offline preference on
OR the network is unreachable at boot), set `setOfflineMode(true, …)`, establish the local guest,
preload the bundled dicts, and land in the **lobby** (not `Login`). Preserve the existing online
path (guest/email) when reachable.
- `offlinePreloadEligible` (offline.ts:69): treat a native channel as eligible (drop the
`standalone && hasEmail` web gate for native), or bypass preload entirely on native since the
dicts are bundled.
4. **Lazy server-guest reconciliation** — when native and online and no server session: background
`auth.guest` (the silent variant from C2), cache the session, adopt it; unlock online features.
Guard against duplicates (only when no cached session). Local games remain device-only. On
`update_required`, stay offline silently (no overlay).
5. **Ensure both offline modes render for the local guest.** `NewGame.svelte` gates the offline
flows on `guest || offlineMode.active`; the local guest makes `offlineMode.active` true, so both
the "quick" (vs_ai) and "with friends" (hotseat) flows show. Verify the vs_ai human seat uses the
local-guest id.
6. **Soft registration** stays: the local/server guest can register by email → upgrade (existing
flow), adopting the server guest first if present.
**Tests:** extend the local-game/offline unit tests for the bundled-dict tier (a `fetch` mock **Decisions locked this session (owner-approved) — bake these before implementing:**
returning a blob) and the local-guest boot decision (`shouldBootOffline` native-no-session); a - **The blocking Login is bypassed on native only.** Web / PWA / Telegram / VK keep the current
Playwright offline-first spec that boots with the network hook off and plays a local vs_ai move and online-session rule (they still need a prior session). The native channel always lands the user in the
starts a hotseat game. (Follow `docs/TESTING.md` layers; the mock e2e bypasses the codec, so keep the lobby, online or offline.
gate's server path in Go tests.) - **Soft registration reuses the existing `Profile` screen** as the guest sign-in / account surface (it
already shows the email / Telegram / VK upgrade options for a guest). No new sign-in UI is built in D.
- **Hide the Telegram + VK link buttons on the native build** on Profile: VK ID web-login is a full-page
redirect to `id.vk.com` that cannot return into the Capacitor app (it strands on the web redirect URI),
and the Telegram Login Widget is unreliable in a WebView. **Email works** (pure gateway calls, no
redirect). Native Telegram/VK login (native SDKs / deep-link OAuth — "the pretty native popups") is a
**separate later stage**, consistent with the locked "guest+email" surface and "Out of scope: VK/Telegram
login in the native build".
- **Local-guest display name** = localized `common.guest` ("Гость" / "Guest").
1. **Bundle dictionaries in the APK. ✅ DONE.**
- `ui/scripts/bundle-dicts.mjs`: copies the DAWGs from the unpacked **`scrabble-dictionary` release**
(dir via **`DICT_DIR`**; **NOT** `../scrabble-solver/dawg`) → `<OUT_DIR|dist>/dict/<variant>@<version>.dawg`.
`dictKey(variant,version)` = `` `${variant}@${version}` `` (`lib/dict/store.ts:31`). `dawgFor` maps
`scrabble_en→en_sowpods`, `scrabble_ru→ru_scrabble`, `erudit_ru→ru_erudit` (mirrors `e2e-dict.mjs`).
Version = `VITE_DICT_VERSION` (default `dev`); `OUT_DIR` overrides the root (the e2e points it at
`dist-e2e`). Run only in the native pipeline (after `pnpm build`, before `cap sync`); web builds skip
it and stay slim.
- Vite `define` `__DICT_VERSION__` (from `VITE_DICT_VERSION`, default `dev`; mirrors `__APP_VERSION__`)
+ declared in `vite-env.d.ts`.
- **Loader tier** — `ui/src/lib/dict/loader.ts` `load()`: a **bundled tier** sits between the IndexedDB
tier (now tier 1) and the network tier (now tier 3). **CORRECTION vs the original plan:** it is
**native-gated** (`clientChannel()` android/ios), NOT "fetch always, 404 on web". A relative
`fetch('./dict/'+key+'.dawg')` on the web would hit the **gateway's own session-gated `/dict/` route**
(a real server route), not a clean 404 — so the bundled fetch is skipped off-native and only tried in
a packaged app (its own assets). On a hit: build the `Dawg`, cache in memory, seed IndexedDB, return
(no network metric — a bundled hit is a local asset). The e2e simulates native (see Tests).
- Offline creates request the **bundled version**: `NewGame.svelte` offline vs_ai (`~line 84`) and
hotseat (`~line 277`) use `app.profile?.dictVersions?.[v] ?? __DICT_VERSION__`, so a profile-less
local guest gets the bundled `(variant, version)`.
2. **Local-guest identity. ✅ DONE.** `ui/src/lib/localguest.ts`: `localGuestId()` mints + persists a
device-local id in `localStorage` (`scrabble.localGuestId`, prefix `localguest:`, no crypto API for the
old engines) + `isLocalGuestId()`. The **display name is not in the module** — it is `t('common.guest')`
at the call site, so the module stays i18n-free and node-testable. `NewGame.svelte` vs_ai human seat
(`~line 99`) now uses `accountId: app.session?.userId ?? localGuestId()` and
`name: app.profile?.displayName ?? t('common.guest')`. Hotseat seats stay independent local identities
(`buildSeats` — unchanged).
3. **Cold offline-first boot — ✅ DONE & verified (2026-07-12)** (`ui/src/lib/app.svelte.ts`). **High
blast-radius: app startup for every platform — every change is native-gated; web / PWA / TG / VK stay
byte-for-byte.** As built:
- The blocking Login was **`bootstrap()`'s `else` of `if (saved)`** (no cached session, formerly
`navigate('/login')`). On native it is now the offline-first entry: `localGuestId()` +
`setOfflineMode(true, false)` (**non-sticky** auto-offline, so reconciliation may clear it) + land in
the lobby (`navigate('/')` only if on login/confirm) + `scheduleRecovery(0)` to kick reconciliation.
Web keeps the `navigate('/login')` else. `app.ready = true`.
- **CORRECTION — `shouldBootOffline` was NOT changed.** The native-no-session case is fully caught in
that `else` branch (there `hasSession` is provably false and `saved`'s `if`-block — the web
canOffline logic — is skipped), so a `native` param would be unused in the load-bearing path. The
plan's "add a native-no-session path to `shouldBootOffline`" is dropped, and the matching unit test
with it (the boot decision is exercised by `e2e/native.spec.ts`).
- **`offlinePreloadEligible` was NOT changed either** — it already returns false off a standalone web
PWA (native is not `isStandalone()`), so the server preload is already skipped on native; no edit needed.
- The lobby renders without a session/profile: `Lobby.svelte`/`NewGame.svelte` use optional chaining
and the offline branch loads only device-local games (no session-gated `gamesList`). `NewGame` gates
its offline flows on `guest || offlineMode.active`, both true for the local guest.
4. **Lazy server-guest reconciliation + the silent seam — ✅ DONE & verified (2026-07-12).** As built:
- **Silent seam:** `exec` gained `opts?: { silent?: boolean; allowOffline?: boolean }`.
**CORRECTION — `silent` alone was not enough:** reconciliation runs while the app is still in
auto-offline, so `exec`'s `assertOnline()` kill switch would refuse it — hence **`allowOffline`**
(bypass the kill switch, like the reachability probe). `silent` gates the two `exec`
`reportUpdateRequired()` sites (result-code + catch); the `subscribe` catch keeps the overlay
(reconciliation never subscribes). New `authGuestSilent(locale)` on the `GatewayClient` interface
(`lib/client.ts`), the real transport (`{ silent: true, allowOffline: true }`) and the mock.
- **Reconciliation — `reconcileServerGuest()` (`app.svelte.ts`):** native + no session → `authGuestSilent`,
`setOfflineMode(false)` **before** `adoptSession` (so adopt's `profileGet`/stream ride the online
transport, not the kill switch), then adopt. Guarded by `reconcileInFlight` + `app.session` (never a
second guest). `update_required`/unreachable are swallowed (stay a local guest). Local games stay
device-only. Fired at boot and by the recovery poll + the online event.
- **CORRECTION — `checkReachable` cannot gate a session-less guest:** its authenticated probe rejects
with no token, so it never reports "reachable" for a local guest. The recovery poll (`scheduleRecovery`)
therefore routes the **no-session** case straight through `reconcileServerGuest` (the mint attempt IS
the reachability check); the session case keeps `checkReachable`.
- **`scheduleRecovery` is now mock-guarded** (like `initNetworkReactivity`) so the e2e's boot does not
race a mock reconcile to online before the spec observes the offline lobby; the e2e drives
reconciliation via a new **`window.__native.reconcile()`** hook (mirrors `__conn`/`__maint`/`__update`).
5. **Both offline modes render for the local guest — ✅ DONE & verified (2026-07-12).** `NewGame` gates the
offline flows on `guest || offlineMode.active`; the local guest makes `offlineMode.active` true, so both
"quick" (vs_ai) and "with friends" (hotseat) show. The vs_ai human seat already uses the local-guest id
(D.2). Verified: `e2e/native.spec.ts` plays a vs_ai move + starts a hotseat game, and the on-device smoke
(Pixel_10 / API 37) played a full offline vs_ai turn (Hint "FEZ", robot "NEEDFIRE") and reached New Game
offering both modes.
6. **Soft registration = reuse the `Profile` screen — ✅ DONE & verified (2026-07-12).** As built:
- **Reaching Profile needed no change:** guests keep the Profile tab (`SettingsHub.svelte:27` only hides
friends/wallet for guests; `:33` hides profile/friends/wallet in **offline** mode), so once the native
guest is online and reconciled (D.4 clears the auto-offline) Profile shows and email sign-in works.
Offline you cannot register anyway (every method needs the server), so the offline-hidden behaviour is
correct.
- **Hid tg/vk on native:** `Profile.svelte` gates `telegramLinkable = loginWidgetAvailable() && !nativeShell`
and `vkLinkable = vkWebLinkAvailable() && !nativeShell` (`nativeShell = clientChannel()` android/ios), so
both LINK buttons are hidden on native while email + account management (and an existing link's UNLINK — a
redirect-free `link.unlink` call) stay. Verified in `e2e/native.spec.ts` (the reconcile test opens Profile
and asserts no Link Telegram / Link VK, email present; `loginWidgetAvailable`/`vkWebLinkAvailable` are true
under the mock, so the buttons show on web — the native count-0 proves the gate).
**Tests — ✅ DONE for D.3/D.4 (2026-07-12):**
- **Unit (vitest, node):** no NEW unit test was added for D.3/D.4 — the boot decision, reconciliation and
silent seam live in `$state`/transport modules the plugin-less vitest cannot import (cf. `update.svelte.ts`),
and the `shouldBootOffline` native path was dropped (§D.3). `localguest` + the bundled-dict tier are D.1/D.2
coverage (a bundled-tier `fetch`-mock unit test remains a D-close nicety if wanted). `vitest` stays 591.
- **Playwright — new `e2e/native.spec.ts`** (NOT an extension of `offline.spec`): **GOTCHA — inject
`window.androidBridge`, NOT `window.Capacitor.getPlatform`.** `@capacitor/core` (loaded during boot by
`initNativeShell`) derives the platform from `window.androidBridge` / `window.webkit` and **replaces** any
pre-set `window.Capacitor` with its own shim whose `getPlatform()` is `web` — so a bare injected
`window.Capacitor = { getPlatform: () => 'android' }` is clobbered and the boot falls to `/login`.
`simulateNative` injects `window.androidBridge = { postMessage(){} }` (+ a Capacitor stub for the pre-core
window), which makes `clientChannel()` resolve to `android`. Two tests: (1) boot → **assert the offline guest
lobby (not login)**, play a local vs_ai move (bundled dawg), Back, `window.__native.reconcile()` → assert
**online lights up** (offline tint gone, the seeded online game appears, Stats re-enabled); (2) boot → start a
2-player hotseat game. The `playwright.config.ts` webServer now also runs `DICT_DIR="${E2E_DICT_DIR:-…}"
OUT_DIR=dist-e2e node scripts/bundle-dicts.mjs` → `dist-e2e/dict/<variant>@<version>.dawg` (version flows from
`VITE_DICT_VERSION`, default `dev`, matching `__DICT_VERSION__`). **Ran locally green (chromium + webkit, 4/4)
against the installed browsers + the sibling `scrabble-solver/dawg`; also runs in CI.**
- **GOTCHA resolved:** `initNativeShell` was made **bridge-tolerant** (`try/catch` around the `@capacitor/app`
import + `addListener`) rather than stubbing a fake Capacitor bridge — cleaner and defensive for a real WebView
that lacks the App plugin.
**Done when:** a native build with airplane mode on cold-launches to the lobby as a guest, starts and **Done when:** a native build with airplane mode on cold-launches to the lobby as a guest, starts and
plays both a local vs_ai game and a 2-player hotseat game with no network; turning the network on plays both a local vs_ai game and a 2-player hotseat game with no network; turning the network on
silently establishes a server guest and lights up online play. silently establishes a server guest (Profile + online play light up). Emulator smoke recipe: `.claude/CLAUDE.md`
→ "Native Android build", with the extra `DICT_DIR=<release> VITE_DICT_VERSION=<ver> node
scripts/bundle-dicts.mjs` between `pnpm build` and `cap sync`.
### E. CI — signed APK artifact ### E. CI — signed APK artifact — ✅ CODE-COMPLETE (workflow written + `build.gradle` wired + locally proven; the signed dispatch is §G)
New **manual** workflow `.gitea/workflows/android-build.yaml` (mirror `prod-deploy.yaml`'s New **manual** workflow `.gitea/workflows/android-build.yaml` (mirror `prod-deploy.yaml`'s
`workflow_dispatch` + `confirm` gate; from `master`; not on PRs). Steps: `workflow_dispatch` + `confirm` gate; from `master`; not on PRs). Steps:
1. Checkout this repo **and the sibling `scrabble-solver`** — copy the checkout + Node/pnpm setup 1. Checkout this repo; copy the Node/pnpm setup block from `.gitea/workflows/ci.yaml` (the `ui` job).
block from `.gitea/workflows/ci.yaml` (the `ui` job). The sibling is needed for the bundled dicts. Add the **"Fetch dictionary DAWGs"** step verbatim from `ci.yaml` (the `curl …
scrabble-dictionary/releases/download/${DICT_VERSION}/scrabble-dawg-${DICT_VERSION}.tar.gz` +
untar into `${GITHUB_WORKSPACE}/dawg`), keyed on the `DICT_VERSION` Gitea variable — **this**, not
the `scrabble-solver` sibling, is the source of the bundled dicts. (`scrabble-solver` is not needed
by the Android build: `ui` is a Node project outside `go.work`.)
2. `pnpm install --frozen-lockfile`. 2. `pnpm install --frozen-lockfile`.
3. `pnpm build` with the native env (below), `VITE_APP_VERSION` = `git describe --tags`, 3. `pnpm build` with the native env (below), `VITE_APP_VERSION` = `git describe --tags`,
`VITE_DICT_VERSION` = the release dict version. `VITE_DICT_VERSION` = the release dict version.
4. `node ui/scripts/bundle-dicts.mjs` (copy the `.dawg` into `ui/dist/dict/`). 4. `DICT_DIR=${GITHUB_WORKSPACE}/dawg node ui/scripts/bundle-dicts.mjs` (copy the release `.dawg`
5. Setup JDK 17 (`actions/setup-java@v4`, temurin 17). files into `ui/dist/dict/`).
6. Install Android SDK via `sdkmanager` (pin `platform-tools`, `platforms;android-34`, 5. Setup JDK 21 (`actions/setup-java@v4`, temurin 21).
`build-tools;34.0.0`); cache the SDK. **No emulator** — assemble only; on-device smoke is manual. 6. Install Android SDK via `sdkmanager` (pin `platform-tools`, `platforms;android-36`,
`build-tools;36.0.0`); cache the SDK. **No emulator** — assemble only; on-device smoke is manual.
*As built:* the runner is a **host-executor** on a remote Debian host, so the **SDK is host-provisioned**
(`ANDROID_SDK_DIR` var, default `/opt/android-sdk`) and a **Verify the host Android SDK** step fail-fasts on a
missing package / no runner-user access; JDK 21 still comes from `actions/setup-java`.
7. `npx cap sync android`. 7. `npx cap sync android`.
8. Decode the keystore from `ANDROID_KEYSTORE_BASE64`; compute `versionCode`/`versionName` from the 8. Decode the keystore from `ANDROID_KEYSTORE_BASE64`; compute `versionCode`/`versionName` from the
tag (scheme below). tag (scheme below).
@@ -417,11 +734,14 @@ for the MVP.
**`versionCode`/`versionName` scheme** (deterministic from the tag): `v{MA}.{MI}.{PA}` → **`versionCode`/`versionName` scheme** (deterministic from the tag): `v{MA}.{MI}.{PA}` →
`versionCode = MA*1_000_000 + MI*1_000 + PA` (e.g. `v1.17.0` → `1017000`), `versionName = "{MA}.{MI}.{PA}"`. `versionCode = MA*1_000_000 + MI*1_000 + PA` (e.g. `v1.17.0` → `1017000`), `versionName = "{MA}.{MI}.{PA}"`.
Wire in `ui/android/app/build.gradle` `defaultConfig`: Wire in `ui/android/app/build.gradle` `defaultConfig`:
`versionCode (project.findProperty('versionCode') ?: '1').toInteger()`, `versionCode = (project.findProperty('versionCode') ?: '1').toInteger()`,
`versionName (project.findProperty('versionName') ?: '0.0.0')`, plus a `signingConfigs.release` `versionName = (project.findProperty('versionName') ?: '0.0.0').toString()`, plus a guarded
reading env/props (guarded so a local `assembleDebug` still builds unsigned). `signingConfigs.release` reading env/props. **Use `=` assignment, not the command form**
`versionCode (…).toInteger()` — the latter binds `.toInteger()` to the DSL setter's null return
(`> Value is null`). The signing block attaches only when `ANDROID_KEYSTORE_FILE` exists, so a keyless
`assembleRelease` (and every `assembleDebug`) builds UNSIGNED instead of failing.
### F. Docs (bake in the same PR) ### F. Docs (bake in the same PR) — ✅ DONE
- `docs/ARCHITECTURE.md`: §2 Transport — the client-version gate + the **frozen wire contract** + - `docs/ARCHITECTURE.md`: §2 Transport — the client-version gate + the **frozen wire contract** +
the gate×offline rule. New sections — the **identity model** (local guest / server guest / the gate×offline rule. New sections — the **identity model** (local guest / server guest /
@@ -434,14 +754,31 @@ reading env/props (guarded so a local `assembleDebug` still builds unsigned).
test, and the **manual on-device Android smoke checklist** (installs; airplane-mode cold launch to test, and the **manual on-device Android smoke checklist** (installs; airplane-mode cold launch to
guest lobby; local vs_ai move; 2-player hotseat; Back button; share link resolves to the gateway; guest lobby; local vs_ai move; 2-player hotseat; Back button; share link resolves to the gateway;
network on → online lights up; overlay when `GATEWAY_MIN_CLIENT_VERSION` is bumped). network on → online lights up; overlay when `GATEWAY_MIN_CLIENT_VERSION` is bumped).
- Config/README docs: new vars `GATEWAY_MIN_CLIENT_VERSION`, `VITE_STORE_URL`, - Config/README docs: new vars `GATEWAY_MIN_CLIENT_VERSION`, `VITE_RUSTORE_URL`,
`VITE_PAYMENTS_DISABLED`, `VITE_DICT_VERSION`, and the native `VITE_GATEWAY_URL` value. `VITE_PAYMENTS_DISABLED`, `VITE_DICT_VERSION`, and the native `VITE_GATEWAY_URL` value. Gitea CI vars/secrets
for the APK build: `ANDROID_RUSTORE_URL` (var, empty until publish), `PROD_PUBLIC_BASE_URL` (reused as the
gateway origin), and the release-signing secrets `ANDROID_KEYSTORE_BASE64` / `ANDROID_KEYSTORE_PASSWORD` /
`ANDROID_KEY_ALIAS` / `ANDROID_KEY_PASSWORD`.
- `deploy/README.md`: the Android build/release runbook (keystore + secrets, dispatch, RuStore - `deploy/README.md`: the Android build/release runbook (keystore + secrets, dispatch, RuStore
upload) **and** the discipline rule — bump `GATEWAY_MIN_CLIENT_VERSION` in the same prod deploy that upload) **and** the discipline rule — bump `GATEWAY_MIN_CLIENT_VERSION` in the same prod deploy that
ships an incompatible wire change. ships an incompatible wire change.
### G. Release + owner handoff ### G. Release + owner handoff
> **Status 2026-07-13 — see the ▶ RESUME block at the top of §Progress.** Since this chain was
> written, a **legal-documents track** was added and merged (PR #253 — the RuStore hosted
> privacy-policy URL and the `/privacy/ /eula/ /offer/` pages), and a **release-prep hygiene** patch is
> in flight (PR #254, open), and the **icon rebrand is DONE** (brand master → full set; see the RESUME
> block). The remaining gates are the **keystore** and the **RuStore account** (owner-side). The steps
> below are the original release chain; the resume
> block supersedes them with the current, exhaustive state.
0. **Land the offline-model redesign — ✅ DONE (O1O7, 2026-07-13; staged detail below).** Resolved the deferred
native local-game-visibility decision — a native guest now sees / resumes their device-local games once online
(the **unified lobby**, O5) — as part of the owner-approved change: the explicit offline toggle is gone, offline
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.
@@ -450,6 +787,324 @@ reading env/props (guarded so a local `assembleDebug` still builds unsigned).
--- ---
## Offline-model redesign (design — resolves G-step-0)
> Owner-approved design (brainstormed 2026-07-12). Cross-cutting web + native + a small **additive**
> backend/wire bit. This is the Android release's G-step-0 and its own staged work; `writing-plans` turns it
> into the implementation plan. English throughout; the FBS change is additive-only (§frozen contract).
### Goal & locked decisions
Remove the explicit online/offline toggle. Offline becomes an **implicit, detected** state driven by a single
**net-state machine**. vs_ai stays as-is (offline → local, online → server; the app decides by the detected
state). The lobby becomes **unified**. The version gate gains a **soft** tier and its hard tier degrades to
offline instead of a terminal lockout. TG/VK stay **always-online**. Client-only except the additive
soft-tier threshold + signal.
| Decision | Choice |
|---|---|
| Offline trigger | Implicit — detected connectivity + version, **no user toggle** |
| State model | **Single net-state machine** (replaces the two-layer `connection` / `offlineMode` split) |
| Switch UX | **Auto + a toast**, no dialog; hysteresis against flap; self-heal to online |
| Server vs_ai | **Kept** — offline → local, online → server; identical create flow, app decides by state |
| With-friends create | Explicit online/offline choice; offline **disabled (greyed)** when no network; create always works (→ local hotseat) |
| Lobby offline | Unified; server games **greyed from the last cache**, local games active |
| Version — critical (hard) | Terminal lockout → now a **notice "Update / Play offline"**, then forced offline |
| Version — recommended (soft) | **Built now** — notify but still play online (`GATEWAY_RECOMMENDED_CLIENT_VERSION` + an additive signal) |
| TG/VK | **Always online** (exempt); no local lobby / offline states |
| Native net signal | Add **`@capacitor/network`** as a hint; web keeps `navigator` + gateway probes |
| Migration | Clear the persisted `offlinePref` (nobody stuck without a toggle) |
### The net-state machine
One reactive `netState` absorbs `connection.svelte.ts` + `offline.svelte.ts`; the ~14 readers of
`offlineMode.active` migrate to a derived `offline` boolean and the "Connecting…" readers to `connecting`.
Written as a pure reducer (reusable for a future iOS shell).
**States**
- `online` — gateway reachable, version accepted. Full features. May carry an orthogonal dismissible
**`updateRecommended`** nudge (soft tier); play continues.
- `connecting` — a call/probe is currently failing but the hysteresis window has not elapsed. "Connecting…";
chrome stays online. Transient — the anti-flap buffer. **Not** offline (no kill-switch).
- `offlineNoNetwork` — sustained unreachable (hysteresis exceeded). Offline mode (blue chrome, local-only
active lobby, transport kill-switch). Self-heals.
- `offlineVersionLocked` — gateway reachable but version < min (critical). Offline mode + the
"Update / Play offline" notice. **Sticky** — exits only via an app update (fresh version next boot).
`offline` (kill-switch / blue chrome / local play) = `offlineNoNetwork || offlineVersionLocked`.
**Events:** `callFailed` · `callOk`/`probeOk` · `probeFailed` · `osOffline`/`osOnline` (navigator /
`@capacitor/network` hints) · `versionRejected` (update_required) · `versionRecommended` (soft) · `boot`.
**Transitions**
| From | Event | To | Side-effect |
|---|---|---|---|
| online | callFailed / osOffline | connecting | start hysteresis (probe + timer) |
| connecting | probeOk / callOk | online | — |
| connecting | hysteresis exceeded (K fails / `OFFLINE_DEBOUNCE_MS`) | offlineNoNetwork | toast "offline" |
| offlineNoNetwork | osOnline | (probe) | trigger a probe; stay until it wins |
| offlineNoNetwork | probeOk / callOk | online | toast "back online" |
| **any** | versionRejected | offlineVersionLocked | show the Update/Offline notice (supersedes the soft nudge) |
| offlineVersionLocked | probe (still rejected) | offlineVersionLocked | stays; only an app update exits |
| online | versionRecommended | online (+ nudge) | dismissible "update available" banner |
| boot (no net) | — | connecting → offlineNoNetwork | offline-first |
| boot (version too old) | versionRejected | offlineVersionLocked | notice |
**Hysteresis / anti-flap (explicit):** `online → offline` only after the probe fails **and** (K consecutive
failures **or** `connecting` held ≥ `OFFLINE_DEBOUNCE_MS`); a single blip lives entirely in `connecting`
(spinner only). Recovery to `online` is immediate on the first `probeOk`. `osOnline` never flips to online by
itself — it only **triggers** a probe (navigator / `@capacitor/network` can lie; the probe decides). Reuses
the existing probe watcher + backoff.
**Session-less guest (from D):** the probe is authenticated, so a not-yet-reconciled local guest cannot probe;
there the **reconcile attempt (`auth.guest`) IS the connectivity signal** — success → online + adopt;
failure / `update_required` → stays offline (swallowed, as D already does).
### Edge cases (each gets a test)
1. Rapid flap (fail→ok inside the window) → never leaves `connecting`; no chrome thrash.
2. Cold boot, no network → `offlineNoNetwork`, offline-first lobby.
3. Cold boot, gateway up but version < min → `offlineVersionLocked` + notice.
4. Cold boot, session-less guest → reconcile = probe; ok→online, fail→offline.
5. Mid-session min-version bump → next call `versionRejected` → `offlineVersionLocked` mid-play.
6. Captive portal: `osOnline` fires but the gateway is down → probe fails → stays offline.
7. Recovery race: `probeOk` + a queued `callOk` together → one idempotent transition to online.
8. Soft nudge, then a hard reject → escalate to `offlineVersionLocked` (notice supersedes the banner).
9. `offlineVersionLocked` on plain web with no cached dicts → offline but create disabled → the notice is the only action.
10. Offline → create local vs_ai → back online → the local game persists and shows in the unified lobby.
11. Persisted stale `offlinePref` on upgrade → ignored/cleared; never stuck offline.
12. TG/VK → the machine is inert (always `online`); no offline states, no local lobby.
### Version gate — two tiers (backend + wire, additive)
- **Hard (critical) — `GATEWAY_MIN_CLIENT_VERSION`** (exists): `version < min` → `update_required` (Execute
result_code / Subscribe `FailedPrecondition`). Client reaction **changes**: no terminal overlay →
`offlineVersionLocked` + a **notice** with "Update" (native → `VITE_RUSTORE_URL`, web → reload) and
"Play offline" (dismiss → stay offline). Background reconcile stays silent (D).
- **Soft (recommended) — NEW `GATEWAY_RECOMMENDED_CLIENT_VERSION`**: `min ≤ version < recommended` → a
**non-blocking** signal. Wire: **additive** — a **response header** `X-Update-Recommended: 1` on gated
responses (headers are the version-tolerant layer, like `X-Client-Version`); the call still succeeds and
never carries a breaking payload change. Client shows a **dismissible** "update available" banner and plays
online. Empty ⇒ soft tier off.
- Config: `recommended` must be ≥ `min` (validated at load); both empty ⇒ the gate is fully dormant (web
unchanged). Frozen-contract compliance: the soft signal is additive/trailing; the `update_required`
sentinel is unchanged.
### Unified lobby
Merge `localSource.list()` + `gateway.gamesList()`. Offline: local games active + **server games greyed from
the last `lobbycache` snapshot** (un-openable, an "offline" hint). Identity: recognise **both** the
`server user id` and the `localGuestId` as "you" (they differ after reconciliation) for turn / medal logic.
One `lobbysort`; ids never collide. Removes the `loadSeq` mode-exclusive branch — both sources feed one list.
TG/VK: server-only (unchanged).
### Create flows
- **vs_ai (quick):** unchanged logic — offline → `localSource.create`, online → `lobbyEnqueue` — driven by
`netState`. **Dict guard:** if the variant's dawg is unavailable offline, disable create with a reason.
- **With-friends:** a new **online/offline segmented control** (online = friend invite, offline = hotseat;
both forms exist). No network → the online segment is disabled ("needs network"), offline preselected.
### Settings / chrome / migration / platform
Remove the offline-toggle section from `Settings.svelte` (+ `requestOffline`, the `offlineEligible` gate);
`SettingsHub` keeps disabling Profile/Friends/Wallet while offline (now from `netState`). On upgrade,
ignore/clear the persisted `offlinePref` (never strand a deliberate-offline user). Implicit offline applies to
**native (always) + web (plain tab + PWA)**; **TG/VK exempt**. Plain-web offline is best-effort (local play
only for variants whose dawg is cached). No server / data migration (server vs_ai + all server games
untouched).
### Test matrix (exhaustive — owner requirement)
- **vitest (pure):** the net-state machine as a pure reducer — **every** transition, the hysteresis/debounce
(blip vs sustained), the session-less-guest reconcile-as-probe branch, the two-tier version decision
(min / recommended ordering + all three bands), the dict guard, the identity "self" test (both ids). Every
edge case #1#12 gets a case.
- **Go:** `clientver`/config extended for `GATEWAY_RECOMMENDED_CLIENT_VERSION` (parse + ordering validation);
`connectsrv` for the soft signal (`v < min` ⇒ hard, `min ≤ v < recommended` ⇒ soft, `v ≥ recommended` ⇒
none, absent/garbled ⇒ fail-open).
- **Playwright (mock):** auto online→offline (toast) + self-heal; `offlineVersionLocked` notice → "Play
offline" → local play; unified lobby (greyed server games offline; both active online); with-friends toggle
(online disabled offline); the soft nudge banner; update `native.spec.ts`.
- **Docs (revise the F bake):** ARCHITECTURE §2 gate → two tiers + graceful degrade; §3 offline model;
FUNCTIONAL (+`_ru`) offline + "update required"; TESTING; deploy/README the two version vars.
### Scope / sequencing
Client refactor + a small **additive** backend/wire bit; **contour-safe** (both version vars empty ⇒ dormant;
the wire add is additive). It is **G-step-0** and gates the Android release. After it lands: the normal G
chain (PR → development → contour verify → master → tag → dispatch `android-build`).
### Out of scope
Dropping server vs_ai (kept). TG/VK offline. In-app store-update SDK. The iOS shell (the machine is written to
be reusable for it; iOS is not built here).
### Implementation stages
Executed via `stage-implementation` (interview on every fork, tests at each layer, bake docs). Ordered so each
stage leaves the app working and independently testable. **TDD:** the pure machine (O1) is written test-first.
Standing constraints (from the design): one `netState` machine; hysteresis explicit; **server vs_ai
untouched**; wire changes **additive-only**; **TG/VK inert** (always online); **contour-safe** (both version
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).
- **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`.
- Interfaces (as built) — Produces: `type NetState = 'online'|'connecting'|'offlineNoNetwork'|'offlineVersionLocked'`;
`type NetEvent` (`boot`/`callOk`/`callFailed`/`probeOk`/`probeFailed`/`osOnline`/`osOffline`/`versionRejected`/
`versionRecommended`); `reduce(prev: NetSnapshot, ev: NetInput, cfg: NetConfig) => NetResult` where
`NetInput = { type: NetEvent; at: number }` (**time-on-the-event** — owner-chosen option A: the reducer stays
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
sustained hysteresis (a single fail→ok stays `connecting`; K/debounce → `offlineNoNetwork`); `versionRejected`
from **any** state → `offlineVersionLocked`; `versionRecommended` sets the nudge only from `online`.
- 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. — ✅ DONE (2026-07-12).**
- Files (as built) — Create: `ui/src/lib/netstate.svelte.ts` (the `$state` store running `reduce`; the derived
`state`/`online`/`connecting`/`offline`/`versionLocked` getters; one self-rescheduling probe/recovery watcher —
backoff while `connecting`, a 4 s poll while `offlineNoNetwork`, idle otherwise; the OS-signal wiring). Modify:
`ui/src/lib/connection.svelte.ts` + `ui/src/lib/offline.svelte.ts` (thin shims over `netState` — `connection.online`
= the online state, `offlineMode.active` = the machine `offline`; `reportOffline`→`callFailed` **once** from online,
`reportOnline`→`callOk`), `ui/src/lib/update.svelte.ts` (`reportUpdateRequired` also emits `versionRejected`),
`ui/src/lib/native.ts` (+`initNativeNetwork` — `@capacitor/network` → `osOnline`/`osOffline`),
`ui/src/lib/app.svelte.ts` (the boot/recovery-seam rewrite), `ui/src/lib/gateway.ts` (the mock `__net` hook),
`ui/src/App.svelte` (dialog removal), `ui/src/lib/i18n/{en,ru}.ts` (`net.offline`/`net.online` toast), `ui/package.json`
(+`@capacitor/network@8.0.1`). **Owner-confirmed scope A (full migration):** the store subsumes the old
`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. — ✅ DONE (2026-07-12).**
- Files (as built) — Modify: `ui/src/screens/Settings.svelte` (deleted the whole "Play mode" Online/Offline segment +
`offlineEligible` + `goOffline` + the `checking`/`needsData` state + the now-unused `isStandalone`/`insideVK`/offline
imports + the `.onote` CSS), `ui/src/lib/offline.svelte.ts` (dropped the inert `setOfflineMode`/`requestOffline`/
`TOGGLE_READY_BUDGET_MS` stubs + the dead `offlineMode.auto` getter — `offlineMode.active` = `netState.offline` stays),
`ui/src/lib/offline.ts` (dropped `loadOfflinePref`/`saveOfflinePref`/`shouldBootOffline`/`offlineReady`/`missingDicts`/
`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. — ✅ DONE (2026-07-13).**
- Files (as built) — Backend: `gateway/internal/config/config.go` (+`RecommendedClientVersion` from
`GATEWAY_RECOMMENDED_CLIENT_VERSION`; validate parseable + `≥ MinClientVersion`, an empty min imposing no lower bound),
`gateway/internal/connectsrv/server.go` (soft-tier fields `recClient`/`recOn`; `clientUpdateRecommended` = `min ≤ v <
recommended`, fail-open; Execute sets `X-Update-Recommended: 1` on any served response in the band via a named-return
`defer`; hard band + Subscribe unchanged), `gateway/cmd/gateway/main.go` (dep). Client: `ui/src/lib/transport.ts` (a
Connect-ES **interceptor** reads `x-update-recommended` on unary responses ⇒ `reportUpdateRecommended`),
`ui/src/lib/update.svelte.ts` (dropped the terminal `updateRequired` latch — `reportUpdateRequired` now only emits
`versionRejected`; new `updateRecommended` store + `latchUpdateNudge`/`dismissUpdateNudge`/`reportUpdateRecommended` +
shared `openUpdate`), `ui/src/lib/netstate.svelte.ts` (`registerNetNudge`; the `setNudge` effect fires it),
`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. — ✅ DONE (2026-07-13).**
- Files (as built) — `ui/src/screens/Lobby.svelte` (`load()` merges `localSource.list()` + `gateway.gamesList()`;
online ⇒ `[...local, ...server]`; offline ⇒ `[...localFresh, ...cachedServer]` greyed; the mode-exclusive branch is
gone; `grey(g) = offline && !isLocalGameId` drives a `.rowwrap.greyed` dim + an un-openable `openGame` that toasts
`net.offline`; **invitations hidden offline** — owner addition — via `{#if invitations.length && !offline}`), `ui/src/lib/lobbysort.ts`
+ `ui/src/lib/result.ts` (`myId: string` → `myIds: readonly string[]` self-set across `isMyTurn`/`scoreStanding`/
`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. — ✅ DONE (2026-07-13).**
- Files (as built) — `ui/src/screens/NewGame.svelte`: **with-friends** now carries a `friendsMode` segmented control
(`new.playRemote` = online invite / `new.playLocal` = offline hotseat); the online segment is `disabled` when
`offlineMode.active` and an `$effect` forces `friendsMode='offline'` there (a `new.needsNetwork` note explains it);
the friends markup is restructured from the mode-exclusive `{:else if offlineMode.active}` chain into
`{:else} <segment> {#if friendsMode==='offline'} hotseat {:else if !friends} noFriends {:else} invite`. **vs_ai
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. — ✅ DONE (2026-07-13).**
- Files (as built) — `docs/ARCHITECTURE.md`: **§2** rewrote the connectivity signal into the **net-state machine**
(four states, implicit offline, hysteresis, self-heal, TG/VK-exempt), the version gate into the **two tiers** (hard →
`offlineVersionLocked` + dismissable notice, *not* terminal; soft → `X-Update-Recommended` nudge), and the gate×offline
rule; **§13** (PWA/offline) rewrote the offline model from the deliberate toggle/dialog to implicit detection + the
**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`.
---
## Google Play variant (later — design now so we don't repaint) ## Google Play variant (later — design now so we don't repaint)
One codebase, two build variants selected by flags: One codebase, two build variants selected by flags:
@@ -472,8 +1127,10 @@ target, built later.
## Build & env matrix ## Build & env matrix
**Native Android build** (`pnpm build` → `bundle-dicts` → `cap sync`): **Native Android build** (`pnpm build` → `bundle-dicts` → `cap sync`):
- `VITE_GATEWAY_URL=https://erudit-game.ru` — absolute origin (the native-critical var). - `VITE_GATEWAY_URL` — absolute gateway origin; the workflow reuses `vars.PROD_PUBLIC_BASE_URL`
- `VITE_STORE_URL=https://www.rustore.ru/catalog/app/ru.eruditgame.app` — the update-overlay target. (`https://erudit-game.ru`, trailing slash stripped). The native-critical var.
- `VITE_RUSTORE_URL` — the update-overlay store target. **Empty in the MVP** (set the `ANDROID_RUSTORE_URL`
Gitea variable once published); the gate is dormant so the button never fires yet.
- `VITE_APP_VERSION=$(git describe --tags)` — feeds `__APP_VERSION__` = the `X-Client-Version` header. - `VITE_APP_VERSION=$(git describe --tags)` — feeds `__APP_VERSION__` = the `X-Client-Version` header.
- `VITE_DICT_VERSION=<release dict version>` — the bundled-dict version the offline path requests. - `VITE_DICT_VERSION=<release dict version>` — the bundled-dict version the offline path requests.
- `VITE_PAYMENTS_DISABLED=1` — hide purchases in the MVP. (`VITE_GP_BUILD` unset — RuStore, not GP.) - `VITE_PAYMENTS_DISABLED=1` — hide purchases in the MVP. (`VITE_GP_BUILD` unset — RuStore, not GP.)
@@ -516,7 +1173,10 @@ breaking release deliberately sets the min version.
Google Play publication (beyond keeping the codebase variant-ready), iOS, OTA/live-updates, in-app Google Play publication (beyond keeping the codebase variant-ready), iOS, OTA/live-updates, in-app
purchases in the native build (RuStore billing SDK) and the GP anti-steering fix, VK/Telegram login in purchases in the native build (RuStore billing SDK) and the GP anti-steering fix, VK/Telegram login in
the native build, a soft "recommended update" tier, native splash/status-bar plugins, push the native build, a soft "recommended update" tier, **in-app store-update flows** (Google Play In-App
Updates API / RuStore In-App Update SDK — a future enhancement that would swap the update overlay's
action from opening the store listing to a store-driven immediate in-app update; needs a Capacitor
plugin bridge, and is orthogonal to the server-driven gate), native splash/status-bar plugins, push
notifications (FCM), and automated RuStore-API upload. notifications (FCM), and automated RuStore-API upload.
## End-to-end verification ## End-to-end verification
+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
+7
View File
@@ -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).
+28
View File
@@ -118,6 +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)_ | **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). |
@@ -236,6 +238,32 @@ redeploy the matching old tag. This dump is a belt-and-braces net for a bad migr
**point-in-time recovery** (below) is the primary recovery path once armed, and the only one **point-in-time recovery** (below) is the primary recovery path once armed, and the only one
that survives losing the host. that survives losing the host.
## Android app build & release (RuStore)
The standalone Android app is built by a **manual** workflow, never automatically, and is separate from the
web/prod rollout — a signed APK uploaded to RuStore by hand. The build/release runbook follows.
- **Trigger:** `Actions → android-build → Run workflow` from **`master`**, input `confirm=build` (mirrors
`prod-deploy`). **Tag the release first** (`git tag vX.Y.Z` on `master`) — the workflow refuses anything
but a clean `vMAJOR.MINOR.PATCH` and derives `versionCode = MA*1_000_000 + MI*1_000 + PA`,
`versionName = MA.MI.PA`. Watch it green with `python3 ~/.claude/bin/gitea-ci-watch.py`.
- **Output:** a `release APK` run artifact — **signed** when the signing secrets are present, an
**unsigned** release APK otherwise (a dry run still proves the pipeline).
- **Runner (host-executor):** JDK 21 comes from `setup-java`; the **Android SDK is host-provisioned**
install it once (`sdkmanager 'platform-tools' 'platforms;android-36' 'build-tools;36.0.0'`) and grant the
`runner` user read+exec (`sudo chmod -R a+rX /opt/android-sdk`). Override the path with the
`ANDROID_SDK_DIR` variable. The workflow's `Verify the host Android SDK` step fails fast with the fix if
the SDK is missing or unreadable.
- **Release keystore** (create once, **back up off-host** — losing it means the app can never be updated):
`keytool -genkeypair -v -keystore erudit-release.jks -alias erudit -keyalg RSA -keysize 4096 -validity 10000`,
then set the Gitea **secrets** `ANDROID_KEYSTORE_BASE64` (`base64 -w0 erudit-release.jks`),
`ANDROID_KEYSTORE_PASSWORD`, `ANDROID_KEY_ALIAS`, `ANDROID_KEY_PASSWORD`. Set the `ANDROID_RUSTORE_URL`
variable to the store listing once published (empty until then — the in-app update button no-ops).
- **Wire-break discipline:** the prod deploy that ships an incompatible wire change **also** bumps
`GATEWAY_MIN_CLIENT_VERSION` to that release (see Optional variables), so an old installed APK is turned
away cleanly instead of failing blind.
- **Upload:** download the artifact and upload it to RuStore by hand (no automated RuStore-API upload in the MVP).
## Point-in-time recovery (PITR) ## Point-in-time recovery (PITR)
The main host archives Postgres continuously with **pgBackRest** to **Selectel S3** The main host archives Postgres continuously with **pgBackRest** to **Selectel S3**
+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'
+123 -45
View File
@@ -100,15 +100,26 @@ 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 and the OS hint (`navigator` /
other traffic is in flight; the live `Subscribe` stream's drop/recovery feeds the same signal. `@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** — always online: they are never fed an
offline signal, and `offlineMode.active` is additionally hard-gated on `offlineCapable()` (false in
those mini-apps), so nothing — not even a version lock — puts them in offline mode: no blue chrome, no
local lobby, no transport kill switch and no device-local create paths.
**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,6 +151,40 @@ 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 — 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
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 —
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`/`Subscribe` check it at the top,
before registry lookup / auth / decode), so the first online call is already gated. Both tiers **fail
open** (an absent or unparseable header passes) and are **dormant** until deliberately configured (both
vars empty ⇒ off, web behaviour unchanged).
- **Hard (critical) — `GATEWAY_MIN_CLIENT_VERSION`**: `version < min` ⇒ the domain envelope
`result_code = "update_required"` (HTTP 200) on `Execute` and `CodeFailedPrecondition` on `Subscribe`;
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
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`
code — never changes; (3) the FBS schema stays **additive** (append trailing fields; `(deprecated)`, never
delete — deleting shifts field IDs and breaks older readers). A breaking wire change happens only *inside*
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
rollout (see [`../deploy/README.md`](../deploy/README.md)).
- **Gate × offline** (UX rule): offline is the net-state machine's `offlineNoNetwork` / `offlineVersionLocked`
(implicit — no toggle) and its kill switch refuses calls, so the hard gate never fires *while already
offline* — an old install always plays local vs_ai / hotseat. A hard `update_required` on a
**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
@@ -224,6 +269,19 @@ arrive from a platform rather than completing a mandatory registration).
`BACKEND_GUEST_REAP_INTERVAL` sweep, so transient guest rows do not accumulate. `BACKEND_GUEST_REAP_INTERVAL` sweep, so transient guest rows do not accumulate.
Platform and email users are auto-provisioned **durable** accounts with an Platform and email users are auto-provisioned **durable** accounts with an
identity. identity.
- **Local guest vs. server guest** (native offline-first, §13). The **native** app splits the local-play
identity from the server account. A **local guest** is device-local with **no DB row**: a device-generated
id + the localized default name (*Guest* / *Гость*) persisted on the device, existing from the very first
launch with no network. It fills the human seat in a local vs_ai game and is the "you" for device-local
games; a purely-offline user never consumes a server row. The **server guest** is the durable `is_guest`
row above — minted **lazily** via `auth.guest` the first time the app reaches the network, its session
cached and reused (exactly one per device, guarded by the cached session so it never double-mints). It
unlocks online features (matchmaking, friends). **Reconciliation:** on gaining network with no server
session the native app silently `auth.guest`s in the background and adopts it — best-effort, swallowing an
`update_required` to stay a local guest rather than interrupting play (the gate × offline rule, §2).
**Local games stay device-only** (both local vs_ai and hotseat persist only on the device); an identity
transition never migrates them. Web/PWA/Telegram/VK are unchanged — they have no local guest and keep the
prior online-session rule.
> **Decision (2026-06-20) — single bot, preference-based variant gating.** The former > **Decision (2026-06-20) — single bot, preference-based variant gating.** The former
> two-bot model (one bot per service language, with `accounts.service_language`, a > two-bot model (one bot per service language, with `accounts.service_language`, a
@@ -909,10 +967,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
@@ -1307,7 +1369,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
@@ -1332,13 +1396,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
@@ -1366,36 +1435,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
@@ -1468,6 +1528,24 @@ Two contours, two secret/variable prefixes (`TEST_` / `PROD_`):
client IPs). The `vpn`+`bot` pair is gated to a `telegram-local` compose profile the test client IPs). The `vpn`+`bot` pair is gated to a `telegram-local` compose profile the test
contour activates; the prod main host omits it. contour activates; the prod main host omits it.
**Native Android build (Capacitor).** The SPA is also packaged as a standalone **Android app** (Capacitor 8,
appId `ru.eruditgame.app`, "Эрудит"; minSdk 24, compile/targetSdk 36, JDK 21), first for **RuStore**. It is
a **bundle** model — the WebView loads the packaged `dist/` from app assets (**no `server.url`**), so there
is no OTA: updates ship through the store, and the **client-version gate** (§2) turns away a build too old to
speak the current wire contract. Because the packaged origin is `file://`, the native build talks to an
**absolute** gateway origin (`VITE_GATEWAY_URL` = the production origin; `lib/origin.ts` centralises how absolute URLs are built), and the service worker is skipped (the bundle is
the cache). It is **offline-first**:
`ui/scripts/bundle-dicts.mjs` copies the versioned `scrabble-dictionary` release DAWGs into
`dist/dict/<variant>@<version>.dawg` (keyed on `VITE_DICT_VERSION`), so a cold first launch with no network
enters as a **local guest** (§3) and plays local vs_ai / hotseat from the bundled dictionaries. Purchases are
hidden in the MVP (`VITE_PAYMENTS_DISABLED`). The APK is built by a **manual** `workflow_dispatch` workflow
(`.gitea/workflows/android-build.yaml`, from `master`, `confirm=build`) that builds the native SPA, bundles
the dicts, and assembles a **release APK** artifact — signed when the `ANDROID_KEYSTORE_*` secrets are
present, unsigned otherwise. `versionCode`/`versionName` are deterministic from the release tag `vMA.MI.PA`
(`versionCode = MA*1_000_000 + MI*1_000 + PA`, `versionName = "MA.MI.PA"`). The runner is a host-executor
with a host-provisioned Android SDK; RuStore upload is manual. Runbook:
[`../deploy/README.md`](../deploy/README.md).
## 14. CI & branches ## 14. CI & branches
- **Two long-lived branches**: **`development`** is the integration - **Two long-lived branches**: **`development`** is the integration
+64 -33
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
@@ -277,22 +282,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 +316,31 @@ 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 ### Staying up to date
dialog — is the player's choice: it is kept, and never undone automatically. 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)
The game also ships as a standalone **Android app** (first on **RuStore**), a packaged build of the same
client. Everything it needs is bundled, so it opens with **no network**: a first launch offline drops you
straight into the lobby as a **guest** (no sign-in wall) and you can play right away — against the robot, or
a **pass-and-play** game with friends on one device — using the dictionaries shipped inside the app. When the
network is available the app quietly establishes a guest in the background and online features (random
opponents, friends, statistics) light up without interrupting play; local games you started offline stay on
the device. To keep a durable account you sign in with **email** from Profile (the guest becomes your
account); Telegram and VK sign-in are not offered in the native app. In-app purchases are hidden in this
first release. Because an installed app can be far older than the server, a build that is **too old** to talk
to the current server shows a single, non-dismissable *update* screen whose button opens the store listing —
this appears only on an online action, never while you are playing offline.
### Social: friends, block, chat, nudge ### Social: friends, block, chat, nudge
Become friends in two ways: redeem a **one-time code** the other player issues (six Become friends in two ways: redeem a **one-time code** the other player issues (six
@@ -407,9 +435,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
@@ -512,7 +542,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
+65 -34
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), без картинок; после ввода игрок входит. Новый адрес заводится при первом
запросе, но становится постоянным аккаунтом только после подтверждения кода — так брошенная, запросе, но становится постоянным аккаунтом только после подтверждения кода — так брошенная,
@@ -283,22 +287,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 +321,32 @@ e-mail) либо ввод фразы. Активные игры форфейтя
идущей или завершённой — из лобби спрашивает пароль ведущего, чтобы никто не стёр партию сразу после идущей или завершённой — из лобби спрашивает пароль ведущего, чтобы никто не стёр партию сразу после
своего хода. своего хода.
Приложение также **само включает офлайн-режим**, когда не может достучаться до сети. При холодном Уход в офлайн и возврат — оба **автоматические**. При холодном запуске без связи приложение сразу
запуске без связи оно переходит в офлайн на текущую сессию; когда устройство онлайн, но шлюз молчит стартует в офлайн-лобби; пока оно открыто, потеря сети — режим полёта — сама уводит в офлайн, а
несколько секунд, оно сперва спрашивает — *Нет связи. Включить офлайн-режим?*, с выбором **Включить** восстановление сети само возвращает онлайн. Ни диалога, ни переключателя, который надо помнить:
(уйти в офлайн) или **Ждать сеть** (продолжить попытки онлайн). Пока приложение открыто, потеря сети — кратковременный сбой пережидается тихо, и только устойчивая потеря переводит интерфейс в офлайн, так что
режим полёта — переключает в офлайн автоматически, а её восстановление само возвращает онлайн. вас не прерывают из-за короткой икоты.
Самостоятельно включённый офлайн временный: он возвращается в онлайн, как только сеть появилась, и
следующий запуск проверяет заново. **Осознанный** офлайн — тумблер в Настройках или **Включить** в том ### Актуальная версия
диалоге — это выбор игрока: он сохраняется и никогда не отменяется автоматически. Когда требуется новая версия клиента, приложение не блокирует вас наглухо. В **нативном** приложении
слишком старая сборка показывает уведомление с **Обновить** (открывает магазин) и **Играть офлайн**
(закрыть и продолжить играть на устройстве); в вебе вместо этого предлагается **Обновить** (перезагрузка).
Более мягкий, ненавязчивый баннер **«Доступно обновление»** появляется в лобби, когда есть новее — но пока
не обязательная — версия; его можно обновить или закрыть, игра продолжается в любом случае.
### Нативное приложение (Android)
Игра также выходит как отдельное **приложение для Android** (сначала в **RuStore**) — упакованная сборка
того же клиента. Всё необходимое встроено, поэтому оно открывается **без сети**: первый запуск офлайн сразу
открывает лобби как **гость** (без экрана входа), и можно играть немедленно — против робота или в игру **по
очереди на одном устройстве** (pass-and-play) с друзьями — используя словари, вшитые в приложение. Когда сеть
доступна, приложение тихо заводит гостя в фоне, и онлайн-возможности (случайные соперники, друзья,
статистика) включаются, не прерывая игру; локальные игры, начатые офлайн, остаются на устройстве. Чтобы
сохранить постоянный аккаунт, вход выполняется по **email** из Профиля (гость становится вашим аккаунтом);
вход через Telegram и VK в нативном приложении не предлагается. Встроенные покупки в этом первом релизе
скрыты. Поскольку установленное приложение может быть намного старше сервера, сборка, которая **слишком
стара** для общения с текущим сервером, показывает единственный несбрасываемый экран *обновления*, кнопка
которого открывает страницу в магазине — он появляется только при онлайн-действии, никогда во время
офлайн-игры.
### Социальное: друзья, блок, чат, nudge ### Социальное: друзья, блок, чат, nudge
Подружиться можно двумя способами: погасить **одноразовый код**, который выпускает Подружиться можно двумя способами: погасить **одноразовый код**, который выпускает
@@ -418,9 +445,11 @@ Telegram. На сенсорном устройстве в настройках
обратную связь (роль, а не полная блокировка аккаунта), видит кнопку отправки недоступной. обратную связь (роль, а не полная блокировка аккаунта), видит кнопку отправки недоступной.
### Чат поддержки в Telegram ### Чат поддержки в Telegram
С операторами можно поговорить и прямо из Telegram-бота: всё, что пользователь присылает боту, С операторами можно поговорить и прямо из Telegram-бота. Команда `/support` отвечает графиком работы
кроме `/start` — текст, фото, голосовое, файл, — пересылается в закрытую группу поддержки операторов поддержки и напоминанием, что приложить (подробное описание и, по возможности, скриншоты). Всё
и собирается в отдельную ветку на пользователя. Пользователь не получает автоответа; оператор остальное, что пользователь присылает боту, кроме `/start` — текст, фото, голосовое, файл, —
пересылается в закрытую группу поддержки операторов и собирается в отдельную ветку на пользователя.
Пользователь не получает автоответа на пересланное сообщение; оператор
отвечает из этой ветки, и бот доставляет ответ обратно обычным сообщением — для пользователя это отвечает из этой ветки, и бот доставляет ответ обратно обычным сообщением — для пользователя это
тихий диалог один на один. Операторы могут заблокировать пользователя (тогда бот молча игнорирует тихий диалог один на один. Операторы могут заблокировать пользователя (тогда бот молча игнорирует
его сообщения) или очистить сообщения ветки. Это независимо от встроенной «Обратной связи» выше — его сообщения) или очистить сообщения ветки. Это независимо от встроенной «Обратной связи» выше —
@@ -526,7 +555,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) |
+37 -5
View File
@@ -17,21 +17,43 @@ 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
build's `/e2edict/`, copied in by `scripts/e2e-dict.mjs` from `E2E_DICT_DIR` — the `ui` build's `/e2edict/`, copied in by `scripts/e2e-dict.mjs` from `E2E_DICT_DIR` — the `ui`
CI job fetches the `scrabble-dictionary` release like the Go jobs; locally it defaults to CI job fetches the `scrabble-dictionary` release like the Go jobs; locally it defaults to
the sibling `scrabble-solver/dawg`. These dawgs are never committed and never enter the the sibling `scrabble-solver/dawg`. These dawgs are never committed and never enter the
production build. production build. The **native offline-first** spec (`e2e/native.spec.ts`) injects
`window.androidBridge` (so `@capacitor/core` resolves the platform to `android` — a bare
`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
(`playwright.config.ts` bundles the dicts into `dist-e2e/dict/`), starts a hotseat game, and drives
`window.__native.reconcile()` to prove online lights up — the device-local game stays listed in the
**unified lobby** — and Profile hides the Telegram/VK link buttons.
The **version-gate** spec (`e2e/update.spec.ts`) drives the `__update` hook to prove both tiers — the
hard *Update / Play offline* notice (and that "Play offline" drops into the offline lobby) and the soft
dismissable *update available* nudge in the lobby; `retry.test.ts` covers the `FailedPrecondition →
update_required` mapping.
- **Client-version gate** (Go, two tiers) — `gateway/internal/clientver` unit-tests the `v?MAJOR.MINOR.PATCH`
parse (with/without `v`, a `-N-gSHA` suffix, `dev`/empty ⇒ !ok) and ordering. `connectsrv`'s server tests
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
@@ -175,6 +197,16 @@ tests or touching CI.
`inttest` drives the **feedback lifecycle** end to end: the guest / ban / pending gates, the reply `inttest` drives the **feedback lifecycle** end to end: the guest / ban / pending gates, the reply
read + one-week visibility window, the account-role grant/revoke, and the admin queue filters with read + one-week visibility window, the account-role grant/revoke, and the admin queue filters with
delete / delete-all. The admin-console render test renders the feedback list + detail pages. delete / delete-all. The admin-console render test renders the feedback list + detail pages.
- **Native Android (manual on-device smoke)** — the packaged APK is verified by hand on a device /
emulator before release (the automated e2e covers the offline-first logic; WebView-specific chrome and
store behaviour are not reproducible in Playwright). Checklist: installs and cold-launches; in
**airplane mode** the cold launch lands in the **guest lobby**; play a local **vs_ai** move and a
**2-player hotseat** game with no network; the hardware **Back** button navigates then exits at the
root; a share / export link resolves to `erudit-game.ru` (not `file://`); turning the **network on**
lights up online play (a server guest is established); Profile offers **email** sign-in but no
Telegram / VK link; and with `GATEWAY_MIN_CLIENT_VERSION` bumped above the build, an online action
raises the terminal **update** overlay. Measure native chrome (safe-area / edge-to-edge) by CDP, not by
eyeballing a screenshot — an emulator WebView may be newer than a user's device (see `.claude/CLAUDE.md`).
## Principles ## Principles
+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 -16
View File
@@ -221,22 +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,
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
+57
View File
@@ -0,0 +1,57 @@
// Package clientver parses and compares the leading MAJOR.MINOR.PATCH of a client
// version string so the edge can turn away a build too old to speak the current wire
// contract. It is deliberately dependency-free and tolerant: the version rides an HTTP
// header (X-Client-Version) that a build stamps from `git describe --tags`, so any
// `-N-gSHA` or `+meta` suffix is ignored, and anything unparseable is reported as such
// (the caller fails open — an absent or garbled header is never treated as too old).
package clientver
import (
"strconv"
"strings"
)
// Version is a parsed semantic version triple. Only MAJOR.MINOR.PATCH participate in the
// ordering; any pre-release or build suffix is dropped at parse time.
type Version struct {
Major, Minor, Patch int
}
// Parse extracts the leading MAJOR.MINOR.PATCH from s, tolerating an optional leading
// "v" and any `-N-gSHA` or `+meta` suffix (as produced by `git describe --tags`). It
// reports ok=false when s has fewer than three numeric components or any component is not
// an integer, so the caller can distinguish a real version from a dev/empty string.
func Parse(s string) (Version, bool) {
s = strings.TrimPrefix(strings.TrimSpace(s), "v")
if i := strings.IndexAny(s, "-+"); i >= 0 {
s = s[:i]
}
p := strings.SplitN(s, ".", 4)
if len(p) < 3 {
return Version{}, false
}
var v Version
var err error
if v.Major, err = strconv.Atoi(p[0]); err != nil {
return Version{}, false
}
if v.Minor, err = strconv.Atoi(p[1]); err != nil {
return Version{}, false
}
if v.Patch, err = strconv.Atoi(p[2]); err != nil {
return Version{}, false
}
return v, true
}
// Less reports whether a orders before b by MAJOR, then MINOR, then PATCH. Equal versions
// are not Less than each other, so a client exactly at the minimum passes the gate.
func Less(a, b Version) bool {
if a.Major != b.Major {
return a.Major < b.Major
}
if a.Minor != b.Minor {
return a.Minor < b.Minor
}
return a.Patch < b.Patch
}
@@ -0,0 +1,63 @@
package clientver
import "testing"
// TestParse covers the version strings the edge actually sees: a plain and a "v"-prefixed
// triple, a `git describe --tags` suffix, build metadata, surrounding space, and the
// non-version strings (dev/empty/too-few/non-numeric) that must report ok=false so the
// gate fails open.
func TestParse(t *testing.T) {
tests := []struct {
name string
in string
want Version
wantK bool
}{
{"plain", "1.16.0", Version{1, 16, 0}, true},
{"v-prefixed", "v1.16.0", Version{1, 16, 0}, true},
{"git describe suffix", "v1.16.0-3-gabc1234", Version{1, 16, 0}, true},
{"build metadata", "1.16.0+ci42", Version{1, 16, 0}, true},
{"surrounding space", " v2.0.1 ", Version{2, 0, 1}, true},
{"extra component ignored", "v1.16.0.4", Version{1, 16, 0}, true},
{"dev", "dev", Version{}, false},
{"empty", "", Version{}, false},
{"too few components", "1.16", Version{}, false},
{"non-numeric patch", "1.16.x", Version{}, false},
}
for _, tc := range tests {
t.Run(tc.name, func(t *testing.T) {
got, ok := Parse(tc.in)
if ok != tc.wantK {
t.Fatalf("Parse(%q) ok = %v, want %v", tc.in, ok, tc.wantK)
}
if ok && got != tc.want {
t.Errorf("Parse(%q) = %+v, want %+v", tc.in, got, tc.want)
}
})
}
}
// TestLess covers the ordering by MAJOR, then MINOR, then PATCH, and that an equal version
// is not Less (a client exactly at the minimum passes the gate).
func TestLess(t *testing.T) {
tests := []struct {
name string
a, b Version
want bool
}{
{"equal", Version{1, 16, 0}, Version{1, 16, 0}, false},
{"major less", Version{1, 9, 9}, Version{2, 0, 0}, true},
{"major greater", Version{2, 0, 0}, Version{1, 9, 9}, false},
{"minor less", Version{1, 16, 5}, Version{1, 17, 0}, true},
{"minor greater", Version{1, 17, 0}, Version{1, 16, 9}, false},
{"patch less", Version{1, 16, 0}, Version{1, 16, 1}, true},
{"patch greater", Version{1, 16, 2}, Version{1, 16, 1}, false},
}
for _, tc := range tests {
t.Run(tc.name, func(t *testing.T) {
if got := Less(tc.a, tc.b); got != tc.want {
t.Errorf("Less(%+v, %+v) = %v, want %v", tc.a, tc.b, got, tc.want)
}
})
}
}
+44 -9
View File
@@ -9,6 +9,7 @@ import (
"strings" "strings"
"time" "time"
"scrabble/gateway/internal/clientver"
pkgtel "scrabble/pkg/telemetry" pkgtel "scrabble/pkg/telemetry"
) )
@@ -54,6 +55,16 @@ type Config struct {
// MaxBodyBytes caps one inbound request body on the public listener and one // MaxBodyBytes caps one inbound request body on the public listener and one
// Connect message read; oversized requests are refused without buffering. // Connect message read; oversized requests are refused without buffering.
MaxBodyBytes int MaxBodyBytes int
// MinClientVersion, when non-empty, is the lowest client version (MAJOR.MINOR.PATCH)
// the edge serves. A client reporting an older X-Client-Version is turned away with an
// "update required" signal before its payload is decoded. Empty leaves the gate dormant
// (every client is served) — the default for web-only deployments.
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).
@@ -154,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,
} }
} }
@@ -226,14 +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"),
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"),
@@ -332,6 +350,23 @@ func (c Config) validate() error {
if c.MaxBodyBytes <= 0 { if c.MaxBodyBytes <= 0 {
return fmt.Errorf("config: GATEWAY_MAX_BODY_BYTES must be positive") return fmt.Errorf("config: GATEWAY_MAX_BODY_BYTES must be positive")
} }
if c.MinClientVersion != "" {
if _, ok := clientver.Parse(c.MinClientVersion); !ok {
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")
} }
+59
View File
@@ -47,6 +47,65 @@ func TestLoadMaxBodyBytes(t *testing.T) {
} }
} }
// TestLoadMinClientVersion verifies the client-version gate config: dormant (empty) by
// default, a parseable version accepted, and an unparseable one rejected.
func TestLoadMinClientVersion(t *testing.T) {
c, err := Load()
if err != nil {
t.Fatalf("Load: %v", err)
}
if c.MinClientVersion != "" {
t.Errorf("MinClientVersion = %q, want empty (gate dormant)", c.MinClientVersion)
}
t.Setenv("GATEWAY_MIN_CLIENT_VERSION", "v1.16.0")
if c, err = Load(); err != nil {
t.Fatalf("Load with a valid min version: %v", err)
}
if c.MinClientVersion != "v1.16.0" {
t.Errorf("MinClientVersion = %q, want %q", c.MinClientVersion, "v1.16.0")
}
t.Setenv("GATEWAY_MIN_CLIENT_VERSION", "dev")
if _, err := Load(); err == nil {
t.Fatal("Load: expected an error for an unparseable GATEWAY_MIN_CLIENT_VERSION, got nil")
}
}
// 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) {
+1
View File
@@ -12,6 +12,7 @@ var (
errInternal = errors.New("internal error") errInternal = errors.New("internal error")
errMissingToken = errors.New("missing session token") errMissingToken = errors.New("missing session token")
errInvalidSession = errors.New("invalid or expired session") errInvalidSession = errors.New("invalid or expired session")
errUpdateRequired = errors.New("client too old, update required")
) )
// errUnknownMessageType reports an unregistered message type. // errUnknownMessageType reports an unregistered message type.
+124 -1
View File
@@ -26,6 +26,7 @@ import (
"golang.org/x/net/http2/h2c" "golang.org/x/net/http2/h2c"
"scrabble/gateway/internal/backendclient" "scrabble/gateway/internal/backendclient"
"scrabble/gateway/internal/clientver"
"scrabble/gateway/internal/config" "scrabble/gateway/internal/config"
"scrabble/gateway/internal/push" "scrabble/gateway/internal/push"
"scrabble/gateway/internal/ratelimit" "scrabble/gateway/internal/ratelimit"
@@ -46,6 +47,21 @@ const heartbeatKind = "heartbeat"
// spoofer, so trusting it is safe. // spoofer, so trusting it is safe.
const honeypotHeader = "X-Scrabble-Honeypot" const honeypotHeader = "X-Scrabble-Honeypot"
// clientVersionHeader carries the client build version (stamped from `git describe --tags`) so
// 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
// counterpart connect.CodeFailedPrecondition — that any build, however old, recognises as
// "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 (
clientVersionHeader = "X-Client-Version"
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
// class field of the periodic rejection report. // class field of the periodic rejection report.
const ( const (
@@ -88,6 +104,17 @@ type Server struct {
maxBodyBytes int maxBodyBytes int
// minClient is the lowest client version served; gateOn is false when the gate is dormant
// (GATEWAY_MIN_CLIENT_VERSION empty or unparseable).
minClient clientver.Version
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
@@ -128,6 +155,13 @@ type Deps struct {
// MaxBodyBytes caps one inbound request body and one Connect message read; // MaxBodyBytes caps one inbound request body and one Connect message read;
// zero or negative selects config.DefaultMaxBodyBytes. // zero or negative selects config.DefaultMaxBodyBytes.
MaxBodyBytes int MaxBodyBytes int
// MinClientVersion is the lowest client version (MAJOR.MINOR.PATCH) the edge serves; an
// older X-Client-Version is turned away with "update required". Empty or unparseable leaves
// the gate dormant.
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.
@@ -160,6 +194,30 @@ func NewServer(d Deps) *Server {
if rl == (config.RateLimitConfig{}) { if rl == (config.RateLimitConfig{}) {
rl = config.DefaultRateLimit() rl = config.DefaultRateLimit()
} }
// Parse the minimum client version once. Config.validate already rejects an unparseable
// value, so the warn branch only guards a direct (test) construction; an empty value leaves
// the gate dormant.
var minClient clientver.Version
gateOn := false
if d.MinClientVersion != "" {
if v, ok := clientver.Parse(d.MinClientVersion); ok {
minClient, gateOn = v, true
} else {
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,
@@ -176,6 +234,10 @@ func NewServer(d Deps) *Server {
adminProxy: d.AdminProxy, adminProxy: d.AdminProxy,
metrics: newServerMetrics(d.Meter, blocklist), metrics: newServerMetrics(d.Meter, blocklist),
maxBodyBytes: maxBody, maxBodyBytes: maxBody,
minClient: minClient,
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),
@@ -285,15 +347,71 @@ func (s *Server) abuseGuard(next http.Handler) http.Handler {
}) })
} }
// clientTooOld reports whether the X-Client-Version header names a version below the configured
// minimum. It fails open: with the gate dormant, or an absent or unparseable header, it returns
// false. The header is a client-controlled compatibility signal, not an access control (it is
// trivially spoofable), so a missing or garbled value — an old build predating the header, or a
// non-browser caller — must never be blocked spuriously.
func (s *Server) clientTooOld(header string) bool {
if !s.gateOn {
return false
}
v, ok := clientver.Parse(header)
if !ok {
return false
}
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 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).
if s.clientTooOld(req.Header().Get(clientVersionHeader)) {
result = resultUpdateRequired
return connect.NewResponse(&edgev1.ExecuteResponse{
RequestId: req.Msg.GetRequestId(),
ResultCode: resultUpdateRequired,
}), nil
}
op, ok := s.registry.Lookup(msgType) op, ok := s.registry.Lookup(msgType)
if !ok { if !ok {
result = "unknown_type" result = "unknown_type"
@@ -363,6 +481,11 @@ func (s *Server) Execute(ctx context.Context, req *connect.Request[edgev1.Execut
// Subscribe streams the authenticated user's live events with a keep-alive // Subscribe streams the authenticated user's live events with a keep-alive
// heartbeat until the client disconnects. // heartbeat until the client disconnects.
func (s *Server) Subscribe(ctx context.Context, req *connect.Request[edgev1.SubscribeRequest], stream *connect.ServerStream[edgev1.Event]) error { func (s *Server) Subscribe(ctx context.Context, req *connect.Request[edgev1.SubscribeRequest], stream *connect.ServerStream[edgev1.Event]) error {
// A stream carries no result_code, so a too-old client is refused with the Connect
// FailedPrecondition counterpart of the update_required sentinel.
if s.clientTooOld(req.Header().Get(clientVersionHeader)) {
return connect.NewError(connect.CodeFailedPrecondition, errUpdateRequired)
}
uid, _, _, err := s.resolve(ctx, req.Header(), peerIP(req.Peer().Addr, req.Header())) uid, _, _, err := s.resolve(ctx, req.Header(), peerIP(req.Peer().Addr, req.Header()))
if err != nil { if err != nil {
return err return err
+166 -7
View File
@@ -22,8 +22,13 @@ import (
) )
// newEdge wires a connectsrv.Server over a fake backend and returns a Connect // newEdge wires a connectsrv.Server over a fake backend and returns a Connect
// client plus a cleanup func. // client plus a cleanup func. The client-version gate is off.
func newEdge(t *testing.T, backendHandler http.HandlerFunc) (edgev1connect.GatewayClient, func()) { func newEdge(t *testing.T, backendHandler http.HandlerFunc) (edgev1connect.GatewayClient, func()) {
return newEdgeMin(t, "", backendHandler)
}
// newEdgeMin is newEdge with the client-version gate armed at minVersion (empty leaves it off).
func newEdgeMin(t *testing.T, minVersion string, backendHandler http.HandlerFunc) (edgev1connect.GatewayClient, func()) {
t.Helper() t.Helper()
backendSrv := httptest.NewServer(backendHandler) backendSrv := httptest.NewServer(backendHandler)
backend, err := backendclient.New(backendSrv.URL, "localhost:9090", 2*time.Second) backend, err := backendclient.New(backendSrv.URL, "localhost:9090", 2*time.Second)
@@ -31,12 +36,40 @@ func newEdge(t *testing.T, backendHandler http.HandlerFunc) (edgev1connect.Gatew
t.Fatalf("backendclient: %v", err) t.Fatalf("backendclient: %v", err)
} }
edge := connectsrv.NewServer(connectsrv.Deps{ edge := connectsrv.NewServer(connectsrv.Deps{
Registry: transcode.NewRegistry(backend, nil), Registry: transcode.NewRegistry(backend, nil),
Sessions: session.NewCache(backend, time.Minute, 100), Sessions: session.NewCache(backend, time.Minute, 100),
Limiter: ratelimit.New(), Limiter: ratelimit.New(),
Hub: push.NewHub(0), Hub: push.NewHub(0),
RateLimit: config.DefaultRateLimit(), RateLimit: config.DefaultRateLimit(),
Heartbeat: 15 * time.Second, Heartbeat: 15 * time.Second,
MinClientVersion: minVersion,
})
edgeSrv := httptest.NewServer(edge.HTTPHandler())
client := edgev1connect.NewGatewayClient(http.DefaultClient, edgeSrv.URL)
return client, func() {
edgeSrv.Close()
_ = backend.Close()
backendSrv.Close()
}
}
// 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()) edgeSrv := httptest.NewServer(edge.HTTPHandler())
client := edgev1connect.NewGatewayClient(http.DefaultClient, edgeSrv.URL) client := edgev1connect.NewGatewayClient(http.DefaultClient, edgeSrv.URL)
@@ -108,6 +141,132 @@ func TestExecuteGuestGate(t *testing.T) {
} }
} }
// TestExecuteVersionGate verifies the client-version gate on the unary path: a build older
// than the configured minimum is turned away with the update_required result code before the
// registry lookup (an unknown message type is gated too, proving the short-circuit), while an
// equal, newer, absent, or unparseable version passes; and the gate is dormant when unset.
func TestExecuteVersionGate(t *testing.T) {
// The backend answers auth.guest with a session; a gated call never reaches it.
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
header string
msgType string
want string
}{
{"too old is gated before lookup", "v1.16.0", "v1.0.0", "bogus.unknown", "update_required"},
{"equal passes", "v1.16.0", "v1.16.0", transcode.MsgAuthGuest, "ok"},
{"newer passes", "v1.16.0", "v2.0.0", transcode.MsgAuthGuest, "ok"},
{"absent header fails open", "v1.16.0", "", transcode.MsgAuthGuest, "ok"},
{"unparseable header fails open", "v1.16.0", "dev", transcode.MsgAuthGuest, "ok"},
{"gate dormant when unset", "", "v1.0.0", transcode.MsgAuthGuest, "ok"},
}
for _, tc := range tests {
t.Run(tc.name, func(t *testing.T) {
client, cleanup := newEdgeMin(t, tc.min, backend)
defer cleanup()
req := connect.NewRequest(&edgev1.ExecuteRequest{MessageType: tc.msgType, 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.want {
t.Fatalf("result_code = %q, want %q", got, tc.want)
}
})
}
}
// 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
// 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).
func TestSubscribeVersionGate(t *testing.T) {
noBackend := func(_ http.ResponseWriter, _ *http.Request) {}
tests := []struct {
name string
min string
header string
want connect.Code
}{
{"too old refused", "v1.16.0", "v1.0.0", connect.CodeFailedPrecondition},
{"equal admitted then auth-gated", "v1.16.0", "v1.16.0", connect.CodeUnauthenticated},
{"gate dormant admits", "", "v1.0.0", connect.CodeUnauthenticated},
}
for _, tc := range tests {
t.Run(tc.name, func(t *testing.T) {
client, cleanup := newEdgeMin(t, tc.min, noBackend)
defer cleanup()
req := connect.NewRequest(&edgev1.SubscribeRequest{})
if tc.header != "" {
req.Header().Set("X-Client-Version", tc.header)
}
stream, err := client.Subscribe(context.Background(), req)
if err == nil {
for stream.Receive() {
}
err = stream.Err()
}
if got := connect.CodeOf(err); got != tc.want {
t.Fatalf("code = %v, want %v", got, tc.want)
}
})
}
}
func TestExecuteAuthedRequiresSession(t *testing.T) { func TestExecuteAuthedRequiresSession(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) {
t.Error("backend must not be called without a session") t.Error("backend must not be called without a session")
@@ -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)
}
})
}
+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');
}); });
+5
View File
@@ -31,6 +31,11 @@ enables the "Link Telegram" web sign-in (the Login Widget) — inert until the s
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://t.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 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
`VITE_APP_VERSION` (`git describe --tags` → the `X-Client-Version` gate header) — all wired by
`.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 `/`).
+101
View File
@@ -0,0 +1,101 @@
# Using Android gitignore template: https://github.com/github/gitignore/blob/HEAD/Android.gitignore
# Built application files
*.apk
*.aar
*.ap_
*.aab
# Files for the ART/Dalvik VM
*.dex
# Java class files
*.class
# Generated files
bin/
gen/
out/
# Uncomment the following line in case you need and you don't have the release build type files in your app
# release/
# Gradle files
.gradle/
build/
# Local configuration file (sdk path, etc)
local.properties
# Proguard folder generated by Eclipse
proguard/
# Log Files
*.log
# Android Studio Navigation editor temp files
.navigation/
# Android Studio captures folder
captures/
# IntelliJ
*.iml
.idea/workspace.xml
.idea/tasks.xml
.idea/gradle.xml
.idea/assetWizardSettings.xml
.idea/dictionaries
.idea/libraries
# Android Studio 3 in .gitignore file.
.idea/caches
.idea/modules.xml
# Comment next line if keeping position of elements in Navigation Editor is relevant for you
.idea/navEditor.xml
# Keystore files
# Release signing material must never be committed (keystore loss/leak risk).
*.jks
*.keystore
# External native build folder generated in Android Studio 2.2 and later
.externalNativeBuild
.cxx/
# Google Services (e.g. APIs or Firebase)
# google-services.json
# Freeline
freeline.py
freeline/
freeline_project_description.json
# fastlane
fastlane/report.xml
fastlane/Preview.html
fastlane/screenshots
fastlane/test_output
fastlane/readme.md
# Version control
vcs.xml
# lint
lint/intermediates/
lint/generated/
lint/outputs/
lint/tmp/
# lint/reports/
# Android Profiling
*.hprof
# Cordova plugins for Capacitor
capacitor-cordova-android-plugins
# Copied web assets
app/src/main/assets/public
# Generated Config files
app/src/main/assets/capacitor.config.json
app/src/main/assets/capacitor.plugins.json
app/src/main/res/xml/config.xml
+2
View File
@@ -0,0 +1,2 @@
/build/*
!/build/.npmkeep
+81
View File
@@ -0,0 +1,81 @@
apply plugin: 'com.android.application'
android {
namespace = "ru.eruditgame.app"
compileSdk = rootProject.ext.compileSdkVersion
// Release signing is supplied by the android-build CI workflow via env: it decodes the keystore
// secret to a file and points ANDROID_KEYSTORE_FILE at it. A local build or a keyless CI run leaves
// it unset, so the release APK is produced UNSIGNED rather than failing — assembleDebug and
// assembleRelease both build without the keystore. The keystore is never committed (see .gitignore).
def keystoreFile = System.getenv('ANDROID_KEYSTORE_FILE')
def hasKeystore = keystoreFile != null && !keystoreFile.isEmpty() && file(keystoreFile).exists()
defaultConfig {
applicationId "ru.eruditgame.app"
minSdkVersion rootProject.ext.minSdkVersion
targetSdkVersion rootProject.ext.targetSdkVersion
// Deterministic from the release tag by the android-build workflow (vMA.MI.PA ->
// MA*1_000_000 + MI*1_000 + PA); the defaults keep a local assembleDebug building.
versionCode = (project.findProperty('versionCode') ?: '1').toInteger()
versionName = (project.findProperty('versionName') ?: '0.0.0').toString()
testInstrumentationRunner "androidx.test.runner.AndroidJUnitRunner"
aaptOptions {
// Files and dirs to omit from the packaged assets dir, modified to accommodate modern web apps.
// Default: https://android.googlesource.com/platform/frameworks/base/+/282e181b58cf72b6ca770dc7ca5f91f135444502/tools/aapt/AaptAssets.cpp#61
ignoreAssetsPattern = '!.svn:!.git:!.ds_store:!*.scc:.*:!CVS:!thumbs.db:!picasa.ini:!*~'
}
}
signingConfigs {
release {
// Populated only when the workflow provided a keystore (see hasKeystore above); left empty
// otherwise so it is never referenced with a null storeFile.
if (hasKeystore) {
storeFile file(keystoreFile)
storePassword System.getenv('ANDROID_KEYSTORE_PASSWORD')
keyAlias System.getenv('ANDROID_KEY_ALIAS')
keyPassword System.getenv('ANDROID_KEY_PASSWORD')
}
}
}
buildTypes {
release {
minifyEnabled false
proguardFiles getDefaultProguardFile('proguard-android.txt'), 'proguard-rules.pro'
// Sign only when a keystore was supplied; a keyless release build stays unsigned instead
// of failing.
if (hasKeystore) {
signingConfig signingConfigs.release
}
}
}
}
repositories {
flatDir{
dirs '../capacitor-cordova-android-plugins/src/main/libs', 'libs'
}
}
dependencies {
implementation fileTree(include: ['*.jar'], dir: 'libs')
implementation "androidx.appcompat:appcompat:$androidxAppCompatVersion"
implementation "androidx.coordinatorlayout:coordinatorlayout:$androidxCoordinatorLayoutVersion"
implementation "androidx.core:core-splashscreen:$coreSplashScreenVersion"
implementation project(':capacitor-android')
testImplementation "junit:junit:$junitVersion"
androidTestImplementation "androidx.test.ext:junit:$androidxJunitVersion"
androidTestImplementation "androidx.test.espresso:espresso-core:$androidxEspressoCoreVersion"
implementation project(':capacitor-cordova-android-plugins')
}
apply from: 'capacitor.build.gradle'
try {
def servicesJSON = file('google-services.json')
if (servicesJSON.text) {
apply plugin: 'com.google.gms.google-services'
}
} catch(Exception e) {
logger.info("google-services.json not found, google-services plugin not applied. Push Notifications won't work")
}
+20
View File
@@ -0,0 +1,20 @@
// DO NOT EDIT THIS FILE! IT IS GENERATED EACH TIME "capacitor update" IS RUN
android {
compileOptions {
sourceCompatibility JavaVersion.VERSION_21
targetCompatibility JavaVersion.VERSION_21
}
}
apply from: "../capacitor-cordova-android-plugins/cordova.variables.gradle"
dependencies {
implementation project(':capacitor-app')
implementation project(':capacitor-network')
}
if (hasProperty('postBuildExtras')) {
postBuildExtras()
}
+21
View File
@@ -0,0 +1,21 @@
# Add project specific ProGuard rules here.
# You can control the set of applied configuration files using the
# proguardFiles setting in build.gradle.
#
# For more details, see
# http://developer.android.com/guide/developing/tools/proguard.html
# If your project uses WebView with JS, uncomment the following
# and specify the fully qualified class name to the JavaScript interface
# class:
#-keepclassmembers class fqcn.of.javascript.interface.for.webview {
# public *;
#}
# Uncomment this to preserve the line number information for
# debugging stack traces.
#-keepattributes SourceFile,LineNumberTable
# If you keep the line number information, uncomment this to
# hide the original source file name.
#-renamesourcefileattribute SourceFile
@@ -0,0 +1,26 @@
package com.getcapacitor.myapp;
import static org.junit.Assert.*;
import android.content.Context;
import androidx.test.ext.junit.runners.AndroidJUnit4;
import androidx.test.platform.app.InstrumentationRegistry;
import org.junit.Test;
import org.junit.runner.RunWith;
/**
* Instrumented test, which will execute on an Android device.
*
* @see <a href="http://d.android.com/tools/testing">Testing documentation</a>
*/
@RunWith(AndroidJUnit4.class)
public class ExampleInstrumentedTest {
@Test
public void useAppContext() throws Exception {
// Context of the app under test.
Context appContext = InstrumentationRegistry.getInstrumentation().getTargetContext();
assertEquals("com.getcapacitor.app", appContext.getPackageName());
}
}
@@ -0,0 +1,41 @@
<?xml version="1.0" encoding="utf-8"?>
<manifest xmlns:android="http://schemas.android.com/apk/res/android">
<application
android:allowBackup="true"
android:icon="@mipmap/ic_launcher"
android:label="@string/app_name"
android:roundIcon="@mipmap/ic_launcher_round"
android:supportsRtl="true"
android:theme="@style/AppTheme">
<activity
android:configChanges="orientation|keyboardHidden|keyboard|screenSize|locale|smallestScreenSize|screenLayout|uiMode|navigation|density"
android:name=".MainActivity"
android:label="@string/title_activity_main"
android:theme="@style/AppTheme.NoActionBarLaunch"
android:launchMode="singleTask"
android:exported="true">
<intent-filter>
<action android:name="android.intent.action.MAIN" />
<category android:name="android.intent.category.LAUNCHER" />
</intent-filter>
</activity>
<provider
android:name="androidx.core.content.FileProvider"
android:authorities="${applicationId}.fileprovider"
android:exported="false"
android:grantUriPermissions="true">
<meta-data
android:name="android.support.FILE_PROVIDER_PATHS"
android:resource="@xml/file_paths"></meta-data>
</provider>
</application>
<!-- Permissions -->
<uses-permission android:name="android.permission.INTERNET" />
</manifest>
@@ -0,0 +1,5 @@
package ru.eruditgame.app;
import com.getcapacitor.BridgeActivity;
public class MainActivity extends BridgeActivity {}
Binary file not shown.

After

Width:  |  Height:  |  Size: 9.6 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 3.5 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 5.4 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 16 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 4.5 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 8.0 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 30 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 46 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 66 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 16 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 23 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 30 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 4.5 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 7.3 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 2.9 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 4.3 KiB

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