From 4a6fe318bee5f907ac95c5f4af6174bbdc13dbde Mon Sep 17 00:00:00 2001 From: Ilia Denisov Date: Tue, 30 Jun 2026 07:51:11 +0200 Subject: [PATCH 01/71] docs: correct VK invite-link reality + note desktop board drag-to-pan Two doc edits that did not land alongside their code: - FUNCTIONAL (+_ru) claimed a friend invitation is shareable as a Telegram *or VK* deep link "that opens it directly". On VK that is false: vkShareLink emits a plain vk.com/app link (VK forwards no payload to the Mini App), so the recipient enters the copied code by hand (#142, commit d76f1f4 pivoted off the earlier #f plan; the doc kept the old wording). - UI_DESIGN omitted the desktop / landscape-iframe mouse drag-to-pan of the zoomed board added in #141 (touch scrolls the viewport natively; a mouse cannot, so an explicit drag-to-pan handler moves it). --- docs/FUNCTIONAL.md | 3 ++- docs/FUNCTIONAL_ru.md | 3 ++- docs/UI_DESIGN.md | 5 ++++- 3 files changed, 8 insertions(+), 3 deletions(-) diff --git a/docs/FUNCTIONAL.md b/docs/FUNCTIONAL.md index 0857b04..8f03e6b 100644 --- a/docs/FUNCTIONAL.md +++ b/docs/FUNCTIONAL.md @@ -125,7 +125,8 @@ and any incidental perpendicular words are ignored and not scored — while on i standard Scrabble. English games are always standard and show no such toggle. In auto-match the choice joins the pairing key, so a player only meets opponents who picked the same rule. Friend games (2–4) are formed by inviting players from the friend list (an invitation, like a friend code, -is shareable as a Telegram or VK deep link that opens it directly): the inviter chooses the +is shareable as a Telegram deep link that opens it directly — on VK, which forwards no payload to the +Mini App, the link only opens the app and the recipient enters the copied code by hand): the inviter chooses the settings and the game starts once every invitee has accepted — any decline cancels it, and an unanswered invitation expires after seven days. diff --git a/docs/FUNCTIONAL_ru.md b/docs/FUNCTIONAL_ru.md index ce733ac..a2c5bf3 100644 --- a/docs/FUNCTIONAL_ru.md +++ b/docs/FUNCTIONAL_ru.md @@ -131,7 +131,8 @@ _Вход сейчас только через провайдера, поэто показывают. В авто-подборе выбор входит в ключ подбора, поэтому игрок сводится только с теми, кто выбрал то же правило. Игры с друзьями (2–4) формируются приглашением игроков из списка друзей (приглашение, как и код друга, -можно отправить deep-link'ом в Telegram или VK, который откроет его сразу): инициатор +можно отправить deep-link'ом в Telegram, который откроет его сразу — на VK, который не передаёт Mini App +никакой нагрузки, ссылка лишь открывает приложение, а скопированный код получатель вводит вручную): инициатор выбирает настройки, и партия стартует, когда приняли все приглашённые — любой отказ отменяет приглашение, а без ответа приглашение протухает через семь дней. diff --git a/docs/UI_DESIGN.md b/docs/UI_DESIGN.md index 3ca2fd6..64adb17 100644 --- a/docs/UI_DESIGN.md +++ b/docs/UI_DESIGN.md @@ -137,7 +137,10 @@ dismisses as soon as the lobby is ready. The pure layout and timing live in `lib the first time. A **swipe down on the zoom-out board** opens the history, but only when the board is scrolled to its top so it never fights the stage's own vertical scroll (the conflict that once retired this gesture) — and it is suppressed on the zoomed board, where the - one-finger drag pans. History also opens on a **tap of the score bar** and closes on a tap or + one-finger drag pans (and on desktop / the landscape iframe, where a mouse cannot drag-scroll the + viewport natively, a **drag-to-pan** handler moves the zoomed board instead — active only while + zoomed, off pending tiles, past a small threshold, swallowing the trailing click). History also + opens on a **tap of the score bar** and closes on a tap or an **upward swipe** of the then-inert board (below). A **hint** auto-zooms centred on the hint's placement, not the top-left. From 373aa2aa6d96d53feb1ac54f307b54efafdfa864 Mon Sep 17 00:00:00 2001 From: Ilia Denisov Date: Tue, 30 Jun 2026 08:05:38 +0200 Subject: [PATCH 02/71] docs: sync VK theme-follow + home-bar safe-area into FUNCTIONAL/UI_DESIGN/.claude MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The #142 polish shipped three VK behaviours the docs never picked up (they were written for the #140 auth MVP): - the "auto" theme follows the VK client light/dark (VKWebAppUpdateConfig), not the VK webview's prefers-color-scheme; - the layout clears the VK mobile home bar via CSS env() max'd with the VK bridge insets (VKWebAppUpdateInsets) — the bridge value is needed on Android, where the VK webview exposes no env() inset (.claude said env() handled it alone); - share/copy route through the bridge (VKWebAppShare/VKWebAppCopyText). FUNCTIONAL (+_ru) VK paragraph gains theme+home-bar parity with the Telegram one; UI_DESIGN gets a VK-integration note beside the Telegram one. --- .claude/vk-games-integration.md | 6 ++++-- docs/FUNCTIONAL.md | 4 +++- docs/FUNCTIONAL_ru.md | 4 +++- docs/UI_DESIGN.md | 7 +++++++ 4 files changed, 17 insertions(+), 4 deletions(-) diff --git a/.claude/vk-games-integration.md b/.claude/vk-games-integration.md index 4859e87..f1b999c 100644 --- a/.claude/vk-games-integration.md +++ b/.claude/vk-games-integration.md @@ -83,8 +83,10 @@ incl. the `%2C` comma case for `vk_access_token_settings`). blocked. **Used** as the copy-code / copy-link path. - `VKWebAppUpdateConfig` (subscribe) — light/dark scheme; the app follows it while the theme pref is "auto" (the VK webview's prefers-color-scheme does not track it). **Used.** -- `VKWebAppSetViewSettings` / `VKWebAppSetSwipeSettings` — viewport / swipe-back (mobile); not used — - the bottom home-bar safe area is handled by CSS `env(safe-area-inset-*)` (viewport-fit=cover). +- `VKWebAppSetViewSettings` / `VKWebAppSetSwipeSettings` — viewport / swipe-back (mobile); not used. +- `VKWebAppUpdateInsets` (+ `VKWebAppUpdateConfig`) — device safe-area insets; the app **max'es** them + with CSS `env(safe-area-inset-*)` (viewport-fit=cover) so the bottom home bar is cleared. The bridge + value is needed on Android, where the VK webview exposes no `env()` inset. **Used.** The bridge talks to the embedding VK client over postMessage; it is NOT an external fetch, so it has no telegram.org-style load-hang risk. The SDK reads browser globals at import — we import diff --git a/docs/FUNCTIONAL.md b/docs/FUNCTIONAL.md index 8f03e6b..a554993 100644 --- a/docs/FUNCTIONAL.md +++ b/docs/FUNCTIONAL.md @@ -38,7 +38,9 @@ deployment), the Mini App retries quietly and then shows a small "couldn't load" A **VK Mini App** launch works the same way: it authenticates from VK's signed launch parameters (verified by the gateway), and on first contact seeds the new account's interface language from `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). The same quiet-retry "couldn't load" screen applies inside 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 +"couldn't load" screen applies inside VK. Telegram runs a **single bot**: every player uses the same bot, and all of its chat and out-of-app notifications are written in the player's own **interface language** (en/ru). A separate optional **promo bot** can run alongside the diff --git a/docs/FUNCTIONAL_ru.md b/docs/FUNCTIONAL_ru.md index a2c5bf3..52c2f66 100644 --- a/docs/FUNCTIONAL_ru.md +++ b/docs/FUNCTIONAL_ru.md @@ -40,7 +40,9 @@ Mini App** авторизует по подписанным `initData` плат Telegram не место. Запуск **VK Mini App** работает так же: авторизует по подписанным launch-параметрам VK (их проверяет gateway), и при первом контакте задаёт язык интерфейса нового аккаунта по `vk_language`, а отображаемое имя — по профилю VK (читается на клиенте, -так как VK не кладёт имя в подписанный запуск). Тот же экран тихого повтора «не удалось +так как VK не кладёт имя в подписанный запуск). Пока тема в режиме «авто», приложение следует +светлой/тёмной схеме VK-клиента, а раскладка обходит нижнюю home-bar VK на мобильных. Тот же экран +тихого повтора «не удалось загрузить» действует и внутри VK. Telegram держит **единого бота**: все игроки пользуются одним и тем же ботом, а весь его чат и внеприложенческие уведомления пишутся на **языке интерфейса** самого игрока (en/ru). Рядом с основным может работать отдельный опциональный diff --git a/docs/UI_DESIGN.md b/docs/UI_DESIGN.md index 64adb17..10c523d 100644 --- a/docs/UI_DESIGN.md +++ b/docs/UI_DESIGN.md @@ -113,6 +113,13 @@ dismisses as soon as the lobby is ready. The pure layout and timing live in `lib by a background suspend reconnects silently on return — the connection banner is suppressed while hidden and for a short grace after resume (visibilitychange + pageshow/pagehide + Telegram `activated`/`deactivated`). +- **VK integration** (`lib/vk.ts`): inside the VK Mini App the **auto** theme follows the VK + client's light/dark (`VKWebAppUpdateConfig`), as the VK webview's `prefers-color-scheme` does not + track it; explicit light/dark prefs still win. The device safe-area comes from CSS + `env(safe-area-inset-*)` (the meta already sets `viewport-fit=cover`) **max'd** with the VK bridge + insets (`VKWebAppUpdateInsets`, needed on Android, where the VK webview exposes no `env()` inset), + so the layout clears the VK home bar. Share and copy route through the bridge (`VKWebAppShare` / + `VKWebAppCopyText`) because `navigator.share` / `clipboard` are absent in the desktop VK iframe. ## Tiles & board From d0f860a33e8ab3b2e4914c5c3efda2aa0e252764 Mon Sep 17 00:00:00 2001 From: Ilia Denisov Date: Tue, 30 Jun 2026 18:29:51 +0200 Subject: [PATCH 03/71] =?UTF-8?q?feat(assets):=20VK=20loading-screen=20Lot?= =?UTF-8?q?tie=20=E2=80=94=20bouncing=20Erudit=20tile?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit A 96x96 transparent Lottie for the VK app loading screen: the «Э» tile (score 8) drops in under gravity, lands with a soft cushion squash (left/right edges bulge convex, corners follow via a chordal spline), springs back and loops (~1.1s). A warm glint is caught on the bounce. Pure 2D shapes (ddd:0) for player compatibility; ~11KB (<24KB limit). Glyphs are real LiberationSans outlines (Arial-metric, matching the game's font stack). Includes a reproducible, dependency-light build pipeline (build/generate.js + extract.js + glyphs.json) and a README. The preview GIF's green-baize background is preview-only; the asset itself is transparent. --- assets/vk/README.md | 115 +++++++++++++++++++++ assets/vk/build/build-preview.js | 31 ++++++ assets/vk/build/extract.js | 67 ++++++++++++ assets/vk/build/generate.js | 153 ++++++++++++++++++++++++++++ assets/vk/build/glyphs.json | 1 + assets/vk/erudit-loader-preview.gif | Bin 0 -> 92951 bytes assets/vk/erudit-loader.json | 1 + 7 files changed, 368 insertions(+) create mode 100644 assets/vk/README.md create mode 100644 assets/vk/build/build-preview.js create mode 100644 assets/vk/build/extract.js create mode 100644 assets/vk/build/generate.js create mode 100644 assets/vk/build/glyphs.json create mode 100644 assets/vk/erudit-loader-preview.gif create mode 100644 assets/vk/erudit-loader.json diff --git a/assets/vk/README.md b/assets/vk/README.md new file mode 100644 index 0000000..8ad21b8 --- /dev/null +++ b/assets/vk/README.md @@ -0,0 +1,115 @@ +# Erudit — VK loading-screen logo (Lottie) + +Animated logo for the **VK app loading screen** (shown before the SPA assets load). +The Erudit «Э» tile (score `8`) **drops in under gravity, lands with a soft squash — +its left/right edges bulging into a cushion — then springs back up**, looping. A light +"glint" is caught at the moment of the bounce. + +| File | Purpose | +|------|---------| +| `erudit-loader.json` | The Lottie to upload to VK. | +| `erudit-loader-preview.gif` | Looping preview. Rendered on a green "baize" background **only in the preview** — the real asset has a **transparent** background. | +| `build/` | The reproducible build pipeline (see *Regenerate*). | + +**VK requirements met:** 96×96 px · vector Lottie JSON · ≤ 24 KB (~11 KB) · seamless +~1.1 s loop · transparent background. + +Design reference: a photo of the physical wooden Erudit tile. + +--- + +## How it works + +A single flat layer holds the tile — a rounded-rect **face path**, the two glyphs, and +a thin border — and everything is driven by that layer's transform plus a few colour / +shape tracks. The anchor sits on the tile's **bottom edge**, so the squash happens +against the floor. + +### Bounce (gravity) +`position.y` of the bottom edge goes apex → floor → apex with **no dwell** (the apex is +an instantaneous turn-around). The fall uses a strong **ease-in** (accelerate) and the +rise a strong **ease-out** (decelerate), so it reads as real gravity. While falling fast +the tile slightly **stretches** (tall+thin); that is the squash-and-stretch setup. + +### Soft cushion (squash) +On contact the layer **squashes** (scaleY↓, scaleX↑) about the bottom anchor, with a +touch of skew. Crucially the squash/cushion is **decoupled from the fall speed** — it +eases in and out *slowly* (`ES`), so the landing feels soft even though the fall is +fast. The squash is deliberately gentle (≈ 86 % / 112 %). + +### The cushion shape (the hard part) +The face is **not** a plain rounded rect — it is a path whose left/right contour bows +out into a convex cushion on impact: +- the rest rounded-rect outline is sampled (fine corner arcs + edge mids), and on impact + every point is pushed outward by a smooth **barrel** profile `f(y) = b·(1 − (y/HH)²)` + (max at the middle, fading to zero at the top/bottom); +- so the **whole side — corners included — bows out as one piece**, never just the + straight middle; +- bezier handles are computed as a **chordal spline** (handle length ∝ the adjacent + chord), which stays smooth across the uneven corner/edge point spacing. This is what + keeps the corner↔cushion junction kink-free — both at rest and fully bulged — which a + naïve uniform Catmull-Rom (or moving discrete points with fixed tangents) does not. + +### Glint +At the bounce the face flashes a little lighter (`FACE_GLINT`) and the glyphs warm +toward brown-red (`BLACK_LIT`), then settle back. A cheap, warm "catch the light". + +### Border & background +The tile carries a thin static **border** a touch darker than the face (`BORDER`) so it +reads on any background. The **green baize background is added only when rendering the +preview GIF** — the Lottie itself is transparent. + +### Player compatibility +Only plain 2-D shapes (`ddd:0`) — no 3-D layers, expressions or effects — so every +Lottie player (lottie-web on the web, rlottie on native) renders it identically. + +--- + +## Glyphs + +`Э` (U+042D) and `8` are **real outlines from LiberationSans-Regular** — metric- +compatible with Arial, matching the game's `--font: system-ui … Arial` stack +(see `ui/src/app.css`). `build/extract.js` pulls the contours into `build/glyphs.json` +as Lottie cubic paths; a hair of same-colour stroke adds a touch of weight. + +--- + +## Regenerate + +Requirements: Node ≥ 18. `extract.js` additionally needs `opentype.js` +(`npm i opentype.js`); **`generate.js` has no dependencies**. + +```sh +cd assets/vk + +# 1. (optional) re-extract the glyphs — only if you change the font: +# default font: /usr/share/fonts/truetype/liberation/LiberationSans-Regular.ttf +node build/extract.js [/path/to/font.ttf] # -> build/glyphs.json + +# 2. build the animation (reads build/glyphs.json): +node build/generate.js erudit-loader.json # -> erudit-loader.json + +# 3. (optional) live preview — needs lottie-web (npm i lottie-web): +node build/build-preview.js erudit-loader.json preview.html +# then open preview.html in a browser. +``` + +The preview GIF is assembled by rendering the Lottie frame-by-frame (lottie-web canvas, +filled with the green baize) and stitching with `ffmpeg`; the live HTML preview is the +quickest way to eyeball changes. + +## Tunables (top of `build/generate.js`) + +| Symbol | Meaning | +|--------|---------| +| `OP`, `FR` | loop length (frames) / fps — overall speed | +| `T_HIT / T_PEAK / T_LIFT / T_REC` | beats: contact / squash peak / lift-off / cushion recovered | +| `EI / EO / ES` | easings: fast fall / fast rise / slow soft cushion | +| `APEX`, `FLOOR` | bottom-edge screen-y at the top of the bounce / at rest | +| `HW`, `HH`, `CR` | tile half-width / half-height / corner radius | +| `scl` squash values | the squash amount (scaleX↑ / scaleY↓) | +| `bulge` `b` | cushion depth (how far the sides bow out) | +| `FACE / FACE_GLINT` | wood face / glint flash | +| `BORDER` | tile rim colour | +| `BLACK / BLACK_LIT` | glyph / glyph-at-glint (brown-red) | +| preview bg `#3C7858` | the green-baize colour used **only** in the GIF | diff --git a/assets/vk/build/build-preview.js b/assets/vk/build/build-preview.js new file mode 100644 index 0000000..a490ee4 --- /dev/null +++ b/assets/vk/build/build-preview.js @@ -0,0 +1,31 @@ +'use strict'; +const fs = require('fs'); +const data = fs.readFileSync(process.argv[2] || 'erudit.json', 'utf8'); +const lottiePath = __dirname + '/node_modules/lottie-web/build/player/lottie.min.js'; +const html = `
+ +`; +fs.writeFileSync(process.argv[3] || 'preview.html', html); +console.log('wrote', process.argv[3] || 'preview.html'); diff --git a/assets/vk/build/extract.js b/assets/vk/build/extract.js new file mode 100644 index 0000000..a225381 --- /dev/null +++ b/assets/vk/build/extract.js @@ -0,0 +1,67 @@ +'use strict'; +// Extract the Cyrillic "Э" (U+042D) and the digit "8" outlines from a grotesque +// font (LiberationSans = Arial-metric, matching the game's system-ui/Arial stack) +// and emit Lottie cubic-bezier contours, centred at the origin, y-down. +const opentype = require('opentype.js'); +const fs = require('fs'); + +const FONT = process.argv[2] || '/usr/share/fonts/truetype/liberation/LiberationSans-Regular.ttf'; +const b = fs.readFileSync(FONT); +const font = opentype.parse(b.buffer.slice(b.byteOffset, b.byteOffset + b.byteLength)); +const FS = 1000; // em scale + +function glyphContours(ch) { + const p = font.charToGlyph(ch).getPath(0, 0, FS); // baseline at y=0, y-down + const contours = []; + let cur = null, prev = null; + for (const c of p.commands) { + if (c.type === 'M') { + if (cur) contours.push(cur); + cur = [{ v: [c.x, c.y], i: [c.x, c.y], o: [c.x, c.y] }]; + prev = { x: c.x, y: c.y }; + } else if (c.type === 'L') { + cur.push({ v: [c.x, c.y], i: [c.x, c.y], o: [c.x, c.y] }); + prev = { x: c.x, y: c.y }; + } else if (c.type === 'C') { + cur[cur.length - 1].o = [c.x1, c.y1]; + cur.push({ v: [c.x, c.y], i: [c.x2, c.y2], o: [c.x, c.y] }); + prev = { x: c.x, y: c.y }; + } else if (c.type === 'Q') { + const c1 = [prev.x + 2 / 3 * (c.x1 - prev.x), prev.y + 2 / 3 * (c.y1 - prev.y)]; + const c2 = [c.x + 2 / 3 * (c.x1 - c.x), c.y + 2 / 3 * (c.y1 - c.y)]; + cur[cur.length - 1].o = c1; + cur.push({ v: [c.x, c.y], i: c2, o: [c.x, c.y] }); + prev = { x: c.x, y: c.y }; + } else if (c.type === 'Z') { + if (cur && cur.length > 1) { + const last = cur[cur.length - 1], first = cur[0]; + if (Math.hypot(last.v[0] - first.v[0], last.v[1] - first.v[1]) < 1e-3) { + first.i = last.i; // fold the duplicate closing point into the first + cur.pop(); + } + } + if (cur) { contours.push(cur); cur = null; } + } + } + if (cur) contours.push(cur); + + let minx = Infinity, miny = Infinity, maxx = -Infinity, maxy = -Infinity; + const out = contours.map(ct => { + const v = [], i = [], o = []; + ct.forEach(pt => { + v.push(pt.v); + i.push([pt.i[0] - pt.v[0], pt.i[1] - pt.v[1]]); + o.push([pt.o[0] - pt.v[0], pt.o[1] - pt.v[1]]); + minx = Math.min(minx, pt.v[0]); maxx = Math.max(maxx, pt.v[0]); + miny = Math.min(miny, pt.v[1]); maxy = Math.max(maxy, pt.v[1]); + }); + return { i, o, v, c: true }; + }); + return { contours: out, bbox: { x: minx, y: miny, w: maxx - minx, h: maxy - miny } }; +} + +const E = glyphContours('Э'); +const D8 = glyphContours('8'); +fs.writeFileSync('glyphs.json', JSON.stringify({ em: FS, E, D8 })); +console.log('Э bbox', E.bbox, 'contours', E.contours.map(c => c.v.length)); +console.log('8 bbox', D8.bbox, 'contours', D8.contours.map(c => c.v.length)); diff --git a/assets/vk/build/generate.js b/assets/vk/build/generate.js new file mode 100644 index 0000000..5e515e9 --- /dev/null +++ b/assets/vk/build/generate.js @@ -0,0 +1,153 @@ +'use strict'; +// VK preloader Lottie: a flat wooden Erudit tile ("Э" + score "8") that drops in +// under gravity, squashes on impact (convex cushion bulging out the left/right edges +// + a touch of skew), and springs back up — looping. A light "glint" is caught at the +// moment of the bounce. Pure 2D shapes (ddd:0). Glyphs are real LiberationSans +// outlines (Arial-metric, = the game's font stack); see extract.js -> glyphs.json. +const fs = require('fs'); +const G = JSON.parse(fs.readFileSync(__dirname + '/glyphs.json', 'utf8')); + +// ---- canvas / timing ------------------------------------------------------- +const W = 96, H = 96, cx = 48; +const FR = 30, OP = 34; // ~1.13 s loop (dynamic, 25% faster) +// keyframe beats (frames): fast fall -> soft squash -> lift -> fast rise -> apex (no dwell) +const T_HIT = 13, T_PEAK = 18, T_LIFT = 19, T_REC = 28; + +// ---- geometry -------------------------------------------------------------- +const HW = 26, HH = 26, CR = 6; // tile half-width / half-height / corner radius +const FLOOR = 90, APEX = 60; // bottom-centre screen-y at rest / at the top of the bounce + +// ---- palette --------------------------------------------------------------- +const col = h => [parseInt(h.slice(1,3),16)/255, parseInt(h.slice(3,5),16)/255, parseInt(h.slice(5,7),16)/255]; +const FACE = col('#D9B978'); // wood face (flat) +const FACE_GLINT = col('#E7CD95'); // face flash caught on impact (gentle, not blinding) +const BORDER = col('#B49559'); // tile rim: a touch darker than the face, reads on any bg +const BLACK = col('#1A1A1A'); +const BLACK_LIT = col('#421A0B'); // glyph warms to brown-red on the glint +const lerp = (a, b, t) => a.map((v, i) => v + (b[i] - v) * t); + +// ---- property / easing helpers --------------------------------------------- +const still = v => ({ a: 0, k: v }); +const EI = { o: { x: [0.82], y: [0] }, i: { x: [1], y: [1] } }; // strong accelerate (gravity fall) +const EO = { o: { x: [0], y: [0] }, i: { x: [0.18], y: [1] } }; // strong decelerate (rise to apex) +const ES = { o: { x: [0.42], y: [0] }, i: { x: [0.58], y: [1] } }; // soft/slow (the cushion) +function anim(samples) { // samples: {t, v, e?} ; e = easing toward the NEXT key + return { a: 1, k: samples.map((s, idx) => { + const kf = { t: s.t, s: s.v }; + if (idx < samples.length - 1) { const e = s.e || ES; kf.o = e.o; kf.i = e.i; } + return kf; + }) }; +} +const animColor = s => anim(s.map(x => ({ t: x.t, v: [...x.v, 1], e: x.e }))); + +// ---- shape helpers --------------------------------------------------------- +const tr = () => ({ ty: 'tr', p: still([0,0]), a: still([0,0]), s: still([100,100]), r: still(0), o: still(100) }); +const grp = (items, nm) => ({ ty: 'gr', it: [...items, tr()], nm }); +const fill = c => ({ ty: 'fl', c, o: still(100), r: 1, bm: 0 }); +const stroke = (c,w) => ({ ty: 'st', c: still(c), o: still(100), w: still(w), lc: 2, lj: 2, ml: 4, bm: 0 }); +function glyph(gd, hpx, px, py, bold, colour, nm) { // font glyph -> filled+stroked group + const sc = hpx / gd.bbox.h; + const bcx = gd.bbox.x + gd.bbox.w / 2, bcy = gd.bbox.y + gd.bbox.h / 2; + const shapes = gd.contours.map(ct => ({ + ty: 'sh', d: 1, ks: still({ + i: ct.i.map(p => [p[0]*sc, p[1]*sc]), o: ct.o.map(p => [p[0]*sc, p[1]*sc]), + v: ct.v.map(p => [(p[0]-bcx)*sc + px, (p[1]-bcy)*sc + py]), c: true, + }), + })); + return grp([...shapes, fill(colour), stroke(BLACK, bold)], nm); +} + +// Sample the rest rounded-rect outline (clockwise): fine corner arcs + one mid point +// per straight edge, so a smooth interpolant reproduces it cleanly. +function arc(ccx, ccy, a0, a1, n) { + const out = []; + for (let i = 0; i < n; i++) { const a = (a0 + (a1 - a0) * i / (n - 1)) * Math.PI / 180; out.push([ccx + CR*Math.cos(a), ccy + CR*Math.sin(a)]); } + return out; +} +const REST = (() => { + const NC = 7, yi = HH - CR, xi = HW - CR; + const C = [ arc(xi,-yi,-90,0,NC), arc(xi,yi,0,90,NC), arc(-xi,yi,90,180,NC), arc(-xi,-yi,180,270,NC) ]; + const pts = []; + for (let k = 0; k < 4; k++) { + pts.push(...C[k]); + const a = C[k][NC-1], b = C[(k+1)%4][0]; + pts.push([(a[0]+b[0])/2, (a[1]+b[1])/2]); // straight-edge mid point (keeps the edge straight) + } + return pts; +})(); +// The whole left/right contour (corners + side) bows out by one smooth barrel profile; +// Catmull-Rom tangents (from neighbours) make every junction C1-smooth -> no kink, the +// corners follow the cushion and the cushion melts into the corners, bulged or not. +function facePath(b) { + const f = y => b * (1 - (y/HH) * (y/HH)); // 0 at top/bottom, max at the middle + const V = REST.map(([x, y]) => [x + Math.sign(x) * f(y), y]); // push the sides out, top/bottom stay + const n = V.length, I = [], O = []; + for (let k = 0; k < n; k++) { + const p0 = V[(k-1+n)%n], p1 = V[k], p2 = V[(k+1)%n]; + let dx = p2[0]-p0[0], dy = p2[1]-p0[1]; // tangent direction (neighbours) + const dl = Math.hypot(dx, dy) || 1; dx /= dl; dy /= dl; + const ln = Math.hypot(p2[0]-p1[0], p2[1]-p1[1]) / 3; // handles ~ 1/3 of the adjacent chord + const lp = Math.hypot(p1[0]-p0[0], p1[1]-p0[1]) / 3; // -> chordal spline, no overshoot/facets + O.push([dx*ln, dy*ln]); I.push([-dx*lp, -dy*lp]); + } + return { i: I, o: O, v: V, c: true }; +} +const facePathKeys = samples => ({ a: 1, k: samples.map((s, idx) => { + const kf = { t: s.t, s: [facePath(s.b)] }; + if (idx < samples.length - 1) { const e = s.e || ES; kf.o = e.o; kf.i = e.i; } + return kf; +}) }); + +// ---- animation tracks ------------------------------------------------------ +// bottom-centre screen-y (the tile rests/squashes on its bottom edge) +const posY = anim([ + { t: 0, v: [cx, APEX, 0], e: EI }, // apex -> immediately falls (no dwell) + { t: T_HIT, v: [cx, FLOOR, 0], e: ES }, // FAST fall (accelerate) -> floor + { t: T_LIFT, v: [cx, FLOOR, 0], e: EO }, // sit on the floor while the cushion absorbs + { t: OP, v: [cx, APEX, 0] }, // FAST rise (decelerate) -> apex = loop start +]); +// scale about the bottom anchor: stretch while falling, gentle/slow cushion squash on impact +const scl = anim([ + { t: 0, v: [100, 100, 100], e: ES }, + { t: T_HIT-5, v: [95, 106, 100], e: ES }, // stretch while falling fast + { t: T_HIT, v: [104, 95, 100], e: ES }, // touch down + { t: T_PEAK, v: [112, 86, 100], e: ES }, // soft squash peak (gentler, less plче) + { t: T_REC, v: [100, 100, 100], e: ES }, // slow cushion release -> soft landing + { t: OP, v: [100, 100, 100] }, +]); +// a touch of skew through the squash (organic deform), back to 0 +const skw = anim([ + { t: T_HIT, v: [0], e: ES }, { t: T_PEAK, v: [4], e: ES }, { t: T_REC, v: [0] }, { t: OP, v: [0] }, +]); +// left/right cushion bulge: 0 -> gentle peak -> 0 (never inward) +const bulge = facePathKeys([ + { t: T_HIT, b: 0, e: ES }, { t: T_PEAK, b: 4, e: ES }, { t: T_REC, b: 0 }, { t: OP, b: 0 }, +]); +// glint: face + glyph catch the light at the bounce +const faceCol = animColor([ + { t: T_HIT-2, v: FACE, e: ES }, { t: T_PEAK, v: FACE_GLINT, e: ES }, { t: T_REC, v: FACE }, { t: OP, v: FACE }, +]); +const glyphCol = animColor([ + { t: T_HIT-2, v: BLACK, e: ES }, { t: T_PEAK, v: BLACK_LIT, e: ES }, { t: T_REC, v: BLACK }, { t: OP, v: BLACK }, +]); + +// ---- layer (face + glyphs share the bounce/squash transform) --------------- +const faceShape = grp([ { ty: 'sh', d: 1, ks: bulge }, fill(faceCol), stroke(BORDER, 1.8) ], 'face'); +const glyphE = glyph(G.E, 32, 0, -1, 1.0, glyphCol, 'E'); // centred Э +const glyph8 = glyph(G.D8, 8.5, HW-6.5, HH-7.5, 0.5, glyphCol, 'score'); // 8 in the corner + +const tile = { + ddd: 0, ind: 1, ty: 4, nm: 'tile', sr: 1, + ks: { o: still(100), r: still(0), p: posY, a: still([0, HH, 0]), s: scl, sk: skw, sa: still(0) }, + ao: 0, shapes: [ glyph8, glyphE, faceShape ], ip: 0, op: OP, st: 0, bm: 0, +}; + +const root = { + v: '5.7.4', fr: FR, ip: 0, op: OP, w: W, h: H, nm: 'erudit-vk-loader', ddd: 0, + assets: [], layers: [ tile ], markers: [], +}; + +const out = process.argv[2] || 'erudit.json'; +const round = (k, v) => (typeof v === 'number' ? Math.round(v * 100) / 100 : v); +fs.writeFileSync(out, JSON.stringify(root, round)); +console.log('wrote', out, fs.statSync(out).size, 'bytes'); diff --git a/assets/vk/build/glyphs.json b/assets/vk/build/glyphs.json new file mode 100644 index 0000000..0771598 --- /dev/null +++ b/assets/vk/build/glyphs.json @@ -0,0 +1 @@ +{"em":1000,"E":{"contours":[{"i":[[0,0],[0,0],[35.481770833333314,-21.97265625],[14.6484375,-38.41145833333337],[0,0],[0,0],[-49.15364583333333,28.64583333333337],[-73.2421875,0],[0,0],[-59.24479166666663,-62.17447916666663],[0,-109.70052083333331],[0,0],[26.3671875,-53.7109375],[50.29296875,-28.971354166666664],[69.98697916666663,0],[0,0],[59.89583333333333,121.09375],[0,0],[0,0],[-37.434895833333314,-24.90234375],[-47.200520833333314,0],[0,0],[-43.45703125,44.10807291666666],[-6.184895833333371,74.86979166666666],[0,0],[0,0],[0,0],[0,0],[41.82942708333337,41.17838541666663],[69.3359375,0]],"o":[[0,0],[-49.479166666666686,0],[-35.481770833333314,21.97265625],[0,0],[0,0],[23.111979166666657,-56.31510416666663],[49.153645833333314,-28.64583333333337],[0,0],[104.81770833333331,0],[59.24479166666663,62.17447916666663],[0,0],[0,72.59114583333331],[-26.3671875,53.7109375],[-50.29296875,28.971354166666664],[0,0],[-138.671875,0],[0,0],[0,0],[22.4609375,45.57291666666666],[37.434895833333314,24.90234375],[0,0],[69.3359375,0],[43.45703125,-44.10807291666666],[0,0],[0,0],[0,0],[0,0],[-6.8359375,-74.54427083333331],[-41.829427083333314,-41.17838541666663],[0,0]],"v":[[348.14453125,-622.0703125],[348.14453125,-622.0703125],[220.703125,-589.111328125],[145.5078125,-498.53515625],[145.5078125,-498.53515625],[57.12890625,-527.83203125],[165.52734375,-655.2734375],[349.12109375,-698.2421875],[349.12109375,-698.2421875],[595.21484375,-604.98046875],[684.08203125,-347.16796875],[684.08203125,-347.16796875],[644.53125,-157.71484375],[529.541015625,-33.69140625],[349.12109375,9.765625],[349.12109375,9.765625],[51.26953125,-171.875],[51.26953125,-171.875],[127.44140625,-209.9609375],[217.28515625,-104.248046875],[344.23828125,-66.89453125],[344.23828125,-66.89453125],[513.427734375,-133.056640625],[587.890625,-311.5234375],[587.890625,-311.5234375],[268.5546875,-311.5234375],[268.5546875,-386.71875],[587.890625,-386.71875],[514.892578125,-560.302734375],[348.14453125,-622.0703125]],"c":true}],"bbox":{"x":51.26953125,"y":-698.2421875,"w":632.8125,"h":708.0078125}},"D8":{"contours":[{"i":[[0,0],[0,0],[40.364583333333314,-35.48177083333333],[75.52083333333331,0],[0,0],[41.50390625,34.830729166666664],[0,64.12760416666666],[0,0],[-25.71614583333333,30.598958333333314],[-40.0390625,6.510416666666686],[0,0],[0,0],[21.647135416666657,29.296875],[0,39.388020833333314],[0,0],[-39.22526041666666,32.55208333333337],[-66.08072916666666,0],[0,0],[-39.225260416666686,-31.90104166666663],[0,-54.36197916666663],[0,0],[21.809895833333314,-29.296875],[37.760416666666686,-7.486979166666686],[0,0],[0,0],[-24.4140625,-30.110677083333314],[0,-45.247395833333314]],"o":[[0,0],[0,63.4765625],[-40.364583333333314,35.48177083333333],[0,0],[-73.56770833333331,0],[-41.50390625,-34.83072916666666],[0,0],[0,-44.921875],[25.71614583333333,-30.598958333333314],[0,0],[0,0],[-37.434895833333314,-8.7890625],[-21.647135416666657,-29.296875],[0,0],[0,-52.40885416666663],[39.22526041666666,-32.55208333333337],[0,0],[67.70833333333331,0],[39.225260416666686,31.90104166666663],[0,0],[0,39.388020833333314],[-21.809895833333314,29.296875],[0,0],[0,0],[43.9453125,7.161458333333314],[24.4140625,30.110677083333314],[0,0]],"v":[[512.6953125,-191.89453125],[512.6953125,-191.89453125],[452.1484375,-43.45703125],[278.3203125,9.765625],[278.3203125,9.765625],[105.712890625,-42.48046875],[43.45703125,-190.91796875],[43.45703125,-190.91796875],[82.03125,-304.19921875],[180.6640625,-359.86328125],[180.6640625,-359.86328125],[180.6640625,-361.81640625],[92.041015625,-418.9453125],[59.5703125,-521.97265625],[59.5703125,-521.97265625],[118.408203125,-649.4140625],[276.3671875,-698.2421875],[276.3671875,-698.2421875],[436.767578125,-650.390625],[495.60546875,-520.99609375],[495.60546875,-520.99609375],[462.890625,-417.96875],[373.53515625,-362.79296875],[373.53515625,-362.79296875],[373.53515625,-360.83984375],[476.07421875,-304.931640625],[512.6953125,-191.89453125]],"c":true},{"i":[[0,0],[0,0],[85.28645833333331,0],[0,0],[21.647135416666657,-19.53125],[0,-38.73697916666663],[0,0],[-22.298177083333343,-20.670572916666686],[-40.69010416666666,0],[0,0],[-21.647135416666686,19.04296875],[0,42.643229166666686]],"o":[[0,0],[0,-77.79947916666663],[0,0],[-41.341145833333314,0],[-21.647135416666657,19.53125],[0,0],[0,39.388020833333314],[22.298177083333343,20.670572916666686],[0,0],[41.341145833333314,0],[21.647135416666686,-19.04296875],[0,0]],"v":[[404.296875,-516.11328125],[404.296875,-516.11328125],[276.3671875,-632.8125],[276.3671875,-632.8125],[181.884765625,-603.515625],[149.4140625,-516.11328125],[149.4140625,-516.11328125],[182.861328125,-426.025390625],[277.34375,-395.01953125],[277.34375,-395.01953125],[371.826171875,-423.583984375],[404.296875,-516.11328125]],"c":true},{"i":[[0,0],[0,0],[25.390625,21.647135416666686],[45.8984375,0],[0,0],[25.065104166666657,-23.274739583333314],[0,-40.69010416666666],[0,0],[-96.6796875,0],[0,0],[-23.4375,22.94921875],[0,50.130208333333314]],"o":[[0,0],[0,-42.64322916666666],[-25.390625,-21.647135416666686],[0,0],[-44.59635416666666,0],[-25.065104166666657,23.274739583333314],[0,0],[0,94.7265625],[0,0],[47.8515625,0],[23.4375,-22.94921875],[0,0]],"v":[[421.38671875,-200.1953125],[421.38671875,-200.1953125],[383.30078125,-296.630859375],[276.3671875,-329.1015625],[276.3671875,-329.1015625],[171.875,-294.189453125],[134.27734375,-198.2421875],[134.27734375,-198.2421875],[279.296875,-56.15234375],[279.296875,-56.15234375],[386.23046875,-90.576171875],[421.38671875,-200.1953125]],"c":true}],"bbox":{"x":43.45703125,"y":-698.2421875,"w":469.23828125,"h":708.0078125}}} \ No newline at end of file diff --git a/assets/vk/erudit-loader-preview.gif b/assets/vk/erudit-loader-preview.gif new file mode 100644 index 0000000000000000000000000000000000000000..141df50f749ad9d72f6fa2711eb98383b10c3a55 GIT binary patch literal 92951 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%0D53j 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 V8{!6Rm%rX=?_Tk?#sh%i{{iS~&RqZi literal 0 HcmV?d00001 diff --git a/assets/vk/erudit-loader.json b/assets/vk/erudit-loader.json new file mode 100644 index 0000000..13bd1c2 --- /dev/null +++ b/assets/vk/erudit-loader.json @@ -0,0 +1 @@ +{"v":"5.7.4","fr":30,"ip":0,"op":34,"w":96,"h":96,"nm":"erudit-vk-loader","ddd":0,"assets":[],"layers":[{"ddd":0,"ind":1,"ty":4,"nm":"tile","sr":1,"ks":{"o":{"a":0,"k":100},"r":{"a":0,"k":0},"p":{"a":1,"k":[{"t":0,"s":[48,60,0],"o":{"x":[0.82],"y":[0]},"i":{"x":[1],"y":[1]}},{"t":13,"s":[48,90,0],"o":{"x":[0.42],"y":[0]},"i":{"x":[0.58],"y":[1]}},{"t":19,"s":[48,90,0],"o":{"x":[0],"y":[0]},"i":{"x":[0.18],"y":[1]}},{"t":34,"s":[48,60,0]}]},"a":{"a":0,"k":[0,26,0]},"s":{"a":1,"k":[{"t":0,"s":[100,100,100],"o":{"x":[0.42],"y":[0]},"i":{"x":[0.58],"y":[1]}},{"t":8,"s":[95,106,100],"o":{"x":[0.42],"y":[0]},"i":{"x":[0.58],"y":[1]}},{"t":13,"s":[104,95,100],"o":{"x":[0.42],"y":[0]},"i":{"x":[0.58],"y":[1]}},{"t":18,"s":[112,86,100],"o":{"x":[0.42],"y":[0]},"i":{"x":[0.58],"y":[1]}},{"t":28,"s":[100,100,100],"o":{"x":[0.42],"y":[0]},"i":{"x":[0.58],"y":[1]}},{"t":34,"s":[100,100,100]}]},"sk":{"a":1,"k":[{"t":13,"s":[0],"o":{"x":[0.42],"y":[0]},"i":{"x":[0.58],"y":[1]}},{"t":18,"s":[4],"o":{"x":[0.42],"y":[0]},"i":{"x":[0.58],"y":[1]}},{"t":28,"s":[0],"o":{"x":[0.42],"y":[0]},"i":{"x":[0.58],"y":[1]}},{"t":34,"s":[0]}]},"sa":{"a":0,"k":0}},"ao":0,"shapes":[{"ty":"gr","it":[{"ty":"sh","d":1,"ks":{"a":0,"k":{"i":[[0,0],[0,0],[0.48,-0.43],[0.91,0],[0,0],[0.5,0.42],[0,0.77],[0,0],[-0.31,0.37],[-0.48,0.08],[0,0],[0,0],[0.26,0.35],[0,0.47],[0,0],[-0.47,0.39],[-0.79,0],[0,0],[-0.47,-0.38],[0,-0.65],[0,0],[0.26,-0.35],[0.45,-0.09],[0,0],[0,0],[-0.29,-0.36],[0,-0.54]],"o":[[0,0],[0,0.76],[-0.48,0.43],[0,0],[-0.88,0],[-0.5,-0.42],[0,0],[0,-0.54],[0.31,-0.37],[0,0],[0,0],[-0.45,-0.11],[-0.26,-0.35],[0,0],[0,-0.63],[0.47,-0.39],[0,0],[0.81,0],[0.47,0.38],[0,0],[0,0.47],[-0.26,0.35],[0,0],[0,0],[0.53,0.09],[0.29,0.36],[0,0]],"v":[[22.32,20.33],[22.32,20.33],[21.59,22.11],[19.5,22.75],[19.5,22.75],[17.43,22.12],[16.68,20.34],[16.68,20.34],[17.15,18.98],[18.33,18.31],[18.33,18.31],[18.33,18.29],[17.27,17.6],[16.88,16.37],[16.88,16.37],[17.58,14.84],[19.48,14.25],[19.48,14.25],[21.41,14.82],[22.11,16.38],[22.11,16.38],[21.72,17.61],[20.65,18.28],[20.65,18.28],[20.65,18.3],[21.88,18.97],[22.32,20.33]],"c":true}}},{"ty":"sh","d":1,"ks":{"a":0,"k":{"i":[[0,0],[0,0],[1.02,0],[0,0],[0.26,-0.23],[0,-0.47],[0,0],[-0.27,-0.25],[-0.49,0],[0,0],[-0.26,0.23],[0,0.51]],"o":[[0,0],[0,-0.93],[0,0],[-0.5,0],[-0.26,0.23],[0,0],[0,0.47],[0.27,0.25],[0,0],[0.5,0],[0.26,-0.23],[0,0]],"v":[[21.02,16.44],[21.02,16.44],[19.48,15.04],[19.48,15.04],[18.35,15.39],[17.96,16.44],[17.96,16.44],[18.36,17.52],[19.49,17.89],[19.49,17.89],[20.63,17.55],[21.02,16.44]],"c":true}}},{"ty":"sh","d":1,"ks":{"a":0,"k":{"i":[[0,0],[0,0],[0.3,0.26],[0.55,0],[0,0],[0.3,-0.28],[0,-0.49],[0,0],[-1.16,0],[0,0],[-0.28,0.28],[0,0.6]],"o":[[0,0],[0,-0.51],[-0.3,-0.26],[0,0],[-0.54,0],[-0.3,0.28],[0,0],[0,1.14],[0,0],[0.57,0],[0.28,-0.28],[0,0]],"v":[[21.22,20.23],[21.22,20.23],[20.76,19.07],[19.48,18.68],[19.48,18.68],[18.23,19.1],[17.77,20.25],[17.77,20.25],[19.51,21.96],[19.51,21.96],[20.8,21.55],[21.22,20.23]],"c":true}}},{"ty":"fl","c":{"a":1,"k":[{"t":11,"s":[0.1,0.1,0.1,1],"o":{"x":[0.42],"y":[0]},"i":{"x":[0.58],"y":[1]}},{"t":18,"s":[0.26,0.1,0.04,1],"o":{"x":[0.42],"y":[0]},"i":{"x":[0.58],"y":[1]}},{"t":28,"s":[0.1,0.1,0.1,1],"o":{"x":[0.42],"y":[0]},"i":{"x":[0.58],"y":[1]}},{"t":34,"s":[0.1,0.1,0.1,1]}]},"o":{"a":0,"k":100},"r":1,"bm":0},{"ty":"st","c":{"a":0,"k":[0.1,0.1,0.1]},"o":{"a":0,"k":100},"w":{"a":0,"k":0.5},"lc":2,"lj":2,"ml":4,"bm":0},{"ty":"tr","p":{"a":0,"k":[0,0]},"a":{"a":0,"k":[0,0]},"s":{"a":0,"k":[100,100]},"r":{"a":0,"k":0},"o":{"a":0,"k":100}}],"nm":"score"},{"ty":"gr","it":[{"ty":"sh","d":1,"ks":{"a":0,"k":{"i":[[0,0],[0,0],[1.6,-0.99],[0.66,-1.74],[0,0],[0,0],[-2.22,1.29],[-3.31,0],[0,0],[-2.68,-2.81],[0,-4.96],[0,0],[1.19,-2.43],[2.27,-1.31],[3.16,0],[0,0],[2.71,5.47],[0,0],[0,0],[-1.69,-1.13],[-2.13,0],[0,0],[-1.96,1.99],[-0.28,3.38],[0,0],[0,0],[0,0],[0,0],[1.89,1.86],[3.13,0]],"o":[[0,0],[-2.24,0],[-1.6,0.99],[0,0],[0,0],[1.04,-2.55],[2.22,-1.29],[0,0],[4.74,0],[2.68,2.81],[0,0],[0,3.28],[-1.19,2.43],[-2.27,1.31],[0,0],[-6.27,0],[0,0],[0,0],[1.02,2.06],[1.69,1.13],[0,0],[3.13,0],[1.96,-1.99],[0,0],[0,0],[0,0],[0,0],[-0.31,-3.37],[-1.89,-1.86],[0,0]],"v":[[-0.88,-13.56],[-0.88,-13.56],[-6.64,-12.07],[-10.04,-7.97],[-10.04,-7.97],[-14.04,-9.3],[-9.14,-15.06],[-0.84,-17],[-0.84,-17],[10.28,-12.78],[14.3,-1.13],[14.3,-1.13],[12.51,7.43],[7.32,13.04],[-0.84,15],[-0.84,15],[-14.3,6.79],[-14.3,6.79],[-10.86,5.07],[-6.8,9.85],[-1.06,11.54],[-1.06,11.54],[6.59,8.54],[9.95,0.48],[9.95,0.48],[-4.48,0.48],[-4.48,-2.92],[9.95,-2.92],[6.65,-10.77],[-0.88,-13.56]],"c":true}}},{"ty":"fl","c":{"a":1,"k":[{"t":11,"s":[0.1,0.1,0.1,1],"o":{"x":[0.42],"y":[0]},"i":{"x":[0.58],"y":[1]}},{"t":18,"s":[0.26,0.1,0.04,1],"o":{"x":[0.42],"y":[0]},"i":{"x":[0.58],"y":[1]}},{"t":28,"s":[0.1,0.1,0.1,1],"o":{"x":[0.42],"y":[0]},"i":{"x":[0.58],"y":[1]}},{"t":34,"s":[0.1,0.1,0.1,1]}]},"o":{"a":0,"k":100},"r":1,"bm":0},{"ty":"st","c":{"a":0,"k":[0.1,0.1,0.1]},"o":{"a":0,"k":100},"w":{"a":0,"k":1},"lc":2,"lj":2,"ml":4,"bm":0},{"ty":"tr","p":{"a":0,"k":[0,0]},"a":{"a":0,"k":[0,0]},"s":{"a":0,"k":[100,100]},"r":{"a":0,"k":0},"o":{"a":0,"k":100}}],"nm":"E"},{"ty":"gr","it":[{"ty":"sh","d":1,"ks":{"a":1,"k":[{"t":13,"s":[{"i":[[-6.67,-0.06],[-0.5,-0.14],[-0.45,-0.26],[-0.37,-0.37],[-0.26,-0.45],[-0.14,-0.5],[0,-0.52],[0,-6.67],[0.06,-6.67],[0.14,-0.5],[0.26,-0.45],[0.37,-0.37],[0.45,-0.26],[0.5,-0.14],[0.52,0],[6.67,0],[6.67,0.06],[0.5,0.14],[0.45,0.26],[0.37,0.37],[0.26,0.45],[0.14,0.5],[0,0.52],[0,6.67],[-0.06,6.67],[-0.14,0.5],[-0.26,0.45],[-0.37,0.37],[-0.45,0.26],[-0.5,0.14],[-0.52,0],[-6.67,0]],"o":[[0.52,0],[0.5,0.14],[0.45,0.26],[0.37,0.37],[0.26,0.45],[0.14,0.5],[0.06,6.67],[0,6.67],[0,0.52],[-0.14,0.5],[-0.26,0.45],[-0.37,0.37],[-0.45,0.26],[-0.5,0.14],[-6.67,0.06],[-6.67,0],[-0.52,0],[-0.5,-0.14],[-0.45,-0.26],[-0.37,-0.37],[-0.26,-0.45],[-0.14,-0.5],[-0.06,-6.67],[0,-6.67],[0,-0.52],[0.14,-0.5],[0.26,-0.45],[0.37,-0.37],[0.45,-0.26],[0.5,-0.14],[6.67,-0.06],[6.67,0]],"v":[[20,-26],[21.55,-25.8],[23,-25.2],[24.24,-24.24],[25.2,-23],[25.8,-21.55],[26,-20],[26,0],[26,20],[25.8,21.55],[25.2,23],[24.24,24.24],[23,25.2],[21.55,25.8],[20,26],[0,26],[-20,26],[-21.55,25.8],[-23,25.2],[-24.24,24.24],[-25.2,23],[-25.8,21.55],[-26,20],[-26,0],[-26,-20],[-25.8,-21.55],[-25.2,-23],[-24.24,-24.24],[-23,-25.2],[-21.55,-25.8],[-20,-26],[0,-26]],"c":true}],"o":{"x":[0.42],"y":[0]},"i":{"x":[0.58],"y":[1]}},{"t":18,"s":[{"i":[[-6.67,-0.06],[-0.53,-0.13],[-0.52,-0.26],[-0.47,-0.37],[-0.39,-0.46],[-0.27,-0.52],[-0.08,-0.55],[0,-6.71],[0.91,-6.65],[0.26,-0.49],[0.38,-0.44],[0.47,-0.37],[0.54,-0.26],[0.56,-0.14],[0.54,-0.01],[6.67,0],[6.67,0.06],[0.53,0.13],[0.52,0.26],[0.47,0.37],[0.39,0.46],[0.27,0.52],[0.08,0.55],[0,6.71],[-0.91,6.65],[-0.26,0.49],[-0.38,0.44],[-0.47,0.37],[-0.54,0.26],[-0.56,0.14],[-0.54,0.01],[-6.67,0]],"o":[[0.54,0.01],[0.56,0.14],[0.54,0.26],[0.47,0.37],[0.38,0.44],[0.26,0.49],[0.91,6.65],[0,6.71],[-0.08,0.55],[-0.27,0.52],[-0.39,0.46],[-0.47,0.37],[-0.52,0.26],[-0.53,0.13],[-6.67,0.06],[-6.67,0],[-0.54,-0.01],[-0.56,-0.14],[-0.54,-0.26],[-0.47,-0.37],[-0.38,-0.44],[-0.26,-0.49],[-0.91,-6.65],[0,-6.71],[0.08,-0.55],[0.27,-0.52],[0.39,-0.46],[0.47,-0.37],[0.52,-0.26],[0.53,-0.13],[6.67,-0.06],[6.67,0]],"v":[[20,-26],[21.62,-25.8],[23.24,-25.2],[24.77,-24.24],[26.07,-23],[27.05,-21.55],[27.63,-20],[30,0],[27.63,20],[27.05,21.55],[26.07,23],[24.77,24.24],[23.24,25.2],[21.62,25.8],[20,26],[0,26],[-20,26],[-21.62,25.8],[-23.24,25.2],[-24.77,24.24],[-26.07,23],[-27.05,21.55],[-27.63,20],[-30,0],[-27.63,-20],[-27.05,-21.55],[-26.07,-23],[-24.77,-24.24],[-23.24,-25.2],[-21.62,-25.8],[-20,-26],[0,-26]],"c":true}],"o":{"x":[0.42],"y":[0]},"i":{"x":[0.58],"y":[1]}},{"t":28,"s":[{"i":[[-6.67,-0.06],[-0.5,-0.14],[-0.45,-0.26],[-0.37,-0.37],[-0.26,-0.45],[-0.14,-0.5],[0,-0.52],[0,-6.67],[0.06,-6.67],[0.14,-0.5],[0.26,-0.45],[0.37,-0.37],[0.45,-0.26],[0.5,-0.14],[0.52,0],[6.67,0],[6.67,0.06],[0.5,0.14],[0.45,0.26],[0.37,0.37],[0.26,0.45],[0.14,0.5],[0,0.52],[0,6.67],[-0.06,6.67],[-0.14,0.5],[-0.26,0.45],[-0.37,0.37],[-0.45,0.26],[-0.5,0.14],[-0.52,0],[-6.67,0]],"o":[[0.52,0],[0.5,0.14],[0.45,0.26],[0.37,0.37],[0.26,0.45],[0.14,0.5],[0.06,6.67],[0,6.67],[0,0.52],[-0.14,0.5],[-0.26,0.45],[-0.37,0.37],[-0.45,0.26],[-0.5,0.14],[-6.67,0.06],[-6.67,0],[-0.52,0],[-0.5,-0.14],[-0.45,-0.26],[-0.37,-0.37],[-0.26,-0.45],[-0.14,-0.5],[-0.06,-6.67],[0,-6.67],[0,-0.52],[0.14,-0.5],[0.26,-0.45],[0.37,-0.37],[0.45,-0.26],[0.5,-0.14],[6.67,-0.06],[6.67,0]],"v":[[20,-26],[21.55,-25.8],[23,-25.2],[24.24,-24.24],[25.2,-23],[25.8,-21.55],[26,-20],[26,0],[26,20],[25.8,21.55],[25.2,23],[24.24,24.24],[23,25.2],[21.55,25.8],[20,26],[0,26],[-20,26],[-21.55,25.8],[-23,25.2],[-24.24,24.24],[-25.2,23],[-25.8,21.55],[-26,20],[-26,0],[-26,-20],[-25.8,-21.55],[-25.2,-23],[-24.24,-24.24],[-23,-25.2],[-21.55,-25.8],[-20,-26],[0,-26]],"c":true}],"o":{"x":[0.42],"y":[0]},"i":{"x":[0.58],"y":[1]}},{"t":34,"s":[{"i":[[-6.67,-0.06],[-0.5,-0.14],[-0.45,-0.26],[-0.37,-0.37],[-0.26,-0.45],[-0.14,-0.5],[0,-0.52],[0,-6.67],[0.06,-6.67],[0.14,-0.5],[0.26,-0.45],[0.37,-0.37],[0.45,-0.26],[0.5,-0.14],[0.52,0],[6.67,0],[6.67,0.06],[0.5,0.14],[0.45,0.26],[0.37,0.37],[0.26,0.45],[0.14,0.5],[0,0.52],[0,6.67],[-0.06,6.67],[-0.14,0.5],[-0.26,0.45],[-0.37,0.37],[-0.45,0.26],[-0.5,0.14],[-0.52,0],[-6.67,0]],"o":[[0.52,0],[0.5,0.14],[0.45,0.26],[0.37,0.37],[0.26,0.45],[0.14,0.5],[0.06,6.67],[0,6.67],[0,0.52],[-0.14,0.5],[-0.26,0.45],[-0.37,0.37],[-0.45,0.26],[-0.5,0.14],[-6.67,0.06],[-6.67,0],[-0.52,0],[-0.5,-0.14],[-0.45,-0.26],[-0.37,-0.37],[-0.26,-0.45],[-0.14,-0.5],[-0.06,-6.67],[0,-6.67],[0,-0.52],[0.14,-0.5],[0.26,-0.45],[0.37,-0.37],[0.45,-0.26],[0.5,-0.14],[6.67,-0.06],[6.67,0]],"v":[[20,-26],[21.55,-25.8],[23,-25.2],[24.24,-24.24],[25.2,-23],[25.8,-21.55],[26,-20],[26,0],[26,20],[25.8,21.55],[25.2,23],[24.24,24.24],[23,25.2],[21.55,25.8],[20,26],[0,26],[-20,26],[-21.55,25.8],[-23,25.2],[-24.24,24.24],[-25.2,23],[-25.8,21.55],[-26,20],[-26,0],[-26,-20],[-25.8,-21.55],[-25.2,-23],[-24.24,-24.24],[-23,-25.2],[-21.55,-25.8],[-20,-26],[0,-26]],"c":true}]}]}},{"ty":"fl","c":{"a":1,"k":[{"t":11,"s":[0.85,0.73,0.47,1],"o":{"x":[0.42],"y":[0]},"i":{"x":[0.58],"y":[1]}},{"t":18,"s":[0.91,0.8,0.58,1],"o":{"x":[0.42],"y":[0]},"i":{"x":[0.58],"y":[1]}},{"t":28,"s":[0.85,0.73,0.47,1],"o":{"x":[0.42],"y":[0]},"i":{"x":[0.58],"y":[1]}},{"t":34,"s":[0.85,0.73,0.47,1]}]},"o":{"a":0,"k":100},"r":1,"bm":0},{"ty":"st","c":{"a":0,"k":[0.71,0.58,0.35]},"o":{"a":0,"k":100},"w":{"a":0,"k":1.8},"lc":2,"lj":2,"ml":4,"bm":0},{"ty":"tr","p":{"a":0,"k":[0,0]},"a":{"a":0,"k":[0,0]},"s":{"a":0,"k":[100,100]},"r":{"a":0,"k":0},"o":{"a":0,"k":100}}],"nm":"face"}],"ip":0,"op":34,"st":0,"bm":0}],"markers":[]} \ No newline at end of file From 6636d7c309c9403a6137bb853be579c5203e1955 Mon Sep 17 00:00:00 2001 From: Ilia Denisov Date: Tue, 30 Jun 2026 21:48:56 +0200 Subject: [PATCH 04/71] feat(ui): first-run onboarding coachmarks A one-time coachmark overlay walks a new player through the lobby and their first game board: a light dimmed layer draws one tail-pointed hint bubble at a time, advancing on a tap anywhere and removing itself for good after the last hint. Two independent series (lobby: settings/stats/new game; game: header/pass-exchange/hints/shuffle/rack), gated by a per-device persisted flag and marked done only after the last hint, so an interrupted run replays from the start. A deep-link into Settings -> Friends still triggers the lobby series on the first trip back to the lobby. Targets carry a data-coach attribute, so one positioning engine anchors the bubble in both portrait and landscape, re-measuring each frame until the geometry settles (route slide, hidden-banner reflow, fonts). The promo banner hides while the overlay is up (app.coachActive); a hidden DebugPanel "Reset visited" control replays the walk-through. Off by default in the mock build so the Playwright smoke is unaffected; ?coach forces it on for the dedicated e2e. Pure geometry (step lists, nextVisibleStep, placeBubble) in lib/coachmark.ts (unit-tested); Coachmark.svelte renders. Docs: FUNCTIONAL(+ru) onboarding story, UI_DESIGN coachmark section. --- docs/FUNCTIONAL.md | 14 ++ docs/FUNCTIONAL_ru.md | 15 ++ docs/UI_DESIGN.md | 29 ++- ui/e2e/onboarding.spec.ts | 48 ++++ ui/src/App.svelte | 2 + ui/src/components/Coachmark.svelte | 330 ++++++++++++++++++++++++++++ ui/src/components/DebugPanel.svelte | 33 ++- ui/src/components/Header.svelte | 7 +- ui/src/game/Game.svelte | 10 +- ui/src/lib/app.svelte.ts | 42 +++- ui/src/lib/coachmark.test.ts | 90 ++++++++ ui/src/lib/coachmark.ts | 149 +++++++++++++ ui/src/lib/i18n/en.ts | 11 + ui/src/lib/i18n/ru.ts | 10 + ui/src/lib/session.ts | 27 +++ ui/src/screens/Lobby.svelte | 6 +- 16 files changed, 809 insertions(+), 14 deletions(-) create mode 100644 ui/e2e/onboarding.spec.ts create mode 100644 ui/src/components/Coachmark.svelte create mode 100644 ui/src/lib/coachmark.test.ts create mode 100644 ui/src/lib/coachmark.ts diff --git a/docs/FUNCTIONAL.md b/docs/FUNCTIONAL.md index a554993..7393042 100644 --- a/docs/FUNCTIONAL.md +++ b/docs/FUNCTIONAL.md @@ -25,6 +25,20 @@ theme, and links to the matching per-language Telegram channel; the game itself `/app/` (web), `/telegram/` (the Telegram Mini App) and `/vk/` (the VK Mini App). The landing's theme is ephemeral (it follows the system scheme, not the saved preference); its language choice is saved. +### First-run onboarding +The first time a player opens the app it walks them through the interface with a light +**coachmark overlay**: a dimmed layer draws one hint bubble at a time, each pointing — with a +tail — at a real control, and a **tap anywhere** advances to the next; after the last hint the +overlay is gone for good. Two independent series run. The **lobby** series points at the +⚙️ settings, ✏️ statistics and 🎲 new-game tabs. The **game** series, on the player's first game +board, points at the scoreboard header (move history / chat / word-check), the 🔄 pass-and-exchange, +🛟 hints and 🔀 shuffle controls, and the rack. A series counts as seen only **after its last hint**, +so leaving mid-way replays it from the start next time; it is remembered per device. Arriving at the +lobby is the lobby trigger, so a player who deep-links straight into Settings → Friends still gets the +lobby walk-through on their first trip back. Both series work the same in portrait and landscape (the +bubbles re-anchor to wherever the controls move) and the hint texts are localized (en/ru). The +advertising banner hides while the overlay is up and returns when it closes. + ### Identity & sessions A player arrives from a platform (Telegram first), via email login, or as an ephemeral guest. The gateway validates the credential once and mints a thin diff --git a/docs/FUNCTIONAL_ru.md b/docs/FUNCTIONAL_ru.md index 52c2f66..29225cd 100644 --- a/docs/FUNCTIONAL_ru.md +++ b/docs/FUNCTIONAL_ru.md @@ -26,6 +26,21 @@ top-1 подсказку, безлимитную проверку слова с `/app/` (веб), `/telegram/` (Telegram Mini App) и `/vk/` (VK Mini App). Тема на странице эфемерна (берётся из системной настройки, а не из сохранённой), выбор языка сохраняется. +### Первый запуск: онбординг +При первом открытии приложения игрока один раз проводят по интерфейсу лёгким +**coachmark-оверлеем**: затемнённый слой показывает по одной подсказке-«облачку» за раз, каждое +«хвостиком» указывает на реальный элемент управления, а **тап в любом месте** переходит к +следующей; после последней подсказки оверлей убирается навсегда. Серий две. Серия **лобби** +указывает на вкладки ⚙️ настроек, ✏️ статистики и 🎲 новой игры. Серия **игры**, на первой партии +игрока, указывает на шапку со счётом (история ходов / чат / проверка слова), кнопки 🔄 пас-и-обмен, +🛟 подсказок и 🔀 перемешивания, и на стойку с фишками. Серия считается просмотренной только +**после последней подсказки**, поэтому выход на середине в следующий раз покажет её с начала; +запоминается она по устройству. Триггер серии лобби — попадание в лобби, так что игрок, пришедший +по deeplink сразу в Настройки → Друзья, всё равно получит проводку по лобби при первом возврате. +Обе серии одинаково работают в portrait и landscape (облачка переустанавливаются туда, куда +переезжают элементы), тексты подсказок локализованы (en/ru). Рекламный баннер скрывается, пока +оверлей показан, и возвращается по закрытию. + ### Личность и сессии Игрок приходит с платформы (сначала Telegram), через email-вход или как эфемерный гость. Gateway один раз валидирует доступ и выдаёт тонкий diff --git a/docs/UI_DESIGN.md b/docs/UI_DESIGN.md index 10c523d..387f68d 100644 --- a/docs/UI_DESIGN.md +++ b/docs/UI_DESIGN.md @@ -49,6 +49,32 @@ fast load shows it for ~1.25 s. Each tile **drops in** with a brief scale + fade dismisses as soon as the lobby is ready. The pure layout and timing live in `lib/splash.ts` (unit-tested); `Splash.svelte` is the renderer. +## First-run onboarding coachmarks (`components/Coachmark.svelte`, `lib/coachmark.ts`) + +A one-time **coachmark overlay** introduces the interface to a new player. Like the splash it is an +**App-level overlay**; it runs two independent series chosen by the route — **lobby** (⚙️ settings → +✏️ stats → 🎲 new game) and **game** (scoreboard header → 🔄 pass/exchange → 🛟 hints → 🔀 shuffle → +rack). It draws **one bubble at a time** over a light scrim (`rgba(0,0,0,0.32)`); the whole layer +captures pointer events, so a **tap anywhere advances** and the UI underneath stays inert. After the +last hint the series is recorded done (`session.ts` `onboarding` key) and the overlay removes itself; +a series is marked only after its **last** hint, so an interrupted player replays it from the start. +The lobby series gates on `app.splashDone`, the game series on its first target laying out; both defer +while a modal (`welcomeRedeem` / `staleInvite`) is open. + +Each target carries a **`data-coach` attribute**, so the **same positioning engine anchors it in both +orientations** wherever the layout moves it (the tab bar to the landscape left panel, etc.). The +bubble points at the live `getBoundingClientRect()` of its target, **re-measuring each frame until the +geometry settles** (the route-change slide, the hidden-banner reflow, async fonts) and on resize / +rotation; a target absent in the current state is skipped. The bubble matches the in-game counter text +size (`0.85rem`, larger in landscape), wraps by word and never fills the width; its **tail** sits on +the edge facing the target, centred on it (clamped near a corner). The pure geometry (step lists, +`nextVisibleStep`, `placeBubble`) lives in `lib/coachmark.ts` (unit-tested); `Coachmark.svelte` is the +renderer. While the overlay is up the **advertising banner is hidden** (`app.coachActive`) so its +scroll does not run behind the scrim. The hidden DebugPanel offers a **Reset visited** control that +clears the flags so the walk-through replays on the next launch. In the **mock build** the overlay +does not auto-show (so it cannot block the Playwright smoke); `?coach` forces it on for the dedicated +e2e and the screenshots. + ## Navigation - **Back**: a thin, compact `<` drawn from two rotated CSS borders (`Header.svelte` @@ -253,7 +279,8 @@ the surroundings in the light theme and a touch lighter in the dark theme, mappe linkified). It is **server-driven**: the campaigns and display timings ride the `profile.get` response (`app.profile.banner`, present only for an eligible viewer — see ARCHITECTURE §10), so `Header` renders the strip only when that block is present, and a `notify` `banner` event re-fetches -the profile to show or hide it in place. +the profile to show or hide it in place. It is also hidden while a first-run onboarding overlay is up +(`app.coachActive`), reappearing by this same condition once the overlay closes. The rotation runs in a **persistent module engine** (`lib/bannerEngine`): the scheduler and timer live outside the components, so navigating between screens (which remounts the view) **continues the diff --git a/ui/e2e/onboarding.spec.ts b/ui/e2e/onboarding.spec.ts new file mode 100644 index 0000000..c91b816 --- /dev/null +++ b/ui/e2e/onboarding.spec.ts @@ -0,0 +1,48 @@ +import { expect, test, type Page } from './fixtures'; + +// The first-run onboarding coachmarks. `?coach` forces the overlay on in the mock build (where it +// is otherwise off so it cannot eat the smoke's taps) and bypasses the persisted "seen" flag, so +// the series replay deterministically here. A tap anywhere on the overlay advances; after the last +// hint the overlay removes itself. + +// Tap the overlay near a corner (clear of the bubble) to advance to the next hint. +async function advance(page: Page): Promise { + await page.locator('[data-coach-overlay]').click({ position: { x: 5, y: 5 } }); +} + +// Drive both series end to end: guest login -> lobby coachmarks (3) -> open the seeded game -> +// game coachmarks (5) -> gone. Shared by the portrait and landscape projects/cases. +async function runOnboarding(page: Page): Promise { + const overlay = page.locator('[data-coach-overlay]'); + + await page.goto('/?coach=1'); + await page.getByRole('button', { name: /guest/i }).click(); + + // Lobby series: settings -> stats -> new game. + await expect(overlay).toBeVisible({ timeout: 10000 }); + await advance(page); + await advance(page); + await advance(page); + await expect(overlay).toBeHidden(); + + // The lobby is interactive again: open the seeded active game. + await page.getByRole('button', { name: /Ann/ }).click(); + await expect(page.locator('[data-cell]').first()).toBeVisible(); + + // Game series: header -> pass/exchange -> hints -> shuffle -> rack. + await expect(overlay).toBeVisible({ timeout: 10000 }); + for (let i = 0; i < 5; i++) await advance(page); + await expect(overlay).toBeHidden(); +} + +test('onboarding walks the lobby then the first game (portrait)', async ({ page }) => { + await runOnboarding(page); +}); + +test.describe('landscape', () => { + test.use({ viewport: { width: 1100, height: 640 } }); + + test('onboarding anchors and advances in the wide layout', async ({ page }) => { + await runOnboarding(page); + }); +}); diff --git a/ui/src/App.svelte b/ui/src/App.svelte index bba6053..e337352 100644 --- a/ui/src/App.svelte +++ b/ui/src/App.svelte @@ -8,6 +8,7 @@ import Splash from './components/Splash.svelte'; import StaleInviteModal from './components/StaleInviteModal.svelte'; import WelcomeRedeemModal from './components/WelcomeRedeemModal.svelte'; + import Coachmark from './components/Coachmark.svelte'; import DebugPanel from './components/DebugPanel.svelte'; import Login from './screens/Login.svelte'; import Lobby from './screens/Lobby.svelte'; @@ -120,6 +121,7 @@ + {#if routeIsLobby && !app.splashDone && !app.blocked && !app.bootError && !app.launchError} diff --git a/ui/src/components/Coachmark.svelte b/ui/src/components/Coachmark.svelte new file mode 100644 index 0000000..1e70172 --- /dev/null +++ b/ui/src/components/Coachmark.svelte @@ -0,0 +1,330 @@ + + +{#if activeSeries && stepIndex >= 0} + + +
+
+ {t(stepsFor(activeSeries)[stepIndex].key)} + +
+
+{/if} + + diff --git a/ui/src/components/DebugPanel.svelte b/ui/src/components/DebugPanel.svelte index 0513084..8bdf6b0 100644 --- a/ui/src/components/DebugPanel.svelte +++ b/ui/src/components/DebugPanel.svelte @@ -4,7 +4,7 @@ // snapshot (no secrets, no initData values, no IP) and shares it through the OS share sheet (or a // clipboard copy on desktop) — a support aid for reproducing client-specific issues, e.g. the // Telegram Android presentation quirks. Drawn from the top, just under the app header. - import { app, closeDebug } from '../lib/app.svelte'; + import { app, closeDebug, resetOnboarding } from '../lib/app.svelte'; import { connection } from '../lib/connection.svelte'; import { shareText } from '../lib/share'; import { telegramChromeDiag } from '../lib/telegram'; @@ -26,12 +26,25 @@ setTimeout(() => (label = 'Share'), 1500); } } + + // Clear the first-run coachmark "seen" flags so the onboarding replays on the next launch — a + // manual re-test aid. Stops the click from also closing the panel, like the Share control. + let resetLabel = $state('Reset visited'); + function resetVisited(e: MouseEvent): void { + e.stopPropagation(); + resetOnboarding(); + resetLabel = 'Cleared — relaunch'; + setTimeout(() => (resetLabel = 'Reset visited'), 1800); + }
- +
+ + +
{report}
@@ -49,6 +62,12 @@ gap: 10px; align-items: flex-start; } + .actions { + flex: 0 0 auto; + display: flex; + gap: 10px; + flex-wrap: wrap; + } .share { flex: 0 0 auto; padding: 7px 16px; @@ -58,6 +77,16 @@ border-radius: var(--radius-sm); font-size: 0.95rem; } + /* Secondary (outline) styling so it reads as a utility next to the primary Share action. */ + .reset { + flex: 0 0 auto; + padding: 7px 16px; + border: 1px solid var(--accent); + background: transparent; + color: var(--accent); + border-radius: var(--radius-sm); + font-size: 0.95rem; + } .body { margin: 0; width: 100%; diff --git a/ui/src/components/Header.svelte b/ui/src/components/Header.svelte index 7d79b29..67b8e6c 100644 --- a/ui/src/components/Header.svelte +++ b/ui/src/components/Header.svelte @@ -48,8 +48,11 @@ - {#if app.profile?.banner && app.profile.banner.campaigns.length} + it (banner under the title, board pinned to the bottom). It is hidden while a first-run + coachmark overlay is up (app.coachActive) so the scrolling strip does not run behind the + dimmed onboarding layer; the engine keeps rotating (module scope) and the strip reappears, + per this same condition, once onboarding closes. --> + {#if app.profile?.banner && app.profile.banner.campaigns.length && !app.coachActive} -
+
{#if badge}{/if} {#each view.game.seats as s (s.seat)}
@@ -1250,7 +1250,7 @@ {#snippet rackRow()} -
+
- 🔄{t('game.draw')} + 🔄{t('game.draw')} - 🛟{#if hintCount > 0}{hintCount}{/if} + 🛟{#if hintCount > 0}{hintCount}{/if} {t('game.hint')} {#if placement.pending.length > 0} @@ -1288,7 +1288,7 @@ {:else} {/if} {/snippet} diff --git a/ui/src/lib/app.svelte.ts b/ui/src/lib/app.svelte.ts index 2a9de50..980a6d3 100644 --- a/ui/src/lib/app.svelte.ts +++ b/ui/src/lib/app.svelte.ts @@ -35,7 +35,18 @@ import { import { onVKPath, insideVK, vkInit, vkLaunchParams, vkUserName, vkStartParam, vkOnScheme, vkOnInsets } from './vk'; import { CLOUD_PREFS_KEY, decodeClientPrefs, encodeClientPrefs } from './cloudprefs'; import { parseStartParam } from './deeplink'; -import { clearSession, loadPrefs, loadSession, saveSession, savePrefs } from './session'; +import { + clearOnboarding, + clearSession, + loadOnboarding, + loadPrefs, + loadSession, + type OnboardingState, + saveOnboarding, + saveSession, + savePrefs, +} from './session'; +import type { CoachSeries } from './coachmark'; import { connection, reportOffline, reportOnline, resetConnection } from './connection.svelte'; import { isConnectionCode } from './retry'; import { clearGameCache, getCachedGame, setCachedGame } from './gamecache'; @@ -86,6 +97,12 @@ export const app = $state<{ locale: Locale; reduceMotion: boolean; boardLabels: BoardLabelMode; + /** Completion flags for the two first-run coachmark series (components/Coachmark.svelte). */ + onboarding: OnboardingState; + /** Whether a first-run coachmark overlay is currently on screen. While set, the scrolling promo + * banner (components/PromoBanner) hides so it does not run above the dimmed onboarding layer, + * and reappears (per its own show logic) once onboarding closes. */ + coachActive: boolean; /** Pending incoming friend requests, for the lobby ⚙️ badge and the Settings Friends tab. */ notifications: number; /** Per-game flag: the player has at least one unread chat entry (message or nudge) in that @@ -128,6 +145,8 @@ export const app = $state<{ locale: 'en', reduceMotion: false, boardLabels: 'beginner', + onboarding: { lobbyDone: false, gameDone: false }, + coachActive: false, notifications: 0, chatUnread: {}, messageUnread: {}, @@ -609,6 +628,7 @@ export async function bootstrap(): Promise { app.theme = prefs.theme ?? 'auto'; app.reduceMotion = prefs.reduceMotion ?? false; app.boardLabels = prefs.boardLabels ?? 'beginner'; + app.onboarding = await loadOnboarding(); applyTheme(app.theme); applyReduceMotion(app.reduceMotion); if (prefs.locale) { @@ -904,6 +924,26 @@ export async function logout(): Promise { navigate('/login'); } +/** + * markOnboardingDone records that a first-run coachmark series has been completed (its last hint + * was shown) and persists the flag, so the series never replays for this client. A series + * interrupted before its last hint is left unmarked and replays from the start next launch. + */ +export function markOnboardingDone(series: CoachSeries): void { + if (series === 'lobby') app.onboarding.lobbyDone = true; + else app.onboarding.gameDone = true; + void saveOnboarding({ ...app.onboarding }); +} + +/** + * resetOnboarding clears both coachmark completion flags (the hidden DebugPanel control), so the + * onboarding replays on the next launch — a manual re-test aid. + */ +export function resetOnboarding(): void { + app.onboarding = { lobbyDone: false, gameDone: false }; + void clearOnboarding(); +} + function persistPrefs(): void { void savePrefs({ theme: app.theme, diff --git a/ui/src/lib/coachmark.test.ts b/ui/src/lib/coachmark.test.ts new file mode 100644 index 0000000..cf314cf --- /dev/null +++ b/ui/src/lib/coachmark.test.ts @@ -0,0 +1,90 @@ +import { describe, expect, it } from 'vitest'; +import { + GAME_STEPS, + LOBBY_STEPS, + nextVisibleStep, + placeBubble, + stepsFor, + type Rect, + type Viewport, +} from './coachmark'; + +describe('stepsFor', () => { + it('returns the owner-defined order for each series', () => { + expect(stepsFor('lobby')).toBe(LOBBY_STEPS); + expect(stepsFor('game')).toBe(GAME_STEPS); + expect(LOBBY_STEPS.map((s) => s.target)).toEqual(['lobby-settings', 'lobby-stats', 'lobby-new']); + expect(GAME_STEPS.map((s) => s.target)).toEqual(['game-header', 'game-turn', 'game-hints', 'game-shuffle', 'game-rack']); + }); +}); + +describe('nextVisibleStep', () => { + const present = () => true; + it('returns the start index when its target is present', () => { + expect(nextVisibleStep(LOBBY_STEPS, 0, present)).toBe(0); + expect(nextVisibleStep(LOBBY_STEPS, 1, present)).toBe(1); + }); + + it('skips steps whose target is absent', () => { + const isPresent = (t: string) => t !== 'game-hints' && t !== 'game-shuffle'; + // From the hints step (index 2), both hints and shuffle are absent, so it lands on the rack. + expect(nextVisibleStep(GAME_STEPS, 2, isPresent)).toBe(4); + }); + + it('returns -1 when no further step is visible', () => { + expect(nextVisibleStep(GAME_STEPS, 5, present)).toBe(-1); + expect(nextVisibleStep(GAME_STEPS, 0, () => false)).toBe(-1); + }); +}); + +describe('placeBubble', () => { + const portrait: Viewport = { width: 390, height: 844 }; + const bubble = { width: 280, height: 80 }; + + it('places the bubble below a target at the top (scoreboard strip)', () => { + const target: Rect = { left: 0, top: 50, width: 390, height: 40 }; + const p = placeBubble(target, portrait, bubble); + expect(p.side).toBe('bottom'); + expect(p.top).toBe(100); // 50 + 40 + gap(10) + expect(p.left).toBe(55); // centred under the target, room to spare + expect(p.tail).toBeGreaterThan(8); + }); + + it('places the bubble above a bottom tab-bar target and clamps it on screen', () => { + const target: Rect = { left: 260, top: 790, width: 120, height: 54 }; + const p = placeBubble(target, portrait, bubble); + expect(p.side).toBe('top'); + expect(p.top).toBe(700); // 790 - gap(10) - height(80) + // Centring would overflow the right edge, so the bubble is clamped... + expect(p.left).toBe(portrait.width - bubble.width - 8); + // ...yet the tail still aims at the target centre. + const tailViewportX = p.left + p.tail; + expect(Math.abs(tailViewportX - (target.left + target.width / 2))).toBeLessThan(1); + }); + + it('places the bubble to the side of a tall left-panel target (landscape)', () => { + const landscape: Viewport = { width: 1100, height: 640 }; + const wide = { width: 300, height: 120 }; + const target: Rect = { left: 30, top: 20, width: 60, height: 600 }; + const p = placeBubble(target, landscape, wide); + expect(p.side).toBe('right'); + expect(p.left).toBe(100); // 30 + 60 + gap(10) + }); + + it('clamps the bubble and the tail at a corner target', () => { + const target: Rect = { left: 0, top: 0, width: 30, height: 30 }; + const p = placeBubble(target, portrait, { width: 200, height: 60 }); + expect(p.side).toBe('bottom'); + expect(p.left).toBe(8); // left margin + expect(p.tail).toBe(8); // tail clamped to its minimum off the corner + }); + + it('falls back to a side and keeps the bubble within the viewport when nothing fits', () => { + const target: Rect = { left: 0, top: 0, width: 390, height: 844 }; + const p = placeBubble(target, portrait, { width: 200, height: 80 }); + expect(p.left).toBeGreaterThanOrEqual(8); + expect(p.left).toBeLessThanOrEqual(portrait.width - 200 - 8); + expect(p.top).toBeGreaterThanOrEqual(8); + expect(p.top).toBeLessThanOrEqual(portrait.height - 80 - 8); + }); +}); diff --git a/ui/src/lib/coachmark.ts b/ui/src/lib/coachmark.ts new file mode 100644 index 0000000..e24c775 --- /dev/null +++ b/ui/src/lib/coachmark.ts @@ -0,0 +1,149 @@ +// Pure logic for the first-run onboarding coachmarks (components/Coachmark.svelte). Kept free of +// Svelte and the DOM so it is unit-testable in the node test env: the component supplies the live +// target rectangles and renders the bubble from the geometry computed here. + +import type { MessageKey } from './i18n/en'; + +/** The two independent onboarding series: the lobby (just registered) and the first game board. */ +export type CoachSeries = 'lobby' | 'game'; + +/** + * A single coachmark step: the `data-coach` attribute of the element the bubble points at and the + * i18n key of the hint text. Steps whose target is absent at show time are skipped. + */ +export interface CoachStep { + target: string; + key: MessageKey; +} + +/** The lobby series, in the owner-defined order: settings → stats → new game. */ +export const LOBBY_STEPS: readonly CoachStep[] = [ + { target: 'lobby-settings', key: 'onboarding.lobbySettings' }, + { target: 'lobby-stats', key: 'onboarding.lobbyStats' }, + { target: 'lobby-new', key: 'onboarding.lobbyNew' }, +]; + +/** The game series, in the owner-defined order: header → pass/exchange → hints → shuffle → rack. */ +export const GAME_STEPS: readonly CoachStep[] = [ + { target: 'game-header', key: 'onboarding.gameHeader' }, + { target: 'game-turn', key: 'onboarding.gameTurn' }, + { target: 'game-hints', key: 'onboarding.gameHints' }, + { target: 'game-shuffle', key: 'onboarding.gameShuffle' }, + { target: 'game-rack', key: 'onboarding.gameRack' }, +]; + +/** Returns the ordered step list for the given series. */ +export function stepsFor(series: CoachSeries): readonly CoachStep[] { + return series === 'lobby' ? LOBBY_STEPS : GAME_STEPS; +} + +/** + * Returns the index of the first step at or after `from` whose target is present, or -1 when no + * further step is visible. The component advances through the steps with this, skipping any whose + * element is not on screen (e.g. a control hidden in the current game state). + */ +export function nextVisibleStep(steps: readonly CoachStep[], from: number, isPresent: (target: string) => boolean): number { + for (let i = Math.max(0, from); i < steps.length; i++) { + if (isPresent(steps[i].target)) return i; + } + return -1; +} + +/** A minimal rectangle (a subset of DOMRect) — the target's position in the viewport. */ +export interface Rect { + left: number; + top: number; + width: number; + height: number; +} + +/** The viewport size the bubble must stay inside. */ +export interface Viewport { + width: number; + height: number; +} + +/** The measured bubble box, used to keep it on screen and to aim the tail. */ +export interface BubbleSize { + width: number; + height: number; +} + +/** Which side of the target the bubble sits on; the tail is drawn on the bubble edge facing it. */ +export type BubbleSide = 'top' | 'bottom' | 'left' | 'right'; + +/** + * The computed bubble placement. `left`/`top` are the bubble's viewport position; `tail` is the + * offset of the tail along the facing edge — an X from the bubble's left for top/bottom sides, a Y + * from the bubble's top for left/right sides — so the tail keeps pointing at the target centre even + * when the bubble is clamped to the viewport edge. + */ +export interface Placement { + side: BubbleSide; + left: number; + top: number; + tail: number; +} + +/** Tuning knobs for {@link placeBubble}; all in CSS pixels. */ +export interface PlaceOpts { + /** Gap between the target and the bubble. */ + gap?: number; + /** Minimum distance the bubble keeps from every viewport edge. */ + margin?: number; + /** Half-width of the tail triangle (it cannot sit closer than this to a bubble corner). */ + tail?: number; +} + +function clamp(v: number, lo: number, hi: number): number { + // When the bubble is larger than the slot (lo > hi) keep the low edge so it stays anchored. + return hi < lo ? lo : Math.max(lo, Math.min(hi, v)); +} + +/** + * Computes where to draw the bubble for a target. It prefers the vertical axis (below a target, + * else above), then the horizontal axis (right, else left) — picking the first side that fits the + * bubble with the gap and edge margin, and otherwise the side with the most room. The bubble is + * clamped inside the viewport and the tail is aimed at the target centre (clamped to the bubble + * edge), so a target near a corner still gets a correctly pointing tail. + */ +export function placeBubble(target: Rect, vp: Viewport, bubble: BubbleSize, opts?: PlaceOpts): Placement { + const gap = opts?.gap ?? 10; + const margin = opts?.margin ?? 8; + const tail = opts?.tail ?? 8; + + const tcx = target.left + target.width / 2; + const tcy = target.top + target.height / 2; + + const space: Record = { + top: target.top, + bottom: vp.height - (target.top + target.height), + left: target.left, + right: vp.width - (target.left + target.width), + }; + + const fits = (side: BubbleSide): boolean => { + if (side === 'top' || side === 'bottom') return space[side] >= bubble.height + gap + margin; + return space[side] >= bubble.width + gap + margin; + }; + + // Bubble below a top target, above a bottom one, then to the side — the layout our targets use. + const order: BubbleSide[] = ['bottom', 'top', 'right', 'left']; + let side = order.find(fits); + if (!side) { + side = (Object.keys(space) as BubbleSide[]).reduce((a, b) => (space[a] >= space[b] ? a : b)); + } + + if (side === 'top' || side === 'bottom') { + const left = clamp(tcx - bubble.width / 2, margin, vp.width - bubble.width - margin); + const rawTop = side === 'top' ? target.top - gap - bubble.height : target.top + target.height + gap; + const top = clamp(rawTop, margin, vp.height - bubble.height - margin); + const tailX = clamp(tcx - left, tail, bubble.width - tail); + return { side, left, top, tail: tailX }; + } + const top = clamp(tcy - bubble.height / 2, margin, vp.height - bubble.height - margin); + const rawLeft = side === 'left' ? target.left - gap - bubble.width : target.left + target.width + gap; + const left = clamp(rawLeft, margin, vp.width - bubble.width - margin); + const tailY = clamp(tcy - top, tail, bubble.height - tail); + return { side, left, top, tail: tailY }; +} diff --git a/ui/src/lib/i18n/en.ts b/ui/src/lib/i18n/en.ts index 07544c3..9e36144 100644 --- a/ui/src/lib/i18n/en.ts +++ b/ui/src/lib/i18n/en.ts @@ -65,6 +65,17 @@ export const en = { 'new.searchHint': 'Finding an opponent can sometimes take a while. If you do not want to wait, close the app after starting the game and come back in a couple of minutes.', + // First-run coachmark hints (components/Coachmark.svelte). The leading emoji in each comment + // names the UI element the bubble's tail points at. + 'onboarding.lobbySettings': 'Friends, game & profile settings, feedback', // ⚙️ + 'onboarding.lobbyStats': 'Stats refresh after each game with an opponent', // ✏️ + 'onboarding.lobbyNew': 'Practise against the AI', // 🎲 + 'onboarding.gameHeader': 'Move history, chat and word check', // scoreboard strip + 'onboarding.gameTurn': 'Pass a turn, swap tiles', // 🔄 + 'onboarding.gameHints': 'Available hints', // 🛟 + 'onboarding.gameShuffle': 'Shuffle your tiles', // 🔀 + 'onboarding.gameRack': 'Pick a tile and place it on the board', // rack + 'game.bag': '{n} in the bag', 'game.bagEmpty': 'Bag is empty', 'game.hints': 'Hints {n}', diff --git a/ui/src/lib/i18n/ru.ts b/ui/src/lib/i18n/ru.ts index 80abbca..0ce61b4 100644 --- a/ui/src/lib/i18n/ru.ts +++ b/ui/src/lib/i18n/ru.ts @@ -66,6 +66,16 @@ export const ru: Record = { 'new.searchHint': 'Иногда поиск соперника может занять некоторое время. Если не захотите ждать после начала игры, можете вернуться в приложение через несколько минут.', + // Подсказки первого запуска (components/Coachmark.svelte). + 'onboarding.lobbySettings': 'Друзья, настройки игры и профиля, обратная связь', // ⚙️ + 'onboarding.lobbyStats': 'Статистика обновляется после каждой игры с соперником', // ✏️ + 'onboarding.lobbyNew': 'Потренируйтесь с ИИ', // 🎲 + 'onboarding.gameHeader': 'История ходов, чат и проверка слова', // шапка над доской + 'onboarding.gameTurn': 'Пропуск хода, обмен фишек', // 🔄 + 'onboarding.gameHints': 'Доступные подсказки', // 🛟 + 'onboarding.gameShuffle': 'Встряхните фишки', // 🔀 + 'onboarding.gameRack': 'Выбирайте фишку и ставьте на доску', // rack + 'game.bag': '{n} в мешке', 'game.bagEmpty': 'Мешок пуст', 'game.hints': 'Подсказки {n}', diff --git a/ui/src/lib/session.ts b/ui/src/lib/session.ts index f4f703d..229aa34 100644 --- a/ui/src/lib/session.ts +++ b/ui/src/lib/session.ts @@ -133,3 +133,30 @@ export async function loadPrefs(): Promise> { export function savePrefs(p: Prefs): Promise { return kvSet(PREFS_KEY, p); } + +const ONBOARDING_KEY = 'onboarding'; + +/** + * Whether each first-run coachmark series has been completed. A series is marked done only after + * its last hint, so a player interrupted mid-series sees it again from the start next launch. + */ +export interface OnboardingState { + lobbyDone: boolean; + gameDone: boolean; +} + +/** Loads the persisted onboarding completion flags, defaulting to not-yet-seen. */ +export async function loadOnboarding(): Promise { + const s = await kvGet>(ONBOARDING_KEY); + return { lobbyDone: s?.lobbyDone ?? false, gameDone: s?.gameDone ?? false }; +} + +/** Persists the onboarding completion flags. */ +export function saveOnboarding(s: OnboardingState): Promise { + return kvSet(ONBOARDING_KEY, s); +} + +/** Clears the onboarding flags so the coachmarks replay on the next launch (DebugPanel reset). */ +export function clearOnboarding(): Promise { + return kvDel(ONBOARDING_KEY); +} diff --git a/ui/src/screens/Lobby.svelte b/ui/src/screens/Lobby.svelte index b06ba42..3a5f3a3 100644 --- a/ui/src/screens/Lobby.svelte +++ b/ui/src/screens/Lobby.svelte @@ -332,13 +332,13 @@ {#snippet tabbar()} From f9acea1d9a94845a092cea6332d5845f8dd3cfde Mon Sep 17 00:00:00 2001 From: Ilia Denisov Date: Tue, 30 Jun 2026 22:51:54 +0200 Subject: [PATCH 05/71] fix(game): clear nudges on game completion MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Nudge badges lingered in the lobby on games that ended by turn-timeout, resignation or forfeit: those paths commit the finish directly, bypassing the move path's per-mover NudgeClearer, so the awaited seat's nudge was never marked read. A finished game's nudges are stale, so clear them all. Add a wired NudgeExpirer (social.ExpireNudges) called from the shared commit finish block — covering every completion path — and from the voidGame recovery path. It clears every seat's nudge bits for the game and leaves chat messages unread; unlike ClearNudges it records no publish-to-read latency, since a completion is an expiry, not a read. An integration test reproduces the timeout case (nudge cleared, chat kept). Docs: ARCHITECTURE §9.1, FUNCTIONAL (+_ru), backend/README. --- backend/README.md | 6 ++- backend/cmd/backend/main.go | 4 +- backend/internal/game/service.go | 44 +++++++++++++++-- backend/internal/inttest/chatread_test.go | 59 +++++++++++++++++++++++ backend/internal/social/chatread.go | 34 +++++++++++++ docs/ARCHITECTURE.md | 11 +++-- docs/FUNCTIONAL.md | 3 +- docs/FUNCTIONAL_ru.md | 3 +- 8 files changed, 152 insertions(+), 12 deletions(-) diff --git a/backend/README.md b/backend/README.md index 5a6230b..e3ba1a3 100644 --- a/backend/README.md +++ b/backend/README.md @@ -43,8 +43,10 @@ per-user blocks, and per-game chat with nudges folded in as a message kind; chat messages are length-capped, content-filtered (no links/emails/phone numbers, including obfuscated forms) and stored with the sender's IP. Each message carries an `unread_seats` read bitmask (a set bit per recipient seat still to read it); `MarkRead` -clears a reader's bit when they open the move history or chat, and a wired `NudgeClearer` -clears a nudge when its recipient moves — both record the publish-to-read latency. +clears a reader's bit when they open the move history or chat, a wired `NudgeClearer` +clears a nudge when its recipient moves, and a wired `NudgeExpirer` clears **all** of a game's +nudges when it finishes (any completion path) — the first two record the publish-to-read latency, +the completion expiry does not (it is not a read); chat messages stay unread on completion. A friend request (or block) aimed at a **disguised pooled robot** is recorded per game+seat in `robot_friend_requests` / `robot_blocks`, never against the shared robot account; a background reaper drops a robot friend request once its game has been finished for **7 days**. diff --git a/backend/cmd/backend/main.go b/backend/cmd/backend/main.go index c8b4bfa..5db95db 100644 --- a/backend/cmd/backend/main.go +++ b/backend/cmd/backend/main.go @@ -180,8 +180,10 @@ func run(ctx context.Context, cfg config.Config, logger *zap.Logger) error { socialSvc := social.NewService(social.NewStore(db), accounts, games) socialSvc.SetNotifier(hub) socialSvc.SetMetrics(tel.MeterProvider().Meter("scrabble/backend/social")) - // A nudge the recipient answered by moving is marked read on the move path. + // A nudge the recipient answered by moving is marked read on the move path; every nudge in a + // game is marked read when the game finishes (a stale badge), on any completion path. games.SetNudgeClearer(socialSvc.ClearNudges) + games.SetNudgeExpirer(socialSvc.ExpireNudges) // Reap per-game disguised-robot friend requests once their game is long finished // (the robot ignores them; the row only pins the in-game "request sent" state). robotReqReaper := social.NewRobotFriendRequestReaper(socialSvc, logger) diff --git a/backend/internal/game/service.go b/backend/internal/game/service.go index 6a8dffb..3ed494a 100644 --- a/backend/internal/game/service.go +++ b/backend/internal/game/service.go @@ -54,8 +54,14 @@ type Service struct { // have committed a move (a nudge answered by moving stops counting as unread). It is // best-effort and kept as a func so the game package never imports the social package. clearNudges func(ctx context.Context, gameID, accountID uuid.UUID) error - metrics *gameMetrics - log *zap.Logger + // expireNudges, when set, marks every pending nudge in a game read once the game + // finishes (the nudge badge is stale on a completed game). Unlike clearNudges it is + // keyed by game alone — it clears all seats' nudges, not one mover's — and runs on + // every completion path through commit. Best-effort; a func so the game package never + // imports the social package. + expireNudges func(ctx context.Context, gameID uuid.UUID) error + metrics *gameMetrics + log *zap.Logger } // NewService constructs a Service. store and accounts wrap the same pool; @@ -107,6 +113,15 @@ func (svc *Service) SetNudgeClearer(fn func(ctx context.Context, gameID, account svc.clearNudges = fn } +// SetNudgeExpirer installs the hook that marks every pending nudge in a game read once the +// game finishes, on any completion path (a closing move, a resignation, a turn-timeout or a +// forfeit). It must be called during startup wiring; the default (nil) leaves a finished +// game's nudges to expire only when a recipient opens the move history or chat. The social +// package wires its ExpireNudges here. Chat messages are deliberately left unread. +func (svc *Service) SetNudgeExpirer(fn func(ctx context.Context, gameID uuid.UUID) error) { + svc.expireNudges = fn +} + // SetFirstMoveEntropy overrides the entropy source for the first-move draw // (docs/ARCHITECTURE.md §6). It must be called during wiring or test setup before any // game is created; the production default is crypto/rand and is never overridden. @@ -699,6 +714,16 @@ func (svc *Service) commit(ctx context.Context, gameID uuid.UUID, g *engine.Game } if c.finished { svc.cache.remove(gameID) + // A finished game's nudges are stale, so clear them all here — every completion path + // funnels through commit (a closing move, a resignation, a forfeit or a turn-timeout), + // and only the move path also clears the mover's nudge on its own. Best-effort like + // clearNudges: the finish has committed, so a cleanup failure is logged, not surfaced. + // ExpireNudges leaves chat messages unread. + if svc.expireNudges != nil { + if err := svc.expireNudges(ctx, gameID); err != nil { + svc.log.Warn("expire nudges on game finish", zap.Error(err)) + } + } } post, err := svc.store.GetGame(ctx, gameID) if err != nil { @@ -1440,13 +1465,24 @@ func (svc *Service) voidGame(ctx context.Context, pre Game, g *engine.Game) erro if err != nil { return err } - return svc.store.VoidGame(ctx, voidCommit{ + if err := svc.store.VoidGame(ctx, voidCommit{ gameID: pre.ID, endReason: g.Reason().String(), scores: scores, now: svc.clock(), stats: buildStats(g, statSeats), - }) + }); err != nil { + return err + } + // A voided game is finished (as a draw) but bypasses commit, so clear its now-stale nudges + // here too. Best-effort, like the commit path: the void has persisted, so a cleanup failure + // is logged, not surfaced. + if svc.expireNudges != nil { + if err := svc.expireNudges(ctx, pre.ID); err != nil { + svc.log.Warn("expire nudges on voided game", zap.Error(err)) + } + } + return nil } // replayMove re-applies one journalled move to g through the decoded engine API. diff --git a/backend/internal/inttest/chatread_test.go b/backend/internal/inttest/chatread_test.go index 3b69423..47c3bc1 100644 --- a/backend/internal/inttest/chatread_test.go +++ b/backend/internal/inttest/chatread_test.go @@ -138,6 +138,65 @@ func TestNudgeClearedByMove(t *testing.T) { } } +// TestGameCompletionExpiresNudgesKeepsChat checks that finishing a game by turn-timeout marks every +// pending nudge in it read — the lobby's nudge badge is stale once the game is over — while leaving +// real chat messages unread. It reproduces the reported bug: the timeout path commits the finish +// directly, bypassing the move path's per-mover nudge clear, so without a completion-driven expiry +// the awaited seat's nudge lingered as a badge on the finished game. +func TestGameCompletionExpiresNudgesKeepsChat(t *testing.T) { + ctx := context.Background() + gameSvc := newGameService() + socialSvc := newSocialService() + gameSvc.SetNudgeExpirer(socialSvc.ExpireNudges) + + seats := []uuid.UUID{provisionAccount(t), provisionAccount(t)} + g, err := gameSvc.Create(ctx, game.CreateParams{ + Variant: engine.VariantEnglish, Seats: seats, TurnTimeout: time.Hour, Seed: openingSeed(t), + }) + if err != nil { + t.Fatalf("create: %v", err) + } + + // Seat 1 nudges the to-move seat 0 (the awaited player), and seat 0 posts a real chat message, + // which seat 1 then holds unread. So before the timeout each side has exactly one unread entry: + // seat 0 a nudge, seat 1 a message — letting HasUnread isolate each kind. + if _, err := socialSvc.Nudge(ctx, g.ID, seats[1]); err != nil { + t.Fatalf("nudge: %v", err) + } + if _, err := socialSvc.PostMessage(ctx, g.ID, seats[0], "good luck", ""); err != nil { + t.Fatalf("post message: %v", err) + } + if unread, _ := socialSvc.HasUnread(ctx, g.ID, seats[0]); !unread { + t.Fatal("seat 0 should hold the nudge unread before the timeout") + } + if unread, _ := socialSvc.HasUnread(ctx, g.ID, seats[1]); !unread { + t.Fatal("seat 1 should hold the message unread before the timeout") + } + + // Age the turn past its deadline and time seat 0 out; an empty away window keeps this + // deterministic regardless of the wall clock. The sweep finishes the game through the direct + // commit path, never the move path. + backdate(t, g.ID, time.Now().UTC().Add(-2*time.Hour)) + setAway(t, seats[0], "UTC", "00:00", "00:00") + if n, err := gameSvc.SweepTimeouts(ctx, time.Now().UTC()); err != nil || n < 1 { + t.Fatalf("sweep swept %d (err %v), want >= 1", n, err) + } + if status, reason := gameStatus(t, gameSvc, g.ID); status != game.StatusFinished || reason != "timeout" { + t.Fatalf("game not timed out: status %q reason %q", status, reason) + } + + // The nudge is stale on a finished game and must be cleared; the chat message must survive. + if unread, _ := socialSvc.HasUnread(ctx, g.ID, seats[0]); unread { + t.Error("the nudge should be expired once the game has finished") + } + if unread, _ := socialSvc.HasUnread(ctx, g.ID, seats[1]); !unread { + t.Error("a real chat message must stay unread after the game finishes") + } + if msg, _ := socialSvc.HasUnreadMessage(ctx, g.ID, seats[1]); !msg { + t.Error("the chat message must remain flagged as an unread message after completion") + } +} + // TestChatToRobotIsBornRead checks a text message to a disguised robot opponent (a pooled // robot substituted into an ordinary, non-AI game) is born read: the robot never opens the // chat, so the message must not linger unread (skewing the count and the read metric). diff --git a/backend/internal/social/chatread.go b/backend/internal/social/chatread.go index 5b1b495..966eee5 100644 --- a/backend/internal/social/chatread.go +++ b/backend/internal/social/chatread.go @@ -55,6 +55,25 @@ func (svc *Service) ClearNudges(ctx context.Context, gameID, accountID uuid.UUID return nil } +// ExpireNudges marks every pending nudge in the game read, for when the game finishes: a nudge +// badge is stale once the game is over, so completion clears them all for every seat. Chat +// messages are left untouched (they stay unread). It satisfies game.NudgeExpirer and is wired +// into the completion path; failures are the caller's to log (the game has already finished). +// Unlike ClearNudges it records no publish-to-read latency — a completion is an expiry, not the +// recipient reading the nudge — so the latency metric is not skewed by games that end hours later. +func (svc *Service) ExpireNudges(ctx context.Context, gameID uuid.UUID) error { + ctx, span := svc.tracer.Start(ctx, "social.ExpireNudges", + trace.WithAttributes(attribute.String("game.id", gameID.String()))) + defer span.End() + n, err := svc.store.expireNudges(ctx, gameID) + if err != nil { + span.RecordError(err) + return err + } + span.SetAttributes(attribute.Int64("expired.count", n)) + return nil +} + // UnreadGames returns the set of games in which viewerID has at least one unread chat // entry, for seeding the lobby's per-card unread badge in a single query. func (svc *Service) UnreadGames(ctx context.Context, viewerID uuid.UUID) (map[uuid.UUID]bool, error) { @@ -140,6 +159,21 @@ RETURNING m.created_at` return out, rows.Err() } +// expireNudges marks every still-unread nudge in the game read for all seats at once, used when +// the game finishes. It returns the number of nudge entries it cleared. Only 'nudge' rows are +// touched, so chat messages keep their unread bits; it returns no post times because a completion +// is an expiry, not a player reading, and must not feed the publish-to-read latency metric. +func (s *Store) expireNudges(ctx context.Context, gameID uuid.UUID) (int64, error) { + const q = `UPDATE backend.chat_messages +SET unread_seats = 0 +WHERE game_id = $1 AND kind = 'nudge' AND unread_seats <> 0` + res, err := s.db.ExecContext(ctx, q, gameID) + if err != nil { + return 0, fmt.Errorf("social: expire nudges: %w", err) + } + return res.RowsAffected() +} + // unreadGames returns the games where viewerID has an unread entry, resolving their // seat per game through the game_players join. func (s *Store) unreadGames(ctx context.Context, viewerID uuid.UUID) (map[uuid.UUID]bool, error) { diff --git a/docs/ARCHITECTURE.md b/docs/ARCHITECTURE.md index ad04ea9..9c0fbdd 100644 --- a/docs/ARCHITECTURE.md +++ b/docs/ARCHITECTURE.md @@ -646,7 +646,11 @@ in either direction (the enqueue excludes the caller's `BlockedWith` set); robot instead clears when the robot answers by moving, as for a human. A seat's bit clears when that player **opens the move history or the chat** (`POST /games/:id/chat/read`, which the client sends only when it holds unread, so a history open is not a constant backend call), and a **nudge additionally clears when its - recipient answers by moving** (the move path calls a wired `NudgeClearer`). The mask is + recipient answers by moving** (the move path calls a wired `NudgeClearer`); and **every nudge in + a game is marked read when that game finishes** — on any completion path (a closing move, a + resignation, a forfeit or a turn-timeout, all funnelling through the shared `commit`), since the + nudge badge is stale once the game is over (a wired `NudgeExpirer`; chat messages stay unread and + this expiry records no read latency). The mask is inverted so "anything unread" is a plain `unread_seats <> 0`, which the per-viewer `unread_chat` game-view flag (seeding the lobby and in-game unread **dot**), the admin unread filter and the unread gauge all use. A second per-viewer flag, **`unread_messages`** @@ -656,8 +660,9 @@ in either direction (the enqueue excludes the caller's `BlockedWith` set); REST-seed-then-event-bump lifecycle (the live-event game-view leaves them false; a nudge event raises only `unread_chat`, a message event raises both). The lobby additionally **floats games with any unread entry to the top** of the your-turn and opponent-turn - sections (the finished section keeps its activity order). On each clear the publish-to-read - latency is recorded; the read time itself is not retained. + sections (the finished section keeps its activity order). On each player-driven clear the + publish-to-read latency is recorded — the completion expiry records none (it is not a read); + the read time itself is not retained. - **Profile**: `preferred_language` (en/ru; tracks the interface language — §4), display name, email (confirm-code binding, see §4), **timezone**, the daily **away window**, the **variant preferences** (`variant_preferences`, the matchable-variant set that gates New diff --git a/docs/FUNCTIONAL.md b/docs/FUNCTIONAL.md index 7393042..6d3d363 100644 --- a/docs/FUNCTIONAL.md +++ b/docs/FUNCTIONAL.md @@ -260,7 +260,8 @@ entry — a message **or a nudge** from an opponent — raises a small **dot** n in the **lobby** and on the game's **score bar**, **red when an unread message is waiting and a softer amber when only nudges are**. Opening the **move history** counts as reading the chat, even without entering it: the dot clears and the 💬 icon **fade-blinks twice**. A nudge -also clears the moment its recipient **takes their move**. +also clears the moment its recipient **takes their move**, and **all of a game's nudges clear once +the game finishes** (the badge is stale once the game is over) — but unread chat messages stay unread. ### Profile & settings Edit the display name (letters joined by a single space / "." / "_" separator, with an diff --git a/docs/FUNCTIONAL_ru.md b/docs/FUNCTIONAL_ru.md index 29225cd..36ed240 100644 --- a/docs/FUNCTIONAL_ru.md +++ b/docs/FUNCTIONAL_ru.md @@ -269,7 +269,8 @@ _Вход сейчас только через провайдера, поэто **лобби** и на **строке счёта** партии, **красную при непрочитанном сообщении и мягкую жёлтую, когда непрочитаны только nudge'и**. Открытие **истории ходов** считается прочтением чата, даже без захода в него: точка гаснет, а значок 💬 **дважды мигает**. Nudge также гаснет в момент, когда -его получатель **делает ход**. +его получатель **делает ход**, и **все nudge'и партии гаснут по её завершении** (после конца +партии бейдж неактуален) — но непрочитанные сообщения чата остаются непрочитанными. ### Профиль и настройки Редактирование отображаемого имени (буквы, разделённые одиночным пробелом / «.» / From 71c3411276bff882c3c21a76bbabebc8030f0ee0 Mon Sep 17 00:00:00 2001 From: Ilia Denisov Date: Tue, 30 Jun 2026 23:00:19 +0200 Subject: [PATCH 06/71] fix(ui): mobile in-app WebView polish (tap-flash, VK haptics, swipe-back) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - Tap-highlight: add -webkit-tap-highlight-color: transparent on #app. Android in-app WebViews (Telegram, VK) flashed a momentary selection-like box on tappable nodes on tap — seen on the header title and the back chevron; user-select (already none) does not govern it. Inherited, so this clears it app-wide. - VK haptics: mirror the Telegram haptic set on VK via VK Bridge taptic (impact / notification / selection), routed through a new shared lib/haptics.ts dispatcher. VK users previously got no haptics; the game and error call sites now fire haptic() instead of telegramHaptic(). - VK swipe-back: disable VK's horizontal swipe-back at launch (VKWebAppSetSwipeSettings history:false) so it does not fight the app's own edge-swipe-back and on-board tile drag — parity with Telegram's disabled vertical swipes; the app owns navigation via its back chevron. Docs: UI_DESIGN (no-select / tap-highlight, VK integration). --- docs/UI_DESIGN.md | 10 +++++++++- ui/src/app.css | 4 ++++ ui/src/game/Game.svelte | 13 +++++++------ ui/src/lib/app.svelte.ts | 9 ++++++--- ui/src/lib/haptics.ts | 16 ++++++++++++++++ ui/src/lib/vk.ts | 38 ++++++++++++++++++++++++++++++++++++++ 6 files changed, 80 insertions(+), 10 deletions(-) create mode 100644 ui/src/lib/haptics.ts diff --git a/docs/UI_DESIGN.md b/docs/UI_DESIGN.md index 387f68d..2aabb33 100644 --- a/docs/UI_DESIGN.md +++ b/docs/UI_DESIGN.md @@ -100,7 +100,10 @@ e2e and the screenshots. square with an accent underline). A red count **badge** rides the icon's corner — on the lobby ⚙️ tab and the hub's 🤝 Friends tab for pending incoming friend requests (invitations keep their own lobby section), and on the Hint tab for the remaining count. No text - selection on nav / tab-bar / buttons (`user-select: none`). + selection on nav / tab-bar / buttons (`user-select: none`), and no tap-highlight flash on any + tappable element (`-webkit-tap-highlight-color: transparent` on `#app`) — Android in-app WebViews + otherwise draw a momentary selection-like box on a clickable node on tap (seen on the header title + and the back chevron). - **Screen transitions** (`App.svelte`): navigation slides directionally — a screen entered from the lobby flies in from the right; returning to the lobby reveals it from the left (back). Transitions are local (so they do not play on first load) and @@ -146,6 +149,11 @@ e2e and the screenshots. insets (`VKWebAppUpdateInsets`, needed on Android, where the VK webview exposes no `env()` inset), so the layout clears the VK home bar. Share and copy route through the bridge (`VKWebAppShare` / `VKWebAppCopyText`) because `navigator.share` / `clipboard` are absent in the desktop VK iframe. + Haptics fire the same set as Telegram via VK Bridge taptic (`VKWebAppTapticImpactOccurred` / + `…NotificationOccurred` / `…SelectionChanged`), through the shared `lib/haptics.ts` dispatcher; and + VK's **horizontal swipe-back** is disabled at launch (`VKWebAppSetSwipeSettings`) so it does not + fight the app's own edge-swipe-back and tile drag — parity with Telegram's disabled vertical + swipes, the app owning navigation through its back chevron. ## Tiles & board diff --git a/ui/src/app.css b/ui/src/app.css index b6070bc..193cabe 100644 --- a/ui/src/app.css +++ b/ui/src/app.css @@ -171,6 +171,10 @@ html.app-shell body { /* No text selection anywhere by default; inputs opt back in below. */ user-select: none; -webkit-user-select: none; + /* No tap-highlight flash on tappable elements. Android in-app WebViews (Telegram, VK) draw a + momentary selection-like box on a clickable node on tap — seen on the header title and the + back chevron; user-select does not govern it. Inherited, so this clears it app-wide. */ + -webkit-tap-highlight-color: transparent; } input, diff --git a/ui/src/game/Game.svelte b/ui/src/game/Game.svelte index c800c80..f413664 100644 --- a/ui/src/game/Game.svelte +++ b/ui/src/game/Game.svelte @@ -24,7 +24,8 @@ import { getCachedGame, setCachedGame, setCachedDraft, type CachedGame } from '../lib/gamecache'; import { patchLobbyGame } from '../lib/lobbycache'; import { applyGameOver, applyMoveDelta, applyOpponentJoined, type DeltaResult } from '../lib/gamedelta'; - import { insideTelegram, telegramDialogsAvailable, telegramHaptic, telegramShowConfirm } from '../lib/telegram'; + import { insideTelegram, telegramDialogsAvailable, telegramShowConfirm } from '../lib/telegram'; + import { haptic } from '../lib/haptics'; import { BLANK, newPlacement, @@ -511,7 +512,7 @@ if (drag && isCoarse() && !zoomed) { focus = c; zoomed = true; - telegramHaptic('light'); + haptic('light'); } }, 700) : null; @@ -623,7 +624,7 @@ return; } placement = place(placement, index, row, col); - telegramHaptic('select'); + haptic('select'); recompute(); scheduleDraftSave(); } @@ -631,7 +632,7 @@ if (!blankPrompt) return; placement = place(placement, blankPrompt.rackIndex, blankPrompt.row, blankPrompt.col, letter); blankPrompt = null; - telegramHaptic('select'); + haptic('select'); recompute(); scheduleDraftSave(); } @@ -687,7 +688,7 @@ busy = true; try { applyMoveResult(await gateway.submitPlay(id, sub.tiles, variant)); - telegramHaptic('success'); + haptic('success'); zoomed = false; } catch (e) { handleError(e); @@ -793,7 +794,7 @@ shuffling = true; setTimeout(() => (shuffling = false), 600); // A short "shake": a few quick light taps rather than one. - for (let i = 0; i < 4; i++) setTimeout(() => telegramHaptic('light'), i * 55); + for (let i = 0; i < 4; i++) setTimeout(() => haptic('light'), i * 55); scheduleDraftSave(); } function openExchange() { diff --git a/ui/src/lib/app.svelte.ts b/ui/src/lib/app.svelte.ts index 980a6d3..858478f 100644 --- a/ui/src/lib/app.svelte.ts +++ b/ui/src/lib/app.svelte.ts @@ -23,7 +23,6 @@ import { telegramSafeAreaInset, telegramDisableVerticalSwipes, telegramShowSettingsButton, - telegramHaptic, telegramLaunch, type TelegramLaunch, telegramOnEvent, @@ -32,7 +31,8 @@ import { telegramCloudGet, telegramCloudSet, } from './telegram'; -import { onVKPath, insideVK, vkInit, vkLaunchParams, vkUserName, vkStartParam, vkOnScheme, vkOnInsets } from './vk'; +import { onVKPath, insideVK, vkInit, vkDisableSwipeBack, vkLaunchParams, vkUserName, vkStartParam, vkOnScheme, vkOnInsets } from './vk'; +import { haptic } from './haptics'; import { CLOUD_PREFS_KEY, decodeClientPrefs, encodeClientPrefs } from './cloudprefs'; import { parseStartParam } from './deeplink'; import { @@ -281,7 +281,7 @@ export function handleError(err: unknown): void { return; } if (isConnectionCode(code) || !connection.online) return; - telegramHaptic('error'); + haptic('error'); showToast(t(code ? errorKey(code) : 'error.generic'), 'error'); } @@ -705,6 +705,9 @@ export async function bootstrap(): Promise { root.style.setProperty('--tg-safe-right', `max(env(safe-area-inset-right, 0px), ${i.right}px)`); }); await vkInit(); + // The app owns navigation (its own back chevron), so silence VK's horizontal swipe-back to keep + // it off our edge-swipe-back and on-board tile drag — parity with telegramDisableVerticalSwipes. + void vkDisableSwipeBack(); await bootVK(); app.ready = true; return; diff --git a/ui/src/lib/haptics.ts b/ui/src/lib/haptics.ts new file mode 100644 index 0000000..5f4dd6b --- /dev/null +++ b/ui/src/lib/haptics.ts @@ -0,0 +1,16 @@ +// Cross-platform haptic feedback. The app fires one Haptic vocabulary from its interaction code +// (tile placement, submit, errors); this routes it to whichever host Mini App is in play — +// Telegram's HapticFeedback or VK Bridge's taptic events — and stays silent in a plain browser. + +import { type Haptic, telegramHaptic } from './telegram'; +import { insideVK, vkHaptic } from './vk'; + +/** + * haptic fires the host Mini App's haptic feedback for kind: Telegram's HapticFeedback inside + * Telegram and VK Bridge's taptic inside VK; a no-op in an ordinary browser. Both backends + * self-guard off their platform, so this is always safe to call. The VK send is fire-and-forget. + */ +export function haptic(kind: Haptic): void { + telegramHaptic(kind); + if (insideVK()) void vkHaptic(kind); +} diff --git a/ui/src/lib/vk.ts b/ui/src/lib/vk.ts index 6f49f21..8240a6d 100644 --- a/ui/src/lib/vk.ts +++ b/ui/src/lib/vk.ts @@ -6,6 +6,8 @@ // (VKWebAppGetUserInfo, since VK omits it from the signed launch params). Every helper is safe to // call outside VK. +import type { Haptic } from './telegram'; + async function bridge() { return (await import('@vkontakte/vk-bridge')).default; } @@ -160,3 +162,39 @@ export async function vkOnInsets(handler: (insets: VKInsets) => void): Promise { + try { + const b = await bridge(); + if (kind === 'select') { + await b.send('VKWebAppTapticSelectionChanged', {}); + } else if (kind === 'success' || kind === 'warning' || kind === 'error') { + await b.send('VKWebAppTapticNotificationOccurred', { type: kind }); + } else { + await b.send('VKWebAppTapticImpactOccurred', { style: kind }); + } + } catch { + // Outside VK / no taptic support: silent, like the Telegram haptic off-platform. + } +} + +/** + * vkDisableSwipeBack turns off VK's horizontal swipe-back gesture (VKWebAppSetSwipeSettings with + * history:false) so it does not fight the app's own edge-swipe-back and on-board tile drag — the + * app owns navigation through its back chevron, as on Telegram (telegramDisableVerticalSwipes). + * Best-effort; a no-op outside VK or on a client without the method. + */ +export async function vkDisableSwipeBack(): Promise { + try { + await (await bridge()).send('VKWebAppSetSwipeSettings', { history: false }); + } catch { + // Outside VK / unsupported: VK's default gesture handling stays as-is. + } +} From f4dbb545e0e9f7822e3b22f945261c5fbc2a6a53 Mon Sep 17 00:00:00 2001 From: Ilia Denisov Date: Wed, 1 Jul 2026 13:06:11 +0200 Subject: [PATCH 07/71] fix(ui): suppress tap-flash directly on the header title and back MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The global -webkit-tap-highlight-color on #app was not enough on Android in-app WebViews (Telegram, VK): they only suppress the selection-like tap flash when -webkit-user-select / -webkit-tap-highlight-color / -webkit-touch-callout sit DIRECTLY on the tapped element, not inherited. Set them on the two tappable header controls — the title (a 10-tap debug target) and the back chevron. --- ui/src/components/Header.svelte | 11 +++++++++++ 1 file changed, 11 insertions(+) diff --git a/ui/src/components/Header.svelte b/ui/src/components/Header.svelte index 67b8e6c..222051d 100644 --- a/ui/src/components/Header.svelte +++ b/ui/src/components/Header.svelte @@ -124,6 +124,17 @@ .back:hover { background: var(--surface-2); } + /* Android in-app WebViews (Telegram, VK) flash a selection-like box on the two tappable header + controls — the title (a 10-tap debug target) and the back chevron — on a plain tap. The global + -webkit-user-select / -webkit-tap-highlight-color on #app is inherited, but those WebViews only + suppress the artifact when the properties sit DIRECTLY on the tapped element. */ + h1, + .back { + -webkit-user-select: none; + user-select: none; + -webkit-touch-callout: none; + -webkit-tap-highlight-color: transparent; + } /* A thin, compact "<" drawn from two borders — lighter than a glyph. */ .chev { width: 11px; From 88b6761e2854cff78728ae0ae153acbf01e395a5 Mon Sep 17 00:00:00 2001 From: Ilia Denisov Date: Wed, 1 Jul 2026 13:17:00 +0200 Subject: [PATCH 08/71] fix(ui): copy GCG to clipboard on Android in-app WebViews MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit On-device diagnostics from Android Telegram and VK confirmed both expose no navigator.share AND no navigator.canShare, so the export fell to a Blob that those WebViews silently ignore — nothing happened. pickGcgDelivery is now a 3-way decision: Web Share where available (iOS), a clipboard copy in an Android in-app WebView (Telegram/VK: no share, dead download), else a desktop Blob download. shareOrDownloadGcg reports the outcome so the game shows a "GCG copied" toast; the copy is VKWebAppCopyText inside VK (which also covers the desktop VK iframe, where navigator.clipboard is blocked) and navigator.clipboard otherwise. Unit tests cover the 3-way choice and the copy/failed outcomes; new i18n key game.gcgCopied (en+ru); docs ARCHITECTURE/FUNCTIONAL(+ru)/UI_DESIGN/TESTING. --- docs/ARCHITECTURE.md | 4 ++- docs/FUNCTIONAL.md | 3 ++- docs/FUNCTIONAL_ru.md | 5 ++-- docs/TESTING.md | 2 +- docs/UI_DESIGN.md | 4 ++- ui/src/game/Game.svelte | 19 +++++++++++++- ui/src/lib/i18n/en.ts | 1 + ui/src/lib/i18n/ru.ts | 1 + ui/src/lib/share.test.ts | 48 ++++++++++++++++++++++++--------- ui/src/lib/share.ts | 57 +++++++++++++++++++++++++++++----------- 10 files changed, 109 insertions(+), 35 deletions(-) diff --git a/docs/ARCHITECTURE.md b/docs/ARCHITECTURE.md index ad04ea9..ccc5745 100644 --- a/docs/ARCHITECTURE.md +++ b/docs/ARCHITECTURE.md @@ -771,7 +771,9 @@ pragmas, `8G`/`H8` coordinates, lower-case blanks, `.` pass-throughs, `-TILES` exchanges), plus `#note` lines for resignations and timeouts, which the standard does not cover. **GCG export is offered only on a finished game** (`game.ErrGameActive` otherwise), so an in-progress journal is never leaked mid-play; the client -shares the `.gcg` file via the Web Share API where available, else downloads it. +shares the `.gcg` file via the Web Share API where available; an Android in-app WebView +(Telegram / VK) has no Web Share and silently ignores an ``, so there it copies the GCG +text to the clipboard instead (the payload is tiny), and a plain desktop browser downloads the file. The alphabet-on-the-wire transport does **not** touch this invariant: the live edge exchanges alphabet indices, but the persisted journal (and everything derived from it — diff --git a/docs/FUNCTIONAL.md b/docs/FUNCTIONAL.md index 7393042..8024493 100644 --- a/docs/FUNCTIONAL.md +++ b/docs/FUNCTIONAL.md @@ -312,7 +312,8 @@ Finished games are archived in a dictionary-independent form and exportable to GCG; the export is offered **only once a game is finished**, and never for an honest-AI practice game (a live game's export would leak the move journal; an AI game is throwaway). The client shares the `.gcg` file where the platform supports -it, otherwise downloads it. Statistics (durable accounts only): +it; on an Android in-app client (Telegram / VK), which has neither Web Share nor a working file +download, it copies the GCG to the clipboard (with a confirming toast); otherwise it downloads the file. Statistics (durable accounts only): wins, losses, draws, max points in a game, and max points for a single move (the best play, which already includes every word it formed plus the all-tiles bonus). It also shows the player's **move count** (their plays — passes and exchanges do not diff --git a/docs/FUNCTIONAL_ru.md b/docs/FUNCTIONAL_ru.md index 29225cd..78c1b22 100644 --- a/docs/FUNCTIONAL_ru.md +++ b/docs/FUNCTIONAL_ru.md @@ -320,8 +320,9 @@ Telegram. Завершённые партии архивируются в независимом от словаря виде и экспортируются в GCG; экспорт доступен **только после завершения партии** и никогда — для тренировочной партии с ИИ (экспорт идущей партии раскрыл бы журнал ходов, а партия -с ИИ одноразовая). Клиент делится файлом `.gcg` там, где платформа это поддерживает, -иначе скачивает его. Статистика (только у постоянных аккаунтов): +с ИИ одноразовая). Клиент делится файлом `.gcg` там, где платформа это поддерживает; +в Android-приложении (Telegram / VK), где нет ни Web Share, ни рабочей загрузки файла, копирует GCG +в буфер обмена (с подтверждающим тостом); иначе скачивает файл. Статистика (только у постоянных аккаунтов): победы, поражения, ничьи, макс. очков за партию и макс. очков за один ход (лучший ход, уже включающий все образованные им слова и бонус за все фишки). Также показываются **число ходов** игрока (его выкладки — пасы и обмены не считаются) и diff --git a/docs/TESTING.md b/docs/TESTING.md index 71e1b78..8d08b35 100644 --- a/docs/TESTING.md +++ b/docs/TESTING.md @@ -17,7 +17,7 @@ tests or touching CI. - **UI** — Vitest (unit) + Playwright (e2e), mirroring the chosen plain-Svelte + Vite toolchain. Vitest covers the FlatBuffers codecs (friend list, invitation, stats), the win-rate - derivation and the GCG share/download choice, plus Playwright specs against the + derivation and the GCG share/copy/download choice, plus Playwright specs against the mock for the friends screen (code issue/redeem, accept a request), the lobby invitations section, the stats screen, profile editing, and the GCG export's finished-only visibility. diff --git a/docs/UI_DESIGN.md b/docs/UI_DESIGN.md index 387f68d..160ce82 100644 --- a/docs/UI_DESIGN.md +++ b/docs/UI_DESIGN.md @@ -394,7 +394,9 @@ enabled on the first, uncached load) and flip in place when an event refreshes t Safari. - **History / GCG**: the in-game slide-down history lays each move out in a per-seat grid (the word(s) and the move score, no running total); *Export GCG* (the 📤 in the history - header) shares or downloads the `.gcg` file and appears only once the game is finished — and + header) delivers the `.gcg` file — Web Share where available, a clipboard copy in an Android in-app + WebView (Telegram / VK, which has neither Web Share nor a working download), else a Blob download — + and appears only once the game is finished — and never in an honest-AI game (throwaway practice). Confirming a resign reveals the full board: it closes the history drawer (portrait) and zooms the board out. - **Finished game**: the board keeps no last-word highlight and no zoom; the history header diff --git a/ui/src/game/Game.svelte b/ui/src/game/Game.svelte index c800c80..c9aff96 100644 --- a/ui/src/game/Game.svelte +++ b/ui/src/game/Game.svelte @@ -21,6 +21,7 @@ import { alphabetLetters, hasAlphabet } from '../lib/alphabet'; import { hintsLeft } from '../lib/hints'; import { shareOrDownloadGcg } from '../lib/share'; + import { insideVK, vkCopyText } from '../lib/vk'; import { getCachedGame, setCachedGame, setCachedDraft, type CachedGame } from '../lib/gamecache'; import { patchLobbyGame } from '../lib/lobbycache'; import { applyGameOver, applyMoveDelta, applyOpponentJoined, type DeltaResult } from '../lib/gamedelta'; @@ -837,12 +838,28 @@ async function exportGcg() { try { - await shareOrDownloadGcg(await gateway.exportGcg(id)); + const gcg = await gateway.exportGcg(id); + const outcome = await shareOrDownloadGcg(gcg, insideTelegram() || insideVK(), copyGcgText); + if (outcome === 'copied') showToast(t('game.gcgCopied'), 'info'); + else if (outcome === 'failed') showToast(t('error.generic'), 'error'); } catch (e) { handleError(e); } } + // The GCG export copies to the clipboard in an Android in-app WebView (no Web Share, a dead + // ): VKWebAppCopyText inside VK — which also works in the desktop VK iframe, where + // navigator.clipboard is blocked — and navigator.clipboard elsewhere. + async function copyGcgText(text: string): Promise { + if (insideVK()) return vkCopyText(text); + try { + await navigator.clipboard.writeText(text); + return true; + } catch { + return false; + } + } + // --- move history: open by tapping the score bar, close by tapping or swiping up the board --- // The boardwrap surface drives two gestures, selected by `historyOpen`: // - open: the slid board is inert (CSS pointer-events), so the whole board reads as a diff --git a/ui/src/lib/i18n/en.ts b/ui/src/lib/i18n/en.ts index 9e36144..5c8ff2e 100644 --- a/ui/src/lib/i18n/en.ts +++ b/ui/src/lib/i18n/en.ts @@ -310,6 +310,7 @@ export const en = { 'game.exportGcg': 'Export GCG', 'game.gcgActiveOnly': 'Available once the game is finished.', + 'game.gcgCopied': 'GCG copied to the clipboard.', 'game.addFriendShort': 'Add friend?', 'game.blockShort': 'Block?', diff --git a/ui/src/lib/i18n/ru.ts b/ui/src/lib/i18n/ru.ts index 0ce61b4..b2aceca 100644 --- a/ui/src/lib/i18n/ru.ts +++ b/ui/src/lib/i18n/ru.ts @@ -310,6 +310,7 @@ export const ru: Record = { 'game.exportGcg': 'Экспорт GCG', 'game.gcgActiveOnly': 'Доступно после завершения игры.', + 'game.gcgCopied': 'GCG скопирован в буфер обмена.', 'game.addFriendShort': 'В друзья?', 'game.blockShort': 'В бан?', diff --git a/ui/src/lib/share.test.ts b/ui/src/lib/share.test.ts index 34fae16..3657257 100644 --- a/ui/src/lib/share.test.ts +++ b/ui/src/lib/share.test.ts @@ -7,26 +7,31 @@ const file = {} as File; const gcg: GcgExport = { gameId: 'g1', filename: 'game.gcg', content: '#title game' }; describe('pickGcgDelivery', () => { - it('shares when the platform can share files', () => { - expect(pickGcgDelivery({ canShare: () => true, share: async () => {} }, file)).toBe('share'); + const canShareNav = { canShare: () => true, share: async () => {} }; + const noShareNav = { canShare: () => false, share: async () => {} }; + + it('shares when the platform can share files, even in an in-app webview (iOS)', () => { + expect(pickGcgDelivery(canShareNav, file, false)).toBe('share'); + expect(pickGcgDelivery(canShareNav, file, true)).toBe('share'); }); - it('downloads when the platform cannot share files', () => { - expect(pickGcgDelivery({ canShare: () => false, share: async () => {} }, file)).toBe('download'); + it('copies in an in-app webview that cannot share files (Android Telegram/VK: no share, dead download)', () => { + expect(pickGcgDelivery(noShareNav, file, true)).toBe('copy'); + expect(pickGcgDelivery(undefined, file, true)).toBe('copy'); + expect(pickGcgDelivery({ canShare: () => true } as never, file, true)).toBe('copy'); }); - it('downloads when there is no navigator', () => { - expect(pickGcgDelivery(undefined, file)).toBe('download'); - }); - - it('downloads when the Web Share API is incomplete', () => { - expect(pickGcgDelivery({ canShare: () => true } as never, file)).toBe('download'); + it('downloads on a plain browser without Web Share (desktop)', () => { + expect(pickGcgDelivery(noShareNav, file, false)).toBe('download'); + expect(pickGcgDelivery(undefined, file, false)).toBe('download'); }); }); describe('shareOrDownloadGcg', () => { afterEach(() => vi.unstubAllGlobals()); + const noCopy = async () => false; + function stubDownloadEnv(canShare: boolean, share: () => Promise) { const anchor = { href: '', download: '', click: vi.fn(), remove: vi.fn() }; const createElement = vi.fn(() => anchor); @@ -45,20 +50,37 @@ describe('shareOrDownloadGcg', () => { const share = vi.fn().mockRejectedValue(new DOMException('cancelled', 'AbortError')); const { createElement } = stubDownloadEnv(true, share); - await shareOrDownloadGcg(gcg); + expect(await shareOrDownloadGcg(gcg, false, noCopy)).toBe('shared'); expect(share).toHaveBeenCalledOnce(); expect(createElement).not.toHaveBeenCalled(); // no download anchor → no webview navigation }); - it('downloads via an anchor when the platform cannot share files', async () => { + it('downloads via an anchor on a desktop browser that cannot share files', async () => { const { anchor, createElement } = stubDownloadEnv(false, vi.fn()); - await shareOrDownloadGcg(gcg); + expect(await shareOrDownloadGcg(gcg, false, noCopy)).toBe('downloaded'); expect(createElement).toHaveBeenCalledWith('a'); expect(anchor.click).toHaveBeenCalledOnce(); }); + + it('copies to the clipboard in an in-app webview that cannot share files (no dead Blob download)', async () => { + // Android Telegram/VK expose no Web Share AND ignore , so the export must copy the + // GCG text instead of silently issuing an anchor click that does nothing. + const copy = vi.fn().mockResolvedValue(true); + const { createElement } = stubDownloadEnv(false, vi.fn()); + + expect(await shareOrDownloadGcg(gcg, true, copy)).toBe('copied'); + + expect(copy).toHaveBeenCalledWith(gcg.content); + expect(createElement).not.toHaveBeenCalled(); + }); + + it('reports failure when the in-app clipboard copy fails', async () => { + stubDownloadEnv(false, vi.fn()); + expect(await shareOrDownloadGcg(gcg, true, async () => false)).toBe('failed'); + }); }); describe('pickTextShare', () => { diff --git a/ui/src/lib/share.ts b/ui/src/lib/share.ts index 4b4528d..6399444 100644 --- a/ui/src/lib/share.ts +++ b/ui/src/lib/share.ts @@ -1,14 +1,26 @@ -// GCG export delivery: share on mobile (Web Share API with a file) where supported, -// otherwise download via a Blob + on desktop. The Capacitor-native file -// save lands with the native wrapper; the Web Share path already covers mobile -// browsers. pickGcgDelivery is the pure decision, unit-tested with a mock navigator. +// GCG export delivery: Web Share (with the file) on mobile where supported — including the iOS +// Telegram Mini App. An Android in-app WebView (Telegram / VK) has NO Web Share and silently ignores +// an , so there the tiny GCG text is copied to the clipboard instead; a plain desktop +// browser, where the anchor download works, still downloads a Blob. The Capacitor-native file save +// lands with the native wrapper. pickGcgDelivery is the pure decision, unit-tested with a mock +// navigator; the caller supplies the platform-aware clipboard copy. import type { GcgExport } from './model'; type ShareNav = Pick; -/** pickGcgDelivery decides between the Web Share API and a Blob download for a file. */ -export function pickGcgDelivery(nav: ShareNav | undefined, file: File): 'share' | 'download' { +/** + * pickGcgDelivery decides how to deliver the GCG file. Web Share (with the file) wins wherever it is + * available — the iOS Telegram Mini App and mobile browsers. Failing that, an in-app WebView (Android + * Telegram / VK) has no Web Share and silently ignores an , so the tiny GCG text is copied + * to the clipboard ('copy'); only a plain desktop browser, where the anchor download works, falls + * through to 'download'. Pure, so it is unit-tested with a mock navigator. + */ +export function pickGcgDelivery( + nav: ShareNav | undefined, + file: File, + inAppWebView: boolean, +): 'share' | 'copy' | 'download' { if ( nav && typeof nav.canShare === 'function' && @@ -17,27 +29,42 @@ export function pickGcgDelivery(nav: ShareNav | undefined, file: File): 'share' ) { return 'share'; } - return 'download'; + return inAppWebView ? 'copy' : 'download'; } -/** shareOrDownloadGcg shares the GCG file where supported, else triggers a download. */ -export async function shareOrDownloadGcg(gcg: GcgExport): Promise { +/** + * shareOrDownloadGcg delivers the GCG export by the best available route and reports which it took — + * 'shared', 'copied', 'downloaded' or 'failed' — so the caller can confirm a silent copy to the user. + * inAppWebView marks an Android Telegram/VK WebView (no Web Share, a dead ); copyText is + * the platform-aware clipboard write (VKWebAppCopyText inside VK, navigator.clipboard otherwise). + */ +export async function shareOrDownloadGcg( + gcg: GcgExport, + inAppWebView: boolean, + copyText: (text: string) => Promise, +): Promise<'shared' | 'copied' | 'downloaded' | 'failed'> { const file = new File([gcg.content], gcg.filename, { type: 'application/x-gcg' }); const nav = typeof navigator !== 'undefined' ? navigator : undefined; - if (pickGcgDelivery(nav, file) === 'share' && nav) { + const decision = pickGcgDelivery(nav, file, inAppWebView); + if (decision === 'share' && nav) { // Web Share is available (mobile, including the iOS Telegram Mini App): use it and stop here - // whatever the outcome. Do NOT fall back to the Blob download — on iOS WKWebView an - // navigates the webview to the blob: URL, replacing the SPA with the raw file - // and stranding the app, so a cancelled or failed share must simply do nothing (the user can - // retry). The download path is only for desktop browsers without Web Share. + // whatever the outcome. Do NOT fall back to the Blob download — on iOS WKWebView an + // navigates the webview to the blob: URL, replacing the SPA with the raw file and stranding the + // app, so a cancelled or failed share must simply do nothing (the user can retry). try { await nav.share({ files: [file], title: gcg.filename }); } catch { /* cancelled or failed — intentionally a no-op (see above) */ } - return; + return 'shared'; + } + if (decision === 'copy') { + // Android Telegram/VK: no Web Share and a dead , so copy the GCG text instead of + // silently issuing an anchor click that saves nothing. + return (await copyText(gcg.content)) ? 'copied' : 'failed'; } downloadFile(gcg.content, gcg.filename); + return 'downloaded'; } function downloadFile(content: string, filename: string): void { From 5f9b4a7a3871801786452fffb02f4c506add09f0 Mon Sep 17 00:00:00 2001 From: Ilia Denisov Date: Wed, 1 Jul 2026 13:54:14 +0200 Subject: [PATCH 09/71] feat(ui): paint VK status bar to the app theme (VKWebAppSetViewSettings) Parity with the Telegram chrome painting: on a VK Mini App launch and on every theme change, set VK's status-bar appearance (and, on Android, the action/navigation bar colours) from the app's live theme tokens, so the VK chrome matches the UI instead of clashing. syncVKChrome mirrors syncTelegramChrome; the status-bar appearance is derived from the --bg token's luminance (appearanceForBg, unit-tested). Wired into the VK onScheme handler (fires on launch + on theme change) and setTheme. Docs: UI_DESIGN VK integration. --- docs/UI_DESIGN.md | 6 +++++- ui/src/lib/app.svelte.ts | 19 ++++++++++++++++++- ui/src/lib/vk.test.ts | 24 +++++++++++++++++++++++- ui/src/lib/vk.ts | 39 +++++++++++++++++++++++++++++++++++++++ 4 files changed, 85 insertions(+), 3 deletions(-) diff --git a/docs/UI_DESIGN.md b/docs/UI_DESIGN.md index d2afb30..00404da 100644 --- a/docs/UI_DESIGN.md +++ b/docs/UI_DESIGN.md @@ -153,7 +153,11 @@ e2e and the screenshots. `…NotificationOccurred` / `…SelectionChanged`), through the shared `lib/haptics.ts` dispatcher; and VK's **horizontal swipe-back** is disabled at launch (`VKWebAppSetSwipeSettings`) so it does not fight the app's own edge-swipe-back and tile drag — parity with Telegram's disabled vertical - swipes, the app owning navigation through its back chevron. + swipes, the app owning navigation through its back chevron. VK's **status bar** (and, on Android, + the action / navigation bars) is painted to the app theme via `VKWebAppSetViewSettings` on launch + and every theme change — parity with the Telegram chrome painting (`syncVKChrome` mirrors + `syncTelegramChrome`); the status-bar appearance follows the `--bg` token's luminance + (`appearanceForBg`). ## Tiles & board diff --git a/ui/src/lib/app.svelte.ts b/ui/src/lib/app.svelte.ts index 858478f..99c870b 100644 --- a/ui/src/lib/app.svelte.ts +++ b/ui/src/lib/app.svelte.ts @@ -31,7 +31,7 @@ import { telegramCloudGet, telegramCloudSet, } from './telegram'; -import { onVKPath, insideVK, vkInit, vkDisableSwipeBack, vkLaunchParams, vkUserName, vkStartParam, vkOnScheme, vkOnInsets } from './vk'; +import { onVKPath, insideVK, vkInit, vkDisableSwipeBack, vkLaunchParams, vkUserName, vkStartParam, vkOnScheme, vkOnInsets, vkSetViewSettings, appearanceForBg } from './vk'; import { haptic } from './haptics'; import { CLOUD_PREFS_KEY, decodeClientPrefs, encodeClientPrefs } from './cloudprefs'; import { parseStartParam } from './deeplink'; @@ -552,6 +552,19 @@ function syncTelegramChrome(): void { ); } +/** + * syncVKChrome paints VK's status bar (and, on Android, the action/navigation bars) from the app's + * live theme tokens, so the surrounding VK chrome matches the UI. A no-op outside VK. Parity with + * syncTelegramChrome. + */ +function syncVKChrome(): void { + if (!insideVK() || typeof document === 'undefined') return; + const cs = getComputedStyle(document.documentElement); + const bg = cs.getPropertyValue('--bg').trim(); + const bgElev = cs.getPropertyValue('--bg-elev').trim(); + void vkSetViewSettings(appearanceForBg(bg), bgElev, bg); +} + /** * syncTelegramSafeArea mirrors Telegram's safe-area insets into CSS vars: the content-safe-area top * (the height Telegram's native nav overlays the viewport in fullscreen) into --tg-content-top @@ -694,6 +707,9 @@ export async function bootstrap(): Promise { // VK mobile webview's prefers-color-scheme does not track it). void vkOnScheme((scheme) => { if (app.theme === 'auto') applyTheme(scheme); + // Repaint VK's status/nav bars to the resolved theme; this handler also fires on launch, so it + // covers the initial paint (auto → the VK scheme just applied, explicit → the theme from boot). + syncVKChrome(); }); // Clear the device safe area from VK's insets — the VK mobile webview does not surface them via // CSS env() (notably the Android home bar). max() keeps whichever of env() / the VK value is real. @@ -995,6 +1011,7 @@ async function reconcileCloudPrefs(): Promise { export function setTheme(theme: ThemePref): void { app.theme = theme; applyTheme(theme); + syncVKChrome(); persistPrefs(); } diff --git a/ui/src/lib/vk.test.ts b/ui/src/lib/vk.test.ts index 400fb84..c3a9c41 100644 --- a/ui/src/lib/vk.test.ts +++ b/ui/src/lib/vk.test.ts @@ -1,5 +1,5 @@ import { afterEach, describe, expect, it, vi } from 'vitest'; -import { insideVK, onVKPath, vkAppId, vkLaunchParams, vkStartParam } from './vk'; +import { insideVK, onVKPath, vkAppId, vkLaunchParams, vkStartParam, appearanceForBg } from './vk'; describe('vk launch detection', () => { afterEach(() => vi.unstubAllGlobals()); @@ -49,3 +49,25 @@ describe('vk launch params', () => { expect(vkStartParam()).toBe(''); }); }); + +describe('appearanceForBg', () => { + it('maps a dark background to the dark appearance (light status-bar icons)', () => { + expect(appearanceForBg('#0f1115')).toBe('dark'); + expect(appearanceForBg('#171a21')).toBe('dark'); + }); + + it('maps a light background to the light appearance (dark status-bar icons)', () => { + expect(appearanceForBg('#f3f4f6')).toBe('light'); + expect(appearanceForBg('#ffffff')).toBe('light'); + }); + + it('accepts shorthand hex and surrounding whitespace (a raw CSS custom-property value)', () => { + expect(appearanceForBg('#000')).toBe('dark'); + expect(appearanceForBg(' #fff ')).toBe('light'); + }); + + it('defaults to light for an unparseable colour', () => { + expect(appearanceForBg('')).toBe('light'); + expect(appearanceForBg('rgb(0, 0, 0)')).toBe('light'); + }); +}); diff --git a/ui/src/lib/vk.ts b/ui/src/lib/vk.ts index 8240a6d..806a034 100644 --- a/ui/src/lib/vk.ts +++ b/ui/src/lib/vk.ts @@ -198,3 +198,42 @@ export async function vkDisableSwipeBack(): Promise { // Outside VK / unsupported: VK's default gesture handling stays as-is. } } + +/** + * appearanceForBg maps a background colour to the VK view appearance: a dark background wants the + * 'dark' appearance (light status-bar icons), a light one 'light'. It accepts a `#rgb` / `#rrggbb` + * string (the app's `--bg` token); anything unparseable defaults to 'light'. Pure, so it is + * unit-tested. + */ +export function appearanceForBg(bg: string): 'light' | 'dark' { + const hex = bg.trim().replace(/^#/, ''); + const full = hex.length === 3 ? [...hex].map((c) => c + c).join('') : hex; + if (full.length !== 6 || /[^0-9a-fA-F]/.test(full)) return 'light'; + const r = parseInt(full.slice(0, 2), 16); + const g = parseInt(full.slice(2, 4), 16); + const b = parseInt(full.slice(4, 6), 16); + // Rec. 601 luma; below the midpoint is a dark background. + return 0.299 * r + 0.587 * g + 0.114 * b < 128 ? 'dark' : 'light'; +} + +/** + * vkSetViewSettings paints VK's status bar — and, on Android, the action and navigation bars — to + * match the app. appearance is the VK status-bar appearance ('light' | 'dark'); actionBarColor and + * navigationBarColor are Android-only hex colours (ignored elsewhere). Best-effort; a no-op outside + * VK or on a client without the method. + */ +export async function vkSetViewSettings( + appearance: 'light' | 'dark', + actionBarColor: string, + navigationBarColor: string, +): Promise { + try { + await (await bridge()).send('VKWebAppSetViewSettings', { + status_bar_style: appearance, + action_bar_color: actionBarColor, + navigation_bar_color: navigationBarColor, + }); + } catch { + // Outside VK / unsupported: the client keeps its default bars. + } +} From e6c5198caacdb5c4b2f7a0719af4fbba9451ac49 Mon Sep 17 00:00:00 2001 From: Ilia Denisov Date: Wed, 1 Jul 2026 16:57:05 +0200 Subject: [PATCH 10/71] build(ui): drop sourcemaps from prod bundle MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The production UI build shipped `.map` files with full `sourcesContent`, and the gateway/landing images serve `dist/` verbatim, so anyone could fetch `/app/assets/main-*.js.map` (same assets under `/vk/`, `/telegram/`) and reconstruct the entire TypeScript/Svelte source at the edge. Gate `build.sourcemap` off for `mode === 'production'` (the Docker image build). Dev and the `mock` e2e build (`vite build --mode mock`) keep maps for debugging. Document the posture in ARCHITECTURE.md §12. --- docs/ARCHITECTURE.md | 7 +++++++ ui/vite.config.ts | 8 +++++++- 2 files changed, 14 insertions(+), 1 deletion(-) diff --git a/docs/ARCHITECTURE.md b/docs/ARCHITECTURE.md index 153de12..8bbf8c6 100644 --- a/docs/ARCHITECTURE.md +++ b/docs/ARCHITECTURE.md @@ -1055,6 +1055,13 @@ valid-code population). Brute-forcing a 6-digit friend code within these limits accepted MVP risk with low blast radius (an unwanted friendship is removable/blockable); a dedicated redeem sub-limit or a longer code is the hardening step if abuse appears. +**Client source exposure.** The production UI build ships **no sourcemaps** +(`vite.config.ts` gates `build.sourcemap` off when `mode === 'production'`), so the gateway +and landing images serve only minified JS and the full TypeScript/Svelte source is not +recoverable from a served `.map` at the edge. Dev and the `mock` e2e build (`vite build +--mode mock`) keep maps for debugging. This is surface reduction, not secrecy — the single +minified bundle still carries all platform code paths and is reversible with effort. + ## 13. Deployment (informational) Single public origin, path-routed. The Vite build has two entries: a lightweight diff --git a/ui/vite.config.ts b/ui/vite.config.ts index 7a26d40..53258e6 100644 --- a/ui/vite.config.ts +++ b/ui/vite.config.ts @@ -34,7 +34,13 @@ export default defineConfig(({ mode }) => ({ }, build: { target: 'es2022', - sourcemap: true, + // Emit sourcemaps everywhere except the production build. A shipped `.map` + // carries full `sourcesContent` — the entire TypeScript/Svelte source — and the + // gateway/landing images serve `dist/` verbatim, so production maps would expose + // the whole client source at the edge. Dev and the `mock` e2e build + // (`vite build --mode mock`) keep maps for debugging; the production `vite build` + // (the Docker image build) drops them. + sourcemap: mode !== 'production', // Two entries: the game SPA (index.html, served at /app/ + /telegram/) and the // public landing page (landing.html, served at /). Assets are shared in dist/assets/, and // the relative base lets one build serve under any path. From 5689f7f6a3fb1172c2f45a09190a5875afbca6da Mon Sep 17 00:00:00 2001 From: Ilia Denisov Date: Wed, 1 Jul 2026 22:58:40 +0200 Subject: [PATCH 11/71] feat: on-device move preview (local eval) with network fallback MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Score and validate a tentative move on-device instead of a per-arrangement network round trip. The dawg reader and the validate/score/direction slice of the scrabble-solver engine are ported to TypeScript (ui/src/lib/dict), pinned byte-for-byte to the Go engine by a `conformance` CI job (full-dictionary reader parity plus a battery of plays across every variant and both cross-word rules, including the inferred orientation). The server stays authoritative — submit_play re-validates — so the local result is an advisory accelerator only. - backend: Registry.DictBytes + an authed GET /api/v1/user/dict/{variant}/{version} (immutable) streaming the pinned per-game dawg; caddy routes /dict to the gateway. - gateway: a session-gated /dict edge route proxying it; fetchDict on the transport. - client: the dictionary loads on game open (low priority so it never starves the game on a slow link; aborted at a 5s cap or when leaving the game), is cached in IndexedDB (best-effort, self-healing on a rejected blob) and reused across sessions; a warm-up overlay covers a cold load, then the network preview is the fallback; a bad-connection breaker stops warming after repeated misses; the move preview cancels its in-flight request when the tiles change. - parity generators backend/cmd/{dictgen,validategen} + gated Vitest suites, run in CI against the release dictionaries. A hidden debug readout lists the cached dictionaries + breaker state, and its reset clears the cache. - docs: ARCHITECTURE §5, TESTING, UI_DESIGN, FUNCTIONAL (+ru). --- .gitea/workflows/ci.yaml | 76 +++- backend/cmd/dictgen/main.go | 193 +++++++++ backend/cmd/validategen/main.go | 486 +++++++++++++++++++++++ backend/internal/engine/registry.go | 32 +- backend/internal/game/service.go | 8 + backend/internal/server/handlers.go | 3 + backend/internal/server/handlers_dict.go | 31 ++ deploy/caddy/Caddyfile | 2 +- docs/ARCHITECTURE.md | 11 + docs/FUNCTIONAL.md | 5 +- docs/FUNCTIONAL_ru.md | 4 +- docs/TESTING.md | 9 + docs/UI_DESIGN.md | 14 + gateway/cmd/gateway/main.go | 1 + gateway/internal/backendclient/api.go | 7 + gateway/internal/backendclient/client.go | 28 ++ gateway/internal/connectsrv/server.go | 79 +++- ui/scripts/bundle-size.mjs | 6 +- ui/src/components/DebugPanel.svelte | 42 +- ui/src/components/DictWarmup.svelte | 81 ++++ ui/src/game/Game.svelte | 96 ++++- ui/src/lib/alphabet.ts | 6 + ui/src/lib/client.ts | 9 +- ui/src/lib/dict/dawg.parity.test.ts | 71 ++++ ui/src/lib/dict/dawg.ts | 202 ++++++++++ ui/src/lib/dict/direction.ts | 73 ++++ ui/src/lib/dict/eval.parity.test.ts | 110 +++++ ui/src/lib/dict/eval.ts | 104 +++++ ui/src/lib/dict/index.ts | 7 + ui/src/lib/dict/loader.ts | 136 +++++++ ui/src/lib/dict/store.ts | 154 +++++++ ui/src/lib/dict/validate.parity.test.ts | 163 ++++++++ ui/src/lib/dict/validate.ts | 338 ++++++++++++++++ ui/src/lib/i18n/en.ts | 1 + ui/src/lib/i18n/ru.ts | 1 + ui/src/lib/mock/client.ts | 7 +- ui/src/lib/premiums.ts | 18 +- ui/src/lib/transport.ts | 26 +- 38 files changed, 2612 insertions(+), 28 deletions(-) create mode 100644 backend/cmd/dictgen/main.go create mode 100644 backend/cmd/validategen/main.go create mode 100644 backend/internal/server/handlers_dict.go create mode 100644 ui/src/components/DictWarmup.svelte create mode 100644 ui/src/lib/dict/dawg.parity.test.ts create mode 100644 ui/src/lib/dict/dawg.ts create mode 100644 ui/src/lib/dict/direction.ts create mode 100644 ui/src/lib/dict/eval.parity.test.ts create mode 100644 ui/src/lib/dict/eval.ts create mode 100644 ui/src/lib/dict/index.ts create mode 100644 ui/src/lib/dict/loader.ts create mode 100644 ui/src/lib/dict/store.ts create mode 100644 ui/src/lib/dict/validate.parity.test.ts create mode 100644 ui/src/lib/dict/validate.ts diff --git a/.gitea/workflows/ci.yaml b/.gitea/workflows/ci.yaml index 265cfaf..97059c7 100644 --- a/.gitea/workflows/ci.yaml +++ b/.gitea/workflows/ci.yaml @@ -207,11 +207,66 @@ jobs: run: pnpm run test:e2e timeout-minutes: 5 + # conformance proves the client's local move preview (the ported dawg reader + + # validator, ui/src/lib/dict) byte-for-byte against the authoritative Go engine: + # a Go step generates golden parity vectors from the release dictionaries, then the + # gated Vitest suite replays them. It spans both toolchains, so it runs whenever the + # Go engine side or the UI side changed. + conformance: + needs: changes + if: ${{ needs.changes.outputs.go == 'true' || needs.changes.outputs.ui == 'true' }} + runs-on: ubuntu-latest + defaults: + run: + shell: bash + env: + GOPRIVATE: gitea.iliadenisov.ru/* + steps: + - name: Checkout + uses: actions/checkout@v4 + + - name: Fetch dictionary DAWGs + run: | + mkdir -p "${GITHUB_WORKSPACE}/dawg" + curl -fsSL -o /tmp/dawg.tar.gz "https://gitea.iliadenisov.ru/developer/scrabble-dictionary/releases/download/${DICT_VERSION}/scrabble-dawg-${DICT_VERSION}.tar.gz" + tar xzf /tmp/dawg.tar.gz -C "${GITHUB_WORKSPACE}/dawg" + + - name: Set up Go + uses: actions/setup-go@v5 + with: + go-version-file: go.work + cache: true + + - name: Generate golden parity vectors + run: | + go run ./backend/cmd/dictgen -dawg-dir "${GITHUB_WORKSPACE}/dawg" -out /tmp/dictgold + go run ./backend/cmd/validategen -dawg-dir "${GITHUB_WORKSPACE}/dawg" -out /tmp/validgold + + - name: Set up Node + uses: actions/setup-node@v4 + with: + node-version: 22 + + - name: Install pnpm + run: npm install -g pnpm@11.0.9 + + - name: Install deps + working-directory: ui + run: pnpm install --frozen-lockfile + + - name: Local-eval conformance (reader + validator vs the Go engine) + working-directory: ui + env: + DICT_DAWG_DIR: ${{ github.workspace }}/dawg + DICT_GOLD_DIR: /tmp/dictgold + DICT_VALID_DIR: /tmp/validgold + run: pnpm exec vitest run src/lib/dict/ + # gate is the single branch-protection required check. It always runs and passes # only when each upstream job succeeded or was skipped (a path-filtered no-op), # failing the merge if any actually failed or was cancelled. gate: - needs: [unit, integration, ui] + needs: [unit, integration, ui, conformance] if: always() runs-on: ubuntu-latest defaults: @@ -221,7 +276,7 @@ jobs: - name: Aggregate required checks run: | fail= - for r in "unit:${{ needs.unit.result }}" "integration:${{ needs.integration.result }}" "ui:${{ needs.ui.result }}"; do + for r in "unit:${{ needs.unit.result }}" "integration:${{ needs.integration.result }}" "ui:${{ needs.ui.result }}" "conformance:${{ needs.conformance.result }}"; do name="${r%%:*}"; res="${r#*:}" echo "$name = $res" case "$res" in @@ -340,6 +395,23 @@ jobs: docker logs --tail 50 scrabble-backend || true exit 1 + - name: Probe the /dict edge route reaches the gateway + run: | + set -u + # The client fetches each game's dictionary blob at {edge}/dict/{variant}/{version} + # for the local move preview. If caddy does not route /dict to the gateway the request + # falls to the static landing and the client silently gets a non-dawg blob. Probed + # unauthenticated it must be the gateway's 401 (the route reaches the gateway), never a + # 404/200 from the landing catch-all. + out="$(docker run --rm --network edge alpine:3.20 wget -S -q -O /dev/null http://scrabble/dict/scrabble_en/v1 2>&1 || true)" + echo "$out" | grep -E "HTTP/" || true + if echo "$out" | grep -q " 401"; then + echo "ok: /dict reaches the gateway (401 unauthenticated)" + else + echo "FAIL: /dict did not reach the gateway (expected 401) — caddy route missing?" + exit 1 + fi + - name: Probe the Telegram validator and bot liveness run: | set -u diff --git a/backend/cmd/dictgen/main.go b/backend/cmd/dictgen/main.go new file mode 100644 index 0000000..d76c538 --- /dev/null +++ b/backend/cmd/dictgen/main.go @@ -0,0 +1,193 @@ +// Command dictgen dumps golden parity vectors from the committed dawg +// dictionaries so the TypeScript dawg reader can be checked byte-for-byte +// against the authoritative Go dafsa reader. +// +// For each *.dawg file it writes, into the output directory: +// +// - .words.bin — every stored word as alphabet-index bytes, in index +// order, framed as [1-byte length][length index bytes]. The word at stream +// position k has IndexOfB == k. +// - .neg.bin — negative lookups (sequences whose IndexOfB is -1), same +// framing, to exercise the not-found path at varying depths. +// - .meta.json — NumAdded/NumNodes/NumEdges plus the alphabet size, for +// a header-parse sanity cross-check on the TS side. +// +// It is a development tool (not built into any service), analogous to +// cmd/jetgen. Run it from the repository root: +// +// go run ./backend/cmd/dictgen -dawg-dir ../scrabble-solver/dawg -out +package main + +import ( + "bufio" + "bytes" + "encoding/json" + "flag" + "fmt" + "math/rand" + "os" + "path/filepath" + "sort" + "strings" + + dawg "github.com/iliadenisov/dafsa" +) + +// meta is the per-dictionary sanity payload cross-checked by the TS reader. +type meta struct { + NumAdded int `json:"numAdded"` + NumNodes int `json:"numNodes"` + NumEdges int `json:"numEdges"` + Alphabet int `json:"alphabet"` +} + +func main() { + dawgDir := flag.String("dawg-dir", "../scrabble-solver/dawg", "directory holding the .dawg files") + outDir := flag.String("out", "", "output directory for the golden files (required)") + negCount := flag.Int("neg", 20000, "number of negative lookups to emit per dictionary") + flag.Parse() + + if *outDir == "" { + fail("-out is required") + } + if err := os.MkdirAll(*outDir, 0o755); err != nil { + fail("mkdir out: %v", err) + } + + files, err := filepath.Glob(filepath.Join(*dawgDir, "*.dawg")) + if err != nil { + fail("glob: %v", err) + } + sort.Strings(files) + if len(files) == 0 { + fail("no .dawg files in %s", *dawgDir) + } + + for _, f := range files { + if err := process(f, *outDir, *negCount); err != nil { + fail("%s: %v", filepath.Base(f), err) + } + } +} + +// process emits the golden files for a single dawg dictionary. +func process(path, outDir string, negCount int) error { + name := strings.TrimSuffix(filepath.Base(path), ".dawg") + + data, err := os.ReadFile(path) + if err != nil { + return err + } + finder, err := dawg.Read(bytes.NewReader(data), 0) + if err != nil { + return fmt.Errorf("read dawg: %w", err) + } + defer finder.Close() + + // Stream every stored word in index order; keep a decimated sample and the + // maximum alphabet index for negative generation. + wf, err := os.Create(filepath.Join(outDir, name+".words.bin")) + if err != nil { + return err + } + bw := bufio.NewWriter(wf) + + var ( + count int + maxIx byte + sample [][]byte + ) + finder.EnumerateB(func(index int, word []byte, final bool) int { + if !final { + return 0 // Continue + } + if index != count { + panic(fmt.Sprintf("%s: enumerate index gap: got %d want %d", name, index, count)) + } + writeWord(bw, word) + for _, b := range word { + if b > maxIx { + maxIx = b + } + } + if count%4 == 0 && len(sample) < 60000 { + sample = append(sample, append([]byte(nil), word...)) + } + count++ + return 0 // Continue + }) + if err := bw.Flush(); err != nil { + return err + } + if err := wf.Close(); err != nil { + return err + } + if count != finder.NumAdded() { + return fmt.Errorf("word count %d != NumAdded %d", count, finder.NumAdded()) + } + + alphabet := int(maxIx) + 1 + + // Negatives: mutate sampled real words and keep the ones the reader rejects. + nf, err := os.Create(filepath.Join(outDir, name+".neg.bin")) + if err != nil { + return err + } + nbw := bufio.NewWriter(nf) + rng := rand.New(rand.NewSource(1)) + neg := 0 + for neg < negCount && len(sample) > 0 { + base := sample[rng.Intn(len(sample))] + cand := append([]byte(nil), base...) + switch rng.Intn(3) { + case 0: // extend by one index + cand = append(cand, byte(rng.Intn(alphabet))) + case 1: // flip one index + if len(cand) > 0 { + cand[rng.Intn(len(cand))] = byte(rng.Intn(alphabet)) + } + case 2: // drop the tail and flip the new last index + if len(cand) > 1 { + cand = cand[:len(cand)-1] + cand[len(cand)-1] = byte(rng.Intn(alphabet)) + } + } + if finder.IndexOfB(cand) == -1 { + writeWord(nbw, cand) + neg++ + } + } + if err := nbw.Flush(); err != nil { + return err + } + if err := nf.Close(); err != nil { + return err + } + + m := meta{NumAdded: finder.NumAdded(), NumNodes: finder.NumNodes(), NumEdges: finder.NumEdges(), Alphabet: alphabet} + mb, err := json.MarshalIndent(m, "", " ") + if err != nil { + return err + } + if err := os.WriteFile(filepath.Join(outDir, name+".meta.json"), mb, 0o644); err != nil { + return err + } + + fmt.Printf("%-12s words=%d negatives=%d alphabet=%d nodes=%d edges=%d\n", + name, count, neg, alphabet, finder.NumNodes(), finder.NumEdges()) + return nil +} + +// writeWord frames one index-byte word as [length][bytes]. +func writeWord(w *bufio.Writer, word []byte) { + if len(word) > 255 { + panic(fmt.Sprintf("word too long to frame: %d", len(word))) + } + w.WriteByte(byte(len(word))) + w.Write(word) +} + +func fail(format string, args ...any) { + fmt.Fprintf(os.Stderr, "dictgen: "+format+"\n", args...) + os.Exit(1) +} diff --git a/backend/cmd/validategen/main.go b/backend/cmd/validategen/main.go new file mode 100644 index 0000000..c47bcca --- /dev/null +++ b/backend/cmd/validategen/main.go @@ -0,0 +1,486 @@ +// Command validategen produces golden conformance fixtures for the TypeScript +// move validator (ui/src/lib/dict/validate.ts). For each variant it self-plays +// greedy games with the authoritative scrabble-solver engine to build realistic +// board positions, then records a battery of candidate plays — the engine's own +// top move, letter-mutated variants, random scatters and (on the empty board) an +// off-centre translation — each paired with the ground-truth result of +// ValidatePlayOpts (legal, score, the words formed). The TS conformance test +// replays these and must agree exactly. +// +// It is a development tool (not built into any service), analogous to +// cmd/dictgen. Run it from the repository root: +// +// go run ./backend/cmd/validategen -dawg-dir ../scrabble-solver/dawg -out +package main + +import ( + "bytes" + "encoding/json" + "flag" + "fmt" + "math/rand" + "os" + "path/filepath" + + "gitea.iliadenisov.ru/developer/scrabble-solver/board" + "gitea.iliadenisov.ru/developer/scrabble-solver/rack" + "gitea.iliadenisov.ru/developer/scrabble-solver/rules" + "gitea.iliadenisov.ru/developer/scrabble-solver/scrabble" + "gitea.iliadenisov.ru/developer/scrabble-solver/selfplay" + dawg "github.com/iliadenisov/dafsa" +) + +// blankTile marks a blank tile in a drawn hand (matches selfplay). +const blankTile byte = 0xff + +// variantSpec pairs a variant label with its ruleset and dawg file. +type variantSpec struct { + name string + rules *rules.Ruleset + dawg string +} + +// cell is an occupied board square or a placement (alphabet-index letter). +type cell struct { + R, C, Letter int + Blank bool +} + +// word mirrors scrabble.Word in index space. +type word struct { + Row, Col, Dir int + Letters []int + Blanks []bool + Score int +} + +// fixture is one candidate play with the engine's ground-truth verdict. +type fixture struct { + Board int `json:"board"` // index into the boards list + Dir int `json:"dir"` + IgnoreCrossWords bool `json:"ignoreCrossWords"` + Tiles []cell `json:"tiles"` + Legal bool `json:"legal"` + Score int `json:"score"` + Bonus int `json:"bonus"` + Main *word `json:"main,omitempty"` + Cross []word `json:"cross,omitempty"` +} + +// alphaEntry mirrors one row of the per-variant alphabet table the server sends the +// client (index, concrete letter as the ruleset emits it, tile value), so the adapter +// cross-test can drive the letter-space client path exactly as production does. +type alphaEntry struct { + Index int `json:"index"` + Letter string `json:"letter"` + Value int `json:"value"` +} + +// variantFile is the whole conformance payload for one variant. +type variantFile struct { + Variant string `json:"variant"` + Rows int `json:"rows"` + Cols int `json:"cols"` + Center int `json:"center"` + RackSize int `json:"rackSize"` + Bingo int `json:"bingo"` + Values []int `json:"values"` + Premiums []int `json:"premiums"` // row-major rules.Premium codes + Alphabet []alphaEntry `json:"alphabet"` + Boards [][]cell `json:"boards"` + Fixtures []fixture `json:"fixtures"` +} + +func main() { + dawgDir := flag.String("dawg-dir", "../scrabble-solver/dawg", "directory holding the .dawg files") + outDir := flag.String("out", "", "output directory for the fixture files (required)") + games := flag.Int("games", 6, "self-play games per (variant, rule)") + plies := flag.Int("plies", 40, "maximum plies captured per game") + flag.Parse() + if *outDir == "" { + fail("-out is required") + } + if err := os.MkdirAll(*outDir, 0o755); err != nil { + fail("mkdir out: %v", err) + } + + specs := []variantSpec{ + {"scrabble_en", rules.English(), "en_sowpods.dawg"}, + {"scrabble_ru", rules.RussianScrabble(), "ru_scrabble.dawg"}, + {"erudit_ru", rules.Erudit(), "ru_erudit.dawg"}, + } + for _, sp := range specs { + if err := generate(sp, *dawgDir, *outDir, *games, *plies); err != nil { + fail("%s: %v", sp.name, err) + } + } +} + +func generate(sp variantSpec, dawgDir, outDir string, games, plies int) error { + data, err := os.ReadFile(filepath.Join(dawgDir, sp.dawg)) + if err != nil { + return err + } + finder, err := dawg.Read(bytes.NewReader(data), 0) + if err != nil { + return fmt.Errorf("read dawg: %w", err) + } + defer finder.Close() + + rs := sp.rules + solver := scrabble.NewSolver(rs, finder) + + out := variantFile{ + Variant: sp.name, Rows: rs.Rows, Cols: rs.Cols, Center: rs.Center, + RackSize: rs.RackSize, Bingo: rs.Bingo, Values: rs.Values, + Premiums: premiumCodes(rs), Alphabet: alphabetOf(rs), + } + + // Capture under both the standard rule and the single-word rule, building the + // board with the same rule so positions are reachable under it. + for _, ignore := range []bool{false, true} { + opts := scrabble.PlayOptions{IgnoreCrossWords: ignore} + for g := range games { + seed := int64(g*1000) + boolseed(ignore) + variantSeed(sp.name) + playAndCapture(&out, rs, solver, opts, seed, plies) + } + } + + b, err := json.Marshal(&out) + if err != nil { + return err + } + if err := os.WriteFile(filepath.Join(outDir, sp.name+".fixtures.json"), b, 0o644); err != nil { + return err + } + fmt.Printf("%-12s boards=%d fixtures=%d\n", sp.name, len(out.Boards), len(out.Fixtures)) + return nil +} + +// playAndCapture greedily self-plays one game, recording candidate plays against +// each board position along the way. +func playAndCapture(out *variantFile, rs *rules.Ruleset, solver *scrabble.Solver, opts scrabble.PlayOptions, seed int64, plies int) { + rng := rand.New(rand.NewSource(seed)) + bag := selfplay.NewBag(rs, seed) + b := board.New(rs.Rows, rs.Cols) + hands := [2][]byte{bag.Draw(rs.RackSize), bag.Draw(rs.RackSize)} + + passes := 0 + for turn := range plies { + p := turn % 2 + rk := rackOf(hands[p], rs.Size()) + moves := solver.GenerateMovesOpts(b, rk, scrabble.Both, opts) + if len(moves) == 0 { + if passes++; passes >= 4 { + break + } + continue + } + passes = 0 + top := moves[0] + + boardIdx := len(out.Boards) + out.Boards = append(out.Boards, boardCells(b)) + captureCandidates(out, rs, solver, opts, b, boardIdx, top, rng) + + scrabble.Apply(b, top) + hands[p] = removeUsed(hands[p], top) + if need := rs.RackSize - len(hands[p]); need > 0 { + hands[p] = append(hands[p], bag.Draw(need)...) + } + if len(hands[p]) == 0 && bag.Len() == 0 { + break + } + } +} + +// captureCandidates records the engine's top move plus derived candidates for one +// board, each with its ValidatePlayOpts verdict. +func captureCandidates(out *variantFile, rs *rules.Ruleset, solver *scrabble.Solver, opts scrabble.PlayOptions, b *board.Board, boardIdx int, top scrabble.Move, rng *rand.Rand) { + size := rs.Size() + record := func(tiles []scrabble.Placement) { + if len(tiles) == 0 { + return + } + out.Fixtures = append(out.Fixtures, makeFixture(solver, opts, b, boardIdx, tiles)) + } + + // The engine's own top move (legal). + record(top.Tiles) + + // Letter-mutated variants: usually reject on the dictionary, occasionally form + // a different legal word. + for range 3 { + mut := clonePlacements(top.Tiles) + i := rng.Intn(len(mut)) + mut[i].Letter = byte((int(mut[i].Letter) + 1 + rng.Intn(size-1)) % size) + record(mut) + } + + // Random scatters: exercise geometry, dictionary and connectivity paths. + for range 3 { + record(randomScatter(b, size, 2+rng.Intn(4), rng)) + } + + // Single tiles abutting the board exercise the direction inference — a single + // tile is ambiguous, its orientation resolved from which axis it extends. + for range 3 { + if t, ok := randomAdjacentSingle(b, size, rng); ok { + record([]scrabble.Placement{t}) + } + } + + // On the empty board, an off-centre translation of the first move exercises the + // first-move centre rule. + if b.IsEmpty() { + shifted := clonePlacements(top.Tiles) + ok := true + for i := range shifted { + shifted[i].Row++ + shifted[i].Col++ + if !b.InBounds(shifted[i].Row, shifted[i].Col) { + ok = false + break + } + } + if ok { + record(shifted) + } + } +} + +// makeFixture validates a candidate against board b and serializes it with its +// ground truth. Word breakdown is recorded only for legal plays (the TS test +// checks words only then); an illegal play records legal=false alone. +func makeFixture(solver *scrabble.Solver, opts scrabble.PlayOptions, b *board.Board, boardIdx int, tiles []scrabble.Placement) fixture { + // Infer the orientation exactly as the backend evaluate does (dir-less), so the + // fixture matches the real eval path and pins the client's ported inference. + dir := playDirectionMirror(solver, b, tiles, opts) + fx := fixture{ + Board: boardIdx, + Dir: int(dir), + IgnoreCrossWords: opts.IgnoreCrossWords, + Tiles: placementCells(tiles), + } + m, err := solver.ValidatePlayOpts(b, dir, tiles, opts) + if err == nil { + fx.Legal = true + fx.Score = m.Score + fx.Bonus = m.Bonus + fx.Main = toWord(m.Main) + for _, cw := range m.Cross { + fx.Cross = append(fx.Cross, *toWord(cw)) + } + } + return fx +} + +func placementCells(ts []scrabble.Placement) []cell { + cs := make([]cell, len(ts)) + for i, t := range ts { + cs[i] = cell{R: t.Row, C: t.Col, Letter: int(t.Letter), Blank: t.Blank} + } + return cs +} + +func toWord(w scrabble.Word) *word { + letters := make([]int, len(w.Letters)) + for i, l := range w.Letters { + letters[i] = int(l) + } + return &word{ + Row: w.Row, Col: w.Col, Dir: int(w.Dir), + Letters: letters, Blanks: append([]bool(nil), w.Blanks...), Score: w.Score, + } +} + +func alphabetOf(rs *rules.Ruleset) []alphaEntry { + n := rs.Alphabet.Size() + out := make([]alphaEntry, n) + for i := range n { + ch, _ := rs.Alphabet.Character(byte(i)) + out[i] = alphaEntry{Index: i, Letter: ch, Value: rs.Values[i]} + } + return out +} + +func premiumCodes(rs *rules.Ruleset) []int { + codes := make([]int, rs.Rows*rs.Cols) + for i := range codes { + codes[i] = int(rs.PremiumAt(i)) + } + return codes +} + +func boardCells(b *board.Board) []cell { + var cs []cell + for r := 0; r < b.Rows(); r++ { + for c := 0; c < b.Cols(); c++ { + if b.Filled(r, c) { + v := b.At(r, c) + cs = append(cs, cell{R: r, C: c, Letter: int(v&0x3f) - 1, Blank: v&0x80 != 0}) + } + } + } + return cs +} + +func clonePlacements(ts []scrabble.Placement) []scrabble.Placement { + return append([]scrabble.Placement(nil), ts...) +} + +// randomScatter picks n distinct empty in-bounds squares with random letters. +func randomScatter(b *board.Board, size, n int, rng *rand.Rand) []scrabble.Placement { + seen := map[[2]int]bool{} + var ts []scrabble.Placement + for tries := 0; tries < n*20 && len(ts) < n; tries++ { + r := rng.Intn(b.Rows()) + c := rng.Intn(b.Cols()) + if seen[[2]int{r, c}] || b.Filled(r, c) { + continue + } + seen[[2]int{r, c}] = true + ts = append(ts, scrabble.Placement{Row: r, Col: c, Letter: byte(rng.Intn(size)), Blank: rng.Intn(10) == 0}) + } + return ts +} + +// randomAdjacentSingle picks a random empty in-bounds square abutting at least one +// filled square, with a random letter — a single-tile play whose orientation the +// inference must resolve. It returns ok=false on an empty board. +func randomAdjacentSingle(b *board.Board, size int, rng *rand.Rand) (scrabble.Placement, bool) { + var cands [][2]int + for r := 0; r < b.Rows(); r++ { + for c := 0; c < b.Cols(); c++ { + if b.Filled(r, c) { + continue + } + if b.Filled(r-1, c) || b.Filled(r+1, c) || b.Filled(r, c-1) || b.Filled(r, c+1) { + cands = append(cands, [2]int{r, c}) + } + } + } + if len(cands) == 0 { + return scrabble.Placement{}, false + } + rc := cands[rng.Intn(len(cands))] + return scrabble.Placement{Row: rc[0], Col: rc[1], Letter: byte(rng.Intn(size)), Blank: rng.Intn(10) == 0}, true +} + +// playDirectionMirror mirrors engine (*Game).playDirection: the geometric +// resolution, except a single tile under the single-word rule tries both +// orientations through the solver and keeps the higher-scoring legal one (H wins +// ties). It reproduces the orientation the backend evaluate infers. +func playDirectionMirror(solver *scrabble.Solver, b *board.Board, placements []scrabble.Placement, opts scrabble.PlayOptions) scrabble.Direction { + geo := resolveDirectionMirror(b, placements) + if len(placements) != 1 || !opts.IgnoreCrossWords { + return geo + } + best, found, bestScore := geo, false, 0 + for _, dir := range [...]scrabble.Direction{scrabble.Horizontal, scrabble.Vertical} { + m, err := solver.ValidatePlayOpts(b, dir, placements, opts) + if err != nil { + continue + } + if !found || m.Score > bestScore { + best, found, bestScore = dir, true, m.Score + } + } + return best +} + +// resolveDirectionMirror mirrors engine.resolveDirection. +func resolveDirectionMirror(b *board.Board, placements []scrabble.Placement) scrabble.Direction { + if len(placements) >= 2 { + row := placements[0].Row + for _, p := range placements[1:] { + if p.Row != row { + return scrabble.Vertical + } + } + return scrabble.Horizontal + } + if len(placements) == 1 { + p := placements[0] + h := runLengthMirror(b, p.Row, p.Col, scrabble.Horizontal) + v := runLengthMirror(b, p.Row, p.Col, scrabble.Vertical) + if v >= 2 && v > h { + return scrabble.Vertical + } + if h >= 2 { + return scrabble.Horizontal + } + if v >= 2 { + return scrabble.Vertical + } + } + return scrabble.Horizontal +} + +// runLengthMirror mirrors engine.runLength. +func runLengthMirror(b *board.Board, row, col int, dir scrabble.Direction) int { + dr, dc := 0, 1 + if dir == scrabble.Vertical { + dr, dc = 1, 0 + } + n := 1 + for r, c := row-dr, col-dc; b.Filled(r, c); r, c = r-dr, c-dc { + n++ + } + for r, c := row+dr, col+dc; b.Filled(r, c); r, c = r+dr, c+dc { + n++ + } + return n +} + +// rackOf builds a generation rack from a hand of tiles (reimplemented from the +// unexported selfplay helper). +func rackOf(tiles []byte, size int) rack.Rack { + r := rack.New(size) + for _, t := range tiles { + if t == blankTile { + r.AddBlank() + } else { + r.Add(t) + } + } + return r +} + +// removeUsed returns the hand with the tiles consumed by m removed. +func removeUsed(tiles []byte, m scrabble.Move) []byte { + out := append([]byte(nil), tiles...) + for _, p := range m.Tiles { + want := p.Letter + if p.Blank { + want = blankTile + } + for i, t := range out { + if t == want { + out = append(out[:i], out[i+1:]...) + break + } + } + } + return out +} + +func boolseed(b bool) int64 { + if b { + return 500000 + } + return 0 +} + +func variantSeed(name string) int64 { + var s int64 + for _, r := range name { + s = s*131 + int64(r) + } + return s +} + +func fail(format string, args ...any) { + fmt.Fprintf(os.Stderr, "validategen: "+format+"\n", args...) + os.Exit(1) +} diff --git a/backend/internal/engine/registry.go b/backend/internal/engine/registry.go index 84a525e..8c41f94 100644 --- a/backend/internal/engine/registry.go +++ b/backend/internal/engine/registry.go @@ -21,11 +21,13 @@ var dictFiles = map[Variant]string{ VariantErudit: "ru_erudit.dawg", } -// entry is one resident dictionary: the loaded finder and the solver built over -// it. The finder is retained so Close can release it. +// entry is one resident dictionary: the loaded finder, the solver built over it +// and the file it was loaded from. The finder is retained so Close can release +// it; path is retained so the raw bytes can be re-read for the client download. type entry struct { finder dawg.Finder solver *scrabble.Solver + path string } // Registry holds the dictionaries resident in memory, addressed by variant and @@ -130,7 +132,7 @@ func (r *Registry) Load(v Variant, version, dir string) error { if old, ok := r.entries[v][version]; ok { _ = old.finder.Close() } - r.entries[v][version] = entry{finder: finder, solver: scrabble.NewSolver(rs, finder)} + r.entries[v][version] = entry{finder: finder, solver: scrabble.NewSolver(rs, finder), path: path} r.latest[v] = version return nil } @@ -202,6 +204,30 @@ func (r *Registry) Versions(v Variant) []string { return versions } +// DictBytes returns the raw serialized DAWG for the (variant, version) pair, +// re-read from the file it was loaded from — the same immutable bytes the solver +// holds. It backs the client-side dictionary download for the local move +// preview. It returns ErrUnknownVariant or ErrUnknownVersion when that dictionary +// is not resident, and wraps any read error. The file is read outside the lock. +func (r *Registry) DictBytes(v Variant, version string) ([]byte, error) { + r.mu.RLock() + versions, ok := r.entries[v] + if !ok { + r.mu.RUnlock() + return nil, fmt.Errorf("%w: %s", ErrUnknownVariant, v) + } + e, ok := versions[version] + r.mu.RUnlock() + if !ok { + return nil, fmt.Errorf("%w: %s/%s", ErrUnknownVersion, v, version) + } + data, err := os.ReadFile(e.path) + if err != nil { + return nil, fmt.Errorf("engine: read %s/%s dictionary bytes from %s: %w", v, version, e.path, err) + } + return data, nil +} + // Lookup reports whether word is present in the (variant, version) dictionary, // backing the unlimited word-check tool. It returns ErrUnknownVariant or // ErrUnknownVersion when that dictionary is not resident, and an error when word diff --git a/backend/internal/game/service.go b/backend/internal/game/service.go index 3ed494a..312f93d 100644 --- a/backend/internal/game/service.go +++ b/backend/internal/game/service.go @@ -1649,6 +1649,14 @@ func (svc *Service) lookupWord(variant engine.Variant, version, word string) (bo return present, nil } +// DictBytes returns the raw serialized dictionary for the (variant, version) pair +// from the registry, backing the client-side dictionary download used by the +// local move preview. It surfaces engine.ErrUnknownVariant / +// engine.ErrUnknownVersion when that dictionary is not resident. +func (svc *Service) DictBytes(variant engine.Variant, version string) ([]byte, error) { + return svc.registry.DictBytes(variant, version) +} + // hintsRemaining is a player's remaining hint budget: the unspent per-game // allowance plus the profile wallet. func hintsRemaining(allowance, used, wallet int) int { diff --git a/backend/internal/server/handlers.go b/backend/internal/server/handlers.go index 2451514..d15fb59 100644 --- a/backend/internal/server/handlers.go +++ b/backend/internal/server/handlers.go @@ -90,6 +90,9 @@ func (s *Server) registerRoutes() { u.GET("/games/:id/draft", s.handleGetDraft) u.PUT("/games/:id/draft", s.handleSaveDraft) u.POST("/games/:id/hide", s.handleHideGame) + // Raw dictionary download for the client-side local move preview, keyed by + // the game's pinned (variant, version); immutable, so cached hard. + u.GET("/dict/:variant/:version", s.handleDictBytes) } if s.feedback != nil { u.POST("/feedback", s.handleFeedbackSubmit) diff --git a/backend/internal/server/handlers_dict.go b/backend/internal/server/handlers_dict.go new file mode 100644 index 0000000..8ba4e2c --- /dev/null +++ b/backend/internal/server/handlers_dict.go @@ -0,0 +1,31 @@ +package server + +import ( + "net/http" + + "github.com/gin-gonic/gin" + + "scrabble/backend/internal/engine" +) + +// handleDictBytes streams the raw serialized dictionary for a (variant, version) +// pair so the client can validate and score moves locally — the local move +// preview — instead of a network round trip per tile arrangement. It is reached +// only through the gateway's session-gated /dict route (which resolves the +// X-User-ID this group requires), and served with a long immutable cache lifetime +// because a published dictionary version never changes. An unknown variant or a +// version that is not resident is a 404. +func (s *Server) handleDictBytes(c *gin.Context) { + variant, err := engine.ParseVariant(c.Param("variant")) + if err != nil { + c.JSON(http.StatusNotFound, gin.H{"error": "unknown variant"}) + return + } + data, err := s.games.DictBytes(variant, c.Param("version")) + if err != nil { + c.JSON(http.StatusNotFound, gin.H{"error": "dictionary not found"}) + return + } + c.Header("Cache-Control", "public, max-age=31536000, immutable") + c.Data(http.StatusOK, "application/octet-stream", data) +} diff --git a/deploy/caddy/Caddyfile b/deploy/caddy/Caddyfile index 50df064..c335674 100644 --- a/deploy/caddy/Caddyfile +++ b/deploy/caddy/Caddyfile @@ -53,7 +53,7 @@ # The game SPA and the Connect edge are served by the gateway. Strip any # client-supplied X-Scrabble-Honeypot here so the gateway only ever honours the # tag the honeypot block sets below (a client cannot self-tag a real request). - @gateway path /app /app/* /telegram /telegram/* /vk /vk/* /scrabble.edge.v1.Gateway/* + @gateway path /app /app/* /telegram /telegram/* /vk /vk/* /dict/* /scrabble.edge.v1.Gateway/* handle @gateway { reverse_proxy gateway:8081 { header_up -X-Scrabble-Honeypot diff --git a/docs/ARCHITECTURE.md b/docs/ARCHITECTURE.md index 8bbf8c6..eb0d3a1 100644 --- a/docs/ARCHITECTURE.md +++ b/docs/ARCHITECTURE.md @@ -352,6 +352,17 @@ Key points: - History is dictionary-independent (§9.1): the engine emits decoded `MoveRecord`s and reconstructs the board from them with `engine.ReplayBoard` (alphabet only, no dictionary). +- The **client mirrors this validation path for an instant move preview** (the + local eval, `ui/src/lib/dict`): the dawg reader and the + validate/score/direction slice are ported to TypeScript, byte-for-byte against + this engine and pinned by the `conformance` CI job. Composing a move is scored + on-device with no round trip. It is an **advisory accelerator only** — + `submit_play` re-validates on the server, so a wrong or spoofed local result + cannot corrupt a game. The pinned per-game dictionary blob is fetched once + through a **session-gated `GET /dict/{variant}/{version}`** edge route + (immutable; cached in IndexedDB best-effort) and reused across sessions; any + miss, storage eviction or a bad-connection breaker falls back to the network + `evaluate`. The warm-up overlay is `docs/UI_DESIGN.md`. ## 6. Game rules diff --git a/docs/FUNCTIONAL.md b/docs/FUNCTIONAL.md index 39c24b0..e05d9d1 100644 --- a/docs/FUNCTIONAL.md +++ b/docs/FUNCTIONAL.md @@ -163,7 +163,10 @@ tile that extends an existing word (down a column or across a row) is accepted. A play is validated against the game's dictionary at submit time and scored; an unlimited preview reports the word(s) a tentative move would form and its score, or that it is not -legal, and the move is offered for submission only once it is confirmed legal. The dictionary check tool is +legal, and the move is offered for submission only once it is confirmed legal. The preview is +computed **on-device** for an instant response once the game's dictionary has loaded — a +brief warm-up shows on the first open otherwise — and falls back to the server whenever the +local dictionary is unavailable. The dictionary check tool is unlimited and offers a complaint on any result; for a word it finds, it also links out to an external reference dictionary (gramota.ru for Russian games, scrabblewordfinder.org for English) to look it up. Hints are governed per game — diff --git a/docs/FUNCTIONAL_ru.md b/docs/FUNCTIONAL_ru.md index f94746d..f0d1aca 100644 --- a/docs/FUNCTIONAL_ru.md +++ b/docs/FUNCTIONAL_ru.md @@ -171,7 +171,9 @@ _Вход сейчас только через провайдера, поэто слово (по столбцу или по строке), принимается. Ход проверяется по словарю партии при сдаче и считается; безлимитный предпросмотр показывает слово (или слова), которое образует предполагаемый ход, и его очки — либо что ход недопустим, — и ход можно -отправить только после подтверждения, что он допустим. Инструмент проверки слова безлимитный и +отправить только после подтверждения, что он допустим. Предпросмотр считается **на устройстве** +и отвечает мгновенно, как только словарь партии загружен — иначе при первом открытии показывается +короткий прогрев, — и уходит на сервер, если локальный словарь недоступен. Инструмент проверки слова безлимитный и предлагает пожаловаться на любой результат; для найденного слова он также даёт ссылку на внешний справочный словарь (gramota.ru для русских игр, scrabblewordfinder.org для английских), чтобы его посмотреть. Подсказки управляются настройками diff --git a/docs/TESTING.md b/docs/TESTING.md index 8d08b35..18d0725 100644 --- a/docs/TESTING.md +++ b/docs/TESTING.md @@ -21,6 +21,15 @@ tests or touching CI. mock for the friends screen (code issue/redeem, accept a request), the lobby invitations section, the stats screen, profile editing, and the GCG export's finished-only visibility. +- **Local-eval conformance** — the client's on-device move preview (the ported + dawg reader + validator, `ui/src/lib/dict`) is checked byte-for-byte against the + authoritative Go engine. `backend/cmd/dictgen` and `backend/cmd/validategen` emit + golden vectors from the release dictionaries — every stored word plus a battery of + plays across both cross-word rules and all variants, each with the engine's + legality, score, words and inferred direction. The gated Vitest suites + (`ui/src/lib/dict/*.parity.test.ts`, skipped without their `DICT_*` env) replay + them and must agree exactly; the `conformance` CI job runs the whole loop, so a + drift in the port fails the merge. - **Engine** — correctness of scoring and move generation is owned by `scrabble-solver`'s own GCG-backed tests. `backend/internal/engine` adds, on top of the embedded solver: per-variant smoke tests (load all three committed diff --git a/docs/UI_DESIGN.md b/docs/UI_DESIGN.md index 00404da..c6bc65d 100644 --- a/docs/UI_DESIGN.md +++ b/docs/UI_DESIGN.md @@ -427,6 +427,20 @@ enabled on the first, uncached load) and flip in place when an event refreshes t raises a badge on the lobby ⚙️ tab (combined with the friend-request count) and a "1" on the Info tab. +## Dictionary warm-up overlay (`components/DictWarmup.svelte`) + +Opening a game whose local move-preview dictionary is not yet cached shows a brief, +non-dismissable warm-up overlay while it downloads (a returning player's cached +dictionary loads from IndexedDB in a few ms, so the flash-guard suppresses it then). +A darker-than-onboarding scrim covers +the board; centred on it, a large emoji cycles through a fixed playful sequence — one +per 500 ms tick (300 ms still, then a 200 ms clockwise spin with the current glyph +fading out and the next fading in), looping — under a constant "Loading…" caption. +Reduce-motion drops the spin to a plain cross-fade. A ~120 ms flash-guard suppresses +the overlay for a disk-cached dictionary that loads in a few ms; a cold (network) load +shows it until the dictionary is ready or a 5 s cap elapses, after which the preview +falls back to the network. See docs/ARCHITECTURE.md §5 (the local eval). + ## Caveat Emoji are rendered by the platform's system emoji font, so their exact look varies across diff --git a/gateway/cmd/gateway/main.go b/gateway/cmd/gateway/main.go index b108877..52428f1 100644 --- a/gateway/cmd/gateway/main.go +++ b/gateway/cmd/gateway/main.go @@ -194,6 +194,7 @@ func run(ctx context.Context, cfg config.Config, logger *zap.Logger) error { edge := connectsrv.NewServer(connectsrv.Deps{ Registry: registry, Sessions: sessions, + Backend: backend, Limiter: limiter, Tracker: tracker, Banlist: banlist, diff --git a/gateway/internal/backendclient/api.go b/gateway/internal/backendclient/api.go index 84ea8cd..9ee4bd0 100644 --- a/gateway/internal/backendclient/api.go +++ b/gateway/internal/backendclient/api.go @@ -483,6 +483,13 @@ func (c *Client) Evaluate(ctx context.Context, userID, gameID string, tiles []Pl return out, err } +// DictBytes fetches the raw serialized dictionary for a (variant, version) pair, +// backing the client-side local move preview. It returns the blob and the +// backend's Cache-Control header, forwarded so the immutable blob is cached hard. +func (c *Client) DictBytes(ctx context.Context, userID, variant, version string) ([]byte, string, error) { + return c.getRaw(ctx, "/api/v1/user/dict/"+url.PathEscape(variant)+"/"+url.PathEscape(version), userID, "Cache-Control") +} + // CheckWord looks a word up in the game's pinned dictionary. The word is carried as // repeated ?idx= alphabet indices; the backend echoes the decoded concrete word. func (c *Client) CheckWord(ctx context.Context, userID, gameID string, word []int) (WordCheckResp, error) { diff --git a/gateway/internal/backendclient/client.go b/gateway/internal/backendclient/client.go index 50ccbb7..ce2a93e 100644 --- a/gateway/internal/backendclient/client.go +++ b/gateway/internal/backendclient/client.go @@ -125,6 +125,34 @@ func (c *Client) do(ctx context.Context, method, path, userID, clientIP string, return nil } +// getRaw performs one REST GET, returning the raw (un-decoded) response body and +// the value of respHeader (e.g. Cache-Control). userID, when non-empty, is +// forwarded as X-User-ID. A non-2xx response is returned as an *APIError. Unlike +// do it does not JSON-decode, so it carries a binary payload such as a dictionary +// blob. +func (c *Client) getRaw(ctx context.Context, path, userID, respHeader string) ([]byte, string, error) { + req, err := http.NewRequestWithContext(ctx, http.MethodGet, c.baseURL+path, nil) + if err != nil { + return nil, "", fmt.Errorf("backendclient: new request: %w", err) + } + if userID != "" { + req.Header.Set("X-User-ID", userID) + } + resp, err := c.http.Do(req) + if err != nil { + return nil, "", fmt.Errorf("backendclient: GET %s: %w", path, err) + } + defer func() { _ = resp.Body.Close() }() + data, err := io.ReadAll(resp.Body) + if err != nil { + return nil, "", fmt.Errorf("backendclient: read response: %w", err) + } + if resp.StatusCode >= http.StatusMultipleChoices { + return nil, "", parseAPIError(resp.StatusCode, data) + } + return data, resp.Header.Get(respHeader), nil +} + // parseAPIError extracts the backend's {error:{code,message}} envelope. func parseAPIError(status int, data []byte) *APIError { var env struct { diff --git a/gateway/internal/connectsrv/server.go b/gateway/internal/connectsrv/server.go index 1b38693..c3d5588 100644 --- a/gateway/internal/connectsrv/server.go +++ b/gateway/internal/connectsrv/server.go @@ -68,6 +68,7 @@ const ( type Server struct { registry *transcode.Registry sessions *session.Cache + backend *backendclient.Client limiter *ratelimit.Limiter tracker *ratelimit.Tracker banlist *ratelimit.Banlist @@ -91,7 +92,10 @@ type Server struct { type Deps struct { Registry *transcode.Registry Sessions *session.Cache - Limiter *ratelimit.Limiter + // Backend is the REST client backing the session-gated /dict blob route; a nil + // value disables that route (it 404s). + Backend *backendclient.Client + Limiter *ratelimit.Limiter // Tracker accumulates limiter rejections for the periodic report; nil // selects a private tracker (rejections are then only counted, never // reported). @@ -142,6 +146,7 @@ func NewServer(d Deps) *Server { return &Server{ registry: d.Registry, sessions: d.Sessions, + backend: d.Backend, limiter: limiter, tracker: tracker, banlist: banlist, @@ -183,6 +188,9 @@ func (s *Server) HTTPHandler() http.Handler { // does not serve the app shell at the operator path. mux.Handle("/_gm/", http.NotFoundHandler()) } + // The client-side local move preview pulls each game's pinned dictionary blob + // through this session-gated route (not public); see dictBytesHandler. + mux.Handle("/dict/", s.dictBytesHandler()) // The embedded UI: the game SPA under /app/ (web), /telegram/ (the Telegram Mini // App) and /vk/ (the VK Mini App) — the single-origin model (docs/ARCHITECTURE.md // §13). All sit below the h2c wrap so the Connect edge (a more specific prefix) keeps @@ -397,6 +405,75 @@ func (s *Server) limitAdmin(next http.Handler) http.Handler { }) } +// dictBytesHandler serves the raw dictionary blob for a (variant, version) pair to +// a signed-in client (the local move preview), proxying the backend's authed +// endpoint. It is session-gated — not public — bounds the download with the +// user-class limiter, and forwards the backend's immutable Cache-Control so the +// browser caches the blob hard. Only GET is allowed; the path is +// /dict/{variant}/{version}. +func (s *Server) dictBytesHandler() http.Handler { + return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + if s.backend == nil { + http.NotFound(w, r) + return + } + if r.Method != http.MethodGet { + http.Error(w, "method not allowed", http.StatusMethodNotAllowed) + return + } + ip := peerIP(r.RemoteAddr, r.Header) + if !s.limiter.Allow("user:"+ip, s.userPolicy) { + s.noteRateLimited(r.Context(), classUser, ip, "dict") + http.Error(w, "rate limited", http.StatusTooManyRequests) + return + } + uid, _, err := s.resolve(r.Context(), r.Header, ip) + if err != nil { + http.Error(w, "unauthorized", http.StatusUnauthorized) + return + } + variant, version, ok := parseDictPath(r.URL.Path) + if !ok { + http.NotFound(w, r) + return + } + data, cacheControl, err := s.backend.DictBytes(r.Context(), uid, variant, version) + if err != nil { + var apiErr *backendclient.APIError + if errors.As(err, &apiErr) && apiErr.Status < http.StatusInternalServerError { + http.NotFound(w, r) // unknown variant or version + return + } + s.log.Warn("dict fetch failed", zap.Error(err)) + http.Error(w, "bad gateway", http.StatusBadGateway) + return + } + if cacheControl != "" { + w.Header().Set("Cache-Control", cacheControl) + } + w.Header().Set("Content-Type", "application/octet-stream") + _, _ = w.Write(data) + }) +} + +// parseDictPath splits /dict/{variant}/{version}; both segments must be present +// and non-empty, and the version must not contain a further slash. +func parseDictPath(p string) (variant, version string, ok bool) { + rest, found := strings.CutPrefix(p, "/dict/") + if !found { + return "", "", false + } + i := strings.IndexByte(rest, '/') + if i <= 0 || i == len(rest)-1 { + return "", "", false + } + variant, version = rest[:i], rest[i+1:] + if strings.Contains(version, "/") { + return "", "", false + } + return variant, version, true +} + // resolve extracts and resolves the Authorization bearer token to an account id // and its guest flag, returning a Connect Unauthenticated error when it is missing // or unknown. diff --git a/ui/scripts/bundle-size.mjs b/ui/scripts/bundle-size.mjs index 76046ef..61c0519 100644 --- a/ui/scripts/bundle-size.mjs +++ b/ui/scripts/bundle-size.mjs @@ -19,8 +19,10 @@ import { join } from 'node:path'; const DIST = 'dist'; -// Per-chunk gzip budgets in KB. -const BUDGET = { app: 100, shared: 30, landing: 5 }; +// Per-chunk gzip budgets in KB. The app entry was raised to 110 when the local +// move-preview wiring landed (the heavy dict subsystem stays in lazy chunks; this +// covers the small in-entry game/debug wiring it needs). +const BUDGET = { app: 110, shared: 30, landing: 5 }; // gzipped returns the gzipped byte size of a built asset, or 0 when the reference is not a // local file (e.g. the Telegram SDK loaded from a CDN) or is missing. diff --git a/ui/src/components/DebugPanel.svelte b/ui/src/components/DebugPanel.svelte index 8bdf6b0..6960589 100644 --- a/ui/src/components/DebugPanel.svelte +++ b/ui/src/components/DebugPanel.svelte @@ -4,18 +4,39 @@ // snapshot (no secrets, no initData values, no IP) and shares it through the OS share sheet (or a // clipboard copy on desktop) — a support aid for reproducing client-specific issues, e.g. the // Telegram Android presentation quirks. Drawn from the top, just under the app header. + import { onMount } from 'svelte'; import { app, closeDebug, resetOnboarding } from '../lib/app.svelte'; import { connection } from '../lib/connection.svelte'; import { shareText } from '../lib/share'; import { telegramChromeDiag } from '../lib/telegram'; - const report = [ - `app: ${__APP_VERSION__}`, - `locale: ${app.locale} theme: ${app.theme} reduceMotion: ${app.reduceMotion}`, - `online: ${connection.online} streamAlive: ${app.streamAlive}`, - `userId: ${app.session?.userId ?? '—'} guest: ${app.profile?.isGuest ?? '—'}`, - telegramChromeDiag(), - ].join('\n'); + // The local move-preview snapshot — the feature flag, the bad-connection breaker and the + // dictionaries cached in IndexedDB with their sizes — loads async so a report about the + // preview is precise. Dynamically imported so the debug panel keeps the dict code lazy. + let dictInfo = $state('local eval: …'); + onMount(() => { + void (async () => { + try { + const m = await import('../lib/dict'); + const list = await m.listCachedDicts(); + const cached = list.length ? list.map((d) => `${d.key}=${(d.bytes / 1024).toFixed(0)}KB`).join(' ') : 'none'; + dictInfo = `dict breaker: ${m.dictLoadingDisabled() ? 'TRIPPED' : 'ok'}\ndicts cached: ${cached}`; + } catch { + dictInfo = 'local eval: n/a'; + } + })(); + }); + + const report = $derived( + [ + `app: ${__APP_VERSION__}`, + `locale: ${app.locale} theme: ${app.theme} reduceMotion: ${app.reduceMotion}`, + `online: ${connection.online} streamAlive: ${app.streamAlive}`, + `userId: ${app.session?.userId ?? '—'} guest: ${app.profile?.isGuest ?? '—'}`, + telegramChromeDiag(), + dictInfo, + ].join('\n'), + ); let label = $state('Share'); async function share(e: MouseEvent): Promise { @@ -33,6 +54,13 @@ function resetVisited(e: MouseEvent): void { e.stopPropagation(); resetOnboarding(); + // Also clear the local move-preview dictionary cache (safe — it re-downloads). + // Dynamically imported so the debug panel keeps the dict code out of the app bundle. + void import('../lib/dict').then((m) => { + m.clearDictCache(); + m.clearDictInstances(); + m.bustDictHttpCache(); // also bypass the browser HTTP cache on the next load (a true cold test) + }); resetLabel = 'Cleared — relaunch'; setTimeout(() => (resetLabel = 'Reset visited'), 1800); } diff --git a/ui/src/components/DictWarmup.svelte b/ui/src/components/DictWarmup.svelte new file mode 100644 index 0000000..0806362 --- /dev/null +++ b/ui/src/components/DictWarmup.svelte @@ -0,0 +1,81 @@ + + +
+
+
+ {#key index} + {EMOJIS[index]} + {/key} +
+ {t('dict.loading')} +
+
+ + diff --git a/ui/src/game/Game.svelte b/ui/src/game/Game.svelte index 4b03091..2ebbd08 100644 --- a/ui/src/game/Game.svelte +++ b/ui/src/game/Game.svelte @@ -4,6 +4,7 @@ import TabBar from '../components/TabBar.svelte'; import TapConfirm from '../components/TapConfirm.svelte'; import Modal from '../components/Modal.svelte'; + import DictWarmup from '../components/DictWarmup.svelte'; import Board from './Board.svelte'; import Rack from './Rack.svelte'; import { gateway } from '../lib/gateway'; @@ -50,6 +51,15 @@ let placement = $state(newPlacement([])); let preview = $state(null); let busy = $state(false); + + // The local move-preview subsystem (dynamically imported when enabled) and the warm-up + // overlay state. dict is null until the subsystem loads, or when the feature is disabled. + let dict = $state(null); + let dictWarming = $state(false); + let warmStarted = false; + // Aborts the in-flight dictionary download (at the warm-up cap, or when leaving the game) + // so a large download does not keep starving the channel on a slow link. + let warmCtrl: AbortController | null = null; let zoomed = $state(false); let selected = $state(null); let focus = $state<{ row: number; col: number } | null>(null); @@ -244,6 +254,24 @@ void load(); void loadFriends(); void loadBlocked(); + // Load the local move-preview subsystem (kept out of the initial bundle) when enabled, + // so composing a move can be scored on-device; the network preview stays the fallback. + // Skipped in mock mode (no dictionary server; the mock uses the network preview path and + // must never see the warm-up overlay, which would intercept the e2e's taps). + if (import.meta.env.MODE !== 'mock') { + void import('../lib/dict').then((m) => { + dict = m; + }); + } + }); + + // Warm the game's dictionary for the local move preview once, when both the game and the + // dynamically-imported preview subsystem are available (see warmDict). + $effect(() => { + if (dict && view && !warmStarted) { + warmStarted = true; + void warmDict(); + } }); // cacheSnapshot returns the open game's current state as a CachedGame for the delta reducers. @@ -569,6 +597,10 @@ clearReorder(); } onDestroy(() => { + // Leaving the game: abort a still-downloading dictionary and any in-flight preview so they + // stop holding the channel. + warmCtrl?.abort(); + evalCtrl?.abort(); window.removeEventListener('pointermove', onWinMove); window.removeEventListener('pointerup', onWinUp); window.removeEventListener('pointerdown', onExtraPointer); @@ -638,19 +670,74 @@ scheduleDraftSave(); } + // warmDict loads the game's dictionary for the local move preview when the player opens the + // game. When it is already cached the preview is instant and no overlay shows; a cold + // dictionary downloads behind a non-dismissable warm-up overlay, which hides on load or after + // a 5s cap — the preview then uses the network until the download finishes in the background. + // The bad-connection breaker skips the overlay and goes straight to the network. + async function warmDict() { + const d = dict; + const v = view; + if (!d || !v) return; + if (d.hasDawg(v.game.variant, v.game.dictVersion) || d.dictLoadingDisabled()) return; + const ctrl = new AbortController(); + warmCtrl = ctrl; + let settled = false; + // A short flash-guard: a disk-cached dictionary loads in a few ms, so only show the + // overlay if the load has not settled by now — a cold (network) load always outlasts it. + const grace = setTimeout(() => { + if (!settled) dictWarming = true; + }, 120); + const loaded = await Promise.race([ + d.getDawg(v.game.variant, v.game.dictVersion, ctrl.signal), + new Promise((r) => setTimeout(() => r(null), 5000)), + ]); + settled = true; + clearTimeout(grace); + dictWarming = false; + if (!loaded) { + // Did not load within the cap: abort the download so it stops starving the channel this + // session, and count the miss toward the bad-connection breaker (3 -> stop warming). + ctrl.abort(); + d.noteDictMiss(); + } + } + let previewTimer: ReturnType | null = null; + let evalCtrl: AbortController | null = null; function recompute() { preview = null; if (previewTimer) clearTimeout(previewTimer); + // The tiles changed: cancel any in-flight network preview (evaluate is non-mutating, so + // aborting is safe) — a stale, out-of-order response cannot overwrite the newer one, and + // rapid placements do not pile up requests on a slow link. + evalCtrl?.abort(); + evalCtrl = null; // Off-turn the composition is position-only: no score preview or evaluate. if (!isMyTurn) return; const sub = toSubmit(placement); if (!sub) return; + // Instant on-device preview when the game's dictionary is warm; the network otherwise. + const d = dict; + const v = view; + if (d && v) { + const reader = d.peekDawg(v.game.variant, v.game.dictVersion); + if (reader && hasAlphabet(v.game.variant)) { + try { + preview = d.evaluateLocal(reader, v.game.variant, board, sub.tiles, v.game.multipleWordsPerTurn); + return; + } catch { + /* fall through to the network preview */ + } + } + } + const ctrl = new AbortController(); + evalCtrl = ctrl; previewTimer = setTimeout(async () => { try { - preview = await gateway.evaluate(id, sub.tiles, variant); + preview = await gateway.evaluate(id, sub.tiles, variant, ctrl.signal); } catch { - /* best-effort */ + /* best-effort (or aborted) */ } }, 250); } @@ -1122,6 +1209,11 @@ {/if} + +{#if dictWarming} + +{/if} + {#snippet scoreboardBlock()} diff --git a/ui/src/lib/alphabet.ts b/ui/src/lib/alphabet.ts index 3f0269f..a89b0ce 100644 --- a/ui/src/lib/alphabet.ts +++ b/ui/src/lib/alphabet.ts @@ -73,6 +73,12 @@ export function valueForLetter(variant: Variant, letter: string): number { return i === undefined ? 0 : t.values[i]; } +/** alphabetValues returns a variant's tile values indexed by alphabet letter index, or an + * empty array when the table is not yet cached. The local move validator scores with it. */ +export function alphabetValues(variant: Variant): readonly number[] { + return cache.get(variant)?.values ?? []; +} + /** indexForLetter maps a display letter to its wire index; a blank ("?") maps to the blank * sentinel. It throws when the letter is outside the cached alphabet — a placement bug, not * user input (the UI constrains every entry point to the variant's alphabet). */ diff --git a/ui/src/lib/client.ts b/ui/src/lib/client.ts index 27a56db..3f0d82b 100644 --- a/ui/src/lib/client.ts +++ b/ui/src/lib/client.ts @@ -91,12 +91,19 @@ export interface GatewayClient { exchange(gameId: string, tiles: string[], variant: Variant): Promise; resign(gameId: string): Promise; hint(gameId: string): Promise; - evaluate(gameId: string, tiles: PlacedTile[], variant: Variant): Promise; + evaluate(gameId: string, tiles: PlacedTile[], variant: Variant, signal?: AbortSignal): Promise; checkWord(gameId: string, word: string, variant: Variant): Promise; complaint(gameId: string, word: string, note: string): Promise; /** Hide a finished game from the caller's own lobby list; per-account, irreversible. */ hideGame(gameId: string): Promise; + // --- dictionary (local move preview) --- + /** Fetch the raw serialized dictionary blob for a (variant, version) pair. Session-gated; + * lets the client validate and score a move locally instead of a network round trip. + * opts.signal aborts a stalled download; opts.reload bypasses the browser HTTP cache (the + * debug reset, for testing a cold load). */ + fetchDict(variant: Variant, version: string, opts?: { signal?: AbortSignal; reload?: boolean }): Promise; + // --- draft --- /** The player's server-persisted client-side composition (rack order + board tiles), so a * reload or a second device resumes the same arrangement. The JSON is opaque to the diff --git a/ui/src/lib/dict/dawg.parity.test.ts b/ui/src/lib/dict/dawg.parity.test.ts new file mode 100644 index 0000000..239047b --- /dev/null +++ b/ui/src/lib/dict/dawg.parity.test.ts @@ -0,0 +1,71 @@ +import { describe, it, expect } from 'vitest'; +import { readFileSync, existsSync } from 'node:fs'; +import { join } from 'node:path'; +import { Dawg } from './dawg'; + +// Conformance gate for the ported dawg reader: it must agree with the +// authoritative Go dafsa reader on indexOf for EVERY stored word and EVERY +// negative, across all three shipped dictionaries. The golden vectors are +// produced by `go run ./backend/cmd/dictgen`; point the test at them with: +// DICT_DAWG_DIR=/dawg DICT_GOLD_DIR= +// When those are unset the suite skips (kept out of the default unit run until +// a committed sample fixture lands in Phase 1). +const dawgDir = process.env.DICT_DAWG_DIR; +const goldDir = process.env.DICT_GOLD_DIR; +const ready = !!dawgDir && !!goldDir && existsSync(dawgDir) && existsSync(goldDir); + +const names = ['en_sowpods', 'ru_scrabble', 'ru_erudit']; + +// eachWord walks the [1-byte length][index bytes] framing emitted by dictgen. +function* eachWord(buf: Buffer): Generator { + let off = 0; + while (off < buf.length) { + const len = buf[off]; + yield buf.subarray(off + 1, off + 1 + len); + off += 1 + len; + } +} + +describe.skipIf(!ready)('dawg reader parity vs Go dafsa', () => { + for (const name of names) { + it( + name, + () => { + const bytes = new Uint8Array(readFileSync(join(dawgDir!, `${name}.dawg`))); + const meta = JSON.parse(readFileSync(join(goldDir!, `${name}.meta.json`), 'utf8')); + const dawg = new Dawg(bytes); + + expect(dawg.numAdded).toBe(meta.numAdded); + expect(dawg.numNodes).toBe(meta.numNodes); + + // Every stored word: indexOf(word) equals its position in index order. + let idx = 0; + let mismatches = 0; + const firstBad: string[] = []; + for (const word of eachWord(readFileSync(join(goldDir!, `${name}.words.bin`)))) { + const got = dawg.indexOf(word); + if (got !== idx) { + mismatches++; + if (firstBad.length < 5) firstBad.push(`idx ${idx}: got ${got} word [${Array.from(word)}]`); + } + idx++; + } + expect(idx).toBe(meta.numAdded); + expect(mismatches, firstBad.join('; ')).toBe(0); + + // Negatives must resolve to -1. + let negMismatches = 0; + const firstNeg: string[] = []; + for (const word of eachWord(readFileSync(join(goldDir!, `${name}.neg.bin`)))) { + const got = dawg.indexOf(word); + if (got !== -1) { + negMismatches++; + if (firstNeg.length < 5) firstNeg.push(`got ${got} word [${Array.from(word)}]`); + } + } + expect(negMismatches, firstNeg.join('; ')).toBe(0); + }, + 120_000, + ); + } +}); diff --git a/ui/src/lib/dict/dawg.ts b/ui/src/lib/dict/dawg.ts new file mode 100644 index 0000000..0953c5f --- /dev/null +++ b/ui/src/lib/dict/dawg.ts @@ -0,0 +1,202 @@ +// In-memory reader for the dafsa DAWG binary format, ported byte-for-byte from +// github.com/iliadenisov/dafsa (bits.go / disk.go / dawg.go). It answers the one +// query the local move validator needs: given a word as alphabet-index bytes, is +// it stored in the dictionary and at what insertion index. +// +// The Go reader treats the file as a big-endian, MSB-first bit stream. Every +// field this reader touches on the lookup path is at most ~31 bits wide, so the +// bit reader accumulates into a plain JavaScript number (no BigInt) and stays +// exact. Faithfulness to the Go reader is enforced by dawg.parity.test.ts, which +// checks indexOf against the authoritative Go output across the whole dictionary. + +// bitsLen returns the number of bits needed to represent x (Go math/bits.Len). +function bitsLen(x: number): number { + return x === 0 ? 0 : 32 - Math.clz32(x); +} + +/** + * Dawg is a read-only view over a serialized dafsa dictionary held in memory. + * Construct it from the raw bytes of a `.dawg` file, then call {@link indexOf}. + */ +export class Dawg { + private readonly bytes: Uint8Array; + private p = 0; // current position, in bits + + private readonly cbits: number; + private readonly abits: number; + private readonly numEdges: number; + private readonly wbits: number; + private readonly firstNodeOffset: number; + private readonly hasEmptyWord: boolean; + + /** Number of words stored (dafsa NumAdded). */ + readonly numAdded: number; + /** Number of graph nodes (dafsa NumNodes). */ + readonly numNodes: number; + + // Scratch for the last edge resolved by getEdge, mirroring dafsa's edgeEnd + + // final flag. Reused to keep the lookup path allocation-free. + private eNode = 0; + private eCount = 0; + private eFinal = false; + + constructor(bytes: Uint8Array) { + this.bytes = bytes; + + // Reject anything that is not a serialized dawg — e.g. an HTML error page or a + // truncated download routed here by mistake. The 32-bit big-endian header size is + // the total byte length; if it does not match, the blob is not a dawg. Without + // this guard a non-dawg blob would parse into a bogus reader that silently reports + // every word as missing (so the caller must throw here to fall back to the network). + const declaredSize = ((bytes[0] << 24) | (bytes[1] << 16) | (bytes[2] << 8) | bytes[3]) >>> 0; + if (declaredSize !== bytes.length) { + throw new Error(`dawg: not a dawg blob (size header ${declaredSize} != ${bytes.length} bytes)`); + } + + // Header: 32-bit size (skipped — the whole file is already in memory), then + // cbits, abits, the language code string, and the word/node/edge counts. + this.p = 32; + this.cbits = this.readBits(8); + this.abits = this.readBits(8); + this.skipString(); // language code — not needed to walk index bytes + + const numAdded = this.readUnsigned(); + const numNodes = this.readUnsigned(); + this.numEdges = this.readUnsigned(); + this.firstNodeOffset = this.p; + this.hasEmptyWord = this.readBits(1) === 1; + + this.numAdded = numAdded; + this.numNodes = numNodes; + this.wbits = bitsLen(numAdded); + } + + /** + * indexOf returns the insertion index of the given word (as alphabet-index + * bytes), or -1 if the word was never added. Mirrors dafsa IndexOfB. + */ + indexOf(word: ArrayLike): number { + let skipped = 0; + let node = 0; // rootNode + let final = this.hasEmptyWord; + for (let i = 0; i < word.length; i++) { + if (!this.getEdge(node, word[i])) { + return -1; + } + node = this.eNode; + final = this.eFinal; + skipped += this.eCount; + } + return final ? skipped : -1; + } + + /** has reports whether the word (as alphabet-index bytes) is in the dictionary. */ + has(word: ArrayLike): boolean { + return this.indexOf(word) >= 0; + } + + // getEdge resolves the outgoing edge for ch from the node at the given bit + // offset. On success it fills eNode/eCount/eFinal and returns true. Mirrors + // dafsa (*dawg).getEdge. + private getEdge(node: number, ch: number): boolean { + if (this.numEdges <= 0) { + return false; + } + const pos = node === 0 ? this.firstNodeOffset : node; + this.p = pos; + const nodeFinal = this.readBits(1); + const fallthrough = this.readBits(1); + + if (fallthrough === 1) { + const edgeCh = this.readBits(this.cbits); + if (edgeCh === ch) { + this.eCount = nodeFinal; + this.eNode = this.p; // the fallthrough target is the physically next node + this.eFinal = this.readBits(1) === 1; + return true; + } + return false; + } + + const singleEdge = this.readBits(1); + let numEdges = 1; + const nskiplen = bitsLen(this.wbits); + let nskip = 0; + if (singleEdge !== 1) { + numEdges = this.readUnsigned(); + nskip = this.readBits(nskiplen); + } + + const base = this.p; // bit offset of the first edge record + const recordBits = this.cbits + nskip + this.abits; + + // Binary search over the edges, which are sorted ascending by character. + let high = numEdges; + let low = -1; + while (high - low > 1) { + const probe = (high + low) >> 1; + // The first edge omits its (zero) skip field, so every later record is + // shifted back by nskip bits. + let seekTo = base + probe * recordBits; + if (probe > 0) { + seekTo -= nskip; + } + this.p = seekTo; + const edgeCh = this.readBits(this.cbits); + const cmp = edgeCh - ch; + if (cmp === 0) { + this.eCount = probe > 0 ? this.readBits(nskip) : nodeFinal; + this.eNode = this.readBits(this.abits); + this.p = this.eNode; + this.eFinal = this.readBits(1) === 1; + return true; + } else if (cmp < 0) { + low = probe; + } else { + high = probe; + } + } + return false; + } + + // readBits reads n (<= 32) bits MSB-first from the current position and + // advances it. Mirrors dafsa (*bitSeeker).ReadBits for the widths on this path. + private readBits(n: number): number { + let result = 0; + let p = this.p; + const bytes = this.bytes; + while (n > 0) { + const byteIndex = p >>> 3; + const bitInByte = p & 7; + const avail = 8 - bitInByte; + const take = avail < n ? avail : n; + const shift = avail - take; + const chunk = (bytes[byteIndex] >>> shift) & ((1 << take) - 1); + result = result * (1 << take) + chunk; + p += take; + n -= take; + } + this.p = p; + return result; + } + + // readUnsigned reads a dafsa "7code" varint (7 payload bits per byte, high bit + // continues). Mirrors dafsa readUnsigned. + private readUnsigned(): number { + let result = 0; + for (;;) { + const d = this.readBits(8); + result = result * 128 + (d & 0x7f); + if ((d & 0x80) === 0) { + break; + } + } + return result; + } + + // skipString advances past a 7code-length-prefixed byte string. + private skipString(): void { + const n = this.readUnsigned(); + this.p += n * 8; + } +} diff --git a/ui/src/lib/dict/direction.ts b/ui/src/lib/dict/direction.ts new file mode 100644 index 0000000..c3e1cfc --- /dev/null +++ b/ui/src/lib/dict/direction.ts @@ -0,0 +1,73 @@ +// Play-direction inference, ported from the backend engine (direction.go +// resolveDirection/runLength and domain.go (*Game).playDirection, v1.1.1). The +// client sends tiles without an orientation; the server infers it, so the local +// move preview must infer the same one or its words and score would diverge. + +import { validatePlay, Horizontal, Vertical, type Board, type Ruleset, type Placement, type Direction, type Dict } from './validate'; + +// runLength mirrors engine.runLength: how many cells the word through (row, col) +// along dir spans, counting the target square plus the filled runs either side. +function runLength(b: Board, row: number, col: number, dir: Direction): number { + let dr = 0; + let dc = 1; + if (dir === Vertical) { + dr = 1; + dc = 0; + } + let n = 1; + for (let r = row - dr, c = col - dc; b.filled(r, c); r -= dr, c -= dc) n++; + for (let r = row + dr, c = col + dc; b.filled(r, c); r += dr, c += dc) n++; + return n; +} + +/** + * resolveDirection infers a play's orientation from geometry alone, mirroring + * engine.resolveDirection: two or more tiles by the line they share; a single tile + * by the axis along which it abuts existing tiles, preferring the longer word and + * horizontal on a tie; a tile abutting nothing falls back to horizontal. + */ +export function resolveDirection(b: Board, placements: Placement[]): Direction { + if (placements.length >= 2) { + const row = placements[0].row; + for (let i = 1; i < placements.length; i++) { + if (placements[i].row !== row) return Vertical; + } + return Horizontal; + } + if (placements.length === 1) { + const p = placements[0]; + const h = runLength(b, p.row, p.col, Horizontal); + const v = runLength(b, p.row, p.col, Vertical); + if (v >= 2 && v > h) return Vertical; + if (h >= 2) return Horizontal; + if (v >= 2) return Vertical; + } + return Horizontal; +} + +/** + * playDirection resolves the orientation for a live play, mirroring engine + * (*Game).playDirection. It is the geometric resolution, except a single tile under + * the single-word rule (ignoreCrossWords) is ambiguous — its two orientations may + * differ in legality — so both are tried through the validator and the + * higher-scoring legal one is kept (horizontal breaks a tie). + */ +export function playDirection(b: Board, rs: Ruleset, dict: Dict, placements: Placement[]): Direction { + const geo = resolveDirection(b, placements); + if (placements.length !== 1 || !rs.ignoreCrossWords) { + return geo; + } + let best = geo; + let found = false; + let bestScore = 0; + for (const dir of [Horizontal, Vertical] as Direction[]) { + const r = validatePlay(b, rs, dict, dir, placements); + if (!r.legal || !r.move) continue; + if (!found || r.move.score > bestScore) { + best = dir; + found = true; + bestScore = r.move.score; + } + } + return best; +} diff --git a/ui/src/lib/dict/eval.parity.test.ts b/ui/src/lib/dict/eval.parity.test.ts new file mode 100644 index 0000000..c34d6e5 --- /dev/null +++ b/ui/src/lib/dict/eval.parity.test.ts @@ -0,0 +1,110 @@ +import { describe, it, expect } from 'vitest'; +import { readFileSync, existsSync } from 'node:fs'; +import { join } from 'node:path'; +import { Dawg } from './dawg'; +import { evaluateLocal } from './eval'; +import { setAlphabet } from '../alphabet'; +import type { Board as ClientBoard, BoardCell } from '../board'; +import type { PlacedTile } from '../client'; +import type { Variant } from '../model'; + +// End-to-end conformance for the local-eval ADAPTER: it drives evaluateLocal through +// the real letter-space client path (the server-sent alphabet table, a letter board, +// letter placements) and asserts it matches the Go engine's EvalResult for every +// fixture. This is the cross-test the algorithm-level validate.parity suite does not +// cover — it exercises the letter<->index mapping, the ruleset assembly and the word +// decode, not just the index-space validator. Env-gated like the other parity suites. +const dawgDir = process.env.DICT_DAWG_DIR; +const validDir = process.env.DICT_VALID_DIR; +const ready = !!dawgDir && !!validDir && existsSync(dawgDir) && existsSync(validDir); + +const dawgFile: Record = { + scrabble_en: 'en_sowpods.dawg', + scrabble_ru: 'ru_scrabble.dawg', + erudit_ru: 'ru_erudit.dawg', +}; + +interface FxCell { + R: number; + C: number; + Letter: number; + Blank: boolean; +} +interface FxWord { + Letters: number[]; +} +interface Fx { + board: number; + ignoreCrossWords: boolean; + tiles: FxCell[]; + legal: boolean; + score: number; + main?: FxWord; + cross?: FxWord[]; +} +interface Alpha { + index: number; + letter: string; + value: number; +} +interface VF { + rows: number; + cols: number; + alphabet: Alpha[]; + boards: (FxCell[] | null)[]; + fixtures: Fx[]; +} + +describe.skipIf(!ready)('local-eval adapter parity vs Go engine', () => { + for (const variant of Object.keys(dawgFile)) { + it( + variant, + () => { + const vf: VF = JSON.parse(readFileSync(join(validDir!, `${variant}.fixtures.json`), 'utf8')); + const dawg = new Dawg(new Uint8Array(readFileSync(join(dawgDir!, dawgFile[variant])))); + const v = variant as Variant; + + // Seed the alphabet cache exactly as the server would (wire entries, lower-cased + // letters); the adapter re-encodes through it, just like production. + setAlphabet(v, vf.alphabet.map((a) => ({ index: a.index, letter: a.letter, value: a.value }))); + const upper = vf.alphabet.map((a) => a.letter.toUpperCase()); + const lower = vf.alphabet.map((a) => a.letter); + + let legalCount = 0; + let mismatches = 0; + const first: string[] = []; + + for (let fi = 0; fi < vf.fixtures.length; fi++) { + const fx = vf.fixtures[fi]; + + // Build the client's letter-space board and placements from the fixture. + const board: ClientBoard = Array.from({ length: vf.rows }, () => + Array.from({ length: vf.cols }, () => null as BoardCell | null), + ); + for (const cl of vf.boards[fx.board] ?? []) board[cl.R][cl.C] = { letter: upper[cl.Letter], blank: cl.Blank }; + const tiles: PlacedTile[] = fx.tiles.map((t) => ({ row: t.R, col: t.C, letter: upper[t.Letter], blank: t.Blank })); + + const res = evaluateLocal(dawg, v, board, tiles, !fx.ignoreCrossWords); + + if (fx.legal) legalCount++; + + let ok = res.legal === fx.legal; + if (ok && fx.legal) { + const wantWords = [fx.main!, ...(fx.cross ?? [])].map((w) => w.Letters.map((i) => lower[i]).join('')); + ok = res.score === fx.score && res.words.length === wantWords.length && res.words.every((w, i) => w === wantWords[i]); + } + if (!ok) { + mismatches++; + if (first.length < 8) { + first.push(`#${fi} exp legal=${fx.legal} score=${fx.score} got legal=${res.legal} score=${res.score} words=${JSON.stringify(res.words)}`); + } + } + } + + expect(legalCount, 'fixtures should include legal plays').toBeGreaterThan(0); + expect(mismatches, first.join(' | ')).toBe(0); + }, + 120_000, + ); + } +}); diff --git a/ui/src/lib/dict/eval.ts b/ui/src/lib/dict/eval.ts new file mode 100644 index 0000000..2db8b2a --- /dev/null +++ b/ui/src/lib/dict/eval.ts @@ -0,0 +1,104 @@ +// The local move preview: it produces the same EvalResult the network `evaluate` +// returns (legality, score, the words formed, the inferred orientation), computed +// on-device from a loaded dictionary so composing a move needs no round trip. It +// adapts the client's letter-space board and placements into the validator's +// index space (lib/dict/validate.ts), assembling the per-variant ruleset from the +// static geometry/constants (lib/premiums.ts) and the server-sent tile values +// (lib/alphabet.ts). The server stays authoritative — submit re-validates — so any +// throw here is caught by the caller, which falls back to the network preview. + +import { validatePlay, Horizontal, type Board as VBoard, type Ruleset, type Placement as VPlacement, type Dict } from './validate'; +import { playDirection } from './direction'; +import type { Board as ClientBoard } from '../board'; +import type { PlacedTile } from '../client'; +import type { EvalResult, Variant } from '../model'; +import { premiumGrid, centre, BOARD_SIZE, RACK_SIZE, BINGO, type Premium } from '../premiums'; +import { alphabetValues, indexForLetter, letterForIndex } from '../alphabet'; + +function letterMultOf(p: Premium): number { + return p === 'DL' ? 2 : p === 'TL' ? 3 : 1; +} +function wordMultOf(p: Premium): number { + return p === 'DW' ? 2 : p === 'TW' ? 3 : 1; +} + +// buildBoard adapts the client's letter-space board into the validator's +// index-space read view, precomputing the letter indices once. +function buildBoard(variant: Variant, board: ClientBoard): VBoard { + const grid: ({ letter: number; blank: boolean } | null)[] = new Array(BOARD_SIZE * BOARD_SIZE).fill(null); + let empty = true; + for (let r = 0; r < BOARD_SIZE; r++) { + for (let c = 0; c < BOARD_SIZE; c++) { + const cell = board[r][c]; + if (cell) { + grid[r * BOARD_SIZE + c] = { letter: indexForLetter(variant, cell.letter), blank: cell.blank }; + empty = false; + } + } + } + const inBounds = (r: number, c: number): boolean => r >= 0 && r < BOARD_SIZE && c >= 0 && c < BOARD_SIZE; + return { + inBounds, + filled: (r, c) => inBounds(r, c) && grid[r * BOARD_SIZE + c] !== null, + cellAt: (r, c) => grid[r * BOARD_SIZE + c]!, + isEmpty: () => empty, + }; +} + +// buildRuleset assembles the per-variant scoring rules the validator needs. +function buildRuleset(variant: Variant, multipleWords: boolean): Ruleset { + const prem = premiumGrid(variant); + const ctr = centre(variant); + const values = alphabetValues(variant); + return { + cols: BOARD_SIZE, + center: ctr.row * BOARD_SIZE + ctr.col, + rackSize: RACK_SIZE, + bingo: BINGO[variant], + values, + letterMult: (r, c) => letterMultOf(prem[r][c]), + wordMult: (r, c) => wordMultOf(prem[r][c]), + ignoreCrossWords: !multipleWords, + }; +} + +// decodeWord turns a word's alphabet indices back into a lower-cased string, the +// form the network EvalResult carries (the caption renders it directly). +function decodeWord(variant: Variant, letters: number[]): string { + let s = ''; + for (const i of letters) s += letterForIndex(variant, i); + return s.toLowerCase(); +} + +/** + * evaluateLocal computes the move preview for tiles placed on board in the given + * game, returning the same shape as the network `evaluate`. dict is the loaded + * dictionary reader for the game's (variant, version); multipleWords is the game's + * cross-word rule. It may throw if the variant's alphabet table is not loaded — the + * caller guards with hasAlphabet and falls back to the network on any failure. + */ +export function evaluateLocal( + dict: Dict, + variant: Variant, + board: ClientBoard, + tiles: PlacedTile[], + multipleWords: boolean, +): EvalResult { + const vboard = buildBoard(variant, board); + const rs = buildRuleset(variant, multipleWords); + const vtiles: VPlacement[] = tiles.map((t) => ({ + row: t.row, + col: t.col, + letter: indexForLetter(variant, t.letter), + blank: t.blank, + })); + + const dir = playDirection(vboard, rs, dict, vtiles); + const res = validatePlay(vboard, rs, dict, dir, vtiles); + if (!res.legal || !res.move) { + return { legal: false, score: 0, words: [], dir: '' }; + } + const m = res.move; + const words = [m.main, ...m.cross].map((w) => decodeWord(variant, w.letters)); + return { legal: true, score: m.score, words, dir: dir === Horizontal ? 'H' : 'V' }; +} diff --git a/ui/src/lib/dict/index.ts b/ui/src/lib/dict/index.ts new file mode 100644 index 0000000..cc1d230 --- /dev/null +++ b/ui/src/lib/dict/index.ts @@ -0,0 +1,7 @@ +// Public surface of the local move-preview subsystem, imported dynamically by the +// game so the whole dict subsystem (the reader, the validator, the cache) stays out +// of the initial app bundle — it is a progressive enhancement over the network +// preview, which remains the fallback. +export { evaluateLocal } from './eval'; +export { getDawg, peekDawg, hasDawg, dictLoadingDisabled, noteDictMiss, clearDictInstances, bustDictHttpCache } from './loader'; +export { clearDictCache, listCachedDicts } from './store'; diff --git a/ui/src/lib/dict/loader.ts b/ui/src/lib/dict/loader.ts new file mode 100644 index 0000000..15a64db --- /dev/null +++ b/ui/src/lib/dict/loader.ts @@ -0,0 +1,136 @@ +// Loads the dictionary reader for a (variant, version) pair for the local move +// preview, three tiers deep: an in-memory reader instance, then the persistent +// IndexedDB blob cache, then a session-gated network fetch (which repopulates the +// cache). Every tier is best-effort: on any failure getDawg resolves to null and +// the caller falls back to the network `evaluate`. Concurrent requests for the +// same dictionary share one in-flight load. + +import { Dawg } from './dawg'; +import { dictKey, idbGetDawg, idbPutDawg, idbDelDawg, requestPersist } from './store'; +import { gateway } from '../gateway'; +import type { Variant } from '../model'; + +// Loaded readers by key. A dictionary is immutable, so an instance is reused for +// the whole app session once loaded. +const instances = new Map(); +// In-flight loads by key, so overlapping callers (prefetch + a game open) share one. +const inflight = new Map>(); + +// Bad-connection breaker: after a few dictionaries fail to become available in a session +// (a load that failed or a warm-up that hit its cap), stop attempting them so the player +// is not stuck re-watching the warm-up overlay when switching games. Session-scoped (in +// memory) — an app relaunch is a natural retry, when the connection may have recovered. +// Cached dictionaries still load. +const MISS_LIMIT = 3; +let misses = 0; +let dictDisabled = false; + +/** + * noteDictMiss records that a dictionary did not become available in time — a load that + * failed or a warm-up that hit its cap. After MISS_LIMIT in a session the breaker trips. + */ +export function noteDictMiss(): void { + if (!dictDisabled && ++misses >= MISS_LIMIT) dictDisabled = true; +} + +/** dictLoadingDisabled reports whether the bad-connection breaker has tripped for this + * session, so callers can skip the warm-up overlay and go straight to the network. */ +export function dictLoadingDisabled(): boolean { + return dictDisabled; +} + +// One-shot: the debug reset sets this so the next network fetch bypasses the browser HTTP +// cache. The immutable dict blob is otherwise cached for a year, so clearing IndexedDB alone +// still reloads it instantly from the HTTP cache — this forces a genuinely cold load. +let bustNext = false; + +/** bustDictHttpCache makes the next dictionary download bypass the browser HTTP cache. */ +export function bustDictHttpCache(): void { + bustNext = true; +} + +/** + * getDawg resolves the reader for the (variant, version) dictionary, or null when + * it cannot be obtained (no session, offline, storage or decode failure) — the + * caller then previews over the network instead. Successful loads are cached in + * memory and, when freshly fetched, in IndexedDB. + */ +export function getDawg(variant: Variant, version: string, signal?: AbortSignal): Promise { + const key = dictKey(variant, version); + const have = instances.get(key); + if (have) return Promise.resolve(have); + const pending = inflight.get(key); + if (pending) return pending; + const p = load(variant, version, key, signal).finally(() => inflight.delete(key)); + inflight.set(key, p); + return p; +} + +async function load(variant: Variant, version: string, key: string, signal?: AbortSignal): Promise { + // Tier 1: the persistent cache. + try { + const cached = await idbGetDawg(key); + if (cached) { + const reader = new Dawg(new Uint8Array(cached)); + instances.set(key, reader); + return reader; + } + } catch { + // A cached blob the reader rejected (a stale or partial entry): evict it so the next + // load re-fetches instead of failing on it forever, then fall through to the network. + void idbDelDawg(key); + } + + // Tier 2: the session-gated download — gated by the bad-connection breaker. A caller's + // signal aborts it (the warm-up giving up at its cap, or the game being left) so a large + // download does not keep starving the channel on a slow link; the caller counts the miss. + if (dictDisabled) return null; + let buf: ArrayBuffer; + const reload = bustNext; + bustNext = false; + try { + buf = await gateway.fetchDict(variant, version, { signal, reload }); + } catch { + return null; // network error or aborted + } + + let reader: Dawg; + try { + reader = new Dawg(new Uint8Array(buf)); + } catch { + return null; // not a dawg — do not cache it + } + instances.set(key, reader); + void idbPutDawg(key, buf); + void requestPersist(); + return reader; +} + +/** + * hasDawg reports whether the (variant, version) reader is already loaded in + * memory (a warm hit), so the UI can decide instantly whether to preview locally + * or show the warm-up state while the dictionary loads. + */ +export function hasDawg(variant: Variant, version: string): boolean { + return instances.has(dictKey(variant, version)); +} + +/** + * peekDawg returns the already-loaded reader for (variant, version), or null. It is + * a synchronous in-memory lookup (no load), so the move preview can validate locally + * without awaiting when the dictionary is already warm. + */ +export function peekDawg(variant: Variant, version: string): Dawg | null { + return instances.get(dictKey(variant, version)) ?? null; +} + +/** + * clearDictInstances drops the in-memory readers (e.g. on logout). The persistent + * IndexedDB cache is left intact — a dictionary version is immutable and reusable + * by the next account on the device. + */ +export function clearDictInstances(): void { + instances.clear(); + misses = 0; + dictDisabled = false; +} diff --git a/ui/src/lib/dict/store.ts b/ui/src/lib/dict/store.ts new file mode 100644 index 0000000..f79f7ac --- /dev/null +++ b/ui/src/lib/dict/store.ts @@ -0,0 +1,154 @@ +// Persistent cache for dictionary DAWG blobs, keyed by `${variant}@${version}`. +// It lives in its own IndexedDB database, separate from session.ts's 'scrabble' +// DB, so the multi-hundred-KB binary blobs never interfere with the session / +// prefs store or its schema versioning. Everything here is best-effort: any +// failure resolves to a miss or a no-op and the caller falls back to the network. + +const DB_NAME = 'scrabble-dict'; +const STORE = 'dawg'; + +let dbPromise: Promise | null | undefined; + +function openDb(): Promise | null { + if (dbPromise !== undefined) return dbPromise; + if (typeof indexedDB === 'undefined') { + dbPromise = null; + return null; + } + dbPromise = new Promise((resolve, reject) => { + const req = indexedDB.open(DB_NAME, 1); + req.onupgradeneeded = () => req.result.createObjectStore(STORE); + req.onsuccess = () => resolve(req.result); + req.onerror = () => reject(req.error); + }).catch(() => { + dbPromise = null; + throw new Error('indexedDB unavailable'); + }); + return dbPromise; +} + +/** dictKey is the persistent cache key for a (variant, version) dictionary. */ +export function dictKey(variant: string, version: string): string { + return `${variant}@${version}`; +} + +/** idbGetDawg returns the cached blob for key, or null on a miss or any failure. */ +export async function idbGetDawg(key: string): Promise { + const db = openDb(); + if (!db) return null; + try { + const d = await db; + return await new Promise((resolve, reject) => { + const r = d.transaction(STORE, 'readonly').objectStore(STORE).get(key); + r.onsuccess = () => resolve((r.result ?? null) as ArrayBuffer | null); + r.onerror = () => reject(r.error); + }); + } catch { + return null; + } +} + +/** idbPutDawg stores the blob under key, swallowing any failure (best-effort). */ +export async function idbPutDawg(key: string, data: ArrayBuffer): Promise { + const db = openDb(); + if (!db) return; + try { + const d = await db; + await new Promise((resolve, reject) => { + const tx = d.transaction(STORE, 'readwrite'); + tx.objectStore(STORE).put(data, key); + tx.oncomplete = () => resolve(); + tx.onerror = () => reject(tx.error); + }); + } catch { + /* best-effort: a failed persist just re-downloads next time */ + } +} + +/** idbDelDawg removes a cached blob — e.g. a stale or partial entry the reader rejected, so + * the next load re-fetches instead of failing on it forever. Best-effort. */ +export async function idbDelDawg(key: string): Promise { + const db = openDb(); + if (!db) return; + try { + const d = await db; + await new Promise((resolve) => { + const tx = d.transaction(STORE, 'readwrite'); + tx.objectStore(STORE).delete(key); + tx.oncomplete = () => resolve(); + tx.onerror = () => resolve(); + }); + } catch { + /* best-effort */ + } +} + +let persistRequested = false; + +/** + * requestPersist asks the browser (once) not to evict this origin's storage, so a + * cached dictionary survives storage pressure. The grant is honoured unevenly + * across platforms (see docs), so it is only a hint — the cache stays best-effort. + */ +export async function requestPersist(): Promise { + if (persistRequested) return; + persistRequested = true; + try { + if (typeof navigator !== 'undefined' && navigator.storage?.persist) { + await navigator.storage.persist(); + } + } catch { + /* ignore — persistence is only a hint */ + } +} + +/** + * listCachedDicts returns the key (variant@version) and byte length of every cached + * dictionary blob, for the debug panel's local-dictionary readout. Best-effort: an + * empty list on any failure. + */ +export async function listCachedDicts(): Promise> { + const db = openDb(); + if (!db) return []; + try { + const d = await db; + return await new Promise((resolve) => { + const out: Array<{ key: string; bytes: number }> = []; + const req = d.transaction(STORE, 'readonly').objectStore(STORE).openCursor(); + req.onsuccess = () => { + const cur = req.result; + if (!cur) { + resolve(out); + return; + } + const v = cur.value as ArrayBuffer; + out.push({ key: String(cur.key), bytes: v?.byteLength ?? 0 }); + cur.continue(); + }; + req.onerror = () => resolve(out); + }); + } catch { + return []; + } +} + +/** + * clearDictCache empties the dictionary blob store (backs the DebugPanel reset). The + * store is cleared rather than the database deleted, to avoid a delete blocked on an + * open handle. Best-effort: a failure just leaves the cache to be reused. + */ +export async function clearDictCache(): Promise { + const db = openDb(); + if (!db) return; + try { + const d = await db; + await new Promise((resolve, reject) => { + const tx = d.transaction(STORE, 'readwrite'); + tx.objectStore(STORE).clear(); + tx.oncomplete = () => resolve(); + tx.onerror = () => reject(tx.error); + }); + } catch { + /* best-effort */ + } +} diff --git a/ui/src/lib/dict/validate.parity.test.ts b/ui/src/lib/dict/validate.parity.test.ts new file mode 100644 index 0000000..7839415 --- /dev/null +++ b/ui/src/lib/dict/validate.parity.test.ts @@ -0,0 +1,163 @@ +import { describe, it, expect } from 'vitest'; +import { readFileSync, existsSync } from 'node:fs'; +import { join } from 'node:path'; +import { Dawg } from './dawg'; +import { validatePlay, type Board, type Ruleset, type Placement, type Cell, type Direction } from './validate'; +import { playDirection } from './direction'; +import { premiumGrid, centre, BINGO, RACK_SIZE, BOARD_SIZE } from '../premiums'; +import type { Variant } from '../model'; + +// Conformance gate for the ported validator: for every fixture produced by +// `go run ./backend/cmd/validategen`, validatePlay must agree with the Go engine +// on legality, and — when legal — on the score, the bonus and the exact words +// formed (main + cross), across all variants and both cross-word rules. Point it +// at the fixtures + dictionaries with: +// DICT_DAWG_DIR=/dawg DICT_VALID_DIR= +// It skips when those are unset (kept out of the default unit run until a +// committed sample fixture lands). +const dawgDir = process.env.DICT_DAWG_DIR; +const validDir = process.env.DICT_VALID_DIR; +const ready = !!dawgDir && !!validDir && existsSync(dawgDir) && existsSync(validDir); + +const dawgFile: Record = { + scrabble_en: 'en_sowpods.dawg', + scrabble_ru: 'ru_scrabble.dawg', + erudit_ru: 'ru_erudit.dawg', +}; + +interface FxCell { + R: number; + C: number; + Letter: number; + Blank: boolean; +} +interface FxWord { + Letters: number[]; + Score: number; +} +interface Fx { + board: number; + dir: number; + ignoreCrossWords: boolean; + tiles: FxCell[]; + legal: boolean; + score: number; + bonus: number; + main?: FxWord; + cross?: FxWord[]; +} +interface VF { + rows: number; + cols: number; + center: number; + rackSize: number; + bingo: number; + values: number[]; + premiums: number[]; + boards: (FxCell[] | null)[]; + fixtures: Fx[]; +} + +// Mirror rules.Premium.LetterMult / WordMult over the premium codes +// (0 none, 1 DL, 2 TL, 3 DW, 4 TW) emitted by validategen. +const letterMultOf = (code: number): number => (code === 1 ? 2 : code === 2 ? 3 : 1); +const wordMultOf = (code: number): number => (code === 3 ? 2 : code === 4 ? 3 : 1); + +function makeBoard(rows: number, cols: number, cells: FxCell[]): Board { + const grid: (Cell | null)[] = new Array(rows * cols).fill(null); + for (const cl of cells) grid[cl.R * cols + cl.C] = { letter: cl.Letter, blank: cl.Blank }; + const inBounds = (r: number, c: number) => r >= 0 && r < rows && c >= 0 && c < cols; + return { + inBounds, + filled: (r, c) => inBounds(r, c) && grid[r * cols + c] !== null, + cellAt: (r, c) => grid[r * cols + c] as Cell, + isEmpty: () => cells.length === 0, + }; +} + +function arrEq(a: number[], b: number[]): boolean { + if (a.length !== b.length) return false; + for (let i = 0; i < a.length; i++) if (a[i] !== b[i]) return false; + return true; +} + +describe.skipIf(!ready)('validator parity vs Go engine', () => { + for (const variant of Object.keys(dawgFile)) { + it( + variant, + () => { + const vf: VF = JSON.parse(readFileSync(join(validDir!, `${variant}.fixtures.json`), 'utf8')); + const dawg = new Dawg(new Uint8Array(readFileSync(join(dawgDir!, dawgFile[variant])))); + const v = variant as Variant; + + // Pin the client's static per-variant rule constants to the engine's ruleset. + expect(RACK_SIZE, 'rack size').toBe(vf.rackSize); + expect(BINGO[v], 'bingo').toBe(vf.bingo); + const ctr = centre(v); + expect(ctr.row * BOARD_SIZE + ctr.col, 'centre').toBe(vf.center); + const clientPrem = premiumGrid(v); + const premCode = (p: string): number => (p === 'DL' ? 1 : p === 'TL' ? 2 : p === 'DW' ? 3 : p === 'TW' ? 4 : 0); + let premOk = true; + for (let r = 0; r < vf.rows; r++) + for (let c = 0; c < vf.cols; c++) if (premCode(clientPrem[r][c]) !== vf.premiums[r * vf.cols + c]) premOk = false; + expect(premOk, 'premium grid matches engine').toBe(true); + + let legalCount = 0; + let illegalCount = 0; + let mismatches = 0; + let dirMismatches = 0; + const first: string[] = []; + const firstDir: string[] = []; + + for (let fi = 0; fi < vf.fixtures.length; fi++) { + const fx = vf.fixtures[fi]; + const board = makeBoard(vf.rows, vf.cols, vf.boards[fx.board] ?? []); + const rs: Ruleset = { + cols: vf.cols, + center: vf.center, + rackSize: vf.rackSize, + bingo: vf.bingo, + values: vf.values, + letterMult: (r, c) => letterMultOf(vf.premiums[r * vf.cols + c]), + wordMult: (r, c) => wordMultOf(vf.premiums[r * vf.cols + c]), + ignoreCrossWords: fx.ignoreCrossWords, + }; + const tiles: Placement[] = fx.tiles.map((t) => ({ row: t.R, col: t.C, letter: t.Letter, blank: t.Blank })); + const res = validatePlay(board, rs, dawg, fx.dir as Direction, tiles); + + const gotDir = playDirection(board, rs, dawg, tiles); + if (gotDir !== fx.dir) { + dirMismatches++; + if (firstDir.length < 8) firstDir.push(`#${fi} dir exp ${fx.dir} got ${gotDir}`); + } + + if (fx.legal) legalCount++; + else illegalCount++; + + let ok = res.legal === fx.legal; + if (ok && fx.legal) { + const m = res.move!; + ok = + m.score === fx.score && + m.bonus === fx.bonus && + arrEq(m.main.letters, fx.main!.Letters) && + m.cross.length === (fx.cross?.length ?? 0) && + m.cross.every((w, i) => arrEq(w.letters, fx.cross![i].Letters)); + } + if (!ok) { + mismatches++; + if (first.length < 8) { + first.push(`#${fi} exp legal=${fx.legal} score=${fx.score} got legal=${res.legal} score=${res.move?.score ?? '-'}`); + } + } + } + + expect(legalCount, 'fixtures should include legal plays').toBeGreaterThan(0); + expect(illegalCount, 'fixtures should include illegal plays').toBeGreaterThan(0); + expect(mismatches, first.join(' | ')).toBe(0); + expect(dirMismatches, firstDir.join(' | ')).toBe(0); + }, + 120_000, + ); + } +}); diff --git a/ui/src/lib/dict/validate.ts b/ui/src/lib/dict/validate.ts new file mode 100644 index 0000000..6701d2d --- /dev/null +++ b/ui/src/lib/dict/validate.ts @@ -0,0 +1,338 @@ +// Local move validator + scorer, ported from the scrabble-solver engine +// (scrabble/score.go and scrabble/solver.go, v1.1.1). It answers exactly what the +// server `evaluate` endpoint answers — is a play legal, what words does it form, +// and for how many points — so the client can preview a move without a round +// trip. The server stays authoritative: `submit_play` re-validates on commit, so +// this is an advisory accelerator, never a source of truth. +// +// Everything here works in alphabet-index space, mirroring the Go engine. The +// caller adapts the client's board/placements (letters) into indices via +// lib/alphabet.ts and the premium geometry via lib/premiums.ts. Faithfulness to +// the Go engine is pinned by validate.parity.test.ts against golden fixtures. + +/** Horizontal is an across play (fixed row, axis along columns). Mirrors scrabble.Horizontal. */ +export const Horizontal = 0; +/** Vertical is a down play (fixed column, axis along rows). Mirrors scrabble.Vertical. */ +export const Vertical = 1; +export type Direction = typeof Horizontal | typeof Vertical; + +/** A single newly-placed tile (alphabet-index letter; blank scores 0). */ +export interface Placement { + row: number; + col: number; + letter: number; + blank: boolean; +} + +/** An occupied board square: an alphabet-index letter and whether it is a blank. */ +export interface Cell { + letter: number; + blank: boolean; +} + +/** + * Board is the minimal read view the validator needs over the current position. + * cellAt is only consulted for squares that filled() reports as occupied. + */ +export interface Board { + inBounds(row: number, col: number): boolean; + /** filled reports whether (row, col) is on the board AND occupied. */ + filled(row: number, col: number): boolean; + cellAt(row: number, col: number): Cell; + /** isEmpty reports whether the whole board is empty (first-move detection). */ + isEmpty(): boolean; +} + +/** + * Ruleset carries the per-variant scoring data the validator needs. values is + * indexed by alphabet letter index; letterMult/wordMult return the premium + * multipliers of a square (1 when none); center is the row-major centre index; + * ignoreCrossWords selects the single-word-per-turn rule. + */ +export interface Ruleset { + cols: number; + center: number; + rackSize: number; + bingo: number; + values: readonly number[]; + letterMult(row: number, col: number): number; + wordMult(row: number, col: number): number; + ignoreCrossWords: boolean; +} + +/** Dict is the dictionary membership test (see Dawg.indexOf). */ +export interface Dict { + indexOf(word: ArrayLike): number; +} + +/** A word formed by a play, with its location, letters (indices) and score. */ +export interface Word { + row: number; + col: number; + dir: Direction; + letters: number[]; + blanks: boolean[]; + score: number; +} + +/** A scored play: the main word, any cross words, the bingo bonus and the total. */ +export interface Move { + dir: Direction; + tiles: Placement[]; + main: Word; + cross: Word[]; + bonus: number; + score: number; +} + +/** Reason a play is rejected. Mirrors the error conditions in the Go engine. */ +export type EvalError = + | 'empty' + | 'not-one-line' + | 'off-board' + | 'occupied' + | 'same-square' + | 'gap' + | 'too-short' + | 'main-not-in-dict' + | 'cross-not-in-dict' + | 'not-connected'; + +/** Result of {@link validatePlay}: a scored move plus whether it is legal. */ +export interface ValidateResult { + move?: Move; + legal: boolean; + err?: EvalError; +} + +// coord maps a line coordinate (fixed, axis) to a board (row, col) for dir. +function coord(dir: Direction, fixed: number, axis: number): [number, number] { + return dir === Horizontal ? [fixed, axis] : [axis, fixed]; +} + +// fixedAxis is the inverse of coord: it splits (row, col) into (fixed, axis). +function fixedAxis(dir: Direction, row: number, col: number): [number, number] { + return dir === Horizontal ? [row, col] : [col, row]; +} + +function perpendicular(d: Direction): Direction { + return d === Horizontal ? Vertical : Horizontal; +} + +/** + * evaluate computes the words and score for placing tiles on b in direction dir. + * It checks geometry only (tiles on one line, on empty squares, contiguous); the + * dictionary and connectivity are layered on by {@link validatePlay}. Mirrors + * scrabble.EvaluateOpts. + */ +export function evaluate( + b: Board, + rs: Ruleset, + dir: Direction, + tiles: Placement[], +): { move?: Move; err?: EvalError } { + if (tiles.length === 0) { + return { err: 'empty' }; + } + + const ts = tiles.slice().sort((x, y) => fixedAxis(dir, x.row, x.col)[1] - fixedAxis(dir, y.row, y.col)[1]); + + const fixed = fixedAxis(dir, ts[0].row, ts[0].col)[0]; + let prevAxis = 0; + for (let i = 0; i < ts.length; i++) { + const t = ts[i]; + const [f, a] = fixedAxis(dir, t.row, t.col); + if (f !== fixed) return { err: 'not-one-line' }; + if (!b.inBounds(t.row, t.col)) return { err: 'off-board' }; + if (b.filled(t.row, t.col)) return { err: 'occupied' }; + if (i > 0 && a === prevAxis) return { err: 'same-square' }; + prevAxis = a; + } + + const main = buildMainWord(b, rs, dir, fixed, ts); + if ('err' in main) return { err: main.err }; + + const move: Move = { dir, tiles: ts, main: main.word, cross: [], bonus: 0, score: main.word.score }; + if (!rs.ignoreCrossWords) { + for (const t of ts) { + const cw = crossWord(b, rs, dir, t); + if (cw) { + move.cross.push(cw); + move.score += cw.score; + } + } + } + if (ts.length === rs.rackSize) { + move.bonus = rs.bingo; + move.score += rs.bingo; + } + return { move }; +} + +// buildMainWord assembles and scores the word along dir through the sorted +// placements plus the existing tiles that extend and bridge them. Mirrors +// scrabble.buildMainWord. +function buildMainWord( + b: Board, + rs: Ruleset, + dir: Direction, + fixed: number, + ts: Placement[], +): { word: Word } | { err: EvalError } { + const minA = fixedAxis(dir, ts[0].row, ts[0].col)[1]; + const maxA = fixedAxis(dir, ts[ts.length - 1].row, ts[ts.length - 1].col)[1]; + + let start = minA; + for (;;) { + const [r, c] = coord(dir, fixed, start - 1); + if (!b.filled(r, c)) break; + start--; + } + let end = maxA; + for (;;) { + const [r, c] = coord(dir, fixed, end + 1); + if (!b.filled(r, c)) break; + end++; + } + + const letters: number[] = []; + const blanks: boolean[] = []; + let letterSum = 0; + let wordMult = 1; + let ti = 0; + for (let a = start; a <= end; a++) { + const [r, c] = coord(dir, fixed, a); + if (ti < ts.length) { + const ta = fixedAxis(dir, ts[ti].row, ts[ti].col)[1]; + if (ta === a) { + const t = ts[ti]; + ti++; + if (!t.blank) letterSum += rs.values[t.letter] * rs.letterMult(r, c); + wordMult *= rs.wordMult(r, c); + letters.push(t.letter); + blanks.push(t.blank); + continue; + } + } + if (b.filled(r, c)) { + const cell = b.cellAt(r, c); + if (!cell.blank) letterSum += rs.values[cell.letter]; + letters.push(cell.letter); + blanks.push(cell.blank); + continue; + } + return { err: 'gap' }; + } + + const [wr, wc] = coord(dir, fixed, start); + return { word: { row: wr, col: wc, dir, letters, blanks, score: letterSum * wordMult } }; +} + +// crossWord builds the perpendicular word formed by a single new tile, or null +// when the tile has no perpendicular neighbour. Mirrors scrabble.crossWord. +function crossWord(b: Board, rs: Ruleset, dir: Direction, t: Placement): Word | null { + const cdir = perpendicular(dir); + const [fixed, axis] = fixedAxis(cdir, t.row, t.col); + + let start = axis; + for (;;) { + const [r, c] = coord(cdir, fixed, start - 1); + if (!b.filled(r, c)) break; + start--; + } + let end = axis; + for (;;) { + const [r, c] = coord(cdir, fixed, end + 1); + if (!b.filled(r, c)) break; + end++; + } + if (start === end) return null; + + const letters: number[] = []; + const blanks: boolean[] = []; + let letterSum = 0; + let wordMult = 1; + for (let a = start; a <= end; a++) { + const [r, c] = coord(cdir, fixed, a); + if (a === axis) { + if (!t.blank) letterSum += rs.values[t.letter] * rs.letterMult(r, c); + wordMult *= rs.wordMult(r, c); + letters.push(t.letter); + blanks.push(t.blank); + } else { + const cell = b.cellAt(r, c); + if (!cell.blank) letterSum += rs.values[cell.letter]; + letters.push(cell.letter); + blanks.push(cell.blank); + } + } + const [wr, wc] = coord(cdir, fixed, start); + return { row: wr, col: wc, dir: cdir, letters, blanks, score: letterSum * wordMult }; +} + +/** + * validatePlay scores a play and checks that every word it forms is in the + * dictionary and that it connects to the board (or covers the centre on the + * first move). legal is true exactly when the play is legal. Mirrors + * scrabble.ValidatePlayOpts. + */ +export function validatePlay( + b: Board, + rs: Ruleset, + dict: Dict, + dir: Direction, + tiles: Placement[], +): ValidateResult { + const res = evaluate(b, rs, dir, tiles); + if (res.err) return { legal: false, err: res.err }; + const m = res.move!; + + if (m.main.letters.length < 2) return { move: m, legal: false, err: 'too-short' }; + if (dict.indexOf(m.main.letters) < 0) return { move: m, legal: false, err: 'main-not-in-dict' }; + for (const cw of m.cross) { + if (dict.indexOf(cw.letters) < 0) return { move: m, legal: false, err: 'cross-not-in-dict' }; + } + if (!connected(b, rs, m)) return { move: m, legal: false, err: 'not-connected' }; + return { move: m, legal: true }; +} + +// connected reports whether the play connects to the position (or covers the +// centre on the first move). Mirrors (*Solver).connected. +function connected(b: Board, rs: Ruleset, m: Move): boolean { + if (b.isEmpty()) { + const cr = Math.floor(rs.center / rs.cols); + const cc = rs.center % rs.cols; + return wordCovers(m.main, cr, cc); + } + if (rs.ignoreCrossWords) { + return m.main.letters.length > m.tiles.length; + } + return m.main.letters.length > m.tiles.length || touchesPerpendicular(b, m); +} + +// touchesPerpendicular reports whether any new tile abuts an existing tile +// perpendicular to the main word. Mirrors scrabble.touchesPerpendicular. +function touchesPerpendicular(b: Board, m: Move): boolean { + const cdir = perpendicular(m.dir); + for (const t of m.tiles) { + const [fixed, axis] = fixedAxis(cdir, t.row, t.col); + let [r, c] = coord(cdir, fixed, axis - 1); + if (b.filled(r, c)) return true; + [r, c] = coord(cdir, fixed, axis + 1); + if (b.filled(r, c)) return true; + } + return false; +} + +// wordCovers reports whether word w passes through square (r, c). Mirrors +// scrabble.wordCovers. +function wordCovers(w: Word, r: number, c: number): boolean { + for (let i = 0; i < w.letters.length; i++) { + let rr = w.row; + let cc = w.col; + if (w.dir === Horizontal) cc += i; + else rr += i; + if (rr === r && cc === c) return true; + } + return false; +} diff --git a/ui/src/lib/i18n/en.ts b/ui/src/lib/i18n/en.ts index 5c8ff2e..2254855 100644 --- a/ui/src/lib/i18n/en.ts +++ b/ui/src/lib/i18n/en.ts @@ -4,6 +4,7 @@ export const en = { 'app.title': 'Scrabble', + 'dict.loading': 'Loading…', 'connection.connecting': 'Connecting…', 'blocked.title': 'Account blocked', diff --git a/ui/src/lib/i18n/ru.ts b/ui/src/lib/i18n/ru.ts index b2aceca..53fba60 100644 --- a/ui/src/lib/i18n/ru.ts +++ b/ui/src/lib/i18n/ru.ts @@ -5,6 +5,7 @@ import type { MessageKey } from './en'; export const ru: Record = { 'app.title': 'Scrabble', + 'dict.loading': 'Загрузка…', 'connection.connecting': 'Подключение…', 'blocked.title': 'Учётная запись заблокирована', diff --git a/ui/src/lib/mock/client.ts b/ui/src/lib/mock/client.ts index 1bf2644..d08656f 100644 --- a/ui/src/lib/mock/client.ts +++ b/ui/src/lib/mock/client.ts @@ -161,6 +161,11 @@ export class MockGateway implements GatewayClient { // The mock never blocks; the blocked screen is driven directly via the mock-mode __block seam. return { blocked: false, permanent: false, until: '', reason: '' }; } + async fetchDict(_variant: Variant, _version: string, _opts?: { signal?: AbortSignal; reload?: boolean }): Promise { + // No local dictionary in mock mode; the caller falls back to the mock evaluate. + throw new Error('fetchDict unsupported in mock'); + } + async gamesList(): Promise { return { games: [...this.games.values()].map((g) => structuredClone(g.view)), @@ -432,7 +437,7 @@ export class MockGateway implements GatewayClient { }; } - async evaluate(gameId: string, tiles: PlacedTile[], _variant: Variant): Promise { + async evaluate(gameId: string, tiles: PlacedTile[], _variant: Variant, _signal?: AbortSignal): Promise { const g = this.game(gameId); if (tiles.length === 0) return { legal: false, score: 0, words: [], dir: '' }; let score = tiles.reduce((s, t) => s + valueForLetter(g.view.variant, t.blank ? '?' : t.letter), 0); diff --git a/ui/src/lib/premiums.ts b/ui/src/lib/premiums.ts index d97fb1c..782ec9b 100644 --- a/ui/src/lib/premiums.ts +++ b/ui/src/lib/premiums.ts @@ -85,5 +85,19 @@ export function centre(variant: Variant): { row: number; col: number } { return { row: 7, col: 7 }; } -// Tile values and the per-variant alphabet now arrive from the server (lib/alphabet.ts); -// the board geometry above is all this module owns. +/** RACK_SIZE is the number of tiles drawn to a full rack — 7 for every variant. It sizes + * the all-tiles (bingo) bonus test in the local move preview. */ +export const RACK_SIZE = 7; + +/** BINGO is the all-tiles bonus per variant, ported from scrabble-solver/rules/rules.go: + * 50 for the Scrabble variants, 15 for Эрудит. The local move preview adds it when a play + * uses the whole rack. The dict conformance test pins these to the engine's rulesets. */ +export const BINGO: Record = { + scrabble_en: 50, + scrabble_ru: 50, + erudit_ru: 15, +}; + +// Tile values and the per-variant alphabet arrive from the server (lib/alphabet.ts); the +// board geometry, centre, rack size and bingo above are the static per-variant constants +// this module owns (all ported from scrabble-solver/rules/rules.go). diff --git a/ui/src/lib/transport.ts b/ui/src/lib/transport.ts index 1dfa4e4..892b982 100644 --- a/ui/src/lib/transport.ts +++ b/ui/src/lib/transport.ts @@ -36,12 +36,13 @@ export function createTransport(baseUrl: string): GatewayClient { // exec runs one unary op, auto-retrying transient transport failures with capped backoff (so a // dropped connection or a rate-limit recovers seamlessly) and driving the global Connecting // indicator. A successful round-trip marks the gateway reachable; a domain result_code is final. - async function exec(messageType: string, payload: Uint8Array): Promise { + async function exec(messageType: string, payload: Uint8Array, signal?: AbortSignal): Promise { for (let attempt = 0; ; attempt++) { let res; try { - res = await client.execute({ messageType, payload, requestId: '' }, { headers: headers() }); + res = await client.execute({ messageType, payload, requestId: '' }, { headers: headers(), signal }); } catch (e) { + if (signal?.aborted) throw e; // an intentional cancel (e.g. the tiles moved) — do not retry const err = toGatewayError(e); if (retryable(err.code, messageType) && attempt < MAX_RETRIES) { reportOffline(); @@ -62,6 +63,23 @@ export function createTransport(baseUrl: string): GatewayClient { token = t; }, + async fetchDict(variant, version, opts) { + if (!token) throw new Error('fetchDict: no session'); + // Low priority so the browser schedules the (large) dictionary behind the game's own + // requests — the move eval, state and live events — on a slow link (EDGE). Ignored + // where the Priority Hints API is unsupported (iOS WebKit), which is harmless. The + // signal aborts a stalled download so it stops holding the channel; reload (the debug + // "clear") bypasses the browser HTTP cache to force a fresh copy. + const res = await fetch(`${origin}/dict/${encodeURIComponent(variant)}/${encodeURIComponent(version)}`, { + headers: { authorization: `Bearer ${token}` }, + priority: 'low', + signal: opts?.signal, + cache: opts?.reload ? 'reload' : undefined, + } as RequestInit); + if (!res.ok) throw new Error(`fetchDict ${variant}/${version}: HTTP ${res.status}`); + return res.arrayBuffer(); + }, + async authTelegram(initData) { return codec.decodeSession(await exec('auth.telegram', codec.encodeTelegramLogin(initData, browserOffset()))); }, @@ -119,8 +137,8 @@ export function createTransport(baseUrl: string): GatewayClient { async hint(id) { return codec.decodeHintResult(await exec('game.hint', codec.encodeGameAction(id))); }, - async evaluate(id, tiles, variant) { - return codec.decodeEvalResult(await exec('game.evaluate', codec.encodeEval(id, tiles, variant))); + async evaluate(id, tiles, variant, signal) { + return codec.decodeEvalResult(await exec('game.evaluate', codec.encodeEval(id, tiles, variant), signal)); }, async checkWord(id, word, variant) { return codec.decodeWordCheck(await exec('game.check_word', codec.encodeCheckWord(id, word, variant))); From 2e8fa83814601fd69b5bfd6b15e3de9e6de1b5fc Mon Sep 17 00:00:00 2001 From: Ilia Denisov Date: Wed, 1 Jul 2026 23:31:49 +0200 Subject: [PATCH 12/71] feat(telemetry): local move-preview adoption metrics (Phase 4) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Measure uptake of the client-side local move-preview accelerator (§5) so adoption can be watched before defaulting it on: app cold starts, dictionary loads by result (fetched / cache_hit / miss) and move previews by path (local / network — the backend load shed). A small best-effort client beacon (POST /metrics/local-eval, session-gated) batches counter deltas and posts them on a 60s timer and when the app is backgrounded — never on the gameplay path: the in-app counters are plain in-memory increments, only the periodic flush touches the network and it is fire-and-forget. The gateway folds each batch into three OTel counters (local_eval_cold_start_total, local_eval_dict_load_total, local_eval_preview_total), clamped against a spoofed inflation. - gateway: counters + recordLocalEval + session-gated /metrics/local-eval handler - ui: localeval-metrics accumulator/beacon; hooks in the dict loader, in Game.recompute and in bootstrap (skipped under the mock harness) - caddy: route /metrics/* to the gateway - docs: ARCHITECTURE §11; Grafana "Scrabble — Users" dashboard panels --- deploy/caddy/Caddyfile | 2 +- deploy/grafana/dashboards/users.json | 29 ++++++++- docs/ARCHITECTURE.md | 14 +++++ gateway/internal/connectsrv/metrics.go | 48 ++++++++++++++- gateway/internal/connectsrv/server.go | 49 +++++++++++++++ ui/src/game/Game.svelte | 3 + ui/src/lib/app.svelte.ts | 5 ++ ui/src/lib/client.ts | 3 + ui/src/lib/dict/loader.ts | 4 ++ ui/src/lib/localeval-metrics.ts | 84 ++++++++++++++++++++++++++ ui/src/lib/mock/client.ts | 4 ++ ui/src/lib/transport.ts | 12 ++++ 12 files changed, 254 insertions(+), 3 deletions(-) create mode 100644 ui/src/lib/localeval-metrics.ts diff --git a/deploy/caddy/Caddyfile b/deploy/caddy/Caddyfile index c335674..ffb17ed 100644 --- a/deploy/caddy/Caddyfile +++ b/deploy/caddy/Caddyfile @@ -53,7 +53,7 @@ # The game SPA and the Connect edge are served by the gateway. Strip any # client-supplied X-Scrabble-Honeypot here so the gateway only ever honours the # tag the honeypot block sets below (a client cannot self-tag a real request). - @gateway path /app /app/* /telegram /telegram/* /vk /vk/* /dict/* /scrabble.edge.v1.Gateway/* + @gateway path /app /app/* /telegram /telegram/* /vk /vk/* /dict/* /metrics/* /scrabble.edge.v1.Gateway/* handle @gateway { reverse_proxy gateway:8081 { header_up -X-Scrabble-Honeypot diff --git a/deploy/grafana/dashboards/users.json b/deploy/grafana/dashboards/users.json index 567538b..e7eb072 100644 --- a/deploy/grafana/dashboards/users.json +++ b/deploy/grafana/dashboards/users.json @@ -4,7 +4,7 @@ "tags": ["scrabble"], "timezone": "", "schemaVersion": 39, - "version": 1, + "version": 2, "refresh": "30s", "time": { "from": "now-7d", "to": "now" }, "panels": [ @@ -29,6 +29,33 @@ "gridPos": { "h": 8, "w": 24, "x": 0, "y": 8 }, "datasource": { "type": "prometheus", "uid": "prometheus" }, "targets": [{ "refId": "A", "expr": "sum(accounts_created_total) by (kind)", "legendFormat": "{{kind}}" }] + }, + { + "type": "timeseries", + "title": "App opens vs dictionary fills (cumulative)", + "description": "Local move-preview adoption. App opens are client cold starts; dictionary fills are IndexedDB downloads of a dawg. The gap between them is how many launches reuse an already-cached dictionary. Client-reported, best-effort (a batched beacon), so slightly lossy.", + "gridPos": { "h": 8, "w": 12, "x": 0, "y": 16 }, + "datasource": { "type": "prometheus", "uid": "prometheus" }, + "targets": [ + { "refId": "A", "expr": "sum(local_eval_cold_start_total)", "legendFormat": "app opens (cold start)" }, + { "refId": "B", "expr": "sum(local_eval_dict_load_total{result=\"fetched\"})", "legendFormat": "dictionary fills (IndexedDB)" } + ] + }, + { + "type": "timeseries", + "title": "Dictionary loads by result (rate)", + "description": "Client dictionary loads for the local preview, by tier: fetched (network download), cache_hit (IndexedDB), miss (a warm-up that failed or timed out — trips the bad-connection breaker).", + "gridPos": { "h": 8, "w": 12, "x": 12, "y": 16 }, + "datasource": { "type": "prometheus", "uid": "prometheus" }, + "targets": [{ "refId": "A", "expr": "sum(rate(local_eval_dict_load_total[1h])) by (result)", "legendFormat": "{{result}}" }] + }, + { + "type": "timeseries", + "title": "Move previews by path (rate)", + "description": "Where the move preview is computed: local (on-device, the accelerator) vs network (the fallback to game.evaluate). The local share is the backend load shed.", + "gridPos": { "h": 8, "w": 24, "x": 0, "y": 24 }, + "datasource": { "type": "prometheus", "uid": "prometheus" }, + "targets": [{ "refId": "A", "expr": "sum(rate(local_eval_preview_total[1h])) by (path)", "legendFormat": "{{path}}" }] } ] } diff --git a/docs/ARCHITECTURE.md b/docs/ARCHITECTURE.md index eb0d3a1..2656fd0 100644 --- a/docs/ARCHITECTURE.md +++ b/docs/ARCHITECTURE.md @@ -955,6 +955,20 @@ edits take effect on the next `profile.get` (open/reconnect/foreground), not mid distinct accounts that performed an authenticated edge action in the window. The gauge is single-process by design (single-instance MVP, §10): it is correct for one gateway, resets on restart, and is a live operational figure, not a billing count. +- **Local-preview adoption (client-reported):** three gateway counters measure uptake of + the local move-preview accelerator (§5), fed by a small, best-effort client beacon + (`POST /metrics/local-eval`, session-gated) that batches counter deltas and posts them + on a 60 s timer and when the app is backgrounded — **never on the gameplay path** (the + in-app counters are plain in-memory increments; only the periodic flush touches the + network, fire-and-forget). `local_eval_cold_start_total` counts app cold starts (the + adoption denominator); `local_eval_dict_load_total` (`result` = fetched/cache_hit/miss) + splits a dawg download that fills IndexedDB from a cache hit and from a warm-up that + failed or timed out; `local_eval_preview_total` (`path` = local/network) is the share of + previews computed on-device versus the network `evaluate` fallback — the backend load + shed. It answers the owner's adoption question — how often the app opens versus how often + it fills IndexedDB — and is surfaced on the **Scrabble — Users** dashboard. Lossy by + design (a dropped beacon just retries its batch on the next flush); it is telemetry, never + a game input. - **Rate-limit observability:** every limiter rejection increments the gateway counter `gateway_rate_limited_total` (`class` = user/public/email/admin — aggregate only, honouring the no-per-user-label discipline above) and logs one **Debug** line; diff --git a/gateway/internal/connectsrv/metrics.go b/gateway/internal/connectsrv/metrics.go index e74e4ac..9c619e7 100644 --- a/gateway/internal/connectsrv/metrics.go +++ b/gateway/internal/connectsrv/metrics.go @@ -28,6 +28,10 @@ type serverMetrics struct { rateLimited metric.Int64Counter banned metric.Int64Counter active *activeUsers + // Client-reported local move-preview adoption (see localEvalMetricsHandler). + localColdStart metric.Int64Counter + localDictLoad metric.Int64Counter + localPreview metric.Int64Counter } // newServerMetrics builds the instruments on meter (nil selects a no-op meter), @@ -54,7 +58,12 @@ func newServerMetrics(meter metric.Meter) *serverMetrics { if err != nil { b, _ = noop.NewMeterProvider().Meter(meterName).Int64Counter("gateway_abuse_banned_total") } - m := &serverMetrics{edge: h, rateLimited: c, banned: b, active: newActiveUsers()} + m := &serverMetrics{ + edge: h, rateLimited: c, banned: b, active: newActiveUsers(), + localColdStart: counterOf(meter, "local_eval_cold_start_total", "App cold starts reported by clients — the denominator for local-move-preview adoption."), + localDictLoad: counterOf(meter, "local_eval_dict_load_total", "Client dictionary loads for the local move preview, by result (fetched, cache_hit or miss)."), + localPreview: counterOf(meter, "local_eval_preview_total", "Client move previews, by path (local on-device, or network fallback)."), + } gauge, err := meter.Int64ObservableGauge("active_users", metric.WithDescription("Distinct accounts that performed an authenticated action within the window (in-memory, single gateway instance).")) @@ -98,3 +107,40 @@ func (m *serverMetrics) recordRateLimited(ctx context.Context, class string) { func (m *serverMetrics) recordBan(ctx context.Context, reason string) { m.banned.Add(ctx, 1, metric.WithAttributes(attribute.String("reason", reason))) } + +// localEvalReport is the client-reported local move-preview telemetry batch — deltas since +// the client's previous report. It backs the adoption dashboard: app cold starts vs cached +// dictionaries vs on-device previews. +type localEvalReport struct { + ColdStart int `json:"cold_start"` + DictFetched int `json:"dict_fetched"` + DictCacheHit int `json:"dict_cache_hit"` + DictMiss int `json:"dict_miss"` + PreviewLocal int `json:"preview_local"` + PreviewNetwork int `json:"preview_network"` +} + +// recordLocalEval folds one client report into the edge's local-eval counters. +func (m *serverMetrics) recordLocalEval(ctx context.Context, r localEvalReport) { + add := func(c metric.Int64Counter, n int, opts ...metric.AddOption) { + if n > 0 { + c.Add(ctx, int64(n), opts...) + } + } + add(m.localColdStart, r.ColdStart) + add(m.localDictLoad, r.DictFetched, metric.WithAttributes(attribute.String("result", "fetched"))) + add(m.localDictLoad, r.DictCacheHit, metric.WithAttributes(attribute.String("result", "cache_hit"))) + add(m.localDictLoad, r.DictMiss, metric.WithAttributes(attribute.String("result", "miss"))) + add(m.localPreview, r.PreviewLocal, metric.WithAttributes(attribute.String("path", "local"))) + add(m.localPreview, r.PreviewNetwork, metric.WithAttributes(attribute.String("path", "network"))) +} + +// counterOf builds an Int64Counter on meter, falling back to a no-op on the rare +// construction error so metrics never fail startup. +func counterOf(meter metric.Meter, name, desc string) metric.Int64Counter { + c, err := meter.Int64Counter(name, metric.WithDescription(desc)) + if err != nil { + c, _ = noop.NewMeterProvider().Meter(meterName).Int64Counter(name) + } + return c +} diff --git a/gateway/internal/connectsrv/server.go b/gateway/internal/connectsrv/server.go index c3d5588..ba84d07 100644 --- a/gateway/internal/connectsrv/server.go +++ b/gateway/internal/connectsrv/server.go @@ -9,7 +9,9 @@ package connectsrv import ( "context" "crypto/subtle" + "encoding/json" "errors" + "io" "net" "net/http" "strings" @@ -191,6 +193,8 @@ func (s *Server) HTTPHandler() http.Handler { // The client-side local move preview pulls each game's pinned dictionary blob // through this session-gated route (not public); see dictBytesHandler. mux.Handle("/dict/", s.dictBytesHandler()) + // The client posts its local-move-preview adoption telemetry here (session-gated). + mux.Handle("/metrics/local-eval", s.localEvalMetricsHandler()) // The embedded UI: the game SPA under /app/ (web), /telegram/ (the Telegram Mini // App) and /vk/ (the VK Mini App) — the single-origin model (docs/ARCHITECTURE.md // §13). All sit below the h2c wrap so the Connect edge (a more specific prefix) keeps @@ -474,6 +478,51 @@ func parseDictPath(p string) (variant, version string, ok bool) { return variant, version, true } +// localEvalMetricsHandler ingests a client's local move-preview telemetry batch into the +// edge's adoption counters (docs/ARCHITECTURE.md §11). Session-gated so only real clients +// report; the values are aggregate (no per-user attributes) and clamped against a spoofed +// inflation. Only POST; the body is a small JSON of per-metric deltas. +func (s *Server) localEvalMetricsHandler() http.Handler { + return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + if r.Method != http.MethodPost { + http.Error(w, "method not allowed", http.StatusMethodNotAllowed) + return + } + ip := peerIP(r.RemoteAddr, r.Header) + if _, _, err := s.resolve(r.Context(), r.Header, ip); err != nil { + http.Error(w, "unauthorized", http.StatusUnauthorized) + return + } + var rep localEvalReport + if err := json.NewDecoder(io.LimitReader(r.Body, 512)).Decode(&rep); err != nil { + http.Error(w, "bad request", http.StatusBadRequest) + return + } + clampReport(&rep) + s.metrics.recordLocalEval(r.Context(), rep) + w.WriteHeader(http.StatusNoContent) + }) +} + +// clampReport bounds each counter of a client report against a spoofed inflation — one batch +// reflects at most a few minutes of a single client's activity. +func clampReport(r *localEvalReport) { + const maxPerField = 1000 + clamp := func(n *int) { + if *n < 0 { + *n = 0 + } else if *n > maxPerField { + *n = maxPerField + } + } + clamp(&r.ColdStart) + clamp(&r.DictFetched) + clamp(&r.DictCacheHit) + clamp(&r.DictMiss) + clamp(&r.PreviewLocal) + clamp(&r.PreviewNetwork) +} + // resolve extracts and resolves the Authorization bearer token to an account id // and its guest flag, returning a Connect Unauthenticated error when it is missing // or unknown. diff --git a/ui/src/game/Game.svelte b/ui/src/game/Game.svelte index 2ebbd08..c100585 100644 --- a/ui/src/game/Game.svelte +++ b/ui/src/game/Game.svelte @@ -8,6 +8,7 @@ import Board from './Board.svelte'; import Rack from './Rack.svelte'; import { gateway } from '../lib/gateway'; + import { notePreviewLocal, notePreviewNetwork } from '../lib/localeval-metrics'; import { navigate } from '../lib/router.svelte'; import { app, handleError, showToast, markChatRead, seedChatUnread } from '../lib/app.svelte'; import { connection } from '../lib/connection.svelte'; @@ -725,6 +726,7 @@ if (reader && hasAlphabet(v.game.variant)) { try { preview = d.evaluateLocal(reader, v.game.variant, board, sub.tiles, v.game.multipleWordsPerTurn); + notePreviewLocal(); return; } catch { /* fall through to the network preview */ @@ -736,6 +738,7 @@ previewTimer = setTimeout(async () => { try { preview = await gateway.evaluate(id, sub.tiles, variant, ctrl.signal); + notePreviewNetwork(); } catch { /* best-effort (or aborted) */ } diff --git a/ui/src/lib/app.svelte.ts b/ui/src/lib/app.svelte.ts index 99c870b..80deed6 100644 --- a/ui/src/lib/app.svelte.ts +++ b/ui/src/lib/app.svelte.ts @@ -9,6 +9,7 @@ import { GatewayError } from './client'; import { navigate, router } from './router.svelte'; import { errorKey, localeFrom, setLocale, t, type Locale } from './i18n/index.svelte'; import { languageNeedsServerSync } from './language'; +import { startLocalEvalMetrics } from './localeval-metrics'; import { applyReduceMotion, applyTelegramTheme, applyTheme, type ThemePref, type TelegramThemeParams } from './theme'; import { insideTelegram, @@ -637,6 +638,10 @@ function applyTelegramChrome(launch: TelegramLaunch): void { const TELEGRAM_SDK_TIMEOUT_MS = 10000; export async function bootstrap(): Promise { + // Record the cold start and start the adoption beacon (skipped under the mock harness, which + // has no real backend). Deliberately before any await, so even an early-return launch path + // (e.g. the Telegram launch-error screen) still counts as an app open. + if (import.meta.env.MODE !== 'mock') startLocalEvalMetrics(); const prefs = await loadPrefs(); app.theme = prefs.theme ?? 'auto'; app.reduceMotion = prefs.reduceMotion ?? false; diff --git a/ui/src/lib/client.ts b/ui/src/lib/client.ts index 3f0d82b..b9bcfbf 100644 --- a/ui/src/lib/client.ts +++ b/ui/src/lib/client.ts @@ -103,6 +103,9 @@ export interface GatewayClient { * opts.signal aborts a stalled download; opts.reload bypasses the browser HTTP cache (the * debug reset, for testing a cold load). */ fetchDict(variant: Variant, version: string, opts?: { signal?: AbortSignal; reload?: boolean }): Promise; + /** Post the client's local move-preview adoption telemetry — a small batch of counter deltas, + * session-gated and fire-and-forget (never on the gameplay path). */ + reportLocalEval(counts: Record): Promise; // --- draft --- /** The player's server-persisted client-side composition (rack order + board tiles), so a diff --git a/ui/src/lib/dict/loader.ts b/ui/src/lib/dict/loader.ts index 15a64db..31c63ce 100644 --- a/ui/src/lib/dict/loader.ts +++ b/ui/src/lib/dict/loader.ts @@ -8,6 +8,7 @@ import { Dawg } from './dawg'; import { dictKey, idbGetDawg, idbPutDawg, idbDelDawg, requestPersist } from './store'; import { gateway } from '../gateway'; +import { noteDictFetched, noteDictCacheHit, noteDictMiss as noteDictMissMetric } from '../localeval-metrics'; import type { Variant } from '../model'; // Loaded readers by key. A dictionary is immutable, so an instance is reused for @@ -31,6 +32,7 @@ let dictDisabled = false; */ export function noteDictMiss(): void { if (!dictDisabled && ++misses >= MISS_LIMIT) dictDisabled = true; + noteDictMissMetric(); } /** dictLoadingDisabled reports whether the bad-connection breaker has tripped for this @@ -73,6 +75,7 @@ async function load(variant: Variant, version: string, key: string, signal?: Abo if (cached) { const reader = new Dawg(new Uint8Array(cached)); instances.set(key, reader); + noteDictCacheHit(); return reader; } } catch { @@ -101,6 +104,7 @@ async function load(variant: Variant, version: string, key: string, signal?: Abo return null; // not a dawg — do not cache it } instances.set(key, reader); + noteDictFetched(); void idbPutDawg(key, buf); void requestPersist(); return reader; diff --git a/ui/src/lib/localeval-metrics.ts b/ui/src/lib/localeval-metrics.ts new file mode 100644 index 0000000..513cdd0 --- /dev/null +++ b/ui/src/lib/localeval-metrics.ts @@ -0,0 +1,84 @@ +// Client-side accumulator + beacon for local move-preview adoption telemetry. It counts a +// few events — the app cold start, dictionary loads by result, and move previews by path — +// and posts the running deltas to the edge periodically and when the app is backgrounded, +// so the backend can chart adoption (docs/ARCHITECTURE.md §11). Best-effort throughout: a +// failed beacon keeps its counts to retry, and nothing here throws into the caller. It lives +// outside the lazy dict subsystem so the cold start is recorded even when no game is opened. + +import { gateway } from './gateway'; + +interface Counts { + cold_start: number; + dict_fetched: number; + dict_cache_hit: number; + dict_miss: number; + preview_local: number; + preview_network: number; +} + +function zero(): Counts { + return { cold_start: 0, dict_fetched: 0, dict_cache_hit: 0, dict_miss: 0, preview_local: 0, preview_network: 0 }; +} + +let counts = zero(); +let started = false; + +function total(c: Counts): number { + return c.cold_start + c.dict_fetched + c.dict_cache_hit + c.dict_miss + c.preview_local + c.preview_network; +} + +// flush posts the accumulated deltas and resets them; on failure it folds the batch back in +// so the next flush retries. Best-effort. +async function flush(): Promise { + if (total(counts) === 0) return; + const batch = counts; + counts = zero(); + try { + await gateway.reportLocalEval({ ...batch }); + } catch { + counts.cold_start += batch.cold_start; + counts.dict_fetched += batch.dict_fetched; + counts.dict_cache_hit += batch.dict_cache_hit; + counts.dict_miss += batch.dict_miss; + counts.preview_local += batch.preview_local; + counts.preview_network += batch.preview_network; + } +} + +/** + * startLocalEvalMetrics records the app cold start and begins the periodic beacon. Called once + * at launch (a repeat call is a no-op). It also flushes when the app is backgrounded, so a + * batch is not lost when the tab is closed. + */ +export function startLocalEvalMetrics(): void { + if (started) return; + started = true; + counts.cold_start += 1; + setInterval(() => void flush(), 60_000); + if (typeof document !== 'undefined') { + document.addEventListener('visibilitychange', () => { + if (document.visibilityState === 'hidden') void flush(); + }); + } +} + +/** noteDictFetched: a dictionary was downloaded and cached (an IndexedDB fill). */ +export function noteDictFetched(): void { + counts.dict_fetched += 1; +} +/** noteDictCacheHit: a dictionary loaded from the IndexedDB cache (no download). */ +export function noteDictCacheHit(): void { + counts.dict_cache_hit += 1; +} +/** noteDictMiss: a warm-up did not produce a usable dictionary (failed or timed out). */ +export function noteDictMiss(): void { + counts.dict_miss += 1; +} +/** notePreviewLocal: a move preview was computed on-device. */ +export function notePreviewLocal(): void { + counts.preview_local += 1; +} +/** notePreviewNetwork: a move preview fell back to the network. */ +export function notePreviewNetwork(): void { + counts.preview_network += 1; +} diff --git a/ui/src/lib/mock/client.ts b/ui/src/lib/mock/client.ts index d08656f..d17087d 100644 --- a/ui/src/lib/mock/client.ts +++ b/ui/src/lib/mock/client.ts @@ -166,6 +166,10 @@ export class MockGateway implements GatewayClient { throw new Error('fetchDict unsupported in mock'); } + async reportLocalEval(_counts: Record): Promise { + // Telemetry is a no-op in mock mode. + } + async gamesList(): Promise { return { games: [...this.games.values()].map((g) => structuredClone(g.view)), diff --git a/ui/src/lib/transport.ts b/ui/src/lib/transport.ts index 892b982..ffa9f44 100644 --- a/ui/src/lib/transport.ts +++ b/ui/src/lib/transport.ts @@ -80,6 +80,18 @@ export function createTransport(baseUrl: string): GatewayClient { return res.arrayBuffer(); }, + async reportLocalEval(counts) { + if (!token) throw new Error('reportLocalEval: no session'); + // keepalive so a batch flushed as the app is backgrounded still reaches the edge. + const res = await fetch(`${origin}/metrics/local-eval`, { + method: 'POST', + headers: { authorization: `Bearer ${token}`, 'content-type': 'application/json' }, + body: JSON.stringify(counts), + keepalive: true, + }); + if (!res.ok) throw new Error(`reportLocalEval: HTTP ${res.status}`); + }, + async authTelegram(initData) { return codec.decodeSession(await exec('auth.telegram', codec.encodeTelegramLogin(initData, browserOffset()))); }, From db17287113f52c24930f62d1544da38a5197ed64 Mon Sep 17 00:00:00 2001 From: Ilia Denisov Date: Thu, 2 Jul 2026 00:09:52 +0200 Subject: [PATCH 13/71] fix(ui): route external links out of the Android VK WebView The Android VK client's WebView ignores target=_blank and navigates the Mini App's own window to the target, stranding the player outside the game with no way back (the dictionary lookup, About/Feedback links, the ad banner and the bot-link modal fallbacks). vk-bridge 3.x has no method to open an external URL, so external links are routed through VK's own leave-VK redirect (vk.com/away.php), which the client intercepts natively and hands to the system browser. onExternalLinkClick moves from lib/telegram to a new lib/links that composes the Telegram and VK routers; iOS and desktop VK open _blank correctly and are left alone. --- ui/src/components/AdBanner.svelte | 5 +- ui/src/components/StaleInviteModal.svelte | 5 +- ui/src/components/WelcomeRedeemModal.svelte | 5 +- ui/src/game/CheckScreen.svelte | 2 +- ui/src/lib/links.test.ts | 29 +++++++++ ui/src/lib/links.ts | 31 +++++++++ ui/src/lib/telegram.ts | 17 +---- ui/src/lib/vk.test.ts | 71 ++++++++++++++++++++- ui/src/lib/vk.ts | 56 +++++++++++++++- ui/src/screens/About.svelte | 2 +- ui/src/screens/Feedback.svelte | 2 +- 11 files changed, 200 insertions(+), 25 deletions(-) create mode 100644 ui/src/lib/links.test.ts create mode 100644 ui/src/lib/links.ts diff --git a/ui/src/components/AdBanner.svelte b/ui/src/components/AdBanner.svelte index 2a527fa..28b1e10 100644 --- a/ui/src/components/AdBanner.svelte +++ b/ui/src/components/AdBanner.svelte @@ -9,7 +9,7 @@ } from '../lib/bannerEngine'; import { defaultBannerTimings, linkify, type BannerHost } from '../lib/banner'; import type { BannerCampaign, BannerTimings } from '../lib/model'; - import { onExternalLinkClick } from '../lib/telegram'; + import { onExternalLinkClick } from '../lib/links'; let { campaigns, @@ -112,7 +112,8 @@ + platform opener (onExternalLinkClick uses closest('a')): the Telegram SDK skips the WebView + confirmation, the VK away-redirect keeps the Android WebView on the game. -->
diff --git a/ui/src/components/StaleInviteModal.svelte b/ui/src/components/StaleInviteModal.svelte index 1ababd8..b06579b 100644 --- a/ui/src/components/StaleInviteModal.svelte +++ b/ui/src/components/StaleInviteModal.svelte @@ -4,6 +4,7 @@ import { t } from '../lib/i18n/index.svelte'; import { botUsername } from '../lib/deeplink'; import { insideTelegram, telegramDialogsAvailable, telegramOpenLink, telegramShowPopup } from '../lib/telegram'; + import { openExternalUrl } from '../lib/links'; import { BOT_BUTTON_ID, botInfoPopup } from '../lib/nativedialogs'; import Modal from './Modal.svelte'; @@ -16,8 +17,8 @@ if (username) { const url = `https://t.me/${username}`; // Inside Telegram, openTelegramLink navigates to the bot chat natively; elsewhere fall - // back to a new tab. - if (!telegramOpenLink(url) && typeof window !== 'undefined') window.open(url, '_blank'); + // back to a new tab (routed out of the Android VK WebView by openExternalUrl). + if (!telegramOpenLink(url)) openExternalUrl(url); } dismissStaleInvite(); } diff --git a/ui/src/components/WelcomeRedeemModal.svelte b/ui/src/components/WelcomeRedeemModal.svelte index 9cbc2d9..3bd0c29 100644 --- a/ui/src/components/WelcomeRedeemModal.svelte +++ b/ui/src/components/WelcomeRedeemModal.svelte @@ -4,6 +4,7 @@ import { t } from '../lib/i18n/index.svelte'; import { botUsername } from '../lib/deeplink'; import { insideTelegram, telegramDialogsAvailable, telegramOpenLink, telegramShowPopup } from '../lib/telegram'; + import { openExternalUrl } from '../lib/links'; import { BOT_BUTTON_ID, botInfoPopup } from '../lib/nativedialogs'; import Modal from './Modal.svelte'; @@ -19,8 +20,8 @@ if (username) { const url = `https://t.me/${username}`; // Inside Telegram, openTelegramLink navigates to the bot chat natively; elsewhere fall - // back to a new tab. - if (!telegramOpenLink(url) && typeof window !== 'undefined') window.open(url, '_blank'); + // back to a new tab (routed out of the Android VK WebView by openExternalUrl). + if (!telegramOpenLink(url)) openExternalUrl(url); } dismissWelcomeRedeem(); } diff --git a/ui/src/game/CheckScreen.svelte b/ui/src/game/CheckScreen.svelte index 173afae..67a5a2a 100644 --- a/ui/src/game/CheckScreen.svelte +++ b/ui/src/game/CheckScreen.svelte @@ -5,7 +5,7 @@ import { t } from '../lib/i18n/index.svelte'; import { alphabetLetters } from '../lib/alphabet'; import { canCheckWord, dictionaryLookupUrl, sanitizeCheckWord } from '../lib/checkword'; - import { onExternalLinkClick } from '../lib/telegram'; + import { onExternalLinkClick } from '../lib/links'; import type { Variant } from '../lib/model'; // Word-check on its own screen: unlimited dictionary lookups, each with a diff --git a/ui/src/lib/links.test.ts b/ui/src/lib/links.test.ts new file mode 100644 index 0000000..895c5f9 --- /dev/null +++ b/ui/src/lib/links.test.ts @@ -0,0 +1,29 @@ +import { afterEach, describe, expect, it, vi } from 'vitest'; +import { openExternalUrl } from './links'; + +describe('openExternalUrl', () => { + afterEach(() => vi.unstubAllGlobals()); + + it('routes through the VK away redirect inside the Android VK client', () => { + const open = vi.fn(); + vi.stubGlobal('location', { + pathname: '/vk/', + search: '?vk_user_id=1&vk_platform=mobile_android&sign=x', + href: 'https://app.example/vk/', + origin: 'https://app.example', + }); + vi.stubGlobal('window', { open }); + openExternalUrl('https://t.me/some_bot'); + expect(open).toHaveBeenCalledWith( + `https://vk.com/away.php?to=${encodeURIComponent('https://t.me/some_bot')}`, + '_blank', + ); + }); + + it('opens a plain new tab elsewhere', () => { + const open = vi.fn(); + vi.stubGlobal('window', { open }); + openExternalUrl('https://t.me/some_bot'); + expect(open).toHaveBeenCalledWith('https://t.me/some_bot', '_blank'); + }); +}); diff --git a/ui/src/lib/links.ts b/ui/src/lib/links.ts new file mode 100644 index 0000000..c520e09 --- /dev/null +++ b/ui/src/lib/links.ts @@ -0,0 +1,31 @@ +// Platform-aware external-link opening. Both Mini App hosts mishandle a plain target=_blank +// anchor: Telegram shows the WebView's generic "open this link?" confirmation, and the Android VK +// client navigates the app's own WebView to the target with no way back to the game. The helpers +// below route an external URL through the platform's escape hatch (Telegram's openLink, VK's +// away.php redirect) and leave the anchor's own navigation / window.open in effect elsewhere. + +import { routeExternalLinkInTelegram } from './telegram'; +import { routeExternalLinkInVK, vkOpenExternalUrl } from './vk'; + +/** + * onExternalLinkClick is the anchor click handler for external links. It resolves the clicked + * anchor with closest(), so it works both attached directly on an and delegated on a container + * that renders links (e.g. {@html} markdown). Attach it as onclick on an external link or its + * container; outside Telegram and VK it is a no-op and the anchor's own target=_blank handles the + * click. + */ +export function onExternalLinkClick(e: MouseEvent): void { + const a = (e.target as HTMLElement | null)?.closest('a'); + if (!a) return; + if (routeExternalLinkInTelegram(a) || routeExternalLinkInVK(a)) e.preventDefault(); +} + +/** + * openExternalUrl opens an external URL from code (no anchor to click): through VK's external + * redirect inside the Android VK client, else a plain new tab. Telegram callers try their native + * opener (telegramOpenLink) first and use this as the fallback. + */ +export function openExternalUrl(url: string): void { + if (vkOpenExternalUrl(url)) return; + if (typeof window !== 'undefined') window.open(url, '_blank'); +} diff --git a/ui/src/lib/telegram.ts b/ui/src/lib/telegram.ts index c47f414..0cff5b3 100644 --- a/ui/src/lib/telegram.ts +++ b/ui/src/lib/telegram.ts @@ -179,9 +179,10 @@ export function telegramOpenExternalLink(url: string): boolean { /** * isExternalHttpUrl reports whether href is an absolute http(s) URL on a different origin than the * app — so a same-origin or root-relative in-app (SPA) link is left to normal navigation, only a - * genuinely external link is routed through Telegram's browser. + * genuinely external link is routed through the host platform's browser (the Telegram router here + * and the VK router in vk.ts share it). */ -function isExternalHttpUrl(href: string): boolean { +export function isExternalHttpUrl(href: string): boolean { try { const u = new URL(href, typeof location === 'undefined' ? undefined : location.href); if (u.protocol !== 'http:' && u.protocol !== 'https:') return false; @@ -206,18 +207,6 @@ export function routeExternalLinkInTelegram(anchor: { href: string; target: stri return telegramOpenExternalLink(anchor.href); } -/** - * onExternalLinkClick is the anchor click handler that applies routeExternalLinkInTelegram. It - * resolves the clicked anchor with closest(), so it works both attached directly on an and - * delegated on a container that renders links (e.g. {@html} markdown). Attach it as onclick on an - * external link or its container; outside Telegram it is a no-op and the anchor's own - * target=_blank handles the click. - */ -export function onExternalLinkClick(e: MouseEvent): void { - const a = (e.target as HTMLElement | null)?.closest('a'); - if (a && routeExternalLinkInTelegram(a)) e.preventDefault(); -} - /** * shareTelegramLink opens Telegram's native "share to a chat" picker for url with a * caption, through the Mini App SDK (https://t.me/share/url). Returns false outside diff --git a/ui/src/lib/vk.test.ts b/ui/src/lib/vk.test.ts index c3a9c41..82a7df8 100644 --- a/ui/src/lib/vk.test.ts +++ b/ui/src/lib/vk.test.ts @@ -1,5 +1,15 @@ import { afterEach, describe, expect, it, vi } from 'vitest'; -import { insideVK, onVKPath, vkAppId, vkLaunchParams, vkStartParam, appearanceForBg } from './vk'; +import { + insideVK, + onVKPath, + routeExternalLinkInVK, + vkAndroidWebView, + vkAppId, + vkExternalBrowserUrl, + vkLaunchParams, + vkStartParam, + appearanceForBg, +} from './vk'; describe('vk launch detection', () => { afterEach(() => vi.unstubAllGlobals()); @@ -50,6 +60,65 @@ describe('vk launch params', () => { }); }); +describe('vk external links', () => { + afterEach(() => vi.unstubAllGlobals()); + + function stubVK(platform: string) { + const open = vi.fn(); + vi.stubGlobal('location', { + pathname: '/vk/', + search: `?vk_user_id=1&vk_platform=${platform}&sign=x`, + href: 'https://app.example/vk/', + origin: 'https://app.example', + }); + vi.stubGlobal('window', { open }); + return open; + } + + it('wraps a URL in the away.php redirect with the target encoded', () => { + const url = 'https://gramota.ru/poisk?query=кот&mode=slovari'; + expect(vkExternalBrowserUrl(url)).toBe(`https://vk.com/away.php?to=${encodeURIComponent(url)}`); + }); + + it('detects the Android VK WebView from vk_platform', () => { + stubVK('mobile_android'); + expect(vkAndroidWebView()).toBe(true); + stubVK('mobile_android_messenger'); + expect(vkAndroidWebView()).toBe(true); + stubVK('mobile_iphone'); + expect(vkAndroidWebView()).toBe(false); + stubVK('desktop_web'); + expect(vkAndroidWebView()).toBe(false); + }); + + it('is not the Android WebView without a signed VK launch', () => { + vi.stubGlobal('location', { pathname: '/vk/', search: '?vk_platform=mobile_android' }); + expect(vkAndroidWebView()).toBe(false); + }); + + it('routes an external _blank link through the away redirect on Android', () => { + const open = stubVK('mobile_android'); + expect(routeExternalLinkInVK({ href: 'https://gramota.ru/poisk?query=кот', target: '_blank' })).toBe(true); + expect(open).toHaveBeenCalledWith( + `https://vk.com/away.php?to=${encodeURIComponent('https://gramota.ru/poisk?query=кот')}`, + '_blank', + ); + }); + + it('leaves same-origin and non-_blank links to the browser', () => { + const open = stubVK('mobile_android'); + expect(routeExternalLinkInVK({ href: 'https://app.example/lobby', target: '_blank' })).toBe(false); + expect(routeExternalLinkInVK({ href: 'https://gramota.ru', target: '' })).toBe(false); + expect(open).not.toHaveBeenCalled(); + }); + + it('does nothing outside the Android VK client (iOS / desktop open _blank fine)', () => { + const open = stubVK('mobile_iphone'); + expect(routeExternalLinkInVK({ href: 'https://gramota.ru', target: '_blank' })).toBe(false); + expect(open).not.toHaveBeenCalled(); + }); +}); + describe('appearanceForBg', () => { it('maps a dark background to the dark appearance (light status-bar icons)', () => { expect(appearanceForBg('#0f1115')).toBe('dark'); diff --git a/ui/src/lib/vk.ts b/ui/src/lib/vk.ts index 806a034..f45a896 100644 --- a/ui/src/lib/vk.ts +++ b/ui/src/lib/vk.ts @@ -6,7 +6,7 @@ // (VKWebAppGetUserInfo, since VK omits it from the signed launch params). Every helper is safe to // call outside VK. -import type { Haptic } from './telegram'; +import { isExternalHttpUrl, type Haptic } from './telegram'; async function bridge() { return (await import('@vkontakte/vk-bridge')).default; @@ -87,6 +87,60 @@ export function vkStartParam(): string { return new URLSearchParams(location.search.replace(/^\?/, '')).get('hash') ?? ''; } +/** + * vkPlatform returns the VK client platform from the launch parameters (vk_platform: + * mobile_android, mobile_iphone, desktop_web, mobile_web, …), or '' outside a VK launch. + */ +export function vkPlatform(): string { + if (typeof location === 'undefined') return ''; + return new URLSearchParams(location.search.replace(/^\?/, '')).get('vk_platform') ?? ''; +} + +/** + * vkAndroidWebView reports whether the app runs inside the Android VK client's WebView + * (vk_platform mobile_android / mobile_android_messenger) — the one environment whose WebView + * ignores target=_blank and navigates the app's own window instead of opening a browser. + */ +export function vkAndroidWebView(): boolean { + return insideVK() && vkPlatform().startsWith('mobile_android'); +} + +/** + * vkExternalBrowserUrl wraps url in VK's own leave-VK redirect (vk.com/away.php). The VK mobile + * client intercepts vk.com navigations natively and hands the redirect target to the system + * browser, so an external link escapes the Mini App WebView instead of replacing the game. + * vk-bridge (3.x) offers no method to open an external URL, so this redirect is the supported + * escape hatch. Pure, so it is unit-tested. + */ +export function vkExternalBrowserUrl(url: string): string { + return `https://vk.com/away.php?to=${encodeURIComponent(url)}`; +} + +/** + * vkOpenExternalUrl opens url in the system browser from inside the Android VK client by routing + * it through vkExternalBrowserUrl. Returns false outside the Android VK WebView — iOS and the + * desktop iframe open target=_blank correctly and are left alone — so the caller falls back to + * the anchor's own navigation or a plain window.open. + */ +export function vkOpenExternalUrl(url: string): boolean { + if (typeof window === 'undefined' || !vkAndroidWebView()) return false; + window.open(vkExternalBrowserUrl(url), '_blank'); + return true; +} + +/** + * routeExternalLinkInVK decides whether a clicked anchor must be opened through VK's external + * redirect rather than the WebView's default navigation: only inside the Android VK client, and + * only for an external http(s) link opened in a new tab (target=_blank). Returns true when it + * opened the link — the caller should then preventDefault — and false to let the browser handle + * the click normally. + */ +export function routeExternalLinkInVK(anchor: { href: string; target: string }): boolean { + if (anchor.target !== '_blank') return false; + if (!isExternalHttpUrl(anchor.href)) return false; + return vkOpenExternalUrl(anchor.href); +} + /** * vkShare opens VK's native share dialog for link (VKWebAppShare) — the in-iframe replacement for * navigator.share, which is unavailable in the desktop VK iframe. Resolves true when the share was diff --git a/ui/src/screens/About.svelte b/ui/src/screens/About.svelte index bb1c541..62f3abc 100644 --- a/ui/src/screens/About.svelte +++ b/ui/src/screens/About.svelte @@ -3,7 +3,7 @@ import { app } from '../lib/app.svelte'; import { navigate } from '../lib/router.svelte'; import { aboutContent } from '../lib/aboutContent'; - import { onExternalLinkClick } from '../lib/telegram'; + import { onExternalLinkClick } from '../lib/links'; // The auto-match move clock (mirrors backend game.DefaultTurnTimeout = 24h). const AUTO_MATCH_HOURS = 24; diff --git a/ui/src/screens/Feedback.svelte b/ui/src/screens/Feedback.svelte index af6b2b5..2067a1d 100644 --- a/ui/src/screens/Feedback.svelte +++ b/ui/src/screens/Feedback.svelte @@ -7,7 +7,7 @@ import { connection } from '../lib/connection.svelte'; import { clientChannel } from '../lib/channel'; import { attachmentError, linkify, MAX_BODY } from '../lib/feedback'; - import { onExternalLinkClick } from '../lib/telegram'; + import { onExternalLinkClick } from '../lib/links'; import type { FeedbackState } from '../lib/model'; // The feedback screen: a message (+ optional single attachment) the player sends to From 5f574a765df28ce6776639784aa284e4c99d1b4b Mon Sep 17 00:00:00 2001 From: Ilia Denisov Date: Thu, 2 Jul 2026 00:10:02 +0200 Subject: [PATCH 14/71] feat(landing): VK entry logo + restore the Telegram channel build-arg MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Add the VK Mini App logo next to the Telegram one on the landing hero, linked via the new VITE_VK_APP_LINK build-arg (full URL, wired through compose, CI and prod-deploy from TEST_/PROD_VITE_VK_APP_LINK). Also restore the landing's Telegram link itself: commit 57c778f collapsed the per-language vars to VITE_TELEGRAM_GAME_CHANNEL_NAME in compose/CI but left gateway/Dockerfile with the stale _EN/_RU ARGs and without the plain one, so the built bundle saw the var as undefined and dead-code-eliminated the whole channel-link branch — deployed landings (prod included) have shown no Telegram logo since. --- .gitea/workflows/ci.yaml | 1 + .gitea/workflows/prod-deploy.yaml | 1 + deploy/.env.example | 1 + deploy/README.md | 5 +++-- deploy/docker-compose.yml | 2 ++ docs/FUNCTIONAL.md | 2 +- docs/FUNCTIONAL_ru.md | 2 +- gateway/Dockerfile | 12 ++++------ ui/public/vk-logo.svg | 11 +++++++++ ui/src/Landing.svelte | 37 +++++++++++++++++++++---------- ui/src/lib/i18n/en.ts | 1 + ui/src/lib/i18n/ru.ts | 1 + ui/src/lib/landing.test.ts | 16 ++++++++++++- ui/src/lib/landing.ts | 14 +++++++++++- 14 files changed, 80 insertions(+), 26 deletions(-) create mode 100644 ui/public/vk-logo.svg diff --git a/.gitea/workflows/ci.yaml b/.gitea/workflows/ci.yaml index 97059c7..909d159 100644 --- a/.gitea/workflows/ci.yaml +++ b/.gitea/workflows/ci.yaml @@ -335,6 +335,7 @@ jobs: VITE_TELEGRAM_BOT_ID: ${{ vars.TEST_VITE_TELEGRAM_BOT_ID }} VITE_TELEGRAM_LINK: ${{ vars.TEST_VITE_TELEGRAM_LINK }} VITE_TELEGRAM_GAME_CHANNEL_NAME: ${{ vars.TEST_VITE_TELEGRAM_GAME_CHANNEL_NAME }} + VITE_VK_APP_LINK: ${{ vars.TEST_VITE_VK_APP_LINK }} VITE_GATEWAY_URL: ${{ vars.TEST_VITE_GATEWAY_URL }} # Unset vars render empty -> the compose ":-" defaults apply. POSTGRES_DB: ${{ vars.TEST_POSTGRES_DB }} diff --git a/.gitea/workflows/prod-deploy.yaml b/.gitea/workflows/prod-deploy.yaml index 99602d0..af0fcbd 100644 --- a/.gitea/workflows/prod-deploy.yaml +++ b/.gitea/workflows/prod-deploy.yaml @@ -40,6 +40,7 @@ jobs: VITE_TELEGRAM_BOT_ID: ${{ vars.PROD_VITE_TELEGRAM_BOT_ID }} VITE_TELEGRAM_LINK: ${{ vars.PROD_VITE_TELEGRAM_LINK }} VITE_TELEGRAM_GAME_CHANNEL_NAME: ${{ vars.PROD_VITE_TELEGRAM_GAME_CHANNEL_NAME }} + VITE_VK_APP_LINK: ${{ vars.PROD_VITE_VK_APP_LINK }} VITE_GATEWAY_URL: ${{ vars.PROD_VITE_GATEWAY_URL }} POSTGRES_PASSWORD: ${{ secrets.PROD_POSTGRES_PASSWORD }} GM_BASICAUTH_HASH: ${{ secrets.PROD_GM_BASICAUTH_HASH }} diff --git a/deploy/.env.example b/deploy/.env.example index acd067f..376f154 100644 --- a/deploy/.env.example +++ b/deploy/.env.example @@ -32,6 +32,7 @@ GM_BASICAUTH_HASH= # required; `caddy hash-password` bcrypt VITE_TELEGRAM_BOT_ID= VITE_TELEGRAM_LINK= # friend-invite Mini App link (full URL, e.g. https://t.me//) VITE_TELEGRAM_GAME_CHANNEL_NAME= # landing "Play in Telegram" link, the bot's game channel +VITE_VK_APP_LINK= # landing "Play on VK" link (full URL, https://vk.com/app) VITE_GATEWAY_URL= # --- Grafana ---------------------------------------------------------------- diff --git a/deploy/README.md b/deploy/README.md index f8b4a12..e4e463a 100644 --- a/deploy/README.md +++ b/deploy/README.md @@ -92,9 +92,10 @@ without it Docker's resolver handles `otelcol`, `gateway` and `api.telegram.org` | `VITE_TELEGRAM_BOT_ID` | variable | _(empty)_ | UI build-arg: numeric bot id for the web Login Widget. | | `VITE_TELEGRAM_LINK` | variable | _(empty)_ | UI build-arg: friend-invite Mini App link (full URL, `https://t.me//` — `` is the Mini App short name from BotFather). | | `VITE_TELEGRAM_GAME_CHANNEL_NAME` | variable | _(empty)_ | UI build-arg: the landing "Play in Telegram" link, the bot's game channel (e.g. `https://t.me/Erudit_Game`). | +| `VITE_VK_APP_LINK` | variable | _(empty)_ | UI build-arg: the landing "Play on VK" link, the VK Mini App (full URL, `https://vk.com/app`). | | `VITE_GATEWAY_URL` | variable | _(empty)_ | UI build-arg: gateway origin; empty = same-origin (the usual single-origin deploy). | -The five `VITE_*` are **build-args** baked into the gateway and landing images at +The six `VITE_*` are **build-args** baked into the gateway and landing images at build time (both targets share one UI build stage — keep the args identical so it is built once), so changing them requires a rebuild (`--build`), not just a restart. @@ -198,7 +199,7 @@ BOTLINK_GATEWAY_CERT, BOTLINK_GATEWAY_KEY, BOTLINK_BOT_CERT, BOTLINK_BOT_KEY}`; `PROD_{REGISTRY_USER, MAIN_HOST, TG_HOST, CADDY_SITE_ADDRESS, GM_BASICAUTH_USER, GRAFANA_ROOT_URL, LOG_LEVEL, DICT_VERSION, TELEGRAM_MINIAPP_URL, TELEGRAM_GAME_CHANNEL_ID, TELEGRAM_CHAT_ID, TELEGRAM_BOT_USERNAME, VITE_TELEGRAM_BOT_ID, VITE_TELEGRAM_LINK, -VITE_TELEGRAM_GAME_CHANNEL_NAME}`. +VITE_TELEGRAM_GAME_CHANNEL_NAME, VITE_VK_APP_LINK}`. ## Host-side setup (outside this repo) diff --git a/deploy/docker-compose.yml b/deploy/docker-compose.yml index 34893cb..5ea740a 100644 --- a/deploy/docker-compose.yml +++ b/deploy/docker-compose.yml @@ -134,6 +134,7 @@ services: VITE_TELEGRAM_BOT_ID: ${VITE_TELEGRAM_BOT_ID:-} VITE_TELEGRAM_LINK: ${VITE_TELEGRAM_LINK:-} VITE_TELEGRAM_GAME_CHANNEL_NAME: ${VITE_TELEGRAM_GAME_CHANNEL_NAME:-} + VITE_VK_APP_LINK: ${VITE_VK_APP_LINK:-} VITE_GATEWAY_URL: ${VITE_GATEWAY_URL:-} VITE_APP_VERSION: ${APP_VERSION:-dev} # Go binary version (the SPA's VITE_APP_VERSION is the same git tag). @@ -207,6 +208,7 @@ services: VITE_TELEGRAM_BOT_ID: ${VITE_TELEGRAM_BOT_ID:-} VITE_TELEGRAM_LINK: ${VITE_TELEGRAM_LINK:-} VITE_TELEGRAM_GAME_CHANNEL_NAME: ${VITE_TELEGRAM_GAME_CHANNEL_NAME:-} + VITE_VK_APP_LINK: ${VITE_VK_APP_LINK:-} VITE_GATEWAY_URL: ${VITE_GATEWAY_URL:-} VITE_APP_VERSION: ${APP_VERSION:-dev} restart: unless-stopped diff --git a/docs/FUNCTIONAL.md b/docs/FUNCTIONAL.md index e05d9d1..be03167 100644 --- a/docs/FUNCTIONAL.md +++ b/docs/FUNCTIONAL.md @@ -21,7 +21,7 @@ Settings also pick the board's bonus-label style (beginner / classic / none). A costs nothing when the rack has no legal move. The word-check accepts only the variant's alphabet, remembers answers within the session and rate-limits repeats. A public **landing page** at the site root introduces the game, switches language and -theme, and links to the matching per-language Telegram channel; the game itself runs at +theme, and links to the Telegram game channel and the VK Mini App; the game itself runs at `/app/` (web), `/telegram/` (the Telegram Mini App) and `/vk/` (the VK Mini App). The landing's theme is ephemeral (it follows the system scheme, not the saved preference); its language choice is saved. diff --git a/docs/FUNCTIONAL_ru.md b/docs/FUNCTIONAL_ru.md index f0d1aca..887eaf7 100644 --- a/docs/FUNCTIONAL_ru.md +++ b/docs/FUNCTIONAL_ru.md @@ -22,7 +22,7 @@ top-1 подсказку, безлимитную проверку слова с Проверка слова принимает только алфавит варианта, запоминает ответы в рамках сессии и ограничивает частоту повторов. Публичная **посадочная страница** в корне сайта представляет игру, переключает язык и -тему и ведёт в соответствующий по-язычный Telegram-канал; сама игра живёт по адресам +тему и ведёт в Telegram-канал игры и в VK Mini App; сама игра живёт по адресам `/app/` (веб), `/telegram/` (Telegram Mini App) и `/vk/` (VK Mini App). Тема на странице эфемерна (берётся из системной настройки, а не из сохранённой), выбор языка сохраняется. diff --git a/gateway/Dockerfile b/gateway/Dockerfile index c8e5bf9..3cb62cc 100644 --- a/gateway/Dockerfile +++ b/gateway/Dockerfile @@ -23,18 +23,14 @@ RUN corepack enable && corepack prepare pnpm@11.0.9 --activate # VITE_APP_VERSION carries `git describe` for the About screen (defaults to "dev"). ARG VITE_TELEGRAM_BOT_ID= ARG VITE_TELEGRAM_LINK= -ARG VITE_TELEGRAM_LINK_EN= -ARG VITE_TELEGRAM_LINK_RU= -ARG VITE_TELEGRAM_GAME_CHANNEL_NAME_EN= -ARG VITE_TELEGRAM_GAME_CHANNEL_NAME_RU= +ARG VITE_TELEGRAM_GAME_CHANNEL_NAME= +ARG VITE_VK_APP_LINK= ARG VITE_GATEWAY_URL= ARG VITE_APP_VERSION= ENV VITE_TELEGRAM_BOT_ID=$VITE_TELEGRAM_BOT_ID \ VITE_TELEGRAM_LINK=$VITE_TELEGRAM_LINK \ - VITE_TELEGRAM_LINK_EN=$VITE_TELEGRAM_LINK_EN \ - VITE_TELEGRAM_LINK_RU=$VITE_TELEGRAM_LINK_RU \ - VITE_TELEGRAM_GAME_CHANNEL_NAME_EN=$VITE_TELEGRAM_GAME_CHANNEL_NAME_EN \ - VITE_TELEGRAM_GAME_CHANNEL_NAME_RU=$VITE_TELEGRAM_GAME_CHANNEL_NAME_RU \ + VITE_TELEGRAM_GAME_CHANNEL_NAME=$VITE_TELEGRAM_GAME_CHANNEL_NAME \ + VITE_VK_APP_LINK=$VITE_VK_APP_LINK \ VITE_GATEWAY_URL=$VITE_GATEWAY_URL \ VITE_APP_VERSION=$VITE_APP_VERSION diff --git a/ui/public/vk-logo.svg b/ui/public/vk-logo.svg new file mode 100644 index 0000000..1c61468 --- /dev/null +++ b/ui/public/vk-logo.svg @@ -0,0 +1,11 @@ + + + + + + + + + + + diff --git a/ui/src/Landing.svelte b/ui/src/Landing.svelte index e135e8b..f9d11a9 100644 --- a/ui/src/Landing.svelte +++ b/ui/src/Landing.svelte @@ -4,7 +4,7 @@ import { i18n, localeFrom, setLocale, t, type Locale } from './lib/i18n/index.svelte'; import { loadPrefs, savePrefs, type Prefs } from './lib/session'; import { aboutContent } from './lib/aboutContent'; - import { telegramChannelLink } from './lib/landing'; + import { telegramChannelLink, vkAppLink } from './lib/landing'; // Standalone landing page, the public entry at "/" (the game SPA lives at /app/ and // /telegram/). It reuses the app's theme/i18n/prefs leaf modules — not the app store — so it @@ -17,6 +17,7 @@ const about = $derived(aboutContent(i18n.locale, 24)); // 24h = the auto-match move clock const tgLink = $derived(telegramChannelLink()); + const vkLink = $derived(vkAppLink()); const locales: { code: Locale; label: string }[] = [ { code: 'en', label: '🇬🇧 English' }, { code: 'ru', label: '🇷🇺 Русский' }, @@ -72,10 +73,19 @@

{about.title}

{t('landing.tagline')}

- {#if tgLink} -
- - + {#if tgLink || vkLink} +
+ {#if tgLink} + + + + {/if} + {#if vkLink} + + + + {/if} +
{/if}
@@ -190,19 +200,22 @@ color: var(--text-muted); font-size: 1.05rem; } - /* The Telegram entry is just the bigger logo (no button chrome, no caption); the link - keeps an aria-label for assistive tech. */ - .tg { + /* The platform entries are just the bigger logos in a row (no button chrome, no captions); + each link keeps an aria-label for assistive tech. */ + .entries { align-self: center; - display: inline-flex; + display: flex; + gap: 20px; margin-top: 8px; - border-radius: 50%; } - .tg img { + .entry { + display: inline-flex; + } + .entry img { display: block; transition: transform 0.12s ease; } - .tg:hover img { + .entry:hover img { transform: scale(1.06); } .info { diff --git a/ui/src/lib/i18n/en.ts b/ui/src/lib/i18n/en.ts index 2254855..3e4a125 100644 --- a/ui/src/lib/i18n/en.ts +++ b/ui/src/lib/i18n/en.ts @@ -205,6 +205,7 @@ export const en = { 'landing.tagline': 'Play Scrabble with friends or a smart robot — in your browser or on Telegram.', 'landing.playTelegram': 'Play in Telegram', + 'landing.playVK': 'Play on VK', 'lang.en': 'English', 'lang.ru': 'Русский', diff --git a/ui/src/lib/i18n/ru.ts b/ui/src/lib/i18n/ru.ts index 53fba60..f698955 100644 --- a/ui/src/lib/i18n/ru.ts +++ b/ui/src/lib/i18n/ru.ts @@ -205,6 +205,7 @@ export const ru: Record = { 'landing.tagline': 'Играй в Скрэббл с друзьями или умным роботом — в браузере или в Telegram.', 'landing.playTelegram': 'Играть в Telegram', + 'landing.playVK': 'Играть во ВКонтакте', 'lang.en': 'English', 'lang.ru': 'Русский', diff --git a/ui/src/lib/landing.test.ts b/ui/src/lib/landing.test.ts index 71a9c56..803ff14 100644 --- a/ui/src/lib/landing.test.ts +++ b/ui/src/lib/landing.test.ts @@ -1,5 +1,5 @@ import { afterEach, describe, expect, it, vi } from 'vitest'; -import { telegramChannelLink } from './landing'; +import { telegramChannelLink, vkAppLink } from './landing'; describe('telegramChannelLink', () => { afterEach(() => vi.unstubAllEnvs()); @@ -14,3 +14,17 @@ describe('telegramChannelLink', () => { expect(telegramChannelLink()).toBeNull(); }); }); + +describe('vkAppLink', () => { + afterEach(() => vi.unstubAllEnvs()); + + it('returns the configured VK app link verbatim', () => { + vi.stubEnv('VITE_VK_APP_LINK', 'https://vk.com/app54655464'); + expect(vkAppLink()).toBe('https://vk.com/app54655464'); + }); + + it('returns null when the link is unset or blank', () => { + vi.stubEnv('VITE_VK_APP_LINK', ' '); + expect(vkAppLink()).toBeNull(); + }); +}); diff --git a/ui/src/lib/landing.ts b/ui/src/lib/landing.ts index 7d5ed43..2943705 100644 --- a/ui/src/lib/landing.ts +++ b/ui/src/lib/landing.ts @@ -1,5 +1,5 @@ // Pure helpers for the public landing page, kept out of the Svelte component so the -// Telegram-channel link selection is unit-testable. +// platform-entry link selection is unit-testable. /** * telegramChannelLink returns the t.me link for the single bot's game channel, or null @@ -13,3 +13,15 @@ export function telegramChannelLink(): string | null { const name = (raw as string | undefined)?.trim().replace(/^@/, ''); return name ? `https://t.me/${name}` : null; } + +/** + * vkAppLink returns the VK Mini App link for the landing "Play on VK" entry, or null when it is + * not configured. The link is a build-time var (VITE_VK_APP_LINK, the full URL + * `https://vk.com/app`) because the test and prod contours run the same VK app but the var + * mirrors the per-contour Telegram entries. + */ +export function vkAppLink(): string | null { + const raw = import.meta.env.VITE_VK_APP_LINK; + const link = (raw as string | undefined)?.trim(); + return link ? link : null; +} From aec915d5c1e265588a826dafb983e32a2b9be93c Mon Sep 17 00:00:00 2001 From: Ilia Denisov Date: Thu, 2 Jul 2026 00:10:08 +0200 Subject: [PATCH 15/71] feat(ui): drop the one-word status-bar badge MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The small 1️⃣ in the status bar's score-preview slot read as noise; the single-word rule keeps its spelled-out label in the history header and the lobby invitation card. --- docs/UI_DESIGN.md | 3 +-- ui/e2e/game.spec.ts | 8 ++++---- ui/src/game/Game.svelte | 5 +---- 3 files changed, 6 insertions(+), 10 deletions(-) diff --git a/docs/UI_DESIGN.md b/docs/UI_DESIGN.md index c6bc65d..b25dc6c 100644 --- a/docs/UI_DESIGN.md +++ b/docs/UI_DESIGN.md @@ -214,8 +214,7 @@ e2e and the screenshots. **Drop game** (or 📤 **Export GCG** on a finished game) at the left and the comms 💬 (badged with unread chat) at the right, icon-only. A **single-word-rule** game (a Russian game with "multiple words per turn" off) centres a **"One word per turn"** label between - those two icons, and the status bar shows a small **1️⃣** in the score-preview slot that - yields to the live word/score preview while tiles are pending; standard games show neither. Each **opponent**'s card also gains two + those two icons; standard games show no label. Each **opponent**'s card also gains two edge-pinned controls (non-guests): a 🤝 **add-friend** on the right (hidden once a friend, disabled once requested) and a ✖️ **block** on the left. Each confirms via the fading-✅ tap, swapping the card's score for "Add friend?" — or **"Block?" in the danger colour** — while diff --git a/ui/e2e/game.spec.ts b/ui/e2e/game.spec.ts index e49c028..fd2512e 100644 --- a/ui/e2e/game.spec.ts +++ b/ui/e2e/game.spec.ts @@ -90,16 +90,16 @@ test('new game: auto-match shows the off-by-default rule toggle from the start ( await expect(toggle).not.toBeChecked(); }); -test('single-word game shows the one-word indicator in the status bar and the history header', async ({ page }) => { +test('single-word game shows the one-word rule label in the history header', async ({ page }) => { await page.goto('/'); await page.getByRole('button', { name: /guest/i }).click(); // g3 is a finished Russian single-word game (vs Rick); open it from the lobby. await page.getByRole('button', { name: /Rick/ }).click(); await expect(page.locator('[data-cell]').first()).toBeVisible(); await expect(page.locator('.pane')).toHaveCount(1); - // The status bar carries the small "1️⃣" indicator (single-word, nothing pending). - await expect(page.locator('.oneword')).toBeVisible(); - // Tapping the scoreboard opens the history, whose header shows the spelled-out rule label. + // The status bar no longer carries a "1️⃣" badge (dropped as noise); the rule surfaces in the + // history header instead. Tapping the scoreboard opens the history with the spelled-out label. + await expect(page.locator('.oneword')).toHaveCount(0); await page.locator('.scoreboard').click(); await expect(page.locator('.oneword-label')).toBeVisible(); }); diff --git a/ui/src/game/Game.svelte b/ui/src/game/Game.svelte index c100585..21082a3 100644 --- a/ui/src/game/Game.svelte +++ b/ui/src/game/Game.svelte @@ -1354,7 +1354,7 @@ {isMyTurn ? t('game.yourTurn') : turnLabel()} {/if} - {#if recallOverRack}{:else if preview}{preview.legal ? t('game.previewWords', { words: preview.words.join(', '), n: preview.score }) : t('game.previewIllegal')}{:else if !view.game.multipleWordsPerTurn}1️⃣{/if} + {#if recallOverRack}{:else if preview}{preview.legal ? t('game.previewWords', { words: preview.words.join(', '), n: preview.score }) : t('game.previewIllegal')}{/if}
{/if} @@ -1609,9 +1609,6 @@ min-width: 64px; text-align: right; } - .oneword { - font-size: 0.95rem; - } /* The single-word-rule label centred in the history header between its two icons. */ .oneword-label { flex: 1; From 4e169c368d8cdc18838136d5d226a194cec69417 Mon Sep 17 00:00:00 2001 From: Ilia Denisov Date: Thu, 2 Jul 2026 00:24:16 +0200 Subject: [PATCH 16/71] chore: i18n game descriptions --- ui/src/lib/i18n/en.ts | 2 +- ui/src/lib/i18n/ru.ts | 4 ++-- 2 files changed, 3 insertions(+), 3 deletions(-) diff --git a/ui/src/lib/i18n/en.ts b/ui/src/lib/i18n/en.ts index 3e4a125..4970328 100644 --- a/ui/src/lib/i18n/en.ts +++ b/ui/src/lib/i18n/en.ts @@ -203,7 +203,7 @@ export const en = { 'feedback.banned': 'Feedback submission is unavailable.', 'feedback.replyTitle': 'Reply to your last message', - 'landing.tagline': 'Play Scrabble with friends or a smart robot — in your browser or on Telegram.', + 'landing.tagline': 'Play Scrabble with friends, random opponent or an AI.', 'landing.playTelegram': 'Play in Telegram', 'landing.playVK': 'Play on VK', diff --git a/ui/src/lib/i18n/ru.ts b/ui/src/lib/i18n/ru.ts index f698955..511b166 100644 --- a/ui/src/lib/i18n/ru.ts +++ b/ui/src/lib/i18n/ru.ts @@ -189,7 +189,7 @@ export const ru: Record = { 'about.title': 'О программе', 'about.tab': 'Инфо', - 'about.description': 'Мультиплатформенная игра в скрабл.', + 'about.description': 'Мультиплатформенная игра в «Эрудит».', 'about.version': 'Версия {v}', 'feedback.title': 'Обратная связь', @@ -203,7 +203,7 @@ export const ru: Record = { 'feedback.banned': 'Отправка обратной связи недоступна', 'feedback.replyTitle': 'Ответ на ваше последнее сообщение', - 'landing.tagline': 'Играй в Скрэббл с друзьями или умным роботом — в браузере или в Telegram.', + 'landing.tagline': 'Играй в «Эрудит» с друзьями, случайным соперником или ИИ-роботом.', 'landing.playTelegram': 'Играть в Telegram', 'landing.playVK': 'Играть во ВКонтакте', From 65063621a92880110744ae670ca5423265145087 Mon Sep 17 00:00:00 2001 From: Ilia Denisov Date: Thu, 2 Jul 2026 00:29:38 +0200 Subject: [PATCH 17/71] fix: landing smoke test --- ui/e2e/landing.spec.ts | 2 +- ui/src/lib/i18n/ru.ts | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/ui/e2e/landing.spec.ts b/ui/e2e/landing.spec.ts index 085cbda..d6177ad 100644 --- a/ui/e2e/landing.spec.ts +++ b/ui/e2e/landing.spec.ts @@ -11,7 +11,7 @@ test('landing shows the pitch, switches language via the dropdown, and toggles t // The language dropdown switches the copy to Russian. await page.getByRole('button', { name: 'Language' }).click(); await page.getByRole('menuitem', { name: /Русский/ }).click(); - await expect(page.getByText(/Играй в Скрэббл/)).toBeVisible(); + await expect(page.getByText(/Играй в «Эрудита»/)).toBeVisible(); // The theme toggle flips the document theme (ephemeral, light<->dark). const before = await page.evaluate(() => document.documentElement.getAttribute('data-theme')); diff --git a/ui/src/lib/i18n/ru.ts b/ui/src/lib/i18n/ru.ts index 511b166..10f2850 100644 --- a/ui/src/lib/i18n/ru.ts +++ b/ui/src/lib/i18n/ru.ts @@ -203,7 +203,7 @@ export const ru: Record = { 'feedback.banned': 'Отправка обратной связи недоступна', 'feedback.replyTitle': 'Ответ на ваше последнее сообщение', - 'landing.tagline': 'Играй в «Эрудит» с друзьями, случайным соперником или ИИ-роботом.', + 'landing.tagline': 'Играй в «Эрудита» с друзьями, случайным соперником или ИИ-роботом.', 'landing.playTelegram': 'Играть в Telegram', 'landing.playVK': 'Играть во ВКонтакте', From 1ed624eaf103eb8fcd56008bd34f697c1bcbe922 Mon Sep 17 00:00:00 2001 From: Ilia Denisov Date: Thu, 2 Jul 2026 01:25:19 +0200 Subject: [PATCH 18/71] feat(landing): Russian default + SEO head, icons and OG card MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The landing now always opens in Russian (saved 🌐 choice still wins) — browser-language detection made the indexed content nondeterministic (Googlebot renders with en-US). landing.html gains the static Russian SEO head: title/description, canonical pinned to the production origin, Open Graph card (Telegram/VK link previews), twitter:card, JSON-LD, theme-color and the favicon set; the SPA shell turns noindex and its tab title becomes «Эрудит (Скрэббл)». New assets/icons generator (same tile design as the VK loader) produces favicon.svg/ico, apple-touch-icon.png and og-image.png into ui/public/, plus robots.txt. --- assets/icons/README.md | 41 +++++++++++ assets/icons/build/extract.js | 78 ++++++++++++++++++++ assets/icons/build/generate.js | 128 +++++++++++++++++++++++++++++++++ assets/icons/build/glyphs.json | 1 + docs/ARCHITECTURE.md | 9 ++- docs/FUNCTIONAL.md | 8 ++- docs/FUNCTIONAL_ru.md | 9 ++- ui/e2e/landing.spec.ts | 29 +++++--- ui/index.html | 12 +++- ui/landing.html | 45 +++++++++++- ui/public/apple-touch-icon.png | Bin 0 -> 6405 bytes ui/public/favicon.ico | Bin 0 -> 1237 bytes ui/public/favicon.svg | 1 + ui/public/og-image.png | Bin 0 -> 43130 bytes ui/public/robots.txt | 2 + ui/src/Landing.svelte | 13 +++- ui/src/landing.ts | 9 +++ 17 files changed, 367 insertions(+), 18 deletions(-) create mode 100644 assets/icons/README.md create mode 100644 assets/icons/build/extract.js create mode 100644 assets/icons/build/generate.js create mode 100644 assets/icons/build/glyphs.json create mode 100644 ui/public/apple-touch-icon.png create mode 100644 ui/public/favicon.ico create mode 100644 ui/public/favicon.svg create mode 100644 ui/public/og-image.png create mode 100644 ui/public/robots.txt diff --git a/assets/icons/README.md b/assets/icons/README.md new file mode 100644 index 0000000..3b8cde5 --- /dev/null +++ b/assets/icons/README.md @@ -0,0 +1,41 @@ +# Erudit — site icons + Open Graph card + +The favicon set and the `og:image` link-preview card for the public landing +(`ui/landing.html`) and the SPA shell (`ui/index.html`). Same design language as the +[VK loading-screen logo](../vk/README.md): the wooden Erudit «Э» tile (score `8`), +with the wordmark on the app's dark board green (`ui/src/app.css` tokens). + +| Output (committed to `ui/public/`) | Purpose | +|------|---------| +| `favicon.svg` | Vector favicon, transparent; tile + «Э» only (the score is illegible below ~32 px). | +| `favicon.ico` | 32×32 PNG-in-ICO fallback (also answers the browsers' blind `/favicon.ico` probe). | +| `apple-touch-icon.png` | 180×180 opaque full-bleed tile; iOS masks its own corners. | +| `og-image.png` | 1200×630 card: tile + «Эрудит / Скрэббл — игра в слова». Referenced absolutely as `https://erudit-game.ru/og-image.png`. | + +## How it works + +`build/extract.js` extracts the needed glyph outlines (tile glyphs + every wordmark +character) from LiberationSans (Arial-metric, the game's font stack) with their +advance widths into `build/glyphs.json` (committed). `build/generate.js` composes +plain SVG from those outlines — no font is needed at generation time — writes +`favicon.svg` and rasterises the PNG/ICO outputs by screenshotting the SVGs with the +`ui` package's Playwright chromium (`@playwright/test`); the `.ico` container is +assembled in-script (a single PNG entry). Raster bytes therefore depend on the +installed chromium version; the SVG sources are deterministic. + +## Regenerate + +Requirements: Node ≥ 18, `ui` installed (`pnpm install`, provides Playwright). +`extract.js` additionally needs `opentype.js` (`npm i opentype.js`); **`generate.js` +needs no extra packages**. + +```sh +cd assets/icons + +# 1. (optional) re-extract the glyphs — only if the font or the wordmark changes: +# default font: /usr/share/fonts/truetype/liberation/LiberationSans-Regular.ttf +node build/extract.js [/path/to/font.ttf] # -> build/glyphs.json + +# 2. regenerate everything in ui/public/: +node build/generate.js +``` diff --git a/assets/icons/build/extract.js b/assets/icons/build/extract.js new file mode 100644 index 0000000..0df2800 --- /dev/null +++ b/assets/icons/build/extract.js @@ -0,0 +1,78 @@ +'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('')); diff --git a/assets/icons/build/generate.js b/assets/icons/build/generate.js new file mode 100644 index 0000000..4ac9928 --- /dev/null +++ b/assets/icons/build/generate.js @@ -0,0 +1,128 @@ +'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 ``; +} +// 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: ``, 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 = ``; + 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) => `${body}`; + +// ---- 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, + `` + + `` + + 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 = + `` + + 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(`${markup}`); + 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'); + } +})(); diff --git a/assets/icons/build/glyphs.json b/assets/icons/build/glyphs.json new file mode 100644 index 0000000..a3baa7a --- /dev/null +++ b/assets/icons/build/glyphs.json @@ -0,0 +1 @@ +{"em":1000,"glyphs":{"8":{"adv":556.15234375,"bbox":{"x":43.45703125,"y":-698.2421875,"w":469.23828125,"h":708.0078125},"contours":[{"i":[[0,-45.247395833333314],[0,0],[40.364583333333314,-35.48177083333333],[75.52083333333331,0],[0,0],[41.50390625,34.830729166666664],[0,64.12760416666666],[0,0],[-25.71614583333333,30.598958333333314],[-40.0390625,6.510416666666686],[0,0],[0,0],[21.647135416666657,29.296875],[0,39.388020833333314],[0,0],[-39.22526041666666,32.55208333333337],[-66.08072916666666,0],[0,0],[-39.225260416666686,-31.90104166666663],[0,-54.36197916666663],[0,0],[21.809895833333314,-29.296875],[37.760416666666686,-7.486979166666686],[0,0],[0,0],[-24.4140625,-30.110677083333314]],"o":[[0,0],[0,63.4765625],[-40.364583333333314,35.48177083333333],[0,0],[-73.56770833333331,0],[-41.50390625,-34.83072916666666],[0,0],[0,-44.921875],[25.71614583333333,-30.598958333333314],[0,0],[0,0],[-37.434895833333314,-8.7890625],[-21.647135416666657,-29.296875],[0,0],[0,-52.40885416666663],[39.22526041666666,-32.55208333333337],[0,0],[67.70833333333331,0],[39.225260416666686,31.90104166666663],[0,0],[0,39.388020833333314],[-21.809895833333314,29.296875],[0,0],[0,0],[43.9453125,7.161458333333314],[24.4140625,30.110677083333314]],"v":[[512.6953125,-191.89453125],[512.6953125,-191.89453125],[452.1484375,-43.45703125],[278.3203125,9.765625],[278.3203125,9.765625],[105.712890625,-42.48046875],[43.45703125,-190.91796875],[43.45703125,-190.91796875],[82.03125,-304.19921875],[180.6640625,-359.86328125],[180.6640625,-359.86328125],[180.6640625,-361.81640625],[92.041015625,-418.9453125],[59.5703125,-521.97265625],[59.5703125,-521.97265625],[118.408203125,-649.4140625],[276.3671875,-698.2421875],[276.3671875,-698.2421875],[436.767578125,-650.390625],[495.60546875,-520.99609375],[495.60546875,-520.99609375],[462.890625,-417.96875],[373.53515625,-362.79296875],[373.53515625,-362.79296875],[373.53515625,-360.83984375],[476.07421875,-304.931640625]],"c":true},{"i":[[0,42.643229166666686],[0,0],[85.28645833333331,0],[0,0],[21.647135416666657,-19.53125],[0,-38.73697916666663],[0,0],[-22.298177083333343,-20.670572916666686],[-40.69010416666666,0],[0,0],[-21.647135416666686,19.04296875]],"o":[[0,0],[0,-77.79947916666663],[0,0],[-41.341145833333314,0],[-21.647135416666657,19.53125],[0,0],[0,39.388020833333314],[22.298177083333343,20.670572916666686],[0,0],[41.341145833333314,0],[21.647135416666686,-19.04296875]],"v":[[404.296875,-516.11328125],[404.296875,-516.11328125],[276.3671875,-632.8125],[276.3671875,-632.8125],[181.884765625,-603.515625],[149.4140625,-516.11328125],[149.4140625,-516.11328125],[182.861328125,-426.025390625],[277.34375,-395.01953125],[277.34375,-395.01953125],[371.826171875,-423.583984375]],"c":true},{"i":[[0,50.130208333333314],[0,0],[25.390625,21.647135416666686],[45.8984375,0],[0,0],[25.065104166666657,-23.274739583333314],[0,-40.69010416666666],[0,0],[-96.6796875,0],[0,0],[-23.4375,22.94921875]],"o":[[0,0],[0,-42.64322916666666],[-25.390625,-21.647135416666686],[0,0],[-44.59635416666666,0],[-25.065104166666657,23.274739583333314],[0,0],[0,94.7265625],[0,0],[47.8515625,0],[23.4375,-22.94921875]],"v":[[421.38671875,-200.1953125],[421.38671875,-200.1953125],[383.30078125,-296.630859375],[276.3671875,-329.1015625],[276.3671875,-329.1015625],[171.875,-294.189453125],[134.27734375,-198.2421875],[134.27734375,-198.2421875],[279.296875,-56.15234375],[279.296875,-56.15234375],[386.23046875,-90.576171875]],"c":true}]},"Э":{"adv":718.75,"bbox":{"x":51.26953125,"y":-698.2421875,"w":632.8125,"h":708.0078125},"contours":[{"i":[[69.3359375,0],[0,0],[35.481770833333314,-21.97265625],[14.6484375,-38.41145833333337],[0,0],[0,0],[-49.15364583333333,28.64583333333337],[-73.2421875,0],[0,0],[-59.24479166666663,-62.17447916666663],[0,-109.70052083333331],[0,0],[26.3671875,-53.7109375],[50.29296875,-28.971354166666664],[69.98697916666663,0],[0,0],[59.89583333333333,121.09375],[0,0],[0,0],[-37.434895833333314,-24.90234375],[-47.200520833333314,0],[0,0],[-43.45703125,44.10807291666666],[-6.184895833333371,74.86979166666666],[0,0],[0,0],[0,0],[0,0],[41.82942708333337,41.17838541666663]],"o":[[0,0],[-49.479166666666686,0],[-35.481770833333314,21.97265625],[0,0],[0,0],[23.111979166666657,-56.31510416666663],[49.153645833333314,-28.64583333333337],[0,0],[104.81770833333331,0],[59.24479166666663,62.17447916666663],[0,0],[0,72.59114583333331],[-26.3671875,53.7109375],[-50.29296875,28.971354166666664],[0,0],[-138.671875,0],[0,0],[0,0],[22.4609375,45.57291666666666],[37.434895833333314,24.90234375],[0,0],[69.3359375,0],[43.45703125,-44.10807291666666],[0,0],[0,0],[0,0],[0,0],[-6.8359375,-74.54427083333331],[-41.829427083333314,-41.17838541666663]],"v":[[348.14453125,-622.0703125],[348.14453125,-622.0703125],[220.703125,-589.111328125],[145.5078125,-498.53515625],[145.5078125,-498.53515625],[57.12890625,-527.83203125],[165.52734375,-655.2734375],[349.12109375,-698.2421875],[349.12109375,-698.2421875],[595.21484375,-604.98046875],[684.08203125,-347.16796875],[684.08203125,-347.16796875],[644.53125,-157.71484375],[529.541015625,-33.69140625],[349.12109375,9.765625],[349.12109375,9.765625],[51.26953125,-171.875],[51.26953125,-171.875],[127.44140625,-209.9609375],[217.28515625,-104.248046875],[344.23828125,-66.89453125],[344.23828125,-66.89453125],[513.427734375,-133.056640625],[587.890625,-311.5234375],[587.890625,-311.5234375],[268.5546875,-311.5234375],[268.5546875,-386.71875],[587.890625,-386.71875],[514.892578125,-560.302734375]],"c":true}]},"р":{"adv":556.15234375,"bbox":{"x":64.453125,"y":-537.59765625,"w":449.70703125,"h":745.1171875},"contours":[{"i":[[0,-93.42447916666663],[0,0],[129.55729166666663,0],[0,0],[27.994791666666657,61.197916666666664],[0,0],[0,0],[0,-52.734375],[0,0],[0,0],[0,0],[0,0],[1.953125,17.578125],[0,0],[0,0],[-0.6510416666666572,-7.975260416666629],[-0.8138020833333428,-16.6015625],[0,-6.184895833333314],[0,0],[0,0],[-25.716145833333343,15.13671875],[-41.9921875,0],[0,0],[-32.2265625,-43.61979166666663]],"o":[[0,0],[0,184.24479166666666],[0,0],[-81.38020833333331,0],[0,0],[0,0],[1.3020833333333428,2.6041666666666714],[0,0],[0,0],[0,0],[0,0],[0,-54.361979166666686],[0,0],[0,0],[0.3255208333333428,1.3020833333333712],[0.6510416666666572,7.975260416666686],[0.8138020833333428,16.6015625],[0,0],[0,0],[15.625,-32.552083333333314],[25.716145833333343,-15.13671875],[0,0],[65.10416666666663,0],[32.2265625,43.619791666666686]],"v":[[514.16015625,-266.6015625],[514.16015625,-266.6015625],[319.82421875,9.765625],[319.82421875,9.765625],[155.76171875,-82.03125],[155.76171875,-82.03125],[153.3203125,-82.03125],[155.2734375,0.9765625],[155.2734375,0.9765625],[155.2734375,207.51953125],[67.3828125,207.51953125],[67.3828125,-420.41015625],[64.453125,-528.3203125],[64.453125,-528.3203125],[149.4140625,-528.3203125],[150.87890625,-514.404296875],[153.076171875,-477.5390625],[154.296875,-443.359375],[154.296875,-443.359375],[156.25,-443.359375],[218.26171875,-514.892578125],[319.82421875,-537.59765625],[319.82421875,-537.59765625],[465.8203125,-472.16796875]],"c":true},{"i":[[0,75.52083333333331],[0,0],[19.856770833333314,31.575520833333314],[43.294270833333314,0],[0,0],[19.694010416666657,-14.6484375],[10.25390625,-31.087239583333314],[0,-49.8046875],[0,0],[-22.135416666666657,-32.87760416666666],[-49.8046875,0],[0,0],[-20.01953125,32.06380208333333]],"o":[[0,0],[0,-73.56770833333331],[-19.856770833333314,-31.575520833333314],[0,0],[-34.830729166666686,0],[-19.694010416666657,14.6484375],[-10.25390625,31.087239583333314],[0,0],[0,69.3359375],[22.135416666666657,32.87760416666666],[0,0],[43.619791666666686,0],[20.01953125,-32.063802083333314]],"v":[[421.875,-264.6484375],[421.875,-264.6484375],[392.08984375,-422.36328125],[297.36328125,-469.7265625],[297.36328125,-469.7265625],[215.576171875,-447.75390625],[170.654296875,-379.150390625],[155.2734375,-257.8125],[155.2734375,-257.8125],[188.4765625,-104.4921875],[296.38671875,-55.17578125],[296.38671875,-55.17578125],[391.845703125,-103.271484375]],"c":true}]},"у":{"adv":500,"bbox":{"x":2.44140625,"y":-528.3203125,"w":495.60546875,"h":735.83984375},"contours":[{"i":[[28.971354166666657,0],[0,0],[16.276041666666664,3.580729166666657],[0,0],[0,0],[-14.973958333333329,0],[0,0],[-31.901041666666657,80.40364583333333],[0,0],[0,0],[0,0],[0,0],[0,0],[-2.278645833333343,-6.34765625],[-12.369791666666657,-36.1328125],[-0.9765625,-4.231770833333329],[0,0],[0,0],[0,0],[0,0],[0,0],[18.880208333333343,-27.506510416666657],[22.94921875,-13.509114583333343]],"o":[[0,0],[-24.088541666666657,0],[0,0],[0,0],[12.369791666666664,1.953125],[0,0],[54.6875,0],[0,0],[0,0],[0,0],[0,0],[0,0],[1.6276041666666572,4.557291666666657],[2.278645833333343,6.34765625],[12.369791666666657,36.1328125],[0,0],[0,0],[0,0],[0,0],[0,0],[-21.809895833333314,56.315104166666664],[-18.880208333333343,27.506510416666657],[-22.94921875,13.509114583333343]],"v":[[93.26171875,207.51953125],[93.26171875,207.51953125],[32.71484375,202.1484375],[32.71484375,202.1484375],[32.71484375,136.23046875],[73.73046875,139.16015625],[73.73046875,139.16015625],[203.61328125,18.5546875],[203.61328125,18.5546875],[211.9140625,-2.44140625],[2.44140625,-528.3203125],[96.19140625,-528.3203125],[207.51953125,-236.328125],[213.37890625,-219.970703125],[235.3515625,-156.25],[255.37109375,-95.703125],[255.37109375,-95.703125],[289.55078125,-191.89453125],[405.2734375,-528.3203125],[498.046875,-528.3203125],[294.921875,0],[233.88671875,125.732421875],[171.142578125,187.255859375]],"c":true}]},"д":{"adv":583.49609375,"bbox":{"x":9.765625,"y":-528.3203125,"w":556.640625,"h":727.5390625},"contours":[{"i":[[0,0],[0,0],[0,0],[14.6484375,-65.59244791666663],[20.833333333333343,-47.20052083333333],[0,0]],"o":[[0,0],[0,0],[-12.044270833333343,88.54166666666663],[-14.6484375,65.59244791666666],[0,0],[0,0]],"v":[[407.2265625,-63.96484375],[407.2265625,-464.35546875],[257.8125,-464.35546875],[217.7734375,-233.154296875],[164.55078125,-63.96484375],[164.55078125,-63.96484375]],"c":true},{"i":[[0,0],[0,0],[0,0],[0,0],[0,0],[0,0],[0,0],[0,0],[0,0],[-17.90364583333333,74.86979166666666],[-14.973958333333343,119.140625],[0,0],[0,0],[0,0]],"o":[[0,0],[0,0],[0,0],[0,0],[0,0],[0,0],[0,0],[0,0],[24.4140625,-40.69010416666666],[17.903645833333343,-74.86979166666663],[0,0],[0,0],[0,0],[0,0]],"v":[[566.40625,-63.96484375],[566.40625,199.21875],[486.81640625,199.21875],[486.81640625,0],[89.35546875,0],[89.35546875,199.21875],[9.765625,199.21875],[9.765625,-63.96484375],[67.87109375,-63.96484375],[131.34765625,-237.3046875],[180.6640625,-528.3203125],[180.6640625,-528.3203125],[495.1171875,-528.3203125],[495.1171875,-63.96484375]],"c":true}]},"и":{"adv":558.59375,"bbox":{"x":69.3359375,"y":-528.3203125,"w":419.921875,"h":528.3203125},"contours":[{"i":[[0,0],[0,0],[0,0],[0,0],[0,0],[0,0],[0,0],[0,0],[0,0],[-1.1393229166666856,23.763020833333314],[-0.9765625,10.091145833333314],[0,0],[0,0],[0,0]],"o":[[0,0],[0,0],[0,0],[0,0],[0,0],[0,0],[0,0],[0,0],[0,-11.71875],[1.1393229166666856,-23.763020833333314],[0,0],[0,0],[0,0],[0,0]],"v":[[69.3359375,-528.3203125],[154.296875,-528.3203125],[154.296875,-239.2578125],[149.4140625,-104.00390625],[393.06640625,-528.3203125],[489.2578125,-528.3203125],[489.2578125,0],[405.2734375,0],[405.2734375,-322.265625],[406.982421875,-375.48828125],[410.15625,-426.26953125],[410.15625,-426.26953125],[163.0859375,0],[69.3359375,0]],"c":true}]},"т":{"adv":458.0078125,"bbox":{"x":17.08984375,"y":-528.3203125,"w":423.828125,"h":528.3203125},"contours":[{"i":[[0,0],[0,0],[0,0],[0,0],[0,0],[0,0],[0,0],[0,0]],"o":[[0,0],[0,0],[0,0],[0,0],[0,0],[0,0],[0,0],[0,0]],"v":[[17.08984375,-464.35546875],[17.08984375,-528.3203125],[440.91796875,-528.3203125],[440.91796875,-464.35546875],[272.94921875,-464.35546875],[272.94921875,0],[185.05859375,0],[185.05859375,-464.35546875]],"c":true}]},"С":{"adv":722.16796875,"bbox":{"x":50.78125,"y":-698.2421875,"w":633.30078125,"h":708.0078125},"contours":[{"i":[[48.502604166666686,0],[0,0],[42.317708333333314,-48.99088541666663],[0,-85.28645833333331],[0,0],[-44.10807291666666,-51.26953125],[-75.1953125,0],[0,0],[-48.50260416666663,95.37760416666666],[0,0],[0,0],[51.26953125,-30.924479166666657],[67.70833333333331,0],[0,0],[50.618489583333314,28.80859375],[26.529947916666657,53.54817708333333],[0,73.2421875],[0,0],[-59.24479166666666,62.17447916666663],[-104.81770833333331,0],[0,0],[-49.15364583333337,-28.64583333333337],[-23.11197916666663,-56.31510416666663],[0,0],[0,0],[35.31901041666663,21.15885416666663]],"o":[[0,0],[-76.171875,0],[-42.317708333333314,48.990885416666686],[0,0],[0,84.30989583333331],[44.10807291666666,51.26953125],[0,0],[96.35416666666663,0],[0,0],[0,0],[-28.3203125,59.24479166666666],[-51.26953125,30.924479166666664],[0,0],[-69.3359375,0],[-50.618489583333314,-28.80859375],[-26.529947916666657,-53.548177083333314],[0,0],[0,-109.70052083333331],[59.24479166666666,-62.17447916666663],[0,0],[73.2421875,0],[49.15364583333337,28.64583333333337],[0,0],[0,0],[-15.950520833333371,-40.0390625],[-35.319010416666686,-21.15885416666663]],"v":[[386.71875,-622.0703125],[386.71875,-622.0703125],[208.984375,-548.583984375],[145.5078125,-347.16796875],[145.5078125,-347.16796875],[211.669921875,-143.798828125],[390.625,-66.89453125],[390.625,-66.89453125],[607.91015625,-209.9609375],[607.91015625,-209.9609375],[684.08203125,-171.875],[564.697265625,-36.62109375],[386.23046875,9.765625],[386.23046875,9.765625],[206.298828125,-33.447265625],[90.576171875,-156.982421875],[50.78125,-347.16796875],[50.78125,-347.16796875],[139.6484375,-604.98046875],[385.7421875,-698.2421875],[385.7421875,-698.2421875],[569.3359375,-655.2734375],[677.734375,-527.83203125],[677.734375,-527.83203125],[589.35546875,-498.53515625],[512.451171875,-590.33203125]],"c":true}]},"к":{"adv":437.5,"bbox":{"x":67.3828125,"y":-528.3203125,"w":375,"h":528.3203125},"contours":[{"i":[[0,0],[0,0],[0,0],[0,0],[-7.8125,2.6041666666666856],[-8.463541666666657,8.30078125],[-10.7421875,14.973958333333314],[-63.4765625,102.5390625],[0,0],[0,0],[0,0],[11.393229166666686,-9.440104166666686],[0,0],[0,0],[0,0],[0,0],[10.579427083333343,-1.953125],[8.463541666666657,0],[0,0],[0,0]],"o":[[0,0],[0,0],[0,0],[9.765625,0],[7.8125,-2.6041666666666856],[8.463541666666657,-8.30078125],[10.7421875,-14.973958333333314],[0,0],[0,0],[0,0],[-30.2734375,44.270833333333314],[0,0],[0,0],[0,0],[0,0],[-6.184895833333343,2.604166666666657],[-10.579427083333343,1.953125],[0,0],[0,0],[0,0]],"v":[[67.3828125,0],[67.3828125,-528.3203125],[155.2734375,-528.3203125],[155.2734375,-296.875],[181.640625,-300.78125],[206.0546875,-317.138671875],[234.86328125,-352.05078125],[346.19140625,-528.3203125],[346.19140625,-528.3203125],[437.98828125,-528.3203125],[332.51953125,-369.62890625],[270.01953125,-289.0625],[270.01953125,-289.0625],[442.3828125,0],[344.7265625,0],[208.984375,-242.67578125],[183.837890625,-235.83984375],[155.2734375,-232.91015625],[155.2734375,-232.91015625],[155.2734375,0]],"c":true}]},"э":{"adv":510.25390625,"bbox":{"x":26.85546875,"y":-538.0859375,"w":440.91796875,"h":547.8515625},"contours":[{"i":[[6.8359375,50.45572916666666],[0,0],[0,0],[-21.647135416666657,-16.92708333333333],[-32.552083333333314,0],[0,0],[-23.111979166666686,28.80859375],[-3.90625,61.848958333333314],[0,0],[0,0],[0,0],[0,0],[21.97265625,27.018229166666686],[44.921875,0],[0,0],[10.416666666666657,-63.802083333333314],[0,0],[0,0],[-36.295572916666664,27.180989583333314],[-55.6640625,0],[0,0],[-39.876302083333314,-46.875],[0,-88.54166666666663],[0,0],[39.876302083333314,-46.54947916666666],[75.52083333333331,0],[0,0],[37.109375,29.296875]],"o":[[0,0],[0,0],[5.208333333333329,35.15625],[21.647135416666657,16.92708333333333],[0,0],[41.9921875,0],[23.111979166666686,-28.80859375],[0,0],[0,0],[0,0],[0,0],[-2.6041666666666856,-61.5234375],[-21.97265625,-27.018229166666686],[0,0],[-68.359375,0],[0,0],[0,0],[9.114583333333329,-48.502604166666686],[36.29557291666666,-27.18098958333337],[0,0],[74.86979166666663,0],[39.876302083333314,46.875],[0,0],[0,89.84375],[-39.876302083333314,46.549479166666664],[0,0],[-57.6171875,0],[-37.109375,-29.296875]],"v":[[26.85546875,-153.80859375],[26.85546875,-153.80859375],[115.72265625,-159.66796875],[156.005859375,-81.54296875],[237.3046875,-56.15234375],[237.3046875,-56.15234375],[334.9609375,-99.365234375],[375.48828125,-235.3515625],[375.48828125,-235.3515625],[173.828125,-235.3515625],[173.828125,-299.31640625],[375.48828125,-299.31640625],[338.623046875,-432.12890625],[238.28125,-472.65625],[238.28125,-472.65625],[120.1171875,-376.953125],[120.1171875,-376.953125],[29.78515625,-383.7890625],[97.900390625,-497.314453125],[235.83984375,-538.0859375],[235.83984375,-538.0859375],[407.958984375,-467.7734375],[467.7734375,-264.6484375],[467.7734375,-264.6484375],[407.958984375,-60.05859375],[234.86328125,9.765625],[234.86328125,9.765625],[92.7734375,-34.1796875]],"c":true}]},"б":{"adv":572.75390625,"bbox":{"x":58.59375,"y":-733.3984375,"w":472.16796875,"h":743.1640625},"contours":[{"i":[[0,65.75520833333331],[0,0],[21.484375,31.087239583333314],[48.502604166666686,0],[0,0],[24.251302083333343,-31.575520833333314],[0,-64.77864583333331],[0,0],[-22.623697916666657,-31.73828125],[-48.50260416666666,0],[0,0],[-22.786458333333314,30.436197916666657]],"o":[[0,0],[0,-65.75520833333331],[-21.484375,-31.087239583333314],[0,0],[-51.7578125,0],[-24.251302083333343,31.575520833333314],[0,0],[0,63.15104166666666],[22.623697916666657,31.73828125],[0,0],[52.408854166666686,0],[22.786458333333314,-30.436197916666657]],"v":[[438.4765625,-245.1171875],[438.4765625,-245.1171875],[406.25,-390.380859375],[301.26953125,-437.01171875],[301.26953125,-437.01171875],[187.255859375,-389.6484375],[150.87890625,-245.1171875],[150.87890625,-245.1171875],[184.814453125,-102.783203125],[291.50390625,-55.17578125],[291.50390625,-55.17578125],[404.296875,-100.830078125]],"c":true},{"i":[[-46.875,0],[0,0],[-34.1796875,-42.48046875],[0,-85.28645833333331],[0,0],[40.690104166666686,-42.15494791666666],[77.47395833333331,0],[0,0],[37.923177083333314,52.408854166666664],[0,108.72395833333331],[0,0],[-7.486979166666664,41.829427083333314],[-15.950520833333329,30.2734375],[-25.227864583333343,20.18229166666663],[-38.24869791666666,12.369791666666629],[-124.34895833333331,17.25260416666663],[0,0],[0,0],[40.201822916666686,-15.950520833333371],[16.276041666666657,-34.83072916666663],[1.3020833333333428,-69.3359375],[0,0],[-32.87760416666666,22.786458333333314]],"o":[[0,0],[72.265625,0],[34.1796875,42.48046875],[0,0],[0,86.26302083333331],[-40.690104166666686,42.154947916666664],[0,0],[-80.078125,0],[-37.92317708333333,-52.40885416666666],[0,0],[0,-55.338541666666686],[7.486979166666671,-41.829427083333314],[15.950520833333329,-30.2734375],[25.227864583333343,-20.18229166666663],[38.248697916666686,-12.369791666666629],[0,0],[0,0],[-136.39322916666663,17.578125],[-40.20182291666666,15.950520833333371],[-16.276041666666657,34.830729166666686],[0,0],[13.997395833333343,-40.690104166666686],[32.87760416666666,-22.786458333333314]],"v":[[319.82421875,-501.46484375],[319.82421875,-501.46484375],[479.4921875,-437.744140625],[530.76171875,-246.09375],[530.76171875,-246.09375],[469.7265625,-53.466796875],[292.48046875,9.765625],[292.48046875,9.765625],[115.478515625,-68.84765625],[58.59375,-310.546875],[58.59375,-310.546875],[69.82421875,-456.298828125],[104.98046875,-564.453125],[166.748046875,-640.13671875],[261.962890625,-688.96484375],[505.859375,-733.3984375],[505.859375,-733.3984375],[505.859375,-654.78515625],[240.966796875,-604.4921875],[156.25,-528.3203125],[129.8828125,-372.0703125],[129.8828125,-372.0703125],[200.1953125,-467.28515625]],"c":true}]},"л":{"adv":583.49609375,"bbox":{"x":5.37109375,"y":-528.3203125,"w":508.7890625,"h":538.0859375},"contours":[{"i":[[0,0],[0,0],[0,0],[0,0],[10.416666666666657,-49.641927083333314],[12.044270833333343,-25.390625],[17.740885416666657,-12.044270833333332],[27.994791666666657,0],[0,0],[14.322916666666664,4.231770833333333],[0,0],[0,0],[-13.997395833333332,0],[0,0],[-10.904947916666671,17.90364583333333],[-8.951822916666671,43.9453125],[-11.393229166666657,85.9375],[0,0],[0,0],[0,0]],"o":[[0,0],[0,0],[0,0],[-19.205729166666657,141.92708333333331],[-10.416666666666657,49.64192708333333],[-12.044270833333343,25.390625],[-17.740885416666657,12.044270833333332],[0,0],[-16.276041666666664,0],[0,0],[0,0],[8.138020833333332,2.9296875],[0,0],[17.90364583333333,0],[10.904947916666671,-17.90364583333333],[8.951822916666671,-43.9453125],[0,0],[0,0],[0,0],[0,0]],"v":[[514.16015625,0],[425.78125,0],[425.78125,-464.35546875],[242.67578125,-464.35546875],[198.2421875,-177.001953125],[164.55078125,-64.453125],[119.873046875,-8.30078125],[51.26953125,9.765625],[51.26953125,9.765625],[5.37109375,3.41796875],[5.37109375,3.41796875],[5.37109375,-59.5703125],[38.57421875,-55.17578125],[38.57421875,-55.17578125],[81.787109375,-82.03125],[111.572265625,-174.8046875],[142.08984375,-369.62890625],[142.08984375,-369.62890625],[163.57421875,-528.3203125],[514.16015625,-528.3203125]],"c":true}]},"—":{"adv":1000,"bbox":{"x":0,"y":-287.109375,"w":1000,"h":66.89453125},"contours":[{"i":[[0,0],[0,0],[0,0],[0,0]],"o":[[0,0],[0,0],[0,0],[0,0]],"v":[[1000,-220.21484375],[0,-220.21484375],[0,-287.109375],[1000,-287.109375]],"c":true}]},"г":{"adv":364.74609375,"bbox":{"x":69.3359375,"y":-528.3203125,"w":266.11328125,"h":528.3203125},"contours":[{"i":[[0,0],[0,0],[0,0],[0,0],[0,0],[0,0]],"o":[[0,0],[0,0],[0,0],[0,0],[0,0],[0,0]],"v":[[69.3359375,-528.3203125],[335.44921875,-528.3203125],[335.44921875,-464.35546875],[157.2265625,-464.35546875],[157.2265625,0],[69.3359375,0]],"c":true}]},"а":{"adv":556.15234375,"bbox":{"x":42.48046875,"y":-538.0859375,"w":513.671875,"h":547.8515625},"contours":[{"i":[[42.64322916666666,0],[0,0],[26.69270833333333,27.994791666666664],[0,48.828125],[0,0],[-35.97005208333333,29.296875],[-80.078125,1.953125],[0,0],[0,0],[0,0],[18.229166666666686,18.5546875],[39.0625,0],[0,0],[17.903645833333343,-13.346354166666686],[3.580729166666657,-29.296875],[0,0],[0,0],[-126.30208333333331,0],[0,0],[-33.528645833333314,-30.43619791666663],[0,-57.6171875],[0,0],[0,0],[-6.8359375,-13.18359375],[-19.205729166666686,0],[0,0],[-10.7421875,2.2786458333333357],[0,0],[0,0],[23.111979166666686,0],[0,0],[14.811197916666686,17.08984375],[1.953125,36.45833333333333],[0,0],[0,0],[29.78515625,-16.764322916666664]],"o":[[0,0],[-53.059895833333314,0],[-26.69270833333333,-27.994791666666664],[0,0],[0,-54.6875],[35.970052083333314,-29.296875],[0,0],[0,0],[0,0],[0,-42.96875],[-18.229166666666686,-18.5546875],[0,0],[-39.388020833333314,0],[-17.903645833333343,13.346354166666686],[0,0],[0,0],[14.973958333333329,-95.05208333333331],[0,0],[66.40625,0],[33.528645833333314,30.436197916666686],[0,0],[0,0],[0,26.041666666666657],[6.8359375,13.18359375],[0,0],[8.463541666666629,0],[0,0],[0,0],[-22.13541666666663,5.208333333333333],[0,0],[-32.552083333333314,0],[-14.811197916666686,-17.08984375],[0,0],[0,0],[-22.4609375,40.36458333333333],[-29.78515625,16.764322916666664]],"v":[[202.1484375,9.765625],[202.1484375,9.765625],[82.51953125,-32.2265625],[42.48046875,-147.4609375],[42.48046875,-147.4609375],[96.435546875,-273.4375],[270.5078125,-320.3125],[270.5078125,-320.3125],[389.16015625,-322.265625],[389.16015625,-351.07421875],[361.81640625,-443.359375],[275.87890625,-471.19140625],[275.87890625,-471.19140625],[189.94140625,-451.171875],[157.71484375,-387.20703125],[157.71484375,-387.20703125],[65.91796875,-395.5078125],[277.83203125,-538.0859375],[277.83203125,-538.0859375],[427.734375,-492.431640625],[478.02734375,-360.3515625],[478.02734375,-360.3515625],[478.02734375,-132.8125],[488.28125,-73.974609375],[527.34375,-54.19921875],[527.34375,-54.19921875],[556.15234375,-57.6171875],[556.15234375,-57.6171875],[556.15234375,-2.9296875],[488.28125,4.8828125],[488.28125,4.8828125],[417.236328125,-20.751953125],[392.08984375,-101.07421875],[392.08984375,-101.07421875],[389.16015625,-101.07421875],[310.791015625,-15.380859375]],"c":true},{"i":[[-28.645833333333343,0],[0,0],[-25.065104166666686,14.6484375],[-14.485677083333314,25.553385416666657],[0,27.018229166666657],[0,0],[0,0],[0,0],[21.321614583333343,-7.8125],[11.393229166666657,-16.276041666666657],[0,-26.3671875],[0,0],[-15.462239583333343,-15.625]],"o":[[0,0],[32.2265625,0],[25.065104166666686,-14.6484375],[14.485677083333314,-25.553385416666657],[0,0],[0,0],[0,0],[-41.341145833333314,0.6510416666666856],[-21.321614583333343,7.8125],[-11.393229166666657,16.276041666666657],[0,0],[0,28.64583333333333],[15.462239583333343,15.625]],"v":[[222.16796875,-56.15234375],[222.16796875,-56.15234375],[308.10546875,-78.125],[367.431640625,-138.427734375],[389.16015625,-217.28515625],[389.16015625,-217.28515625],[389.16015625,-260.7421875],[292.96875,-258.7890625],[198.974609375,-246.09375],[149.90234375,-209.9609375],[132.8125,-145.99609375],[132.8125,-145.99609375],[156.005859375,-79.58984375]],"c":true}]}," ":{"adv":277.83203125,"bbox":{"x":0,"y":0,"w":0,"h":0},"contours":[]},"в":{"adv":531.25,"bbox":{"x":69.3359375,"y":-528.3203125,"w":418.45703125,"h":528.3203125},"contours":[{"i":[[0,0],[0,0],[-32.552083333333314,-22.78645833333337],[0,-42.643229166666686],[0,0],[18.5546875,-20.182291666666686],[35.481770833333314,-6.8359375],[0,0],[0,0],[-20.99609375,-21.158854166666686],[0,-34.83072916666666],[0,0],[35.64453125,-26.85546875],[62.825520833333314,0],[0,0],[0,0]],"o":[[0,0],[65.10416666666663,0],[32.552083333333314,22.786458333333314],[0,0],[0,30.2734375],[-18.5546875,20.182291666666686],[0,0],[0,0],[41.9921875,5.533854166666686],[20.99609375,21.158854166666657],[0,0],[0,47.8515625],[-35.64453125,26.85546875],[0,0],[0,0],[0,0]],"v":[[69.3359375,-528.3203125],[275.390625,-528.3203125],[421.875,-494.140625],[470.703125,-395.99609375],[470.703125,-395.99609375],[442.87109375,-320.3125],[361.81640625,-279.78515625],[361.81640625,-279.78515625],[361.81640625,-276.3671875],[456.298828125,-236.328125],[487.79296875,-152.34375],[487.79296875,-152.34375],[434.326171875,-40.283203125],[286.62109375,0],[286.62109375,0],[69.3359375,0]],"c":true},{"i":[[0,0],[0,0],[0,0],[-18.229166666666686,13.834635416666671],[0,30.2734375],[0,0],[19.368489583333314,13.509114583333343],[46.549479166666686,0],[0,0]],"o":[[0,0],[0,0],[44.921875,0],[18.229166666666686,-13.834635416666671],[0,0],[0,-32.87760416666666],[-19.368489583333314,-13.509114583333343],[0,0],[0,0]],"v":[[157.2265625,-241.69921875],[157.2265625,-64.94140625],[272.4609375,-64.94140625],[367.1875,-85.693359375],[394.53125,-151.85546875],[394.53125,-151.85546875],[365.478515625,-221.435546875],[266.6015625,-241.69921875],[266.6015625,-241.69921875]],"c":true},{"i":[[0,0],[0,0],[0,0],[0,0],[-17.740885416666686,11.71875],[0,29.296875],[0,0],[16.6015625,12.369791666666686],[39.388020833333314,0]],"o":[[0,0],[0,0],[0,0],[41.666666666666686,0],[17.740885416666686,-11.71875],[0,0],[0,-26.692708333333314],[-16.6015625,-12.369791666666686],[0,0]],"v":[[269.53125,-461.42578125],[157.2265625,-461.42578125],[157.2265625,-305.17578125],[262.6953125,-305.17578125],[351.806640625,-322.75390625],[378.41796875,-384.27734375],[378.41796875,-384.27734375],[353.515625,-442.87109375],[269.53125,-461.42578125]],"c":true}]},"с":{"adv":500,"bbox":{"x":42.48046875,"y":-538.0859375,"w":431.15234375,"h":547.8515625},"contours":[{"i":[[0,-73.2421875],[0,0],[-22.135416666666657,-33.85416666666666],[-44.59635416666666,0],[0,0],[-20.99609375,16.92708333333333],[-4.8828125,35.15625],[0,0],[0,0],[36.458333333333314,-30.2734375],[55.989583333333314,0],[0,0],[38.899739583333314,46.71223958333333],[0,89.51822916666666],[0,0],[-39.0625,46.712239583333314],[-72.91666666666666,0],[0,0],[-35.64453125,-27.99479166666663],[-9.114583333333314,-49.153645833333314],[0,0],[0,0],[18.5546875,17.252604166666686],[34.1796875,0],[0,0],[20.833333333333343,-30.924479166666686]],"o":[[0,0],[0,70.3125],[22.135416666666657,33.85416666666666],[0,0],[31.25,0],[20.99609375,-16.92708333333333],[0,0],[0,0],[-6.8359375,50.78125],[-36.458333333333314,30.2734375],[0,0],[-73.89322916666666,0],[-38.89973958333333,-46.71223958333333],[0,0],[0,-88.8671875],[39.0625,-46.71223958333337],[0,0],[54.036458333333314,0],[35.64453125,27.994791666666686],[0,0],[0,0],[-4.557291666666686,-29.296875],[-18.5546875,-17.252604166666686],[0,0],[-46.54947916666666,0],[-20.833333333333343,30.924479166666686]],"v":[[134.27734375,-266.6015625],[134.27734375,-266.6015625],[167.48046875,-110.3515625],[267.578125,-59.5703125],[267.578125,-59.5703125],[345.947265625,-84.9609375],[384.765625,-163.0859375],[384.765625,-163.0859375],[473.6328125,-157.2265625],[408.69140625,-35.64453125],[270.01953125,9.765625],[270.01953125,9.765625],[100.830078125,-60.302734375],[42.48046875,-264.6484375],[42.48046875,-264.6484375],[101.07421875,-468.017578125],[269.04296875,-538.0859375],[269.04296875,-538.0859375],[403.564453125,-496.09375],[470.703125,-380.37109375],[470.703125,-380.37109375],[380.37109375,-373.53515625],[345.703125,-443.359375],[266.6015625,-469.23828125],[266.6015625,-469.23828125],[165.52734375,-422.8515625]],"c":true}]},"о":{"adv":556.15234375,"bbox":{"x":41.9921875,"y":-538.0859375,"w":472.16796875,"h":547.8515625},"contours":[{"i":[[0,-93.42447916666663],[0,0],[40.690104166666686,-45.24739583333333],[77.47395833333331,0],[0,0],[39.388020833333314,47.037760416666664],[0,88.8671875],[0,0],[-157.87760416666666,0],[0,0],[-38.0859375,-44.43359375]],"o":[[0,0],[0,92.44791666666666],[-40.690104166666686,45.24739583333333],[0,0],[-77.1484375,0],[-39.38802083333333,-47.03776041666666],[0,0],[0,-182.29166666666663],[0,0],[80.72916666666663,0],[38.0859375,44.43359375]],"v":[[514.16015625,-264.6484375],[514.16015625,-264.6484375],[453.125,-58.10546875],[275.87890625,9.765625],[275.87890625,9.765625],[101.07421875,-60.791015625],[41.9921875,-264.6484375],[41.9921875,-264.6484375],[278.80859375,-538.0859375],[278.80859375,-538.0859375],[457.03125,-471.435546875]],"c":true},{"i":[[0,71.94010416666666],[0,0],[21.647135416666686,33.040364583333314],[51.106770833333314,0],[0,0],[22.94921875,-33.69140625],[0,-71.61458333333331],[0,0],[-22.623697916666657,-34.993489583333314],[-48.50260416666666,0],[0,0],[-22.623697916666686,33.85416666666666]],"o":[[0,0],[0,-72.91666666666663],[-21.647135416666686,-33.040364583333314],[0,0],[-51.43229166666666,0],[-22.94921875,33.69140625],[0,0],[0,69.66145833333331],[22.623697916666657,34.99348958333333],[0,0],[52.734375,0],[22.623697916666686,-33.85416666666666]],"v":[[421.875,-264.6484375],[421.875,-264.6484375],[389.404296875,-423.583984375],[280.2734375,-473.14453125],[280.2734375,-473.14453125],[168.701171875,-422.607421875],[134.27734375,-264.6484375],[134.27734375,-264.6484375],[168.212890625,-107.666015625],[274.90234375,-55.17578125],[274.90234375,-55.17578125],[387.939453125,-105.95703125]],"c":true}]}}} \ No newline at end of file diff --git a/docs/ARCHITECTURE.md b/docs/ARCHITECTURE.md index 2656fd0..a5cd761 100644 --- a/docs/ARCHITECTURE.md +++ b/docs/ARCHITECTURE.md @@ -1099,7 +1099,14 @@ parameters from the URL and the gateway verifies them in-process — §12); a st gateway's `/` 308-redirects to `/app/`. The **landing** ships in its own static container: the `landing` target of `gateway/Dockerfile` (caddy:2-alpine + the same Vite build, `deploy/landing/Caddyfile`) serves it at `/`, so stray public traffic is absorbed by -static file serving and never reaches the Go edge. Hash-named `/assets/*` are served +static file serving and never reaches the Go edge. The landing shell carries the site's +static SEO head — Russian title/description, the Open Graph card (Telegram/VK link +previews), JSON-LD and a canonical link — with absolute URLs pinned to the production +origin, so the test contour canonicalises to production instead of being indexed as a +separate site; the SPA shell is `noindex`, and the landing always boots in Russian (no +browser-language detection — crawlers render with arbitrary languages). The favicon set, +`og-image.png` and `robots.txt` ship unhashed from `ui/public/` (generated by +`assets/icons/`, same tile design as the VK loader). Hash-named `/assets/*` are served `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 in-compose **caddy** is the contour's edge: it owns a single `/_gm` Basic-Auth and diff --git a/docs/FUNCTIONAL.md b/docs/FUNCTIONAL.md index be03167..2b0af0d 100644 --- a/docs/FUNCTIONAL.md +++ b/docs/FUNCTIONAL.md @@ -23,7 +23,13 @@ variant's alphabet, remembers answers within the session and rate-limits repeats A public **landing page** at the site root introduces the game, switches language and theme, and links to the Telegram game channel and the VK Mini App; the game itself runs at `/app/` (web), `/telegram/` (the Telegram Mini App) and `/vk/` (the VK Mini App). The landing's theme is ephemeral -(it follows the system scheme, not the saved preference); its language choice is saved. +(it follows the system scheme, not the saved preference); its language choice is saved. The +landing always opens in **Russian** — the browser language is never auto-detected (crawlers +render with arbitrary languages, so detection would make the indexed content +nondeterministic); a saved 🌐 choice still wins. The page carries a static Russian SEO head +(title/description, the Open Graph card Telegram/VK link previews use, a canonical link +pinned to the production origin, JSON-LD, the favicon set and `robots.txt`), while the SPA +shell is `noindex` — the landing is the only indexable page. ### First-run onboarding The first time a player opens the app it walks them through the interface with a light diff --git a/docs/FUNCTIONAL_ru.md b/docs/FUNCTIONAL_ru.md index 887eaf7..5bb3c73 100644 --- a/docs/FUNCTIONAL_ru.md +++ b/docs/FUNCTIONAL_ru.md @@ -24,7 +24,14 @@ top-1 подсказку, безлимитную проверку слова с Публичная **посадочная страница** в корне сайта представляет игру, переключает язык и тему и ведёт в Telegram-канал игры и в VK Mini App; сама игра живёт по адресам `/app/` (веб), `/telegram/` (Telegram Mini App) и `/vk/` (VK Mini App). Тема на странице эфемерна (берётся из -системной настройки, а не из сохранённой), выбор языка сохраняется. +системной настройки, а не из сохранённой), выбор языка сохраняется. Страница всегда +открывается на **русском** — язык браузера не определяется автоматически (роботы +рендерят страницу с произвольным языком, и автоопределение сделало бы +проиндексированный контент недетерминированным); сохранённый выбор 🌐 по-прежнему +главнее. Страница несёт статическую русскую SEO-«шапку» (title/description, карточка +Open Graph, которую используют превью ссылок в Telegram/VK, канонический адрес +продакшен-домена, JSON-LD, набор favicon и `robots.txt`), а оболочка SPA помечена +`noindex` — индексируется только посадочная страница. ### Первый запуск: онбординг При первом открытии приложения игрока один раз проводят по интерфейсу лёгким diff --git a/ui/e2e/landing.spec.ts b/ui/e2e/landing.spec.ts index d6177ad..e6a5ab6 100644 --- a/ui/e2e/landing.spec.ts +++ b/ui/e2e/landing.spec.ts @@ -2,16 +2,29 @@ import { expect, test } from './fixtures'; // The landing page is a separate Vite entry (landing.html), served at "/" in production while // the game SPA lives at /app/ and /telegram/. In dev it is reachable at /landing.html. -test('landing shows the pitch, switches language via the dropdown, and toggles theme', async ({ page }) => { +test('landing defaults to Russian, switches language via the dropdown, and toggles theme', async ({ page }) => { await page.goto('/landing.html'); - // The tagline renders (English in the default test browser). - await expect(page.getByText(/Play Scrabble/i)).toBeVisible(); - - // The language dropdown switches the copy to Russian. - await page.getByRole('button', { name: 'Language' }).click(); - await page.getByRole('menuitem', { name: /Русский/ }).click(); + // Russian by default regardless of the browser language (the test browser is en-US): + // crawlers render with arbitrary navigator.language, so the landing never auto-detects. await expect(page.getByText(/Играй в «Эрудита»/)).toBeVisible(); + await expect(page).toHaveTitle(/Эрудит \(Скрэббл\)/); + expect(await page.evaluate(() => document.documentElement.lang)).toBe('ru'); + + // The static SEO head is present (the page is client-rendered; this layer is what + // non-rendering crawlers and link-preview bots see). + expect(await page.locator('meta[name="description"]').getAttribute('content')).toContain('Эрудит'); + expect(await page.locator('meta[property="og:image"]').getAttribute('content')).toBe( + 'https://erudit-game.ru/og-image.png', + ); + const jsonLd = await page.locator('script[type="application/ld+json"]').textContent(); + expect(JSON.parse(jsonLd!)['@type']).toBe('WebApplication'); + + // The language dropdown switches the copy to English, and the document language follows. + await page.getByRole('button', { name: 'Language' }).click(); + await page.getByRole('menuitem', { name: /English/ }).click(); + await expect(page.getByText(/Play Scrabble/i)).toBeVisible(); + expect(await page.evaluate(() => document.documentElement.lang)).toBe('en'); // The theme toggle flips the document theme (ephemeral, light<->dark). const before = await page.evaluate(() => document.documentElement.getAttribute('data-theme')); @@ -24,7 +37,7 @@ test('landing shows the pitch, switches language via the dropdown, and toggles t // adds .app-shell); the landing is a normal scrolling document and must keep scrolling. test('the landing is a normal scrolling document (the SPA document-pin does not apply)', async ({ page }) => { await page.goto('/landing.html'); - await expect(page.getByText(/Play Scrabble/i)).toBeVisible(); + await expect(page.getByText(/Играй в «Эрудита»/)).toBeVisible(); const state = await page.evaluate(() => ({ shell: document.documentElement.classList.contains('app-shell'), diff --git a/ui/index.html b/ui/index.html index 8eac93b..e6167a2 100644 --- a/ui/index.html +++ b/ui/index.html @@ -1,7 +1,11 @@ - + + + + + +
diff --git a/ui/landing.html b/ui/landing.html index b92f035..9e25e53 100644 --- a/ui/landing.html +++ b/ui/landing.html @@ -1,10 +1,51 @@ - + - Scrabble + + Эрудит (Скрэббл) — играть онлайн с друзьями или роботом + + + + + + + + + + + + + + + + + + + +
diff --git a/ui/public/apple-touch-icon.png b/ui/public/apple-touch-icon.png new file mode 100644 index 0000000000000000000000000000000000000000..ba28ea8b305118c8b1b895b92401850c71b5a597 GIT binary patch literal 6405 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 diff --git a/ui/public/og-image.png b/ui/public/og-image.png new file mode 100644 index 0000000000000000000000000000000000000000..65bb19f23a70f7acd6537db35069dc6db33d6fbc GIT binary patch literal 43130 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 import { onMount } from 'svelte'; import { applyTheme } from './lib/theme'; - import { i18n, localeFrom, setLocale, t, type Locale } from './lib/i18n/index.svelte'; + import { i18n, setLocale, t, type Locale } from './lib/i18n/index.svelte'; import { loadPrefs, savePrefs, type Prefs } from './lib/session'; import { aboutContent } from './lib/aboutContent'; import { telegramChannelLink, vkAppLink } from './lib/landing'; @@ -9,7 +9,8 @@ // Standalone landing page, the public entry at "/" (the game SPA lives at /app/ and // /telegram/). It reuses the app's theme/i18n/prefs leaf modules — not the app store — so it // stays light. Theme is EPHEMERAL here (no auto, no persistence): it starts from the system - // scheme and the icon toggles light<->dark in memory. Language IS persisted (synced with the app). + // scheme and the icon toggles light<->dark in memory. Language IS persisted (synced with the + // app) but defaults to Russian, never to the browser language — see landing.ts. let theme = $state<'light' | 'dark'>('light'); let langOpen = $state(false); @@ -31,7 +32,13 @@ prefs = await loadPrefs(); theme = systemTheme(); applyTheme(theme); - setLocale(prefs.locale ?? localeFrom(typeof navigator !== 'undefined' ? navigator.language : 'en')); + setLocale(prefs.locale ?? 'ru'); + }); + + // Keep the document language honest for assistive tech and crawlers: the static shell + // declares lang="ru" (the default), and a 🌐 switch to English must follow. + $effect(() => { + document.documentElement.lang = i18n.locale; }); function toggleTheme(): void { diff --git a/ui/src/landing.ts b/ui/src/landing.ts index 2b0c25e..ff86050 100644 --- a/ui/src/landing.ts +++ b/ui/src/landing.ts @@ -1,7 +1,16 @@ import { mount } from 'svelte'; import './app.css'; +import { setLocale } from './lib/i18n/index.svelte'; import Landing from './Landing.svelte'; // Entry for the standalone landing page (served at "/" by the gateway; the game SPA lives at // /app/ and /telegram/). Mounts into the same #app node as the SPA's main.ts. + +// The landing always starts in Russian — the primary audience and the language of the +// static SEO head (crawlers render with arbitrary navigator.language, e.g. Googlebot's +// en-US, so browser detection would make the indexed content nondeterministic). Set +// before mount so the very first paint is Russian; a saved 🌐 choice is applied in +// Landing's onMount once prefs load. +setLocale('ru'); + export default mount(Landing, { target: document.getElementById('app')! }); From 622d3965a7adf17a4589016eab2003d6fd0d67ef Mon Sep 17 00:00:00 2001 From: Ilia Denisov Date: Thu, 2 Jul 2026 15:08:30 +0200 Subject: [PATCH 19/71] fix(game): no placement auto-zoom in landscape Landscape fits the whole board, so the coarse-pointer auto-zoom on tile placement, drag hover-hold and hint only hid the rest of the position. Gate all three on portrait; manual double-tap/pinch zoom is unchanged. New e2e lock both sides: landscape placement stays unzoomed, portrait placement still auto-zooms (touch-emulated, both engines). --- ui/e2e/landscape.spec.ts | 22 ++++++++++++++++++++++ ui/e2e/zoom.spec.ts | 21 +++++++++++++++++++++ ui/src/game/Game.svelte | 16 ++++++++++------ 3 files changed, 53 insertions(+), 6 deletions(-) diff --git a/ui/e2e/landscape.spec.ts b/ui/e2e/landscape.spec.ts index 366d621..709c6ed 100644 --- a/ui/e2e/landscape.spec.ts +++ b/ui/e2e/landscape.spec.ts @@ -53,3 +53,25 @@ test('landscape still places a tile and commits via the make button', async ({ p await expect(page.locator('.cell.pending')).toHaveCount(1); await expect(page.locator('.make')).toBeVisible(); }); + +// Placement auto-zoom is portrait-only (Game.svelte gates it on !landscape): landscape fits the +// whole board, so magnifying on every placed tile would only hide the position. The gate also +// requires a coarse pointer, so the block emulates touch and skips on an engine whose emulation +// does not flip `(pointer: coarse)` (desktop WebKit). The portrait counterpart lives in +// zoom.spec.ts. +test.describe('touch placement', () => { + test.use({ hasTouch: true }); + + test('landscape does not auto-zoom when a tile is placed', async ({ page }) => { + await openGame(page); + test.skip( + !(await page.evaluate(() => matchMedia('(pointer: coarse)').matches)), + 'touch emulation does not report a coarse pointer in this engine', + ); + await page.locator('.rack .tile').first().click(); + await page.locator('[data-cell][data-row="9"][data-col="6"]').click(); + await expect(page.locator('.cell.pending')).toHaveCount(1); + await page.waitForTimeout(400); // the would-be zoom transition window + await expect(page.locator('.viewport.zoomed')).toHaveCount(0); + }); +}); diff --git a/ui/e2e/zoom.spec.ts b/ui/e2e/zoom.spec.ts index de2ff71..5144acf 100644 --- a/ui/e2e/zoom.spec.ts +++ b/ui/e2e/zoom.spec.ts @@ -53,6 +53,27 @@ test('zoomed board clamps overscroll at the edge', async ({ page }) => { expect(ob.y).toBe('none'); }); +// Placement auto-zoom (touch): in portrait, placing a tile magnifies into it. This is the +// counterpart of landscape.spec.ts's no-auto-zoom guard — together they lock the gate to +// orientation only. Skips on an engine whose touch emulation does not flip `(pointer: coarse)`. +test.describe('touch placement', () => { + test.use({ hasTouch: true }); + + test('placing a tile auto-zooms the board in portrait', async ({ page }) => { + await page.goto('/'); + await page.getByRole('button', { name: /guest/i }).click(); + await page.getByRole('button', { name: /Ann/ }).click(); + await expect(page.locator('[data-cell]').first()).toBeVisible(); + test.skip( + !(await page.evaluate(() => matchMedia('(pointer: coarse)').matches)), + 'touch emulation does not report a coarse pointer in this engine', + ); + await page.locator('.rack .tile').first().click(); + await page.locator('[data-cell][data-row="9"][data-col="6"]').click(); + await expect(page.locator('.viewport.zoomed')).toBeVisible(); + }); +}); + // A hint taken while the board is ALREADY zoomed in must still scroll to the played word — the // zoom state does not change, so without an explicit recenter the board stays parked where the // player was looking (the reported rough edge). The mock hint plays at the centre star (7,7), so diff --git a/ui/src/game/Game.svelte b/ui/src/game/Game.svelte index 21082a3..163e61f 100644 --- a/ui/src/game/Game.svelte +++ b/ui/src/game/Game.svelte @@ -509,6 +509,7 @@ draggingPend = src.from === 'board' ? { row: src.row, col: src.col } : null; // No zoom on drag start: the player may still change their mind. Holding the tile // over a cell for ~1s auto-zooms there (hover-hold below); a drop also zooms+centres. + // Both auto-zooms are portrait-only — landscape fits the whole board. } if (!drag) return; drag = { ...drag, x: e.clientX, y: e.clientY }; @@ -539,7 +540,7 @@ ? setTimeout(() => { // Still holding the tile over this cell: magnify into it. Only the first // (zoom-in) hold centres; once zoomed we never move the board on hover. - if (drag && isCoarse() && !zoomed) { + if (drag && isCoarse() && !landscape && !zoomed) { focus = c; zoomed = true; haptic('light'); @@ -652,7 +653,9 @@ if (board[row]?.[col]) return; if (pendingMap.has(`${row},${col}`)) return; focus = { row, col }; - if (isCoarse() && !zoomed) zoomed = true; + // Auto-zoom is portrait-only: landscape fits the whole board, magnifying it there + // only hides the rest of the position. + if (isCoarse() && !landscape && !zoomed) zoomed = true; if (placement.rack[index] === BLANK) { blankPrompt = { rackIndex: index, row, col }; return; @@ -844,12 +847,13 @@ row: Math.round((Math.min(...rows) + Math.max(...rows)) / 2), col: Math.round((Math.min(...cols) + Math.max(...cols)) / 2), }; - // Ask the board to scroll to the hint word. A zoom-out board zooms in (and centres) on - // the next line; this nonce also recentres a board that is already zoomed in, where the - // unchanged zoom state would otherwise leave it parked where the player was looking. + // Ask the board to scroll to the hint word. A zoomed-out board zooms in (and centres) + // on the next line (portrait only); this nonce also recentres a board that is already + // zoomed in, where the unchanged zoom state would otherwise leave it parked where the + // player was looking. recenter++; } - if (isCoarse()) zoomed = true; + if (isCoarse() && !landscape) zoomed = true; view = { ...view, hintsRemaining: h.hintsRemaining, walletBalance: h.walletBalance }; syncWallet(h.walletBalance); // The hint is the engine's own top-ranked, fully scored legal move: reuse it as the From a0eacf301106f6bc8cfcf4a41984f78384f21ba8 Mon Sep 17 00:00:00 2001 From: Ilia Denisov Date: Thu, 2 Jul 2026 16:20:35 +0200 Subject: [PATCH 20/71] feat(game): finished-game export as a PNG image behind a format chooser MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The history header's export button now opens a chooser — Telegram's native popup inside Telegram, the app's own modal elsewhere — offering the GCG file and a new client-rendered PNG of the final position (lib/gameimage, Canvas 2D, lazy dynamic import, zero dependencies): light theme, classic A..O/1..15 axes, label-free premium fills, and a fixed-typography per-seat scoresheet with GCG-style move coordinates, multi-word sub-lines, endgame rack-settlement row, winner trophy and a hostname + device-locale finish date footer; a long game stretches the board, never the typography. Delivery mirrors the GCG rules (Web Share with no blob fallback, else download) except on Android Telegram/VK WebViews and the desktop VK iframe, where a binary PNG has no clipboard-text fallback: those get a preview modal with a long-press/right-click save hint and a copy-image button where ClipboardItem exists. --- docs/FUNCTIONAL.md | 37 ++- docs/FUNCTIONAL_ru.md | 36 ++- docs/UI_DESIGN.md | 33 ++- ui/e2e/export.spec.ts | 90 +++++++ ui/e2e/quickmatch.spec.ts | 2 +- ui/e2e/social.spec.ts | 6 +- ui/src/game/Game.svelte | 122 +++++++++- ui/src/lib/gameimage.test.ts | 146 +++++++++++ ui/src/lib/gameimage.ts | 452 +++++++++++++++++++++++++++++++++++ ui/src/lib/history.test.ts | 24 +- ui/src/lib/history.ts | 17 +- ui/src/lib/i18n/en.ts | 8 +- ui/src/lib/i18n/ru.ts | 8 +- ui/src/lib/share.test.ts | 65 ++++- ui/src/lib/share.ts | 87 ++++++- 15 files changed, 1097 insertions(+), 36 deletions(-) create mode 100644 ui/e2e/export.spec.ts create mode 100644 ui/src/lib/gameimage.test.ts create mode 100644 ui/src/lib/gameimage.ts diff --git a/docs/FUNCTIONAL.md b/docs/FUNCTIONAL.md index 2b0af0d..0e510bb 100644 --- a/docs/FUNCTIONAL.md +++ b/docs/FUNCTIONAL.md @@ -16,7 +16,7 @@ the top-1 hint, the unlimited word-check with complaint, per-game chat and nudge real-time in-app updates, switching interface language (en/ru) and theme, and a read-only profile. It also handles managing friends (including one-time friend codes) and blocks, friend-game invitations, editing the profile and binding an -email, the statistics screen, and the in-game history viewer with GCG export. +email, the statistics screen, and the in-game history viewer with GCG / PNG-image export. Settings also pick the board's bonus-label style (beginner / classic / none). A hint **lays the suggested tiles on the board** for the player to confirm and costs nothing when the rack has no legal move. The word-check accepts only the variant's alphabet, remembers answers within the session and rate-limits repeats. @@ -318,12 +318,35 @@ silently ignores their messages) or clear a thread's messages. This is independe Feedback above — it serves people who write to the bot directly rather than through the app. ### History & statistics -Finished games are archived in a dictionary-independent form and exportable to -GCG; the export is offered **only once a game is finished**, and never for an -honest-AI practice game (a live game's export would leak the move journal; an AI -game is throwaway). The client shares the `.gcg` file where the platform supports -it; on an Android in-app client (Telegram / VK), which has neither Web Share nor a working file -download, it copies the GCG to the clipboard (with a confirming toast); otherwise it downloads the file. Statistics (durable accounts only): +Finished games are archived in a dictionary-independent form and exportable in +**two formats behind one 📤 button** — the GCG file and a rendered **PNG image** of +the final position; the export is offered **only once a game is finished**, and +never for an honest-AI practice game (a live game's export would leak the move +journal; an AI game is throwaway). The format chooser is native where the platform +has one (Telegram's popup), the app's own modal elsewhere. + +The **image** is rendered on the client (Canvas 2D, always the light theme): the +final board with classic coordinate axes A..O / 1..15 on the left — premium squares +as plain colour fills, no text labels — and a compact per-seat scoresheet on the +right: each seat's name and final score in the header (🏆 by the winner), then one +row per move carrying the main word's classic coordinate (an across play is +row-first, `8G`; a down play column-first, `H8` — the GCG convention), the word and +its points; extra words of a multi-word play ride a smaller second line, non-play +moves show as localized notes (pass/exchange/resign/timeout), and a closing ± row +shows the endgame rack settlement when there was one (the final scores are +authoritative — running totals alone do not include it). The footer stamps the site +host and the finish date in the device locale. The scoresheet typography is fixed; +a long game stretches the board (never below its minimum) so the image carries no +dead space. + +Delivery per format: the **GCG** file is Web-Shared where the platform supports it; +on an Android in-app client (Telegram / VK), which has neither Web Share nor a +working file download, it copies the GCG text to the clipboard (with a confirming +toast); otherwise it downloads the file. The **PNG** shares or downloads the same +way where possible, but a binary image has no clipboard-text fallback — those +in-app webviews (and the desktop VK iframe) get a **preview modal** instead: the +image with a long-press / right-click save hint and a copy-image button where the +clipboard-image API exists. Statistics (durable accounts only): wins, losses, draws, max points in a game, and max points for a single move (the best play, which already includes every word it formed plus the all-tiles bonus). It also shows the player's **move count** (their plays — passes and exchanges do not diff --git a/docs/FUNCTIONAL_ru.md b/docs/FUNCTIONAL_ru.md index 5bb3c73..448de8c 100644 --- a/docs/FUNCTIONAL_ru.md +++ b/docs/FUNCTIONAL_ru.md @@ -16,7 +16,7 @@ top-1 подсказку, безлимитную проверку слова с профиль только для чтения. Он также включает управление друзьями (в т.ч. одноразовые коды-приглашения) и блоками, дружеские приглашения в игру, редактирование профиля и привязку email, экран статистики и просмотр истории -партии с экспортом GCG. В настройках также выбирается стиль подписей бонус-клеток +партии с экспортом в GCG / PNG-картинку. В настройках также выбирается стиль подписей бонус-клеток (новичок / классика / без текста). Подсказка **выставляет предложенные фишки на доску** — игрок сам решает сделать ход, и подсказка не тратится, если ходов нет. Проверка слова принимает только алфавит варианта, запоминает ответы в рамках сессии @@ -327,12 +327,34 @@ Telegram. канал для тех, кто пишет боту напрямую, а не через приложение. ### История и статистика -Завершённые партии архивируются в независимом от словаря виде и экспортируются -в GCG; экспорт доступен **только после завершения партии** и никогда — для -тренировочной партии с ИИ (экспорт идущей партии раскрыл бы журнал ходов, а партия -с ИИ одноразовая). Клиент делится файлом `.gcg` там, где платформа это поддерживает; -в Android-приложении (Telegram / VK), где нет ни Web Share, ни рабочей загрузки файла, копирует GCG -в буфер обмена (с подтверждающим тостом); иначе скачивает файл. Статистика (только у постоянных аккаунтов): +Завершённые партии архивируются в независимом от словаря виде и экспортируются в +**двух форматах за одной кнопкой 📤** — файл GCG и отрисованная **PNG-картинка** +финальной позиции; экспорт доступен **только после завершения партии** и никогда — +для тренировочной партии с ИИ (экспорт идущей партии раскрыл бы журнал ходов, а +партия с ИИ одноразовая). Выбор формата — нативный диалог там, где платформа его +даёт (попап Telegram), иначе собственный модал приложения. + +**Картинка** рендерится на клиенте (Canvas 2D, всегда светлая тема): финальная +доска с классическими осями координат A..O / 1..15 слева — бонус-клетки чистой +цветовой заливкой, без текстовых подписей — и компактная таблица ходов по местам +справа: в шапке имя и финальный счёт каждого места (🏆 у победителя), далее по +строке на ход: классическая координата главного слова (горизонтальный ход — +цифра первой, `8G`; вертикальный — буква первой, `H8` — конвенция GCG), слово и +его очки; дополнительные слова мультисловного хода — второй мелкой строкой, +не-игровые ходы — локализованными пометками (пас/обмен/сдался/таймаут), а +замыкающая строка ± показывает концовочную поправку за рэк, когда она была +(финальные счета авторитетны — суммы ходов её не содержат). В подвале — хост сайта +и дата завершения в локали устройства. Типографика таблицы фиксирована; длинная +партия растягивает доску (не ниже минимума), так что пустот на картинке нет. + +Доставка по форматам: файлом **GCG** клиент делится через Web Share там, где +платформа это поддерживает; в Android-приложении (Telegram / VK), где нет ни Web +Share, ни рабочей загрузки файла, копирует текст GCG в буфер обмена (с +подтверждающим тостом); иначе скачивает файл. **PNG** делится и скачивается так +же, где возможно, но у бинарной картинки нет текстового фолбэка через буфер — в +этих встроенных webview (и в десктопном iframe VK) вместо него **модал-превью**: +картинка с подсказкой «долгое нажатие / правый клик — сохранить» и кнопкой +копирования картинки там, где есть clipboard-image API. Статистика (только у постоянных аккаунтов): победы, поражения, ничьи, макс. очков за партию и макс. очков за один ход (лучший ход, уже включающий все образованные им слова и бонус за все фишки). Также показываются **число ходов** игрока (его выкладки — пасы и обмены не считаются) и diff --git a/docs/UI_DESIGN.md b/docs/UI_DESIGN.md index b25dc6c..e0a7ed6 100644 --- a/docs/UI_DESIGN.md +++ b/docs/UI_DESIGN.md @@ -403,15 +403,32 @@ enabled on the first, uncached load) and flip in place when an event refreshes t lobby with a calm modal pointing at the bot (`@`), not a red error on the Friends tab. Flex text inputs carry `min-width:0` so they shrink instead of overflowing in Safari. -- **History / GCG**: the in-game slide-down history lays each move out in a per-seat grid - (the word(s) and the move score, no running total); *Export GCG* (the 📤 in the history - header) delivers the `.gcg` file — Web Share where available, a clipboard copy in an Android in-app - WebView (Telegram / VK, which has neither Web Share nor a working download), else a Blob download — - and appears only once the game is finished — and - never in an honest-AI game (throwaway practice). Confirming a resign reveals the full board: - it closes the history drawer (portrait) and zooms the board out. +- **History / export**: the in-game slide-down history lays each move out in a per-seat grid + (the word(s) and the move score, no running total); the 📤 in the history header appears + only once the game is finished — never in an honest-AI game (throwaway practice) — and + opens a **format chooser**: Telegram's native popup inside Telegram (image / GCG / cancel), + the app's own modal elsewhere (the accent button leads with the image, the GCG file is the + quiet alternative; GCG needs the connection, the image renders locally). The GCG file goes + by Web Share where available, a clipboard copy in an Android in-app WebView (Telegram / VK, + which has neither Web Share nor a working download), else a Blob download. The image goes by + Web Share / Blob download too, but on those in-app WebViews (and the desktop VK iframe) it + opens a **preview modal** instead — the PNG as a data: URL with a long-press / right-click + save hint and a copy-image button where `ClipboardItem` exists. Confirming a resign reveals + the full board: it closes the history drawer (portrait) and zooms the board out. +- **Export image** (`lib/gameimage.ts`, Canvas 2D, dynamically imported): always the light + palette (pinned constants mirroring `app.css`), the final board with classic axes A..O / + 1..15, premium squares as plain colour fills (no text labels), tiles in the in-game style + (bevel, letter top-left, value bottom-right, ✻ for an Erudit blank), no last-move + highlight. The right column is the scoresheet: per-seat name over the final score (🏆 by + the winner), one fixed-typography row per move — muted classic coordinate (across = + row-first `8G`, down = column-first `H8`), the main word, points right-aligned; extra + words of a multi-word play on a smaller second line; italic localized notes for + pass/exchange/resign/timeout; a closing muted ± row for the endgame rack settlement when + present. Footer: `hostname · finish date` in the **device** locale. The scoresheet's + height drives the board side (never below its minimum): a long game stretches the board, + never the typography, so the image has no dead space. - **Finished game**: the board keeps no last-word highlight and no zoom; the history header - offers *Export GCG* (not *Drop game*; an AI game offers neither) and the comms hub hides the 🔎 *Dictionary* tab — and a **finished AI game has no comms at all** (no chat, dictionary closed), so its 💬 entry is dropped from the header too; and + offers *Export game* (not *Drop game*; an AI game offers neither) and the comms hub hides the 🔎 *Dictionary* tab — and a **finished AI game has no comms at all** (no chat, dictionary closed), so its 💬 entry is dropped from the header too; and the footer (rack + tab bar) is **drawn but inert** (greyed, non-interactive) rather than hidden, so the layout does not jump. Chat send / nudge are the ⬆️ / 🛎️ icons. The input row is turn-driven: on your turn the message field shows until your one allowed message is diff --git a/ui/e2e/export.spec.ts b/ui/e2e/export.spec.ts new file mode 100644 index 0000000..f5d7154 --- /dev/null +++ b/ui/e2e/export.spec.ts @@ -0,0 +1,90 @@ +import { expect, test, type Page } from './fixtures'; + +// The finished-game export: the history header's 📤 button opens a format chooser — +// the GCG file or the rendered PNG image (lib/gameimage) — as a native Telegram popup +// inside Telegram and as the app's own modal elsewhere. g3 ("vs Rick") is the seeded +// finished game. Web Share is force-disabled per test so every engine deterministically +// exercises the download (plain browser) or preview (in-app webview) branch. + +async function disableWebShare(page: Page): Promise { + await page.addInitScript(() => { + Object.defineProperty(navigator, 'canShare', { value: () => false, configurable: true }); + Object.defineProperty(navigator, 'share', { value: undefined, configurable: true }); + }); +} + +async function openFinishedHistory(page: Page): Promise { + await page.goto('/'); + await page.getByRole('button', { name: /guest/i }).click(); + await page.getByRole('button', { name: /Rick/ }).click(); + await expect(page.locator('[data-cell]').first()).toBeVisible(); + await page.locator('.scoreboard').click(); + await expect(page.getByRole('button', { name: 'Export game' })).toBeVisible(); +} + +test('the export chooser downloads the PNG image on a plain browser', async ({ page }) => { + await disableWebShare(page); + await openFinishedHistory(page); + await page.getByRole('button', { name: 'Export game' }).click(); + + // The app's own chooser modal (not inside Telegram) offers both formats. + await expect(page.getByRole('button', { name: 'GCG file' })).toBeVisible(); + const download = page.waitForEvent('download'); + await page.getByRole('button', { name: 'Image (PNG)' }).click(); + expect((await download).suggestedFilename()).toMatch(/^game-.+\.png$/); +}); + +test('the export chooser downloads the GCG file on a plain browser', async ({ page }) => { + await disableWebShare(page); + await openFinishedHistory(page); + await page.getByRole('button', { name: 'Export game' }).click(); + + const download = page.waitForEvent('download'); + await page.getByRole('button', { name: 'GCG file' }).click(); + expect((await download).suggestedFilename()).toMatch(/^game-.+\.gcg$/); +}); + +test('inside Telegram the chooser is the native popup and the image lands in the preview modal', async ({ + page, +}) => { + await disableWebShare(page); + // A Telegram WebApp stub whose showPopup records its params and picks the image option — + // the native-chooser path. With no Web Share in the webview, the image must land in the + // long-press preview modal (the Android Telegram delivery), never a dead . + await page.addInitScript(() => { + Object.assign(window, { + Telegram: { + WebApp: { + initData: 'query_id=test&user=%7B%22id%22%3A1%7D&auth_date=1&hash=deadbeef', + initDataUnsafe: {}, + ready() {}, + expand() {}, + showPopup(params: unknown, cb: (id: string) => void) { + (window as unknown as { __popup?: unknown }).__popup = params; + setTimeout(() => cb('image'), 0); + }, + }, + }, + }); + }); + await page.goto('/'); + await expect(page.getByText('Your turn')).toBeVisible(); // auto-authenticated lobby + await page.getByRole('button', { name: /Rick/ }).click(); + await expect(page.locator('[data-cell]').first()).toBeVisible(); + await page.locator('.scoreboard').click(); + await page.getByRole('button', { name: 'Export game' }).click(); + + // The native popup carried both formats plus cancel (≤3 buttons, Bot API 6.2)… + const popup = await page.evaluate( + () => (window as unknown as { __popup?: { buttons?: { id?: string; type?: string }[] } }).__popup, + ); + expect(popup?.buttons?.map((b) => b.id ?? b.type)).toEqual(['image', 'gcg', 'cancel']); + // …and no in-app chooser modal was mounted. + await expect(page.getByRole('button', { name: 'GCG file' })).toHaveCount(0); + + // The preview modal shows the rendered PNG as a data: URL (long-press-saveable) + the hint. + const img = page.locator('.export-preview'); + await expect(img).toBeVisible(); + expect(await img.getAttribute('src')).toMatch(/^data:image\/png/); + await expect(page.locator('.export-hint')).toBeVisible(); +}); diff --git a/ui/e2e/quickmatch.spec.ts b/ui/e2e/quickmatch.spec.ts index 36c27f1..ddf85d2 100644 --- a/ui/e2e/quickmatch.spec.ts +++ b/ui/e2e/quickmatch.spec.ts @@ -80,7 +80,7 @@ test('AI game: after it ends, no GCG export and no comms entry', async ({ page } // Reopen the history (resigning auto-closed it): the finished AI game offers no GCG export and // no comms entry at all. await page.locator('.scoreboard').click(); - await expect(page.getByRole('button', { name: 'Export GCG' })).toHaveCount(0); + await expect(page.getByRole('button', { name: 'Export game' })).toHaveCount(0); await expect(page.getByRole('button', { name: 'Chat' })).toHaveCount(0); }); diff --git a/ui/e2e/social.spec.ts b/ui/e2e/social.spec.ts index 742044f..fe12a2a 100644 --- a/ui/e2e/social.spec.ts +++ b/ui/e2e/social.spec.ts @@ -154,14 +154,14 @@ test('GCG export appears only for a finished game', async ({ page }) => { // The finished game vs Kaya exposes export 📤 in the history header. await page.getByRole('button', { name: /Kaya/ }).click(); await page.locator('.scoreboard').click(); // open the history - await expect(page.getByRole('button', { name: 'Export GCG' })).toBeVisible(); + await expect(page.getByRole('button', { name: 'Export game' })).toBeVisible(); }); test('GCG export is hidden for an active game', async ({ page }) => { await loginLobby(page); await page.getByRole('button', { name: /Ann/ }).click(); await page.locator('.scoreboard').click(); // open the history (shows 🏁 leave, not 📤 export) - await expect(page.getByRole('button', { name: 'Export GCG' })).toHaveCount(0); + await expect(page.getByRole('button', { name: 'Export game' })).toHaveCount(0); }); test('finished game draws an inert footer and trims live-only controls', async ({ page }) => { @@ -172,7 +172,7 @@ test('finished game draws an inert footer and trims live-only controls', async ( await expect(page.locator('.tab').first()).toBeDisabled(); // The history header offers Export GCG, not Drop game, once the game is over. await page.locator('.scoreboard').click(); - await expect(page.getByRole('button', { name: 'Export GCG' })).toBeVisible(); + await expect(page.getByRole('button', { name: 'Export game' })).toBeVisible(); await expect(page.getByRole('button', { name: 'Drop game' })).toHaveCount(0); // The comms hub offers Chat only — the Dictionary tab is hidden for a finished game. await page.getByRole('button', { name: 'Chat' }).click(); // 💬 diff --git a/ui/src/game/Game.svelte b/ui/src/game/Game.svelte index 21082a3..f2eba4a 100644 --- a/ui/src/game/Game.svelte +++ b/ui/src/game/Game.svelte @@ -22,12 +22,12 @@ import { variantNameKey, usesStarBlank, BLANK_STAR } from '../lib/variants'; import { alphabetLetters, hasAlphabet } from '../lib/alphabet'; import { hintsLeft } from '../lib/hints'; - import { shareOrDownloadGcg } from '../lib/share'; + import { blobToDataURL, canCopyImage, copyImageToClipboard, shareOrDownloadGcg, shareOrDownloadImage } from '../lib/share'; import { insideVK, vkCopyText } from '../lib/vk'; import { getCachedGame, setCachedGame, setCachedDraft, type CachedGame } from '../lib/gamecache'; import { patchLobbyGame } from '../lib/lobbycache'; import { applyGameOver, applyMoveDelta, applyOpponentJoined, type DeltaResult } from '../lib/gamedelta'; - import { insideTelegram, telegramDialogsAvailable, telegramShowConfirm } from '../lib/telegram'; + import { insideTelegram, telegramDialogsAvailable, telegramShowConfirm, telegramShowPopup } from '../lib/telegram'; import { haptic } from '../lib/haptics'; import { BLANK, @@ -927,6 +927,30 @@ return view.game.seats.some((s) => s.isWinner) ? t('game.lost') : t('game.tied'); } + // The finished-game export offers two formats — the GCG file and the rendered PNG image — + // behind one 📤 button: a native Telegram popup where available, the app's own modal + // elsewhere (the onResignClick pattern). + let exportOpen = $state(false); + let exportPreviewUrl = $state(''); + let exportBlob: Blob | null = null; + + async function onExportClick() { + if (insideTelegram() && telegramDialogsAvailable()) { + const choice = await telegramShowPopup({ + message: t('game.exportChoice'), + buttons: [ + { id: 'image', type: 'default', text: t('game.exportImageOpt') }, + { id: 'gcg', type: 'default', text: t('game.exportGcgOpt') }, + { type: 'cancel' }, + ], + }); + if (choice === 'image') void exportImage(); + else if (choice === 'gcg') void exportGcg(); + return; + } + exportOpen = true; + } + async function exportGcg() { try { const gcg = await gateway.exportGcg(id); @@ -938,6 +962,39 @@ } } + // exportImage renders the finished game as a PNG (lib/gameimage, dynamically imported so the + // renderer stays out of the startup bundle) and delivers it: Web Share / download where they + // work, else the preview modal (Android Telegram/VK WebView and the desktop VK iframe, where + // neither exists for a binary file) with a long-press/right-click save hint. + async function exportImage() { + if (!view) return; + try { + const { renderGameImage } = await import('../lib/gameimage'); + const blob = await renderGameImage(view.game, moves, { actionLabel: moveActionLabel }); + const file = new File([blob], `game-${id.slice(0, 8)}.png`, { type: 'image/png' }); + const outcome = await shareOrDownloadImage(file, insideTelegram() || insideVK()); + if (outcome === 'preview') { + exportBlob = blob; + exportPreviewUrl = await blobToDataURL(blob); + } + } catch (e) { + handleError(e); + } + } + + function closeExportPreview() { + exportPreviewUrl = ''; + exportBlob = null; + } + + async function copyExportImage() { + if (exportBlob && (await copyImageToClipboard(exportBlob))) { + showToast(t('game.imageCopied'), 'info'); + } else { + showToast(t('error.generic'), 'error'); + } + } + // The GCG export copies to the clipboard in an Android in-app WebView (no Web Share, a dead // ): VKWebAppCopyText inside VK — which also works in the desktop VK iframe, where // navigator.clipboard is blocked — and navigator.clipboard elsewhere. @@ -1270,7 +1327,7 @@ icon pinned right (the header is space-between). --> {:else} - + {/if} {:else} @@ -1452,6 +1509,33 @@ {/if} +{#if exportOpen} + (exportOpen = false)}> +
+ + +
+
+{/if} + +{#if exportPreviewUrl} + + {t('game.exportGcg')} +

{t('game.imageSaveHint')}

+ {#if canCopyImage()} + + {/if} +
+{/if} + From 65f2c87a746439f259df6a6a464ac04112099ea4 Mon Sep 17 00:00:00 2001 From: Ilia Denisov Date: Fri, 3 Jul 2026 04:33:27 +0200 Subject: [PATCH 33/71] docs+test(email): document the confirm deeplink + wire-contract tests MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Document the one-tap confirm deeplink in ARCHITECTURE (§4 the login magic-link / link confirm+profile-refresh, prefetch-safe token) and the new notify 'profile' sub-kind (§10), and add the one-tap link to the FUNCTIONAL email story (+ru). Add codec round-trip assertions for the EmailRequest language field and encodeEmailConfirmLink (the mock e2e bypasses the codec). --- docs/ARCHITECTURE.md | 13 +++++++++++-- docs/FUNCTIONAL.md | 5 ++++- docs/FUNCTIONAL_ru.md | 5 ++++- ui/src/lib/codec.test.ts | 7 +++++++ 4 files changed, 26 insertions(+), 4 deletions(-) diff --git a/docs/ARCHITECTURE.md b/docs/ARCHITECTURE.md index 182212f..c326ad2 100644 --- a/docs/ARCHITECTURE.md +++ b/docs/ARCHITECTURE.md @@ -239,7 +239,13 @@ arrive from a platform rather than completing a mandatory registration). development log mailer when no relay is configured) and, once verified, attaches a confirmed email identity. Sends are throttled per recipient (a 60-second cooldown and a five-per-hour cap). Links in the email are built from - a configured public base URL, never a request Host header (anti-injection). An + a configured public base URL, never a request Host header (anti-injection). The email + also carries a **one-tap confirm deeplink** (`/app/#/confirm/`, an opaque + 256-bit token stored only as its SHA-256, 12-hour TTL): a login mints a session in the + browser that opens it (magic-link), a link confirms the identity and emits a `notify` + profile-refresh to the in-app session, and a would-be merge is deferred to the + interactive flow. The confirm page is prefetch-safe — it confirms only on a button + press, so a mail scanner's GET does not consume the single-use token. An **email-login** account is created flagged `is_guest` and stays reapable until the code is confirmed — so an abandoned, never-confirmed login frees its reserved address — with confirming clearing the flag. Accounts and identities use @@ -933,7 +939,10 @@ edge-pause, scroll speed, and the fade-out → gap → fade-in transition) are o eligibility inputs (grants hints, grants/revokes `no_banner`; a future payment flow sets `paid_account`), the backend emits a `notify` **`banner`** sub-kind (a payload-free re-poll signal), and the open client re-fetches `profile.get` to show or hide the banner in place. Operator *content* -edits take effect on the next `profile.get` (open/reconnect/foreground), not mid-session. +edits take effect on the next `profile.get` (open/reconnect/foreground), not mid-session. The same +mechanism carries a **`profile`** sub-kind — a payload-free re-fetch signal emitted when a viewer's +own account changed out of band (an email confirmed through the one-tap deeplink opened in another +browser), so an open in-app session reflects it at once. > A single `app.load` bootstrap aggregator (collapsing `profile.get` + lobby + badge fetches into > one round-trip) was **considered and deferred**: client↔gateway is HTTP/2 (h2c), so the bootstrap diff --git a/docs/FUNCTIONAL.md b/docs/FUNCTIONAL.md index 09dfed6..abafd3c 100644 --- a/docs/FUNCTIONAL.md +++ b/docs/FUNCTIONAL.md @@ -66,7 +66,10 @@ client's light/dark scheme, and the layout clears the VK mobile home bar. The sa is provisioned on the first request but only becomes a durable account once the code is confirmed, so an abandoned, never-confirmed attempt is cleaned up and its address freed. Sends are rate-limited (a short cooldown between codes and a small hourly cap -per address), so a mistyped address or an impatient tap cannot flood an inbox. +per address), so a mistyped address or an impatient tap cannot flood an inbox. The email +also carries a **one-tap link** that confirms the address — or signs the player straight +in — in a single tap; opening it in another browser reflects in the app at once, and a +mail scanner that merely fetches the link never triggers it (it acts only on a button press). Telegram runs a **single bot**: every player uses the same bot, and all of its chat and out-of-app notifications are written in the player's own **interface language** (en/ru). A separate optional **promo bot** can run alongside the diff --git a/docs/FUNCTIONAL_ru.md b/docs/FUNCTIONAL_ru.md index 1ca51cd..690ddfb 100644 --- a/docs/FUNCTIONAL_ru.md +++ b/docs/FUNCTIONAL_ru.md @@ -71,7 +71,10 @@ launch-параметрам VK (их проверяет gateway), и при пе запросе, но становится постоянным аккаунтом только после подтверждения кода — так брошенная, неподтверждённая попытка вычищается, а адрес освобождается. Отправки ограничены по частоте (короткая пауза между кодами и небольшой часовой лимит на адрес), чтобы опечатка в адресе или нетерпеливый тап -не завалили почтовый ящик. +не завалили почтовый ящик. В письме также есть **ссылка одного нажатия**, которая подтверждает +адрес — или сразу выполняет вход — в один тап; открытие её в другом браузере тут же отражается в +приложении, а почтовый сканер, который просто загружает ссылку, её не срабатывает (действие +только по нажатию кнопки). Telegram держит **единого бота**: все игроки пользуются одним и тем же ботом, а весь его чат и внеприложенческие уведомления пишутся на **языке интерфейса** самого игрока (en/ru). Рядом с основным может работать отдельный опциональный diff --git a/ui/src/lib/codec.test.ts b/ui/src/lib/codec.test.ts index 21b37ae..4e1b1ab 100644 --- a/ui/src/lib/codec.test.ts +++ b/ui/src/lib/codec.test.ts @@ -21,6 +21,7 @@ import { decodeStats, encodeCheckWord, encodeEmailRequest, + encodeEmailConfirmLink, encodeFeedbackSubmit, encodeDraftSave, encodeEnqueue, @@ -121,6 +122,12 @@ describe('codec', () => { ); expect(email.email()).toBe('a@example.com'); expect(email.browserTz()).toBe('+00:00'); + expect(email.language()).toBe('en'); + + const confirm = fb.EmailConfirmLinkRequest.getRootAsEmailConfirmLinkRequest( + new ByteBuffer(encodeEmailConfirmLink('tok-abc')), + ); + expect(confirm.token()).toBe('tok-abc'); const vk = fb.VKLoginRequest.getRootAsVKLoginRequest( new ByteBuffer(encodeVKLogin('vk_user_id=494075&vk_ts=1&sign=abc', '+03:00', 'Иван Петров')), From 5804f7266e600780c5c405c49b560dfd6bf46817 Mon Sep 17 00:00:00 2001 From: Ilia Denisov Date: Fri, 3 Jul 2026 05:10:22 +0200 Subject: [PATCH 34/71] fix(account): seed the email account display name from the local part MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit An email account was provisioned with no display name (unlike Telegram/VK, which seed one), so an email login showed an empty name. Seed it from the email's local part (before '@', trimmed and capped to the column width) on first contact; the user can rename it later. Only new accounts are seeded — an existing account's name is never overwritten. --- backend/internal/account/account.go | 18 ++++++++++++++++-- backend/internal/inttest/email_test.go | 21 +++++++++++++++++++++ 2 files changed, 37 insertions(+), 2 deletions(-) diff --git a/backend/internal/account/account.go b/backend/internal/account/account.go index 9da3bc5..2b10fc8 100644 --- a/backend/internal/account/account.go +++ b/backend/internal/account/account.go @@ -122,21 +122,35 @@ func (s *Store) ProvisionByIdentity(ctx context.Context, kind, externalID string // ProvisionEmail returns the account owning the email identity externalID, creating // it on first contact with browserTZ — the client's detected "±HH:MM" UTC offset — -// seeded into its time zone and language seeded from the client's UI language. Like +// seeded into its time zone, language seeded from the client's UI language, and its +// display name seeded from the email's local part (so it is not left nameless). Like // ProvisionByIdentity it is race-safe and leaves an existing account untouched, so a -// returning user's saved zone and language are never overwritten. The email account is +// returning user's saved zone, language and name are never overwritten. The email account is // created here (the code-request step), not at the later login, so this is where its // zone and language are seeded. It is created flagged is_guest with an unconfirmed // email identity: an abandoned, never-confirmed login is then reaped like any guest, // freeing the reserved address, and confirming the code clears the guest flag. func (s *Store) ProvisionEmail(ctx context.Context, externalID, browserTZ, language string) (Account, error) { return s.provision(ctx, KindEmail, externalID, provisionSeed{ + displayName: emailDisplayName(externalID), timeZone: seedZone(browserTZ), preferredLanguage: supportedLanguage(language), isGuest: true, }) } +// emailDisplayName derives a display name from an email address — the local part +// before '@', trimmed and capped to the column width — so a new email account is not +// left nameless. It is only the first-contact seed; the user can rename it later. +func emailDisplayName(email string) string { + local, _, _ := strings.Cut(email, "@") + local = strings.TrimSpace(local) + if r := []rune(local); len(r) > maxDisplayName { + local = strings.TrimRight(string(r[:maxDisplayName]), " ") + } + return local +} + // supportedLanguage returns code normalised to a supported UI language ("en" or // "ru"), or "" when it maps to neither, so a new account keeps the 'en' default. It // accepts region-tagged codes ("ru-RU"). diff --git a/backend/internal/inttest/email_test.go b/backend/internal/inttest/email_test.go index 8937057..ea3940b 100644 --- a/backend/internal/inttest/email_test.go +++ b/backend/internal/inttest/email_test.go @@ -373,3 +373,24 @@ func TestConfirmByTokenLinkMerge(t *testing.T) { t.Fatalf("merge result = %+v, want NeedsMerge with owner=%s", res, owner) } } + +// TestEmailAccountSeedsDisplayName seeds a new email account's display name from the +// email's local part, so an email login is not left nameless. +func TestEmailAccountSeedsDisplayName(t *testing.T) { + ctx := context.Background() + store := account.NewStore(testDB) + svc := account.NewEmailService(store, &capturingMailer{}, "https://erudit-game.ru") + local := "kaya-" + uuid.NewString()[:8] + + id, err := svc.RequestLoginCode(ctx, local+"@example.com", "", "en") + if err != nil { + t.Fatalf("request login: %v", err) + } + acc, err := store.GetByID(ctx, id) + if err != nil { + t.Fatalf("get: %v", err) + } + if acc.DisplayName != local { + t.Errorf("display name = %q, want the email local part %q", acc.DisplayName, local) + } +} From 77a18a3cc1d266997f8054632988c474302b6a07 Mon Sep 17 00:00:00 2001 From: Ilia Denisov Date: Fri, 3 Jul 2026 05:18:24 +0200 Subject: [PATCH 35/71] fix(account): clear the guest flag on a deeplink email link MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit ConfirmByToken attached the confirmed email to the account but, on the link path, skipped ClearGuest — so a guest who bound an email via the one-tap deeplink stayed a guest (the code-based flow clears it in the link service). Clear the flag on a free link too, promoting the guest to a durable account; the profile live event then refreshes the open session. Integration test added. --- backend/internal/account/email.go | 5 +++++ backend/internal/inttest/email_test.go | 29 ++++++++++++++++++++++++++ 2 files changed, 34 insertions(+) diff --git a/backend/internal/account/email.go b/backend/internal/account/email.go index ecfc967..4cfdf9a 100644 --- a/backend/internal/account/email.go +++ b/backend/internal/account/email.go @@ -319,6 +319,11 @@ func (s *EmailService) ConfirmByToken(ctx context.Context, token string) (LinkCo if err := s.store.confirmEmailIdentity(ctx, pend.id, pend.accountID, pend.email, s.now()); err != nil { return LinkConfirmation{}, err } + // Binding the first email promotes a guest to a durable account, matching the + // code-based link flow (which clears the guest flag in the link service). + if err := s.store.ClearGuest(ctx, pend.accountID); err != nil { + return LinkConfirmation{}, err + } return LinkConfirmation{Purpose: purposeLink, Account: pend.accountID}, nil default: return LinkConfirmation{}, fmt.Errorf("account: unsupported confirmation purpose %q", pend.purpose) diff --git a/backend/internal/inttest/email_test.go b/backend/internal/inttest/email_test.go index ea3940b..667edd1 100644 --- a/backend/internal/inttest/email_test.go +++ b/backend/internal/inttest/email_test.go @@ -394,3 +394,32 @@ func TestEmailAccountSeedsDisplayName(t *testing.T) { t.Errorf("display name = %q, want the email local part %q", acc.DisplayName, local) } } + +// TestConfirmByTokenLinkClearsGuest: binding an email to a guest via the deeplink +// promotes the guest to a durable account (the deeplink path must match the +// code-based link flow, which clears the guest flag). +func TestConfirmByTokenLinkClearsGuest(t *testing.T) { + ctx := context.Background() + store := account.NewStore(testDB) + mailer := &capturingMailer{} + svc := account.NewEmailService(store, mailer, "https://erudit-game.ru") + guest, err := store.ProvisionGuest(ctx, "") + if err != nil { + t.Fatalf("provision guest: %v", err) + } + email := "guest-link-" + uuid.NewString() + "@example.com" + + if err := svc.RequestLinkCode(ctx, guest.ID, email); err != nil { + t.Fatalf("request link: %v", err) + } + if _, err := svc.ConfirmByToken(ctx, tokenFromMail(t, mailer.lastBody)); err != nil { + t.Fatalf("confirm by token: %v", err) + } + acc, err := store.GetByID(ctx, guest.ID) + if err != nil { + t.Fatalf("get: %v", err) + } + if acc.IsGuest { + t.Error("linking an email via the deeplink must promote the guest to durable") + } +} From 1dca6741f13abaf768dff1c20b1511834b4eb21c Mon Sep 17 00:00:00 2001 From: Ilia Denisov Date: Fri, 3 Jul 2026 05:18:24 +0200 Subject: [PATCH 36/71] feat(ui): auto-confirm the email deeplink on load Drop the confirm button: the /confirm screen confirms the token as soon as it loads. The token rides the URL fragment (never sent to the server), so a plain link prefetch cannot reach it, and the manual six-digit code is the fallback if an aggressive scanner runs the page. Update the ARCHITECTURE/FUNCTIONAL wording accordingly and swap the confirm.prompt/action strings for confirm.busy (en+ru). --- docs/ARCHITECTURE.md | 5 +++-- docs/FUNCTIONAL.md | 3 ++- docs/FUNCTIONAL_ru.md | 4 ++-- ui/src/lib/i18n/en.ts | 3 +-- ui/src/lib/i18n/ru.ts | 3 +-- ui/src/screens/Confirm.svelte | 35 +++++++++++------------------------ 6 files changed, 20 insertions(+), 33 deletions(-) diff --git a/docs/ARCHITECTURE.md b/docs/ARCHITECTURE.md index c326ad2..223bc07 100644 --- a/docs/ARCHITECTURE.md +++ b/docs/ARCHITECTURE.md @@ -244,8 +244,9 @@ arrive from a platform rather than completing a mandatory registration). 256-bit token stored only as its SHA-256, 12-hour TTL): a login mints a session in the browser that opens it (magic-link), a link confirms the identity and emits a `notify` profile-refresh to the in-app session, and a would-be merge is deferred to the - interactive flow. The confirm page is prefetch-safe — it confirms only on a button - press, so a mail scanner's GET does not consume the single-use token. An + interactive flow. The confirm runs on load; the token rides the URL fragment (never + sent to the server), so a plain link prefetch cannot reach it, and the manual + six-digit code is the fallback if an aggressive scanner runs the page. An **email-login** account is created flagged `is_guest` and stays reapable until the code is confirmed — so an abandoned, never-confirmed login frees its reserved address — with confirming clearing the flag. Accounts and identities use diff --git a/docs/FUNCTIONAL.md b/docs/FUNCTIONAL.md index abafd3c..ec82aef 100644 --- a/docs/FUNCTIONAL.md +++ b/docs/FUNCTIONAL.md @@ -69,7 +69,8 @@ freed. Sends are rate-limited (a short cooldown between codes and a small hourly per address), so a mistyped address or an impatient tap cannot flood an inbox. The email also carries a **one-tap link** that confirms the address — or signs the player straight in — in a single tap; opening it in another browser reflects in the app at once, and a -mail scanner that merely fetches the link never triggers it (it acts only on a button press). +mail scanner that merely fetches the link cannot reach it — the token rides the URL +fragment, which is never sent to the server. Telegram runs a **single bot**: every player uses the same bot, and all of its chat and out-of-app notifications are written in the player's own **interface language** (en/ru). A separate optional **promo bot** can run alongside the diff --git a/docs/FUNCTIONAL_ru.md b/docs/FUNCTIONAL_ru.md index 690ddfb..410a46a 100644 --- a/docs/FUNCTIONAL_ru.md +++ b/docs/FUNCTIONAL_ru.md @@ -73,8 +73,8 @@ launch-параметрам VK (их проверяет gateway), и при пе пауза между кодами и небольшой часовой лимит на адрес), чтобы опечатка в адресе или нетерпеливый тап не завалили почтовый ящик. В письме также есть **ссылка одного нажатия**, которая подтверждает адрес — или сразу выполняет вход — в один тап; открытие её в другом браузере тут же отражается в -приложении, а почтовый сканер, который просто загружает ссылку, её не срабатывает (действие -только по нажатию кнопки). +приложении, а почтовый сканер, который просто загружает ссылку, до токена не дотянется — +он едет в URL-фрагменте, который не уходит на сервер. Telegram держит **единого бота**: все игроки пользуются одним и тем же ботом, а весь его чат и внеприложенческие уведомления пишутся на **языке интерфейса** самого игрока (en/ru). Рядом с основным может работать отдельный опциональный diff --git a/ui/src/lib/i18n/en.ts b/ui/src/lib/i18n/en.ts index 10f4634..6a7cce1 100644 --- a/ui/src/lib/i18n/en.ts +++ b/ui/src/lib/i18n/en.ts @@ -36,8 +36,7 @@ export const en = { 'login.sendCode': 'Send code', 'login.codePlaceholder': '6-digit code', 'login.signIn': 'Sign in', - 'confirm.prompt': 'Confirm your email to finish.', - 'confirm.action': 'Confirm', + 'confirm.busy': 'Confirming your email…', 'confirm.linked': 'Email confirmed.', 'confirm.merge': 'Almost done — finish the merge in the app.', 'confirm.close': 'You can close this window and return to the game.', diff --git a/ui/src/lib/i18n/ru.ts b/ui/src/lib/i18n/ru.ts index 9cd928c..063b652 100644 --- a/ui/src/lib/i18n/ru.ts +++ b/ui/src/lib/i18n/ru.ts @@ -37,8 +37,7 @@ export const ru: Record = { 'login.sendCode': 'Отправить код', 'login.codePlaceholder': 'Код из 6 цифр', 'login.signIn': 'Войти', - 'confirm.prompt': 'Подтвердите почту, чтобы завершить.', - 'confirm.action': 'Подтвердить', + 'confirm.busy': 'Подтверждаем почту…', 'confirm.linked': 'Почта подтверждена.', 'confirm.merge': 'Почти готово — завершите объединение в приложении.', 'confirm.close': 'Можно закрыть это окно и вернуться в игру.', diff --git a/ui/src/screens/Confirm.svelte b/ui/src/screens/Confirm.svelte index 5af9f62..d97b31a 100644 --- a/ui/src/screens/Confirm.svelte +++ b/ui/src/screens/Confirm.svelte @@ -1,37 +1,35 @@
{t('app.title')}
- {#if stage === 'idle' || stage === 'busy'} -

{t('confirm.prompt')}

- + {#if stage === 'busy'} +

{t('confirm.busy')}

{:else if stage === 'linked'}

{t('confirm.linked')}

{t('confirm.close')}

@@ -81,15 +79,4 @@ .err { color: var(--danger, #c0392b); } - button { - padding: 12px; - border-radius: var(--radius-sm); - border: 1px solid var(--accent); - font-weight: 600; - background: var(--accent); - color: var(--accent-text); - } - button:disabled { - opacity: 0.5; - } From 356bf1a5baf25d65b1d1e6e2baf05e88ac84c3fa Mon Sep 17 00:00:00 2001 From: Ilia Denisov Date: Fri, 3 Jul 2026 05:30:30 +0200 Subject: [PATCH 37/71] feat(admin): search users by email + erase a bound email MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Add an exact (strict) email filter to the /users list (UserFilter.EmailExact → a kind='email' identity match) with a search input, and an 'Erase email' action on the user card that deletes the bound email identity and its pending confirmations, freeing the address. It refuses to remove the account's only identity (ErrLastIdentity), which would leave it unreachable. Integration tests for both. --- backend/internal/account/link.go | 47 +++++++++++ backend/internal/account/userlist.go | 8 +- .../templates/pages/user_detail.gohtml | 5 ++ .../adminconsole/templates/pages/users.gohtml | 1 + backend/internal/adminconsole/views.go | 5 +- backend/internal/inttest/adminemail_test.go | 83 +++++++++++++++++++ .../internal/server/handlers_admin_console.go | 28 +++++++ 7 files changed, 175 insertions(+), 2 deletions(-) create mode 100644 backend/internal/inttest/adminemail_test.go diff --git a/backend/internal/account/link.go b/backend/internal/account/link.go index 1a597ed..08f3b32 100644 --- a/backend/internal/account/link.go +++ b/backend/internal/account/link.go @@ -2,6 +2,7 @@ package account import ( "context" + "database/sql" "errors" "fmt" "time" @@ -16,6 +17,52 @@ import ( // belongs to another account; the caller turns it into a merge. var ErrIdentityTaken = errors.New("account: identity already linked to another account") +// ErrLastIdentity is returned when removing an identity would leave the account with +// none, making it unreachable after logout. The admin email-erase refuses it. +var ErrLastIdentity = errors.New("account: cannot remove the last identity") + +// RemoveEmailIdentity deletes the account's email identity and any pending confirmations +// for it, freeing the address for reuse. It refuses when the email is the account's only +// identity (ErrLastIdentity) — that would leave the account unreachable — and returns +// ErrNotFound when the account has no email identity. It backs the admin console's +// "erase email" action. +func (s *Store) RemoveEmailIdentity(ctx context.Context, accountID uuid.UUID) error { + ids, err := s.Identities(ctx, accountID) + if err != nil { + return err + } + hasEmail, others := false, 0 + for _, id := range ids { + if id.Kind == KindEmail { + hasEmail = true + } else { + others++ + } + } + if !hasEmail { + return ErrNotFound + } + if others == 0 { + return ErrLastIdentity + } + return withTx(ctx, s.db, func(tx *sql.Tx) error { + delID := table.Identities.DELETE().WHERE( + table.Identities.AccountID.EQ(postgres.UUID(accountID)). + AND(table.Identities.Kind.EQ(postgres.String(KindEmail))), + ) + if _, err := delID.ExecContext(ctx, tx); err != nil { + return fmt.Errorf("account: delete email identity %s: %w", accountID, err) + } + delConf := table.EmailConfirmations.DELETE().WHERE( + table.EmailConfirmations.AccountID.EQ(postgres.UUID(accountID)), + ) + if _, err := delConf.ExecContext(ctx, tx); err != nil { + return fmt.Errorf("account: delete email confirmations %s: %w", accountID, err) + } + return nil + }) +} + // RequestLinkCode issues and mails a confirm-code for email to accountID, // replacing any prior pending code. Unlike RequestCode it never refuses up front // (taken or already-confirmed): possession of the address is the authorization for diff --git a/backend/internal/account/userlist.go b/backend/internal/account/userlist.go index f2c39a3..9103c9f 100644 --- a/backend/internal/account/userlist.go +++ b/backend/internal/account/userlist.go @@ -28,11 +28,13 @@ type UserListItem struct { // UserFilter narrows the admin user list: Robots selects robot accounts (otherwise the // non-robot "people"); NameMask and ExternalIDMask are glob masks ('*' = any run, '?' = // one char) matched case-insensitively against the display name / any identity's external -// id. An empty mask means no filter on that field. +// id; EmailExact is a strict (exact) match against an account's email identity. An empty +// value means no filter on that field. type UserFilter struct { Robots bool NameMask string ExternalIDMask string + EmailExact string } // robotExists is the correlated subquery testing whether account a is a robot. @@ -63,6 +65,10 @@ func userListWhere(f UserFilter) (string, []any) { args = append(args, ext) where += fmt.Sprintf(` AND EXISTS (SELECT 1 FROM backend.identities i WHERE i.account_id = a.account_id AND i.external_id ILIKE $%d ESCAPE '\')`, len(args)) } + if email := strings.ToLower(strings.TrimSpace(f.EmailExact)); email != "" { + args = append(args, email) + where += fmt.Sprintf(` AND EXISTS (SELECT 1 FROM backend.identities i WHERE i.account_id = a.account_id AND i.kind = 'email' AND i.external_id = $%d)`, len(args)) + } return where, args } diff --git a/backend/internal/adminconsole/templates/pages/user_detail.gohtml b/backend/internal/adminconsole/templates/pages/user_detail.gohtml index 9be7009..58194f6 100644 --- a/backend/internal/adminconsole/templates/pages/user_detail.gohtml +++ b/backend/internal/adminconsole/templates/pages/user_detail.gohtml @@ -101,6 +101,11 @@ {{else}}no identities (guest){{end}} +{{if .HasEmail}} +
+ +
+{{end}}

Friends

diff --git a/backend/internal/adminconsole/templates/pages/users.gohtml b/backend/internal/adminconsole/templates/pages/users.gohtml index 9a116a0..9503578 100644 --- a/backend/internal/adminconsole/templates/pages/users.gohtml +++ b/backend/internal/adminconsole/templates/pages/users.gohtml @@ -9,6 +9,7 @@ {{if .Robots}}{{end}} +
diff --git a/backend/internal/adminconsole/views.go b/backend/internal/adminconsole/views.go index a4db45b..ffded10 100644 --- a/backend/internal/adminconsole/views.go +++ b/backend/internal/adminconsole/views.go @@ -62,6 +62,7 @@ type UsersView struct { Robots bool NameMask string ExternalIDMask string + EmailExact string FilterQuery template.URL } @@ -161,7 +162,9 @@ type UserDetailView struct { HasStats bool Stats StatsRow Identities []IdentityRow - Games []GameRow + // HasEmail gates the "Erase email" action; set when the account carries an email identity. + HasEmail bool + Games []GameRow // TelegramID and VKID are the account's platform external ids (empty when absent). // TelegramID gates the "Send Telegram message" operator action; VKID surfaces the VK // user id with a link to the VK profile (there is no VK messaging to drive). diff --git a/backend/internal/inttest/adminemail_test.go b/backend/internal/inttest/adminemail_test.go new file mode 100644 index 0000000..7f4b532 --- /dev/null +++ b/backend/internal/inttest/adminemail_test.go @@ -0,0 +1,83 @@ +//go:build integration + +package inttest + +import ( + "context" + "errors" + "testing" + + "github.com/google/uuid" + + "scrabble/backend/internal/account" +) + +// TestRemoveEmailIdentity erases an account's email identity but refuses when the +// email is the account's only identity (which would leave it unreachable). +func TestRemoveEmailIdentity(t *testing.T) { + ctx := context.Background() + store := account.NewStore(testDB) + + // Email is the only identity → refuse. + solo, err := store.ProvisionEmail(ctx, "solo-"+uuid.NewString()+"@example.com", "", "en") + if err != nil { + t.Fatalf("provision email: %v", err) + } + if err := store.RemoveEmailIdentity(ctx, solo.ID); !errors.Is(err, account.ErrLastIdentity) { + t.Fatalf("remove last identity = %v, want ErrLastIdentity", err) + } + + // Telegram + email → erase the email, keep Telegram. + tg, err := store.ProvisionByIdentity(ctx, account.KindTelegram, "tg-"+uuid.NewString()) + if err != nil { + t.Fatalf("provision telegram: %v", err) + } + if err := store.AttachIdentity(ctx, tg.ID, account.KindEmail, "dual-"+uuid.NewString()+"@example.com", true); err != nil { + t.Fatalf("attach email: %v", err) + } + if err := store.RemoveEmailIdentity(ctx, tg.ID); err != nil { + t.Fatalf("remove email: %v", err) + } + ids, err := store.Identities(ctx, tg.ID) + if err != nil { + t.Fatalf("identities: %v", err) + } + if len(ids) != 1 || ids[0].Kind != account.KindTelegram { + t.Errorf("identities after erase = %+v, want only telegram", ids) + } +} + +// TestListUsersEmailExact matches accounts strictly (exactly) by their email identity. +func TestListUsersEmailExact(t *testing.T) { + ctx := context.Background() + store := account.NewStore(testDB) + email := "find-" + uuid.NewString() + "@example.com" + acc, err := store.ProvisionEmail(ctx, email, "", "en") + if err != nil { + t.Fatalf("provision: %v", err) + } + + items, err := store.ListUsers(ctx, account.UserFilter{EmailExact: email}, 50, 0) + if err != nil { + t.Fatalf("list: %v", err) + } + found := false + for _, it := range items { + if it.ID == acc.ID { + found = true + } + } + if !found { + t.Error("the exact email filter did not find the account") + } + + other, err := store.ListUsers(ctx, account.UserFilter{EmailExact: "nope-" + uuid.NewString() + "@example.com"}, 50, 0) + if err != nil { + t.Fatalf("list (no match): %v", err) + } + for _, it := range other { + if it.ID == acc.ID { + t.Error("a non-matching email filter must not return the account") + } + } +} diff --git a/backend/internal/server/handlers_admin_console.go b/backend/internal/server/handlers_admin_console.go index 2ed1902..affda86 100644 --- a/backend/internal/server/handlers_admin_console.go +++ b/backend/internal/server/handlers_admin_console.go @@ -61,6 +61,7 @@ func (s *Server) registerConsole(router *gin.Engine) { gm.POST("/users/:id/unblock", s.consoleUnblockUser) gm.POST("/users/:id/grant-role", s.consoleGrantRole) gm.POST("/users/:id/revoke-role", s.consoleRevokeRole) + gm.POST("/users/:id/remove-email", s.consoleRemoveEmail) gm.GET("/reasons", s.consoleReasons) gm.POST("/reasons", s.consoleCreateReason) gm.POST("/reasons/:id/update", s.consoleUpdateReason) @@ -134,6 +135,7 @@ func (s *Server) consoleUsers(c *gin.Context) { Robots: c.Query("kind") == "robots", NameMask: c.Query("name"), ExternalIDMask: c.Query("ext"), + EmailExact: c.Query("email"), } total, _ := s.accounts.CountUsers(ctx, filter) items, err := s.accounts.ListUsers(ctx, filter, adminPageSize, (page-1)*adminPageSize) @@ -151,9 +153,13 @@ func (s *Server) consoleUsers(c *gin.Context) { if strings.TrimSpace(filter.ExternalIDMask) != "" { q.Set("ext", filter.ExternalIDMask) } + if strings.TrimSpace(filter.EmailExact) != "" { + q.Set("email", filter.EmailExact) + } view := adminconsole.UsersView{ Pager: adminconsole.NewPager(page, adminPageSize, total), Robots: filter.Robots, NameMask: filter.NameMask, ExternalIDMask: filter.ExternalIDMask, + EmailExact: filter.EmailExact, FilterQuery: template.URL(q.Encode()), } ids := make([]uuid.UUID, 0, len(items)) @@ -356,6 +362,9 @@ func (s *Server) consoleUserDetail(c *gin.Context) { } if ids, err := s.accounts.Identities(ctx, id); err == nil { for _, idn := range ids { + if idn.Kind == account.KindEmail { + view.HasEmail = true + } view.Identities = append(view.Identities, adminconsole.IdentityRow{Kind: idn.Kind, ExternalID: idn.ExternalID, Confirmed: idn.Confirmed, CreatedAt: fmtTime(idn.CreatedAt)}) } } @@ -951,6 +960,25 @@ func (s *Server) consoleGrantHints(c *gin.Context) { s.renderConsoleMessage(c, "Granted", fmt.Sprintf("added %d hint(s); the wallet is now %d", n, balance), back) } +// consoleRemoveEmail deletes the account's bound email identity (and any pending +// confirmations), freeing the address. It refuses to remove the account's only +// identity, which would leave it unreachable. +func (s *Server) consoleRemoveEmail(c *gin.Context) { + id, ok := s.consoleUUID(c, "/_gm/users") + if !ok { + return + } + back := "/_gm/users/" + id.String() + switch err := s.accounts.RemoveEmailIdentity(c.Request.Context(), id); { + case errors.Is(err, account.ErrLastIdentity): + s.renderConsoleMessage(c, "Can't remove", "the email is this account's only identity — removing it would leave the account unreachable", back) + case err != nil: + s.consoleError(c, err) + default: + s.renderConsoleMessage(c, "Removed", "the email identity was erased and the address freed", back) + } +} + // consoleBlockUser manually blocks an account: it records the suspension (permanent or until a // parsed deadline, snapshotting the chosen reason's en/ru text) and forfeits the player's active // games, removing them from matchmaking. The block takes effect on the player's next request. From 54af644429104b2ebb72625bde620a8f31a52511 Mon Sep 17 00:00:00 2001 From: Ilia Denisov Date: Fri, 3 Jul 2026 05:54:28 +0200 Subject: [PATCH 38/71] fix(ui): localise the confirm screen, drop the brand on the error state MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The session-less /confirm page defaulted to English, so it showed the English app title. Carry the recipient's language on the deeplink (?lang) and setLocale on load so the page matches the email. Show a localised brand wordmark (Эрудит / Erudit, matching the email) on the success state only; the invalid/expired state now shows just the message, no header. --- backend/internal/account/email.go | 11 ++++++----- ui/src/lib/i18n/en.ts | 1 + ui/src/lib/i18n/ru.ts | 1 + ui/src/screens/Confirm.svelte | 30 ++++++++++++++++-------------- 4 files changed, 24 insertions(+), 19 deletions(-) diff --git a/backend/internal/account/email.go b/backend/internal/account/email.go index 4cfdf9a..64f7d53 100644 --- a/backend/internal/account/email.go +++ b/backend/internal/account/email.go @@ -114,7 +114,7 @@ func (s *EmailService) issueCode(ctx context.Context, accountID uuid.UUID, email if err := s.store.replacePendingConfirmation(ctx, accountID, email, codeHash, tokenHash, purpose, s.now().Add(emailCodeTTL)); err != nil { return err } - msg, err := renderConfirmationEmail(purpose, code, s.confirmURL(token), s.baseURL, locale) + msg, err := renderConfirmationEmail(purpose, code, s.confirmURL(token, locale), s.baseURL, locale) if err != nil { return err } @@ -122,13 +122,14 @@ func (s *EmailService) issueCode(ctx context.Context, accountID uuid.UUID, email return s.mailer.Send(ctx, msg) } -// confirmURL builds the absolute one-tap confirm deeplink for token, or "" when no -// public base URL is configured. -func (s *EmailService) confirmURL(token string) string { +// confirmURL builds the absolute one-tap confirm deeplink for token in locale, or "" +// when no public base URL is configured. The locale rides the fragment as ?lang so the +// confirm screen (opened in a browser with no session) renders in the email's language. +func (s *EmailService) confirmURL(token, locale string) string { if s.baseURL == "" { return "" } - return strings.TrimRight(s.baseURL, "/") + emailConfirmPath + token + return strings.TrimRight(s.baseURL, "/") + emailConfirmPath + token + "?lang=" + normalizeLocale(locale) } // accountLocale returns the account's preferred UI language for localising email, diff --git a/ui/src/lib/i18n/en.ts b/ui/src/lib/i18n/en.ts index 6a7cce1..0223ffc 100644 --- a/ui/src/lib/i18n/en.ts +++ b/ui/src/lib/i18n/en.ts @@ -4,6 +4,7 @@ export const en = { 'app.title': 'Scrabble', + 'app.brand': 'Erudit', 'dict.loading': 'Loading…', 'connection.connecting': 'Connecting…', diff --git a/ui/src/lib/i18n/ru.ts b/ui/src/lib/i18n/ru.ts index 063b652..068a61f 100644 --- a/ui/src/lib/i18n/ru.ts +++ b/ui/src/lib/i18n/ru.ts @@ -5,6 +5,7 @@ import type { MessageKey } from './en'; export const ru: Record = { 'app.title': 'Scrabble', + 'app.brand': 'Эрудит', 'dict.loading': 'Загрузка…', 'connection.connecting': 'Подключение…', diff --git a/ui/src/screens/Confirm.svelte b/ui/src/screens/Confirm.svelte index d97b31a..bc64a97 100644 --- a/ui/src/screens/Confirm.svelte +++ b/ui/src/screens/Confirm.svelte @@ -1,22 +1,27 @@
@@ -244,13 +307,41 @@ {/if} - - + {#if !p.isGuest} + + {/if} + @@ -409,6 +448,28 @@ {/if} +{#if deleting} + {@const d = deleting} + (deleting = null)}> +

{t('profile.deleteWarn')}

+ {#if d.method === 'email'} +

{t('profile.deleteEmailPrompt')}

+
+ +
+ {:else} +

{t('profile.deletePhrasePrompt')}

+
+ +
+ {/if} +
+ + +
+
+{/if} + + + +
+
Э
+

Технические работы

+

Идёт короткое обновление игры. Мы скоро вернёмся — обновите страницу через пару минут.

+

Scrabble is briefly down for an update. Please refresh in a couple of minutes.

+
+ + diff --git a/deploy/docker-compose.yml b/deploy/docker-compose.yml index 97cbf1b..f3288f0 100644 --- a/deploy/docker-compose.yml +++ b/deploy/docker-compose.yml @@ -428,6 +428,11 @@ services: GM_BASICAUTH_HASH: ${GM_BASICAUTH_HASH:?set GM_BASICAUTH_HASH} volumes: - ${SCRABBLE_CONFIG_DIR:-.}/caddy/Caddyfile:/etc/caddy/Caddyfile:ro + # Maintenance page + toggle flag: the caddy config dir holds maintenance.html and the + # `on` flag prod-deploy.sh touches around a rolling swap; the Caddyfile serves a 503 + # from here while the flag exists (read-only mount — the deploy writes the flag on the + # host side). See deploy/caddy/Caddyfile and deploy/prod-deploy.sh. + - ${SCRABBLE_CONFIG_DIR:-.}/caddy:/srv/maint:ro - caddy-data:/data deploy: resources: diff --git a/deploy/prod-deploy.sh b/deploy/prod-deploy.sh index 012260d..d26a65c 100755 --- a/deploy/prod-deploy.sh +++ b/deploy/prod-deploy.sh @@ -42,6 +42,15 @@ PREV_STATE_FILE="${PREV_STATE_FILE:-/opt/scrabble/PREVIOUS_TAG}" PG_USER="${POSTGRES_USER:-scrabble}" PG_DB="${POSTGRES_DB:-scrabble}" +# Edge maintenance page. caddy serves a static 503 "works in progress" page for the +# user-facing routes while this flag file exists (deploy/caddy/Caddyfile). We hold it +# across the roll / migration window and clear it on ANY exit — success, a health +# failure + rollback, or an unexpected error — via the trap, so users get a graceful +# page instead of raw mid-swap 502s and the flag can never get stuck on. +MAINT_FLAG="${MAINT_FLAG:-${SCRABBLE_CONFIG_DIR:-/opt/scrabble}/caddy/on}" +maint_off() { rm -f "$MAINT_FLAG" 2>/dev/null || true; } +trap maint_off EXIT + cd "$COMPOSE_DIR" || { echo "compose dir $COMPOSE_DIR missing"; exit 1; } export REGISTRY # otelcol joins the host docker group to read the socket; the GID varies per host. @@ -119,6 +128,13 @@ if [ -z "$(docker ps -aq -f name=scrabble-backend)" ]; then exit 0 fi +# Existing stack: raise the maintenance page for the whole roll (and any migration +# window). It shows once caddy carries the gate (from this feature's own deploy +# onward); the EXIT trap lowers it however this run ends. +mkdir -p "$(dirname "$MAINT_FLAG")" +: > "$MAINT_FLAG" +echo "maintenance page raised ($MAINT_FLAG)" + # Migration deploy: freeze writes and snapshot a consistent dump before migrating. if [ "$MIGRATION" = 1 ]; then echo "migration deploy: opening maintenance window (stopping the backend = the only writer)" From d67e582c03a9de45c622a3f951be439af5c95b9b Mon Sep 17 00:00:00 2001 From: Ilia Denisov Date: Fri, 3 Jul 2026 22:40:49 +0200 Subject: [PATCH 69/71] feat(ui): in-session maintenance overlay on the edge 503 marker MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The edge caddy 503 carries X-Scrabble-Maintenance during a deploy window, but a user already in the running SPA only sees their calls start failing — the static caddy page catches only a fresh load. Detect that marker (strictly — not any transient 'unavailable', which the Connecting indicator already covers) on the raw ConnectError at the two transport catch sites (unary exec + the live subscribe stream), before toGatewayError discards the response headers, and raise a non-dismissable dimmed overlay that mirrors the static caddy page. It self-clears: a capped-backoff poll (a cheap read, mirroring connection.svelte.ts) lifts it on the first success, and a manual "retry" button forces an immediate re-check — so it can never get stuck. On detection the read retry loop fails fast, so a window doesn't burn the retry budget on every call. - pure detector maintenance.ts (maintenanceRetryMs / parseRetryAfterMs) + unit tests; the store + self-clearing poll in maintenance.svelte.ts (mirrors connection.svelte.ts) - MaintenanceOverlay.svelte (clones Splash's fixed/inset/dimmed shell, non-dismissable), mounted app-global in App.svelte after Coachmark - transport.ts detects + reports at both catch sites, clears on any successful read - i18n RU "Технические работы" / EN "Under maintenance"; a window.__maint mock hook (the mock can't emit a real 503) + a Playwright spec Prod is same-origin (VITE_GATEWAY_URL empty) so the marker header is readable without a CORS expose-header. Verified: pnpm check (0), unit (402), build, e2e (186, Chromium+WebKit). --- ui/e2e/maintenance.spec.ts | 22 ++++++ ui/src/App.svelte | 2 + ui/src/components/MaintenanceOverlay.svelte | 83 +++++++++++++++++++++ ui/src/lib/gateway.ts | 7 ++ ui/src/lib/i18n/en.ts | 3 + ui/src/lib/i18n/ru.ts | 3 + ui/src/lib/maintenance.svelte.ts | 83 +++++++++++++++++++++ ui/src/lib/maintenance.test.ts | 44 +++++++++++ ui/src/lib/maintenance.ts | 37 +++++++++ ui/src/lib/transport.ts | 25 ++++++- 10 files changed, 306 insertions(+), 3 deletions(-) create mode 100644 ui/e2e/maintenance.spec.ts create mode 100644 ui/src/components/MaintenanceOverlay.svelte create mode 100644 ui/src/lib/maintenance.svelte.ts create mode 100644 ui/src/lib/maintenance.test.ts create mode 100644 ui/src/lib/maintenance.ts diff --git a/ui/e2e/maintenance.spec.ts b/ui/e2e/maintenance.spec.ts new file mode 100644 index 0000000..4d913dd --- /dev/null +++ b/ui/e2e/maintenance.spec.ts @@ -0,0 +1,22 @@ +import { expect, test } from './fixtures'; + +// The maintenance overlay is raised in prod by the edge 503 marker (X-Scrabble-Maintenance); +// the mock transport never produces one, so — like the offline indicator — the e2e drives it +// through the window.__maint hook (gateway.ts, mock-only). It is app-global (mounted outside +// the route blocks in App.svelte), so it shows without a session. +test('maintenance overlay covers the app and lifts on recovery', async ({ page }) => { + await page.goto('/'); + await expect(page.getByRole('alertdialog')).toHaveCount(0); + + // A planned deploy window begins: a non-dismissable dimmed overlay covers the app. + await page.evaluate(() => (window as unknown as { __maint: { on(): void } }).__maint.on()); + const overlay = page.getByRole('alertdialog'); + await expect(overlay).toBeVisible(); + // The retry button is present (EN "Try again" / RU "Повторить" depending on locale). + await expect(overlay.getByRole('button', { name: /again|Повторить/i })).toBeVisible(); + + // The window ends — in prod the store's poll lifts it on the first successful read; the mock + // has no probe, so it clears explicitly. The overlay disappears with no page reload. + await page.evaluate(() => (window as unknown as { __maint: { off(): void } }).__maint.off()); + await expect(page.getByRole('alertdialog')).toHaveCount(0); +}); diff --git a/ui/src/App.svelte b/ui/src/App.svelte index a1b8824..ad3f36d 100644 --- a/ui/src/App.svelte +++ b/ui/src/App.svelte @@ -9,6 +9,7 @@ import StaleInviteModal from './components/StaleInviteModal.svelte'; import WelcomeRedeemModal from './components/WelcomeRedeemModal.svelte'; import Coachmark from './components/Coachmark.svelte'; + import MaintenanceOverlay from './components/MaintenanceOverlay.svelte'; import DebugPanel from './components/DebugPanel.svelte'; import Login from './screens/Login.svelte'; import Lobby from './screens/Lobby.svelte'; @@ -128,6 +129,7 @@ + {#if routeIsLobby && !app.splashDone && !app.blocked && !app.bootError && !app.launchError} diff --git a/ui/src/components/MaintenanceOverlay.svelte b/ui/src/components/MaintenanceOverlay.svelte new file mode 100644 index 0000000..e002597 --- /dev/null +++ b/ui/src/components/MaintenanceOverlay.svelte @@ -0,0 +1,83 @@ + + +{#if maintenance.active} +
+
+ +

{t('maintenance.title')}

+

{t('maintenance.body')}

+ +
+
+{/if} + + diff --git a/ui/src/lib/gateway.ts b/ui/src/lib/gateway.ts index 6e2d805..7cbe567 100644 --- a/ui/src/lib/gateway.ts +++ b/ui/src/lib/gateway.ts @@ -6,6 +6,7 @@ import type { GatewayClient } from './client'; import { MockGateway } from './mock/client'; import { createTransport } from './transport'; import { reportOffline, reportOnline } from './connection.svelte'; +import { clearMaintenance, reportMaintenance } from './maintenance.svelte'; const isMock = import.meta.env.MODE === 'mock'; @@ -20,6 +21,12 @@ if (isMock && typeof window !== 'undefined') { offline: reportOffline, online: reportOnline, }; + // The mock never produces a real 503, so the e2e drives the maintenance overlay directly + // (the store's poll is inert in mock — no probe is registered — so `off` clears it). + (window as unknown as { __maint?: { on(retryAfterMs?: number): void; off(): void } }).__maint = { + on: (retryAfterMs = 15000) => reportMaintenance(retryAfterMs), + off: clearMaintenance, + }; // Drive the auto-match opponent join deterministically from the e2e (the mock otherwise // attaches a robot on a timer). ( diff --git a/ui/src/lib/i18n/en.ts b/ui/src/lib/i18n/en.ts index 1739be6..e19daca 100644 --- a/ui/src/lib/i18n/en.ts +++ b/ui/src/lib/i18n/en.ts @@ -7,6 +7,9 @@ export const en = { 'app.brand': 'Erudit', 'dict.loading': 'Loading…', 'connection.connecting': 'Connecting…', + 'maintenance.title': 'Under maintenance', + 'maintenance.body': 'Scrabble is briefly down for an update. It will be back in a moment — no need to reload the page.', + 'maintenance.retry': 'Try again', 'blocked.title': 'Account blocked', 'blocked.permanent': 'Your account is blocked.', diff --git a/ui/src/lib/i18n/ru.ts b/ui/src/lib/i18n/ru.ts index 4ee0347..81e6966 100644 --- a/ui/src/lib/i18n/ru.ts +++ b/ui/src/lib/i18n/ru.ts @@ -8,6 +8,9 @@ export const ru: Record = { 'app.brand': 'Эрудит', 'dict.loading': 'Загрузка…', 'connection.connecting': 'Подключение…', + 'maintenance.title': 'Технические работы', + 'maintenance.body': 'Идёт короткое обновление игры. Мы скоро вернёмся — страницу перезагружать не нужно.', + 'maintenance.retry': 'Повторить', 'blocked.title': 'Учётная запись заблокирована', 'blocked.permanent': 'Ваша учётная запись заблокирована.', diff --git a/ui/src/lib/maintenance.svelte.ts b/ui/src/lib/maintenance.svelte.ts new file mode 100644 index 0000000..65133e8 --- /dev/null +++ b/ui/src/lib/maintenance.svelte.ts @@ -0,0 +1,83 @@ +// Global maintenance signal + self-clearing poll. `active` is true while the edge is in a +// planned deploy window (a caddy 503 carried `X-Scrabble-Maintenance`; see maintenance.ts). +// A non-dismissable overlay (MaintenanceOverlay.svelte) covers the app while active, and a +// cheap read is retried on a capped-backoff cadence (mirroring connection.svelte.ts) — the +// first success lifts the overlay, so it can never get stuck even with no other traffic. +// Mock mode never reports maintenance, so it simply stays inactive. + +import { backoffMs } from './retry'; + +let active = $state(false); +let retryHintMs = $state(0); +let pollTimer: ReturnType | null = null; +let attempt = 0; +let probing = false; +let probe: (() => Promise) | null = null; + +export const maintenance = { + /** active is true while the edge is in a planned maintenance window. */ + get active(): boolean { + return active; + }, + /** retryHintMs is the last Retry-After hint in milliseconds (0 when unknown). */ + get retryHintMs(): number { + return retryHintMs; + }, +}; + +/** registerMaintenanceProbe installs the reachability read the poll fires while active + * (the transport wires a cheap authenticated read). */ +export function registerMaintenanceProbe(fn: () => Promise): void { + probe = fn; +} + +/** reportMaintenance raises the overlay and starts the self-clearing poll. Idempotent: a + * repeat hit only refreshes the hint, it never restarts a running poll. */ +export function reportMaintenance(retryAfterMs: number): void { + retryHintMs = retryAfterMs; + if (active) return; + active = true; + attempt = 0; + if (probe) schedulePoll(); +} + +/** clearMaintenance lowers the overlay and stops the poll (a successful read, or reset). */ +export function clearMaintenance(): void { + active = false; + if (pollTimer) { + clearTimeout(pollTimer); + pollTimer = null; + } +} + +/** retryNow forces an immediate re-check (the overlay's button). It never closes the + * overlay itself — only a successful probe does. A no-op while a probe is in flight. */ +export function retryNow(): void { + if (!active || !probe || probing) return; + if (pollTimer) { + clearTimeout(pollTimer); + pollTimer = null; + } + attempt = 0; + runProbe(); +} + +function schedulePoll(): void { + pollTimer = setTimeout(runProbe, backoffMs(++attempt)); +} + +function runProbe(): void { + pollTimer = null; + if (!active || !probe || probing) return; + probing = true; + probe().then( + () => { + probing = false; + clearMaintenance(); + }, + () => { + probing = false; + if (active) schedulePoll(); + }, + ); +} diff --git a/ui/src/lib/maintenance.test.ts b/ui/src/lib/maintenance.test.ts new file mode 100644 index 0000000..24f0092 --- /dev/null +++ b/ui/src/lib/maintenance.test.ts @@ -0,0 +1,44 @@ +import { describe, expect, it } from 'vitest'; +import { Code, ConnectError } from '@connectrpc/connect'; +import { maintenanceRetryMs, parseRetryAfterMs } from './maintenance'; + +// A raw ConnectError as connect-web builds it from a non-Connect HTTP 503: the response +// headers are preserved as `.metadata` (verified against @connectrpc/connect-web). +const err503 = (headers: Record): ConnectError => + new ConnectError('HTTP 503', Code.Unavailable, new Headers(headers)); + +describe('maintenanceRetryMs', () => { + it('detects the marker and parses Retry-After', () => { + expect(maintenanceRetryMs(err503({ 'x-scrabble-maintenance': '1', 'retry-after': '120' }))).toBe(120_000); + }); + + it('matches the header case-insensitively, defaulting the delay when Retry-After is absent', () => { + expect(maintenanceRetryMs(err503({ 'X-Scrabble-Maintenance': '1' }))).toBe(15_000); + }); + + it('returns null for a 503 without the marker (a plain transient failure)', () => { + expect(maintenanceRetryMs(err503({ 'retry-after': '30' }))).toBeNull(); + }); + + it('returns null for non-Connect errors', () => { + expect(maintenanceRetryMs(new Error('boom'))).toBeNull(); + expect(maintenanceRetryMs(undefined)).toBeNull(); + expect(maintenanceRetryMs('nope')).toBeNull(); + }); +}); + +describe('parseRetryAfterMs', () => { + it('clamps into the 3s–120s band', () => { + expect(parseRetryAfterMs('120')).toBe(120_000); + expect(parseRetryAfterMs('1')).toBe(3_000); // floor + expect(parseRetryAfterMs('9999')).toBe(120_000); // ceiling + }); + + it('falls back to the default on absent / garbage / non-positive', () => { + expect(parseRetryAfterMs(null)).toBe(15_000); + expect(parseRetryAfterMs(undefined)).toBe(15_000); + expect(parseRetryAfterMs('soon')).toBe(15_000); + expect(parseRetryAfterMs('0')).toBe(15_000); + expect(parseRetryAfterMs('-5')).toBe(15_000); + }); +}); diff --git a/ui/src/lib/maintenance.ts b/ui/src/lib/maintenance.ts new file mode 100644 index 0000000..63dbcf4 --- /dev/null +++ b/ui/src/lib/maintenance.ts @@ -0,0 +1,37 @@ +// Detection of a planned edge maintenance window. During a prod deploy roll the edge +// caddy returns HTTP 503 with an `X-Scrabble-Maintenance: 1` header (and a `Retry-After`) +// for every gated route. The SPA keys STRICTLY on that marker — not on any transport +// `unavailable`, which the "Connecting…" indicator already covers — so a planned window +// raises the maintenance overlay while an ordinary blip stays a soft reconnect. +// +// These helpers are pure (no DOM, no runes) so they unit-test in the node vitest env. The +// maintenance marker + Retry-After ride on the raw ConnectError's `metadata`, which +// toGatewayError (retry.ts) discards — so detection must run on the raw error. + +import { ConnectError } from '@connectrpc/connect'; + +const DEFAULT_RETRY_MS = 15_000; +const MIN_RETRY_MS = 3_000; +const MAX_RETRY_MS = 120_000; + +/** + * parseRetryAfterMs converts a `Retry-After` header (delta-seconds — the edge emits + * seconds, never an HTTP-date) into a millisecond hint clamped to a sane band. An absent + * or unparseable value falls back to a default. + */ +export function parseRetryAfterMs(header: string | null | undefined): number { + const secs = header == null ? NaN : Number(header); + if (!Number.isFinite(secs) || secs <= 0) return DEFAULT_RETRY_MS; + return Math.min(MAX_RETRY_MS, Math.max(MIN_RETRY_MS, Math.round(secs * 1000))); +} + +/** + * maintenanceRetryMs returns the maintenance `Retry-After` hint in milliseconds when the + * thrown transport error is the edge maintenance 503 (carries `X-Scrabble-Maintenance: 1`), + * or null for any other error. Header lookup is case-insensitive (Headers.get). + */ +export function maintenanceRetryMs(e: unknown): number | null { + if (!(e instanceof ConnectError)) return null; + if (e.metadata?.get('x-scrabble-maintenance') !== '1') return null; + return parseRetryAfterMs(e.metadata.get('retry-after')); +} diff --git a/ui/src/lib/transport.ts b/ui/src/lib/transport.ts index 1ec3551..2230466 100644 --- a/ui/src/lib/transport.ts +++ b/ui/src/lib/transport.ts @@ -12,6 +12,8 @@ import { GatewayError, type GatewayClient } from './client'; import * as codec from './codec'; import { browserOffset } from './profileValidation'; import { registerProbe, reportOffline, reportOnline } from './connection.svelte'; +import { clearMaintenance, registerMaintenanceProbe, reportMaintenance } from './maintenance.svelte'; +import { maintenanceRetryMs } from './maintenance'; import { backoffMs, isConnectionCode, retryable, toGatewayError } from './retry'; const MAX_RETRIES = 6; @@ -28,10 +30,14 @@ export function createTransport(baseUrl: string): GatewayClient { // The reachability probe the connection watcher fires while offline: a cheap authenticated read // (it must reject when there is no session, so the watcher keeps waiting rather than reporting up). - registerProbe(async () => { + const reachabilityProbe = async (): Promise => { if (!token) throw new Error('no session'); await client.execute({ messageType: 'profile.get', payload: codec.empty(), requestId: '' }, { headers: headers() }); - }); + }; + registerProbe(reachabilityProbe); + // The maintenance overlay reuses the same cheap read to poll for the end of a deploy + // window; the first success lifts the overlay (maintenance.svelte.ts). + registerMaintenanceProbe(reachabilityProbe); // exec runs one unary op, auto-retrying transient transport failures with capped backoff (so a // dropped connection or a rate-limit recovers seamlessly) and driving the global Connecting @@ -43,6 +49,14 @@ export function createTransport(baseUrl: string): GatewayClient { res = await client.execute({ messageType, payload, requestId: '' }, { headers: headers(), signal }); } catch (e) { if (signal?.aborted) throw e; // an intentional cancel (e.g. the tiles moved) — do not retry + const maintMs = maintenanceRetryMs(e); + if (maintMs !== null) { + // A planned deploy window (the edge 503 carried X-Scrabble-Maintenance): raise the + // overlay and fail fast — its own poll drives recovery — instead of burning the + // retry budget on every read for the length of the window. + reportMaintenance(maintMs); + throw toGatewayError(e); + } const err = toGatewayError(e); if (retryable(err.code, messageType) && attempt < MAX_RETRIES) { reportOffline(); @@ -53,6 +67,7 @@ export function createTransport(baseUrl: string): GatewayClient { throw err; } reportOnline(); + clearMaintenance(); if (res.resultCode && res.resultCode !== 'ok') throw new GatewayError(res.resultCode); return res.payload; } @@ -310,7 +325,11 @@ export function createTransport(baseUrl: string): GatewayClient { if (pe) onEvent(pe); } } catch (e) { - if (!ctrl.signal.aborted) onError?.(toGatewayError(e)); + if (!ctrl.signal.aborted) { + const maintMs = maintenanceRetryMs(e); + if (maintMs !== null) reportMaintenance(maintMs); + onError?.(toGatewayError(e)); + } } })(); return () => ctrl.abort(); From 7dcd62fdd7826f6174d68daaa40aa4e519dc854e Mon Sep 17 00:00:00 2001 From: Ilia Denisov Date: Fri, 3 Jul 2026 22:46:55 +0200 Subject: [PATCH 70/71] feat(ui): reload the SPA on maintenance recovery MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The deploy that ends a maintenance window may ship a client-incompatible change (wire/schema bump), and the in-session bundle is the old one. On recovery, reload to pick up the fresh client instead of just hiding the overlay. Ordering is safe: the edge maintenance flag (deploy/prod-deploy.sh) spans the WHOLE roll and clears only at script exit — after the gateway (which serves the embedded SPA) has rolled — so the edge 503s everything until the entire deploy is live; recovery therefore always serves the new SPA. A window.__maint.recover() hook + e2e cover the reload. --- ui/e2e/maintenance.spec.ts | 16 ++++++++++++++++ ui/src/lib/gateway.ts | 7 +++++-- ui/src/lib/maintenance.svelte.ts | 20 ++++++++++++++++++-- ui/src/lib/transport.ts | 6 ++++-- 4 files changed, 43 insertions(+), 6 deletions(-) diff --git a/ui/e2e/maintenance.spec.ts b/ui/e2e/maintenance.spec.ts index 4d913dd..110b8d4 100644 --- a/ui/e2e/maintenance.spec.ts +++ b/ui/e2e/maintenance.spec.ts @@ -20,3 +20,19 @@ test('maintenance overlay covers the app and lifts on recovery', async ({ page } await page.evaluate(() => (window as unknown as { __maint: { off(): void } }).__maint.off()); await expect(page.getByRole('alertdialog')).toHaveCount(0); }); + +test('recovery reloads the SPA to pick up the fresh client', async ({ page }) => { + await page.goto('/'); + await page.evaluate(() => (window as unknown as { __maint: { on(): void } }).__maint.on()); + await expect(page.getByRole('alertdialog')).toBeVisible(); + + // Real recovery reloads the page (the deploy may have shipped an incompatible client, and the + // running bundle is the old one). Wait for the forced navigation, then confirm the app + // re-bootstrapped: the overlay is gone (the store reset by the reload) and the login is back. + await Promise.all([ + page.waitForEvent('load'), + page.evaluate(() => (window as unknown as { __maint: { recover(): void } }).__maint.recover()), + ]); + await expect(page.getByRole('alertdialog')).toHaveCount(0); + await expect(page.getByRole('button', { name: /guest/i })).toBeVisible(); +}); diff --git a/ui/src/lib/gateway.ts b/ui/src/lib/gateway.ts index 7cbe567..a8bef98 100644 --- a/ui/src/lib/gateway.ts +++ b/ui/src/lib/gateway.ts @@ -6,7 +6,7 @@ import type { GatewayClient } from './client'; import { MockGateway } from './mock/client'; import { createTransport } from './transport'; import { reportOffline, reportOnline } from './connection.svelte'; -import { clearMaintenance, reportMaintenance } from './maintenance.svelte'; +import { clearMaintenance, maintenanceRecovered, reportMaintenance } from './maintenance.svelte'; const isMock = import.meta.env.MODE === 'mock'; @@ -23,9 +23,12 @@ if (isMock && typeof window !== 'undefined') { }; // The mock never produces a real 503, so the e2e drives the maintenance overlay directly // (the store's poll is inert in mock — no probe is registered — so `off` clears it). - (window as unknown as { __maint?: { on(retryAfterMs?: number): void; off(): void } }).__maint = { + ( + window as unknown as { __maint?: { on(retryAfterMs?: number): void; off(): void; recover(): void } } + ).__maint = { on: (retryAfterMs = 15000) => reportMaintenance(retryAfterMs), off: clearMaintenance, + recover: maintenanceRecovered, }; // Drive the auto-match opponent join deterministically from the e2e (the mock otherwise // attaches a robot on a timer). diff --git a/ui/src/lib/maintenance.svelte.ts b/ui/src/lib/maintenance.svelte.ts index 65133e8..2745b9d 100644 --- a/ui/src/lib/maintenance.svelte.ts +++ b/ui/src/lib/maintenance.svelte.ts @@ -41,7 +41,8 @@ export function reportMaintenance(retryAfterMs: number): void { if (probe) schedulePoll(); } -/** clearMaintenance lowers the overlay and stops the poll (a successful read, or reset). */ +/** clearMaintenance lowers the overlay and stops the poll without reloading (a reset, or the + * e2e hook). Prod recovery goes through maintenanceRecovered instead. */ export function clearMaintenance(): void { active = false; if (pollTimer) { @@ -50,6 +51,21 @@ export function clearMaintenance(): void { } } +/** maintenanceRecovered runs when the edge answers again after a window we were showing. + * The deploy that ended the window may have shipped a client-incompatible change (a wire / + * schema bump), and the running bundle is the OLD one — so reload to pick up the fresh + * client instead of merely hiding the overlay. A no-op unless the overlay was up; the cover + * stays over the brief reload flash. In prod recovery is the only way the overlay clears. */ +export function maintenanceRecovered(): void { + if (!active) return; + active = false; + if (pollTimer) { + clearTimeout(pollTimer); + pollTimer = null; + } + if (typeof location !== 'undefined') location.reload(); +} + /** retryNow forces an immediate re-check (the overlay's button). It never closes the * overlay itself — only a successful probe does. A no-op while a probe is in flight. */ export function retryNow(): void { @@ -73,7 +89,7 @@ function runProbe(): void { probe().then( () => { probing = false; - clearMaintenance(); + maintenanceRecovered(); }, () => { probing = false; diff --git a/ui/src/lib/transport.ts b/ui/src/lib/transport.ts index 2230466..dd89956 100644 --- a/ui/src/lib/transport.ts +++ b/ui/src/lib/transport.ts @@ -12,7 +12,7 @@ import { GatewayError, type GatewayClient } from './client'; import * as codec from './codec'; import { browserOffset } from './profileValidation'; import { registerProbe, reportOffline, reportOnline } from './connection.svelte'; -import { clearMaintenance, registerMaintenanceProbe, reportMaintenance } from './maintenance.svelte'; +import { maintenanceRecovered, registerMaintenanceProbe, reportMaintenance } from './maintenance.svelte'; import { maintenanceRetryMs } from './maintenance'; import { backoffMs, isConnectionCode, retryable, toGatewayError } from './retry'; @@ -67,7 +67,9 @@ export function createTransport(baseUrl: string): GatewayClient { throw err; } reportOnline(); - clearMaintenance(); + // A read got through: if the maintenance overlay was up, the deploy window has ended — + // reload to pick up the (possibly incompatible) fresh client (maintenance.svelte.ts). + maintenanceRecovered(); if (res.resultCode && res.resultCode !== 'ok') throw new GatewayError(res.resultCode); return res.payload; } From 446ea2ac453b7897232f20f27b3bbbb682a8f446 Mon Sep 17 00:00:00 2001 From: Ilia Denisov Date: Fri, 3 Jul 2026 23:04:19 +0200 Subject: [PATCH 71/71] fix(ci): arm the maintenance flag on the test-contour deploy MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The maintenance overlay never showed on the test contour: only prod-deploy.sh raised the caddy maintenance flag, so a test redeploy was a bare gateway/backend recreate — the SPA saw a transient 502 ("Reconnecting…"), never the 503 + X-Scrabble-Maintenance marker the overlay keys on. So the feature (PR #171) was unverifiable off prod. Raise the flag around the recreate in ci.yaml's deploy job too, mirroring prod. Ordering matters because of the config reseed: ci does `rm -rf $conf` + recreate, so the running caddy sits on the stale pre-reseed bind mount and cannot see a flag written to the new dir until it is recreated. So: raise the flag, force-recreate caddy FIRST (onto the fresh mount, where it carries the flag and 503s), then recreate gateway/backend (the window), then the other config services, then lower it. A trap clears the flag if the step fails, and the next deploy's reseed wipes a stale one — so the contour can't stick in maintenance. The caddy-routed probes run in the next step, after the flag is lowered. Prod was already correct (prod-deploy.sh); this only makes the test contour faithful so the overlay + reload can be seen there. An open SPA must already be on the PR-#171 bundle (which carries the overlay code) — reload once to bootstrap onto it. --- .gitea/workflows/ci.yaml | 24 +++++++++++++++++++++--- 1 file changed, 21 insertions(+), 3 deletions(-) diff --git a/.gitea/workflows/ci.yaml b/.gitea/workflows/ci.yaml index 99880c1..e81e94e 100644 --- a/.gitea/workflows/ci.yaml +++ b/.gitea/workflows/ci.yaml @@ -399,6 +399,13 @@ jobs: mkdir -p "$conf" cp -r caddy otelcol prometheus tempo grafana blackbox "$conf"/ export SCRABBLE_CONFIG_DIR="$conf" + # Maintenance page for the redeploy window, mirroring prod-deploy.sh so the SPA + # overlay is exercised on the test contour too (not only prod). Raised just before + # the recreate and lowered once caddy is back (below); the trap clears it if the + # step fails so the contour never sticks in maintenance (and the reseed above wipes a + # stale flag anyway). The caddy-routed probes run in the NEXT step, after it is lowered. + maint_flag="$conf/caddy/on" + trap 'rm -f "$maint_flag"' EXIT # Derive the public URLs from the one canonical origin instead of storing each as # its own variable (paths are structural SPA routes / the Caddy /_gm sub-path). # Exported before build so the VK ID redirect is baked into the SPA. @@ -430,12 +437,23 @@ jobs: # bot on its own host instead (deploy/docker-compose.bot.yml), and the prod # main host omits both. Without the profile they would not start here. docker compose --ansi never --profile telegram-local build --progress plain + # Raise the maintenance page, THEN bring caddy onto the reseeded config mount so it + # actually carries the flag: the running caddy sits on the stale pre-reseed mount (the + # dir was rm'd + recreated — see the force-recreate note below), so a flag written to + # the new dir is invisible until caddy is recreated. With the fresh caddy up, an open + # SPA sees the 503 marker + overlay for the whole recreate window, not a bare reconnect. + : > "$maint_flag" + docker compose --ansi never up -d --force-recreate --no-deps caddy docker compose --ansi never --profile telegram-local up -d --remove-orphans # The config-only services bind-mount the reseeded config dir. A plain `up -d` # leaves them on the previous bind mount (the dir was rm'd + recreated), so a - # changed Caddyfile or Grafana dashboard is ignored — force-recreate them to - # pick up the fresh config. - docker compose --ansi never up -d --force-recreate --no-deps caddy otelcol prometheus tempo grafana + # changed Grafana dashboard is ignored — force-recreate them to pick up the fresh + # config. (Caddy was already recreated above so it would carry the maintenance flag.) + docker compose --ansi never up -d --force-recreate --no-deps otelcol prometheus tempo grafana + # Lower the maintenance page: services are back. An open SPA's poll now gets through + # (once the gateway finishes booting) and reloads into the fresh client; the caddy + # probes in the next step see 200. The EXIT trap is a backstop if we failed earlier. + rm -f "$maint_flag" - name: Probe the landing, gateway and backend run: |