Files
scrabble-game/PLAN.md
T
Ilia Denisov f166ff30fe
CI / changes (pull_request) Successful in 2s
CI / unit (pull_request) Successful in 8s
CI / integration (pull_request) Successful in 12s
CI / ui (pull_request) Successful in 34s
CI / gate (pull_request) Successful in 0s
CI / deploy (pull_request) Successful in 1m20s
Stage 17 #4: enrich the out-of-app your-turn push + add game-over
The Telegram 'your turn' notification now names the opponent and recaps their last
move (voiced as the opponent: «{name}: my move — «WORD». Score 120:95» for a scoring
play; a short 'swapped / passed, your turn' otherwise), and a new game-over
notification reports the result + final score when a game ends by any path (closing
play, all-pass, resign, timeout). Scores are recipient-first (the reader's score
leads), 2-4 players (120:95:80).

- schema: YourTurnEvent gains opponent_name/last_action/last_word/score_line
  (appended, backward-compatible); new GameOverEvent{result, score_line}. Go + UI
  bindings regenerated (flatc 23.5.26 + pnpm codegen).
- backend: notify.YourTurn enriched + notify.GameOver; emitMove resolves the mover's
  name and emits per-recipient (your_turn to the next mover, game_over to every seat),
  with recipient-first score lines built in one place.
- gateway: game_over joins the out-of-app whitelist (routing.go).
- connector: render builds the enriched your_turn + game_over text per language (en/ru).
- tests: notify round-trip (enriched + game_over), emit (enriched fields + game_over to
  all seats / per-seat result), connector render (en/ru), routing; integration replay
  (play → your_turn with real name; resign → game_over) green.
- docs: ARCHITECTURE push catalog + out-of-app set, FUNCTIONAL (+ _ru), PLAN tracker.
2026-06-09 01:15:18 +02:00

120 KiB
Raw Blame History

Scrabble Game — implementation plan

Living plan and stage tracker. Each stage is implemented in its own session; the rules for starting and finishing a stage are in CLAUDE.md. The architecture/decision record is docs/ARCHITECTURE.md; behaviour is docs/FUNCTIONAL.md. When a stage produces a decision, bake it back here and into the affected docs/code in the same PR.

Context

Greenfield multiplatform Scrabble. Players arrive from a platform (Telegram first; later VK/MAX/iOS/Android) or standalone web (email / guest). Three executables — gateway, backend, ui — plus per-platform side-services. Deliberately simpler than the sibling ../galaxy-game (idea donor, not a template). The ../scrabble-solver engine is embedded in-process as a library.

Locked decisions (recap — full record in docs/ARCHITECTURE.md)

Stack: go.work monorepo, modules scrabble/<name>, Go 1.26.x, backend gin+pgx+Postgres(schema backend)+goose+zap+OTel (deps added when first used). Wire: Connect-RPC + FlatBuffers (client↔gateway), REST/JSON + X-User-ID (gateway↔backend), gRPC server-stream for live events. Auth: platform-native, thin opaque session token, no Ed25519/signing, likely no Redis. UI: pure HTML5/CSS, plain Svelte + Vite, Capacitor for native. MVP surfaces: Telegram + web (email + ephemeral guest) + link/merge. Variants: ru/en/Эрудит. Legality: validate-at-submit. End: empty bag+rack / 6 scoreless / 24h timeout. Hint: top-1. Word-check: unlimited + complaint. Robot: P(win)≈0.40, margin targeting, [2,90]min skewed timing, sleep 00:0007:00 opp-tz, nudge logic. Dictionary: pin per game. History: structured + GCG export, dictionary- independent (see ARCHITECTURE §9.1).

Stage tracker

# Stage Status
0 Scaffolding (go.work, backend skeleton, docs, CI) done
1 Backend foundation (config, server, Postgres+goose, sessions, accounts) done
2 Engine package over scrabble-solver done
3 Game domain (lifecycle, rules, hint, word-check, history+GCG, stats) done
4 Lobby & social (matchmaking, friends, block, chat, profile, nudge) done
5 Robot opponent done
6 Gateway edge (Connect/FB, platform auth, sessions, push bridge, admin) done
7 UI — playable slice + UX polish (Svelte+Vite, board, lobby, chat, hint/word-check, i18n) done
8 UI — social/account/history (friends, blocks, invitations, profile edit, stats, history/GCG) done
9 Telegram integration (bot side-service, deep-link, push) done
10 Admin & dictionary ops (complaint review, version reload) done
11 Account linking & merge done
12 Observability & performance (telemetry, metrics, guest GC) done
13 Alphabet on the wire (UI alphabet-agnostic) done
14 Solver & dictionary split (publish solver + scrabble-dictionary repo/artifact) done
15 Dual Telegram bots & language-gated variants done
16 Deploy infra & test contour (Dockerfiles, gateway static UI, compose, observability) done
17 Test-contour verification & defect fixes done
18 Prod contour deploy (SSH export/import, manual after merge) todo

Scaffolding is incremental: go.work lists only existing modules; each stage adds the modules it needs.

Stages

Each stage: read this plan + relevant docs, interview the owner on the open details below, implement within scope, then update plan/docs/code and get CI green before marking done.

Stage 0 — Scaffolding (done)

Scope: go.work (Go 1.26.3, use ./backend); minimal runnable backend (gin, zap, /healthz, /readyz, env config); docs skeleton; PLAN.md; CLAUDE.md; .gitea/workflows/go-unit.yaml; README; .gitignore. Acceptance: go build ./backend/... + go vet + gofmt clean + go test ./backend/... green; CI green on push.

Stage 1 — Backend foundation

Scope: config/server route groups (/api/v1/{public,user,internal,admin}, probes), Postgres (pgx) + embedded goose migrations + schema backend, telemetry (OTel) wiring, in-memory cache scaffolding, thin sessions + accounts + platform identities. Open details: Postgres version + DSN/search_path convention; jet vs sqlc/sqlx (default jet); migration naming; exact session-token shape (opaque random length, TTL, revocation); account/identity table shape; whether the admin bootstrap lands here or in Stage 10.

Stage 2 — Engine package

Scope: backend/internal/engine over scrabble-solver — versioned DAWG load/registry, GenerateMoves/ValidatePlay/ScorePlay wrappers, bag/rack, the dictionary-independent game-state model + decode helpers. Add replace scrabble-solver => ../scrabble-solver to go.work here and solve the CI sibling-checkout (clone gitea.iliadenisov.ru/.../scrabble-solver). Open details: how CI obtains the solver (clone sibling vs publish/tag the solver module); in-memory game-state representation; how blanks and exchanges are modelled; Эрудит specifics to verify against the solver.

Stage 3 — Game domain

Scope: create/join, turn order, submit play/pass/exchange/resign, validate-at-submit, scoring, end-conditions, 24h timeout/auto-resign, hint, word-check + complaint capture, structured history + GCG writer, stats on finish. Open details: GCG dialect details (blanks, exchanges, notation); exact stats edge cases; turn-timeout scheduler mechanism (cron vs per-game timer); complaint payload shape.

Stage 4 — Lobby & social

Scope: matchmaking pool, friends, block, per-game chat, profile + email confirm-code, nudge. Open details: pool fairness/keying confirmation; deep-link format per platform; chat length limit + retention; friend-request lifecycle; email-code provider (SMTP relay choice).

Stage 5 — Robot opponent

Scope: human-like player — balance ~0.40, margin targeting, skewed [2,90]min timing + sleep + nudge logic, friend/DM blocking, name pool. Open details: exact delay distribution + parameters; margin band; name pool source; how the scheduler drives robot moves; metrics for tuning balance.

Stage 6 — Gateway edge

Scope: Connect/gRPC-Web (h2c), Telegram initData validation → session → X-User-ID, in-memory rate-limit, admin Basic-Auth passthrough, FlatBuffers transcoding, in-app push stream bridging backend push gRPC stream, email + ephemeral-guest paths. Open details: FlatBuffers schema layout + message_type catalog; rate-limit classes/limits; admin surface routing; session cache shape at the gateway.

Stage 7 — UI

Scope: plain Svelte + Vite static; Connect-web + FlatBuffers client; lobby (my games, profile tabs); board (HTML5/CSS grid, drag-n-drop, no assets); chat; hint/word-check; in-app stream; i18n en/ru; in-memory session (+IndexedDB if available); Capacitor-ready structure. Open details: detailed game-board UX (deferred by the owner to this stage); client routing; offline/refresh behaviour; design system / theming.

Suggested layouts (lobby + game screen)

User note:

Detailed interview about UI/UX is strongly required. Too much to discuss.

     ┌────────────────────┐                   
     │ Display_Name      =│- Profile          
     ├────────────────────┤- Settings         
     │ Invitations        │- About            
     │ - list             │                   
     ├────────────────────┤                   
     │ Active games       │                   
     │ - list             │                   
     ├────────────────────┤                   
     │ Finished games     │                   
     │ - list             │                   
     │                    │                   
     │                    │                   
     │                    │                   
     │                    │                   
     │                    │                   
     │                    │                   
     │                    │                   
     ├────────────────────┤                   
     │ ┌───┐  ┌───┐  ┌───┐│                   
     │ New │  Stats  Tourn│                   
     │ └───┘  └───┘  └───┘│                   
     └────────────────────┘                   
     ┌────────────────────┐                   
Lobby│◄                 ==│- History          
     ├────────────────────┤- Chat             
     │You  Ann  Kaya  Rick│- Check word       
     │136  700   179    39│- Drop game        
     ├────────────────────┤                   
     │                    │                   
     │                    │                   
     │                    │                   
     │        c           │                   
     │      words         │                   
     │        o           │                   
     │        s           │                   
     │        s           │                   
     │                    │                   
     │                    │                   
     ├──┬──┬──┬──┬──┬──┬──┤ ┌──┐              
     │A │Q │Z │* │N │I │W │◄│  │MakeMove/Reset
     ├──┴──┴──┴──┴──┴──┴──┤ └──┘              
     │  ┌───┐ ┌───┐ ┌───┐ │                   
     │  Draw│ Skip│ Shfl│ │                   
     │  └───┘ └───┘ └───┘ │                   
     └────────────────────┘                   

Stage 8 — UI: social, account & history surfaces

Scope: the UI surfaces deferred from Stage 7's playable slice, wiring the matching backend/gateway operations as each screen needs them (the Stage 6 vertical-slice pattern): friends (request/accept/decline/list), per-user blocks, friend-game invitations (create 24 player, accept/decline, invitations list), profile editing (account.UpdateProfile + the email confirm-code binding UI), the statistics screen, and the history viewer with GCG export/download. Open details: friends/invitations UX; stats presentation; history/GCG viewer + download mechanics; any new validation the profile-editing forms need.

Stage 9 — Telegram integration

Scope: bot side-service, deep-link invites, platform push (your-turn / nudge), Mini App launch/auth; backend↔platform internal API. Open details: bot framework/library; deep-link scheme; push message templates; internal API contract; Mini App hosting/origin.

Stage 10 — Admin & dictionary ops

Scope: admin endpoints (users, games, complaint review queue, dictionary versions + reload), complaint→dictionary update pipeline. Open details: whether a server-rendered console is wanted or JSON-only; the dictionary rebuild/deploy pipeline; complaint resolution workflow.

Stage 11 — Account linking & merge

Scope: link-via-confirm; merge-into-A (stats sum, transfer games/friends, dedupe). High blast-radius — focused regression tests. Open details: conflict resolution (active games on both, duplicate friends, display-name collisions); irreversibility/audit; confirm-flow per platform.

Stage 12 — Observability & performance

Scope: wire a configurable OTLP exporter (alongside none/stdout), shared in a new pkg/telemetry; add telemetry to the gateway and the Telegram connector (providers + otelgrpc on the gRPC hops) for parity with the backend; add domain/operational metrics close to the business (game replay/validate timings, started/abandoned games, live-cache size, chat/nudge counts, the edge roundtrip, Go runtime metrics); discharge TODO-3 (abandoned-guest GC). The OTLP collector and dashboards are stood up with the deploy (Stage 15); the default exporter stays none, so CI needs no collector. Performance is operational-metric instrumentation, not speculative optimisation (the standing "evidence first" rule — no measured hotspot yet). Open details: exporter default and whether a collector is stood up now; the metric set and its attributes; the guest-reaper trigger given revoke-only sessions.

Stage 13 — Alphabet on the wire (TODO-4)

Scope: make the UI alphabet-agnostic. On game-screen load the client receives the variant's alphabet table (letter, index, value) for display only, caches it in memory by variant (a request flag gates whether the table is included, so it is not resent on every state poll); live play then exchanges letter indices both ways, and word-check sends indices, constraining input to the variant's alphabet. The engine already works in alphabet-index bytes, so the wire does less decoding in live play; the durable journal / history / GCG stay decoded concrete characters (the §9.1 dictionary-independent invariant is untouched). The alphabet comes from the solver's rules (not the DAWG), so the wire table is pinned by the solver version. Index-drift caveat: the running solver, the DAWGs (built against it — Stage 14 / TODO-2) and the wire table must agree, or letter indexing silently corrupts. Blast radius: pkg/fbs (a new Alphabet table; index fields in StateView/rack and in SubmitPlay/Exchange/check_word) → backend DTO encode/decode → UI codec.ts/premiums.ts → board/rack render, the move/exchange/word-check senders, the mock transport and the Vitest tests. Open details: the fbs shape and include_alphabet flag placement; whether to keep concrete-letter fields during the transition; whether tile exchange moves fully to indices; the premiums.ts parity-test rework.

Stage 14 — Solver & dictionary split (TODO-1 + TODO-2)

Re-scoped from the original "CI & deploy": that was several sessions of work, so the deploy + observability + the two-bots idea were split into Stages 1518 below and this stage took only the dependency/artifact split that everything else builds on. Scope: publish scrabble-solver as a versioned Gitea module and split the dictionary build into a new scrabble-dictionary repo delivering a release artifact, then make scrabble-game consume both — discharging TODO-1 and TODO-2.

  • TODO-1 — solver published. scrabble-solver renamed to module gitea.iliadenisov.ru/developer/scrabble-solver, tagged v1.0.0; wordlist/dictdawg de-internalised to public packages (the dict repo imports them); cmd/builddict/dictprep/the dictionaries submodule moved out; internal/dict repointed at the committed dawg/*.dawg fixtures. backend/go.mod pins v1.0.0; the go.work replace and the CI sibling-clone are gone; GOPRIVATE=gitea.iliadenisov.ru/* makes go fetch it directly (no public proxy/checksum DB).
  • TODO-2 — dictionary artifacts. New repo developer/scrabble-dictionary holds the word-list sources + cmd/builddict and builds the three DAWGs against the published solver + pinned dafsa/alphabet v1.1.0, so they are byte-identical to the solver's fixtures (no index drift). Released as scrabble-dawg-vX.Y.Z.tar.gz (flat, one semver per set); the Go workflows download it and point BACKEND_DICT_DIR at it. The runtime contract is unchanged (additive BACKEND_DICT_DIR/<version>/, engine.OpenWithVersions, per-game dict_version pin; a version is safe to retire once no active game pins it).

Stage 15 — Dual Telegram bots & language-gated variants (done)

Re-framed at its start to be service-agnostic: the sign-in service returns, with the user identity, a set of supported game languages (subset of {en, ru}, ≥ 1) that gates the New Game variant choice. Built: the connector hosts two bots in one container (one per service language, each its own token + game channel; the same Telegram user id spans both); ValidateInitData tries each token in turn and returns the validating bot's service_language + supported_languages set. The set rides the Session (FlatBuffers, session-scoped, not persisted) and the UI offers only the matching variants on New Game (en → English; ru → Russian + Эрудит) — gating only the start of a new game (auto-match + friend invite); existing games of any language are unrestricted and the backend does not enforce. The service language is persisted (accounts.service_language, migration 00010, written on every login — last-login-wins) and routes the user-facing out-of-app push (Notify) back through the right bot (falls back to preferred_language). Non-Telegram logins (web/email/guest) carry the gateway default set (GATEWAY_DEFAULT_SUPPORTED_LANGUAGES, all variants). Admin broadcasts (SendToUser/SendToGameChannel) pick the bot by an operator-chosen language in the console — unrelated to ValidateInitData.

Stage 16 — Deploy infra & test contour (done)

Scope: the deploy machinery + the test contour (the bulk of the original Stage 14). Backend + gateway Dockerfiles (multi-stage distroless, mirroring the Stage 9 connector image); the gateway gains static UI servingembedded via go:embed (a node build stage in the gateway image), SPA served at both / (web) and /telegram/ (Mini App), the §13 single-origin model; prod UI build vars (VITE_TELEGRAM_BOT_ID, VITE_TELEGRAM_LINK, VITE_GATEWAY_URL) as image build-args; a root deploy/docker-compose.yml (backend + gateway + Postgres + connector + VPN sidecar + the full observability stack — OTel Collector + Prometheus + Tempo + Grafana with provisioned dashboards) on the external edge network behind the host caddy (VPN sidecar only for the connector); the backend image pulls the DAWG release artifact (Stage 14). The test contour deploys automatically on push to a feature branch (docker compose up -d --build on the local host where the gitea runner lives), with a post-deploy probe (GET / on the gateway). Test-contour secrets use the TEST_ prefix (see Stage 16). Open details (re-interview at start): the dashboard set; the gateway static-serving hook (before the h2c wrap — / + /telegram/ mounts; a committed dist placeholder so go build works without a UI build); Postgres healthcheck/volume; whether the connector-scoped compose is retired for the root one; collector/Tempo/Prometheus retention.

Stage 17 — Test-contour verification & defect fixes (done)

Scope: exercise the deployed test contour end-to-end and fix the defects it surfaces — the "does it actually work in the contour" pass before prod. Bring up the development deploy, then verify each piece against a real run: the gateway serves the SPA at / and /telegram/; the admin console and Grafana sit behind the single /_gm Basic-Auth; the Telegram bots start (test environment) and the Mini App launches/authenticates; a game can be created and played through (web

  • Mini App); the observability stack receives data (Prometheus targets up, the dashboards populate incl. accounts_created_total/active_users, traces reach Tempo); the out-of-app push works. Fix the defects found and harden where the run exposes gaps — notably a CI connector liveness check (the deploy probe only hits the gateway today, so a crash-looping connector is invisible — that is how the Stage 16 test-env miss went unnoticed) and path-conditional CI (skip the jobs whose code did not change, behind a single always-running gate job so branch-protection required checks stay satisfiable — a skipped required check otherwise blocks the merge). Open details (interview at start): the verification checklist + pass bar; which discovered defects are in-scope vs deferred; the changed-paths design + the aggregate gate job; the connector liveness-check grace period (the VPN sidecar handshake lets the connector restart a few times before it settles).

Found caveats (all resolved in Stage 17 — see Refinements → Stage 17)

The owner's collected caveats below were classified (fix-now / verify-then-fix / discuss), discussed where they were forks, and resolved in one session with tests where practical. The per-item outcomes are recorded under Refinements logged during implementation → Stage 17; the raw list is kept here as the record of what the first contour run surfaced.

  • /_gm/grafana/ требует повторного ввода пароля basic auth, хотя до этого я уже зашёл в /_gm/ Такого быть не должно: графана живёт под /_gm/ и ей не нужен свой auth.

  • нужна ещё метрика "продолжительность хода" - сколько игроки тратят на каждый ход, скорее всего, понадобится новое поле last_move_ts если ещё нет, так же нужно будет завести метрику в графане как общую, так и и по конкретному пользователю (можно ли? дорого ли?), а так же с привязкой к номеру хода и без номера хода. Всё это понадобится для анализа способностей игроков, чтобы подогнать под них роботоа. А так же - выявлять читеров.

  • регистрация пользователя из телеграм (как и других коннекторов): пытаться очистить имя от посторонних символов, аналогично проверке при вводе имени в профиле. если после очистки ничего не осталось, поставить имя Player/Игрок-XXXXX (5 рандомных цифр), язык в зависимости от внешнего коннектора.

  • game - chat - nudge. Когда мой ход и я жму nudge, появляется сообщение "сейчас не ваш ход". Думаю, опечатка - "не" лишняя, проверь на всех языках.

  • если открыли игру через telegram, надо в настройках вообще полностью скрыть переключатель темы "авто/светлая/темная", т.к. тему задаёт сам телеграм (уточни, в какой проперти её можно забрать, и нужно ли, сейчас оно уже нормально работает на самих стилях)

  • возможно, к предыдущему пункту: запускаю мини апп на macos/telegram desktop. в самой macos у меня темная тема. когда я включаю тему "авто" в настройках mini app, а в самом телеграме - светлую, всё ломается, nav bar и tab bar рисуются темным фоном, список игр и меню - светлым, поле игры - тёмное, вокруг него светлоая рамка. Провернул тот же трюк на ios - всё чётко, в режиме "авто" он полностью держит ту настройку, которая в самом телеграме задана. Проверь, можно ли это починить для desktop-версии тг, скорее всего там системные настройки как-то в браузер протекают. Ну если не получится понять причину, тогда и черт с ним.

  • не знаю, ошибка это или by design - если у меня открыта игра сразу в desktop telegram и на ios, то когда я делаю ход, в другом окне не обновляется ничего - ни само игровое поле, ни лобби. интересно, как ходят уведомления через gateway - по последнему активному push-каналу, что ли? если так, стоит ли чинить, чтобы у пользователя все пуш-каналы поддерживались или это дорого? нужен твой анализ и совет.

  • надо подкрутить тайминг автоматического хода работа. идея такая: сейчас, насколько я помню, время хода выбирается от 2 до 90 минут с перекосом ближе к 2 минутам (поправь если что). я предлагаю этот интервал сделать динамическим в зависимости от хода. Например, средяя партия это 25-30 ходов, предположительно. На первом ходу интервал должен быть 1..5 минут, на последнем - 10..90 минут, всё так же с перекосом в меньшую сторону. А то я сейчас поиграл, роботы на первых ходах по 15 минут думают. Сможешь такую хитрую формулу составить? Цифры ориентировочные. Потом после набора реальной статистики подкрутим цифры. Заодно напомни, как работает формула "перекоса", можно ли её "заставить" косить почаще в меньшую сторону, как бы имитируя активного игрока. Этот пункт требует тщательного обсуждения, пожалуй.

  • при навигации между лобби и игрой есть задержка едва заметная на глаз, думаю, связанная с тем, что UI все данные по игре перезапрашивает каждый раз. Кроме этого, когда я в лобби возвращаюсь, глаз ловит перерисовку экрана, довольно быстро, но есть какое-то неприятное ощущение, что туда что-то подгружается. А мы можем внутри UI наполнять кэш этими данными и экраны не рисовать каждый раз, а просто подменять? не знаю, как это работает, если честно. Но вот информацию по игре, в которую пользователь проваливался 1 раз, совершенно точно можно положить в кэш и обновлять его когда с сервера приходит новый ход и т.п.

  • при запуске в telegram, надо бы цвет фона nav bar сделать фоном телеграма, а то он "выпадает" из общего дизайна.

  • а вот фон рекламной строчки под nav bar наоборот, сделать бы чуть светлее (в тёмной теме) или темнее (в светлой), чтобы был акцентирован, но не ярко. что-то там есть в стилях телеграма такое готовое? ну и для собственного дефолтного стиля тоже надо выбрать соответствующие.

  • Переключаюсь в ios в другое приложение, по возвращении ловлю "проблема соединения, повторяем". Вроде бы в телеграм-бандле есть обработчики всяких событий, в том числе background in/out, или как там оно зовётся. Посмотри, можно ли что-то с этим сделать? Если да, то именно в случаях когда приложение уходит в фон - не надо рисовать плашку с ошибкой, просто молча пытаться соединиться, то есть плашка появится когда приложение на в фоне на следующем retry.

  • при использовании подсказки в игре ато зум ведёт в лево-верх, а не туда, где была поставлена подсказка.

  • В русских партиях нужны русские имена для роботов, но можно вперемешку с латинскими именами, только чтобы латинских имён было не больше 20%.

  • Сделать анимацию переходов между экранами: наезд справа если из лобби куда-то переходим и наоборот, уезжание вправо и открытие лобби, когда нажимаем back в навигации.

  • Цвет и размер плашки с игроками над доской: давай сделаем не "кнопками" самих игроков, а просто поделим это пространство поровну между игроками, а активного игрока будем показывать за счёт "поднятия" его плашки, за счёт теней слева и справа, чтобы остальные игроки были как бы "утоплены" внутрь.

  • В игре клик/тач по плашке с именами игроков открывает/закрывает историю.

  • В истории ходов странное выравнивание колонки со словами, они буквально скачут влево-вправо.

  • В многословных партиях надо в истории показывать основное слово + дополнительное (если это ещё не сделано, надо проверить)

  • При открытии истории нижнюю границу таблицы ("тень") сразу прибивать к доске, а не растягивать вслед за таблицей.

  • Баг. Открыл игру через ru-телеграм бота, пытаюсь сделать "new -> русский" (это скрэбл с русским алфавитом), появляется красная плашка "что-то пошло не так". при этом "new -> эрудит" работает. Попробуй посмотреть в логах сейчас, может что-то есть. Или как-то иначе проанализируй, или давай вместе будем смотреть, если не получится.

Stage 18 — Prod contour deploy

Scope: the production contour on a remote host over SSH. Deploy by container export/import (docker savescp/ssh → docker loaddocker compose up on the remote), the SSH key + host IP in Gitea secrets; strictly manual (workflow_dispatch) after development is merged to master (the Stage 16 branch model: feature/* → development → master, merge gated green). Two-contour config uses TEST_/PROD_ secret/variable prefixes — Gitea 1.26 has no deployment environments (verified: the environments API 404s), so a flat prefixed namespace is the convention. Reuses the Stage 16 deploy/docker-compose.yml as-is, mapping the PROD_ set onto the same unprefixed compose vars. No host caddy on prod, so the contour's own caddy terminates TLS — set CADDY_SITE_ADDRESS to the prod domain so caddy does its own ACME (the Caddyfile is already parameterised for this; the test contour leaves it :80 behind the host caddy). Open details (re-interview): export/import vs a registry trade-off; prod domain/cert source (ACME vs a provided cert) at the contour caddy; prod VPN; rollback.

Refinements logged during implementation

  • Stage 0: solver replace deferred to Stage 2 (nothing imports it yet; adding the path now would break CI, which checks out only this repo). Docker / compose deferred to a stage that has something to deploy. Trunk is master (owner preference); feature/* + PR from Stage 1; the genesis commit lands on master by necessity.

  • Stage 1 (interview + implementation):

    • Query layer: go-jet over database/sql (pgx stdlib) + otelsql; a cmd/jetgen tool regenerates the committed code from a throwaway container. Postgres 17 pinned for jetgen, tests and prod.
    • Sessions: opaque token stored only as a SHA-256 hash (kept as hex text, not bytea — avoids jet bytea-literal friction), revoke-only (no TTL); revocation-audit table deferred. Backend keeps a warmed write-through session cache that gates /readyz.
    • Data model: UUIDv7 PKs; one unified identities table (kind ∈ telegram|email, widen to vk/max later); no soft-delete / actor-audit columns yet.
    • HTTP surface: service/store/cache layer only. /api/v1/{public,user, internal,admin} groups + X-User-ID middleware are scaffolding (exposed via Server group accessors); the session/account REST handlers land with the gateway in Stage 6. Admin bootstrap deferred to Stage 10.
    • Telemetry: providers + request-timing middleware + otelsql; exporters none (default) / stdout; OTLP + dashboards deferred to Stage 12.
    • Tests/CI: integration tests behind the integration build tag in backend/internal/inttest + new integration.yaml (testcontainers, Ryuk off, serial), firing on push and PR. Backend now hard-depends on Postgres at boot (migrations at startup) — a deliberate contract change from Stage 0, documented in both READMEs. All code stays in the existing backend module under internal/ (+ cmd/jetgen); go.work untouched.
  • Stage 2 (interview + implementation):

    • Scope: internal/engine is a self-contained library (registry, bag, Game state machine, decode/replay). No config/main/server wiring this stage — there is no consumer yet; wiring lands in Stage 3, mirroring Stage 1's deferred handlers.
    • Pure rules engine (interview): the engine owns the in-memory Game, pure transitions (play/pass/exchange/resign + draw) and end-condition detection, including the standard end-game rack-adjustment scoring — a deliberate slice of Stage 3's "scoring/end-conditions" that the pure-engine boundary implies. Stage 3 keeps scheduling, the 24h timeout, persistence and GCG.
    • Solver wiring: replace scrabble-solver => ../scrabble-solver in go.work; backend/go.mod requires scrabble-solver (placeholder version, redirected by the replace) and github.com/iliadenisov/dafsa directly (for dawg.Load). CI clones the public solver repo at master HEAD anonymously into ../scrabble-solver (no token); both Go workflows gained the step (the engine's untagged tests run under the integration workflow too) and set BACKEND_DICT_DIR.
    • Dictionaries: registry loads the committed DAWGs from a directory parameter; dict_version is an explicit string label; the latest version per variant is tracked. Smoke tests validate a known word per variant (English/Russian/Эрудит). Эрудит is handled uniformly — every real difference is already in rules.Erudit(); the move.go "single orientation per turn" note needs no special code (any single play is one-directional).
    • Bag/blanks/exchange: own deterministic Bag (Draw + Return) because selfplay.Bag cannot return tiles; exchange is legal only when the bag holds at least a rack and draws replacements before returning the swapped tiles. A blank is Placement{Blank:true} carrying its designated letter; the history keeps the concrete letter plus a blank flag (decoded via Alphabet.Character / Decode). ReplayBoard reuses scrabble.Apply, so no internal/encoding dependency.
    • Deviation from the approved plan: docs/FUNCTIONAL.md (+_ru) was left unchanged. Stage 2 adds no user-visible behaviour; the variant, per-game dictionary and dictionary-independent-history user stories already live in Stages 34, so a "light touch" here would have duplicated or pre-empted them.
  • Stage 3 (interview + implementation):

    • Scope, as in Stages 12: domain service/store layer + engine wiring, no HTTP (internal/game). The gateway↔backend REST surface lands in Stage 6; the only active driver this stage is a background turn-timeout sweeper started from main. The robot (Stage 5) will consume the same service API.
    • Persistence = event-sourcing + warm cache (interview): durable state is the games row plus an append-only decoded move journal (game_moves); the live position is an engine.Game kept in an in-memory cache with a ~24h idle TTL and rebuilt by replaying the journal on a miss (the seeded bag makes replay exact). Each game is serialised by a per-game mutex; a persistence failure evicts the live game so the next access rebuilds. §9 reworded from "stored structurally" to this model.
    • Resign/timeout split (interview): 2-player resign/timeout only this stage (the other player wins); multiplayer drop-out-and-continue + resigned-tiles disposition deferred to Stage 4. Per-game turn-timeout duration setting (5/10/15/30 min, 1/2/3/6/12/24 h; default 24 h) and a per-user away window (accounts.away_start/away_end, default 00:0007:00 local, honoured by the sweeper with midnight-cross handling) added now; profile editing of the away window is Stage 4 and the robot's sleep (Stage 5) reuses it.
    • Engine Resign fix (interview, in internal/engine): the resigner keeps their accumulated score (no end-game rack adjustment) and never wins; winner excludes the resigner, so a two-player resign/timeout gives the win to the other player regardless of score. Timeout reuses Resign, so the game domain needs no winner override.
    • Additive engine domain API: Direction, Game.SubmitPlay/SubmitExchange/ EvaluatePlay/HintView/Hand, MoveRecord.{Dir,MainRow,MainCol}, Registry.Lookup, ParseVariant — so internal/game never imports scrabble-solver (keeps the §5 single-importer invariant).
    • Create = atomic with seats (interview): Create seats all accounts and starts; lobby seat-filling is Stage 4. Sweeper = periodic goroutine (interview; default 60 s, BACKEND_GAME_TIMEOUT_SWEEP_INTERVAL).
    • Hint = settings + wallet (interview): per-game hints_allowed + hints_per_player, plus a profile wallet accounts.hint_balance (spent after the allowance; purchases later). Category defaults (random 1 / tournament 0 / friendly 1-or-0) are the caller's job (lobby/tournaments).
    • Stats (interview): account_stats with draws added beyond §9's wins/losses; max_word_points = best single move score; ties draw, resign/timeout is a loss, guests get no stats.
    • Complaint (interview): full payload with game_id; word-check is scoped to the game's pinned (variant, dict_version). Stage 10 owns the resolution lifecycle, so the status column carries no value CHECK yet.
    • GCG (interview): standard Poslfit dialect (UTF-8, #player/#lexicon pragmas, 8G/H8 coordinates, lower-case blanks, . pass-throughs, -TILES exchange) plus #note lines for resign/timeout; derived from the journal, so dictionary-independent.
    • Engine wiring + config: main loads the registry (engine.Open, a hard boot dependency like migrations) and starts the sweeper. New config: BACKEND_DICT_DIR (required), BACKEND_DICT_VERSION (default v1), BACKEND_GAME_TIMEOUT_SWEEP_INTERVAL (60 s), BACKEND_GAME_CACHE_TTL (24 h). No CI change — both Go workflows already clone the solver sibling and export BACKEND_DICT_DIR. accounts gained away_start/away_end/hint_balance and the account package gained SpendHint (it owns its table).
  • Stage 4 (interview + implementation):

    • Scope, as in Stages 13: domain service/store layer, no HTTP — REST/stream is Stage 6. Chat and nudges are persisted now; live delivery (push / in-app stream) is Stage 6/8. New packages internal/social (friends, blocks, chat+nudge) and internal/lobby (matchmaking + invitations); profile editing and the email confirm-code extend internal/account. The services have no active driver this stage, so main builds them and hands them to the server, which exposes them via accessors (the Stage 1 scaffolding-accessor pattern) for the Stage 6 handlers.
    • Friends (interview): request → accept on a single friendships table; decline/cancel delete the pending row; blocking severs any friendship.
    • Blocks (interview): the existing global toggles plus a per-user blocks table; block effects are mutual (a block either way suppresses chat visibility and prevents requests/invitations between the pair).
    • Friend games (interview): invitation → accept; the game starts only when all invitees accept, any decline cancels it, and a pending invitation lazily expires after 7 days (checked on access — no new sweeper).
    • Chat (interview): ≤ 60 runes, stored with the game forever, the sender IP kept for moderation (as text, following Stage 1's no-bytea precedent; the gateway forwards it in Stage 6), input content-filtered (links/emails/phone numbers incl. obfuscated forms) via mvdan.cc/xurls/v2 plus a compact leet/separator normaliser and a ≥7-digit phone heuristic — the one new dependency. Nudge is a chat message (kind='nudge'), rate-limited to once per hour per game per sender.
    • Matchmaking (interview): an in-memory FIFO pool keyed by variant only (variant fixes the board language), pairing two humans (seat order randomised). The 10 s wait and robot substitution are deferred to Stage 5. The pool does not consult blocks (auto-match is anonymous) — a deliberate simplification of the plan's optional block-skip that also avoids a DB call under the pool lock.
    • Email confirm-code (interview): 6-digit code, 15-min TTL, ≤ 5 attempts, stored as a SHA-256 hash; a Mailer seam with an SMTP relay (BACKEND_SMTP_*) and a default log mailer. It binds an email to the current account; an email already confirmed by another account → ErrEmailTaken (merge is Stage 11); email-as-login is Stage 6 and reuses this mechanism.
    • Multi-player drop-out (interview; discharges the Stage 3 deferral): the engine's Resign now drops a seat and the rest play on while ≥ 2 are active, finishing (last-survivor wins) when one remains; winner excludes all resigned seats. A per-game dropout_tiles setting (remove default | return) governs the leaver's rack, which is never revealed to the others. Timeout reuses Resign, so a multi-player timeout drops one seat and play continues; game.commit/timeoutGame were already keyed on g.Over(), so they only needed the setting threaded through create/replay.
    • Build/deps: go mod tidy is not run — the bare-path scrabble-solver replace lives only in go.work, so tidy/go get cannot resolve it; the xurls dependency was added with go mod edit -require + go mod download, its checksums recorded in the committed go.work.sum. No CI workflow change (both Go workflows already clone the solver sibling and export BACKEND_DICT_DIR).
  • Stage 5 (interview + implementation):

    • Scope, as in Stages 14: domain layer, no HTTP — the robot consumes the public game API as an ordinary seated player (internal/robot), so only internal/engine still imports the solver. New: engine.Candidates() (decoded ranked plays) and a thin game.Service.Candidates + RobotTurns read.
    • Account model (interview): a pool of durable accounts, each a single identities row kind='robot' (migration 00004 widens the kind CHECK — a CHECK-only change, no jetgen). A curated ~16-name pool in code; EnsurePool provisions them idempotently at boot (a hard dependency, like the registry) with block_chat/block_friend_requests set, which is all the friend/DM blocking needs (no special-casing).
    • Driver + state (interview): a background sweeper goroutine (robot.Service.Run/Drive, mirroring the timeout sweeper); every per-game and per-turn choice is derived deterministically from the game seed (FNV-1a mix, restart-stable — not hash/maphash), so the robot keeps no extra state. playToWin = mix(seed,"win")%100 < 40; per-turn delay; sleep drift.
    • Timing (interview): per-move delay 2 + 88·u^k minutes, u~U(0,1), k≈3.5 → median ~10 min, clamped to [2,90]. A daytime nudge on the robot's turn pulls the move into a 210 min reply window; the robot proactively nudges after 12 h idle on the human's turn (reusing social.Nudge's once-per-hour guard; social.LastNudgeAt added to detect the human's nudge).
    • Sleep (interview — resolves the §7-vs-account.go mismatch): the robot sleeps 00:0007:00 in the opponent's timezone shifted by a per-game drift ∈ [3,+3]h (so its night overlaps the human's rather than running anti-phase), computed on the fly per game — no profile mutation, no concurrency cap. The account.go away-window comment was corrected accordingly.
    • Margin (interview): pick the candidate whose resulting margin (own+moveopp) is closest to [1,30] when playing to win / [30,1] when playing to lose, tie-broken toward the conservative edge; no legal play → exchange the full rack when the bag can refill it, else pass.
    • Substitution (interview): a matchmaker reaper (Reap/RunReaper) substitutes a pooled robot after a 10 s wait (BACKEND_LOBBY_ROBOT_WAIT), NewMatchmaker now takes a RobotProvider. A waiter learns of a match — human pairing or substitution — through a new Poll + results map; production delivery is a match-found notification (session/in-app push + side-service), Stage 6/8 — noted in §10.
    • Metrics (interview, 1+2): robots are durable accounts, so account_stats is the authoritative, complete balance ground-truth (target ~40% robot wins); an OTel counter (robot_games_finished_total, exporter none today) and a structured log cover robot-finished games for live observation.
    • Config: BACKEND_ROBOT_DRIVE_INTERVAL (30 s), BACKEND_LOBBY_ROBOT_WAIT (10 s), BACKEND_LOBBY_REAPER_INTERVAL (1 s). No CI change (both Go workflows already clone the solver sibling and export BACKEND_DICT_DIR).
  • Stage 6 (interview + implementation):

    • Scope = framework + vertical slice (interview): the whole edge mechanism is built and the backend's REST surface + the live-event seam are opened for the first time, but only a representative slice of operations is wired end-to-end — auth (auth.telegram/auth.guest/auth.email.request/ auth.email.login), profile.get, game.submit_play/game.state, lobby.enqueue/lobby.poll, chat.post, all five push events, and the admin passthrough. The remaining domain operations reuse the identical transcode pattern and are wired as the UI needs them: the play-loop ops (pass/exchange/ resign, hint, evaluate, word-check/complaint, history, my-games list, chat list/nudge) in Stage 7; the social/account ops (friends, blocks, invitations, profile editing, stats, GCG export) in Stage 8.
    • Wire contracts in a new shared scrabble/pkg module (interview): the backend push proto (pkg/proto/push/v1) and the FlatBuffers edge payloads (pkg/fbs, one scrabblefb namespace) live here with committed generated Go, imported by both backend and gateway. The Connect envelope proto lives in gateway/proto/edge/v1. Codegen is dev-time (buf generate with local plugins + flatc, driven by per-module Makefiles, mirroring cmd/jetgen); CI only builds the committed output. pkg and gateway are bare-path modules like scrabble-solver, so go.work carries use ./pkg, use ./gateway and a replace scrabble/pkg v0.0.0 => ./pkg (the no-dot path is not VCS-fetchable); deps were added with go mod edit + go work sync (the established no-tidy pattern). flatc is pinned to 23.5.26 to match the flatbuffers Go runtime.
    • Guest = durable account + is_guest (interview): migration 00005 adds accounts.is_guest; a guest is a durable row with no identity (so the sessions/game_players foreign keys hold) that is excluded from statistics (the finish-time recompute skips guest seats) and from friends/history. The earlier "guests never reach this table" comments and §3/§9 were softened to "no profile/friends/stats persisted". Guest-row GC is a logged TODO (TODO-3).
    • Push = in-process Publisher + backend gRPC listener (interview): a new internal/notify hub (a Publisher interface defaulting to Nop, installed on game/social/lobby via SetNotifier during boot — additive, existing tests unchanged) is drained by a new backend gRPC server (internal/pushgrpc, BACKEND_GRPC_ADDR default :9090) serving Push.Subscribe. Emission lives in game.commit (so robot-driver and timeout-sweep moves emit your_turn/ opponent_moved too — the background sources a handler-only design would miss), social (chat_message/nudge) and the matchmaker (match_found). Event payloads are FlatBuffers-encoded in the backend (it imports pkg/fbs); the gateway forwards them verbatim. Revoke/session-invalidation and cursor-resume are deferred (single-instance MVP).
    • Edge envelope = minimal, token in header (interview): the Gateway Connect service is Execute(message_type, payload, request_id) + Subscribe; the session token rides in Authorization: Bearer; auth ops are unauthenticated and return the token in the FlatBuffers Session. Domain outcomes ride back in the ExecuteResponse.result_code (HTTP 200); only edge failures (rate limit, missing session, unknown type, internal) are Connect error codes. No Ed25519/signing (the galaxy donor's crypto stack was dropped, per §3).
    • Admin = gateway validates Basic-Auth (interview): the gateway checks GATEWAY_ADMIN_USER/_PASSWORD and reverse-proxies to backend /api/v1/admin/*; the backend admin surface is a single ping until Stage 10.
    • Rate-limit = 2 dimensions, 3 classes (interview): public per-IP (30/min, burst 10), authenticated per-user (120/min, burst 40), admin per-IP (60/min, burst 20), plus an email-code per-IP sub-limit (5/10 min); token bucket (golang.org/x/time/rate) with a lazy stale-bucket sweep.
    • Email-as-login (discharges the Stage 4 deferral): account.EmailService gained RequestLoginCode/LoginWithCode, reusing the confirm-code mechanism but provisioning-or-finding the account by email identity (it does not refuse an already-confirmed address — that is the returning user).
    • CI: both Go workflows gained gateway/** (and pkg/** where backend depends on it) path filters and now build/vet/test ./backend/... ./pkg/... ./gateway/... (unit) — integration stays ./backend/... (the only module with tagged tests). The solver clone + BACKEND_DICT_DIR steps are unchanged.
  • Stage 7 (interview + implementation):

    • Scope = playable slice (interview): the whole UI shell plus the core play loop end-to-end; the social/account/history surfaces were split out into a new Stage 8 and the later stages shifted +1 (Telegram→9, Admin→10, Linking→11, Polish→12). Stage 7 wires only the operations the slice needs (the Stage 6 "as the UI needs them" pattern): the new gateway/transcode + backend-REST ops games.list, game.{pass,exchange,resign,hint,evaluate,check_word,complaint, history}, chat.{list,nudge}. The only new domain code is game.ListForAccount (the "my games" query) and seat display_name resolution (server DTO layer); SeatView gained a trailing display_name. Friends/blocks/invitations, profile-editing, stats and the history/GCG viewer are Stage 8.
    • Stack (interview): plain Svelte 5 (runes) + TypeScript + Vite, no SvelteKit; @connectrpc/connect-web + the flatbuffers runtime, with the edge TS bindings generated from the same edge.proto (protoc-gen-es) and scrabble.fbs (flatc --ts) and committed under ui/src/gen/ (dev-time codegen, like cmd/jetgen / pkg/Makefile; CI builds the committed output).
    • No board on the wire (discovered): StateView carries no grid, so the client replays the decoded move journal (game.history, newly wired) onto an empty board; premium squares + tile values are a client-side map ported from scrabble-solver/rules/rules.go with a Vitest parity test.
    • Board UX (interview): full-width, borderless; tiles placed by Pointer-Events drag or tap (no HTML5 DnD — it has no touch support); a contextual MakeMove control (short tap → make/reset popup, ~1 s press-and-hold → commit); per-tile recall by tapping a pending tile; a two-state zoom (15↔9 cells) on touch only (auto-zoom-in on placement, double-tap / pinch manual); a blank-letter chooser. All board/tiles/effects are pure HTML5/CSS + Unicode — no image/font/SVG asset.
    • Theming (interview): own CSS custom-property tokens, light/dark via prefers-color-scheme, Telegram-themeParams-ready (a runtime hook can override the tokens; the SDK is wired in the Telegram stage). Navigation (interview): dependency-free hash router; session token in memory + IndexedDB, re-resolved on reload (reopen Subscribe, refetch the open game); stream reconnect on focus. i18n en/ru is a hand-rolled typed catalog (compile-time key parity + a test).
    • Mock transport (owner request): a build-flagged in-memory fake (VITE_MOCK, pnpm start) drives lobby → active game → board with no backend, tree-shaken out of production; it is the same fixture the Playwright smoke uses.
    • Tests/CI (interview): Vitest units (board replay, placement machine, premium parity, i18n parity, FlatBuffers codec) + a Playwright smoke against the mock; a new ui-test.yaml workflow (type-check, unit, build with a bundle-size budget — prod is ~67 KB gzip JS — and a chromium e2e). The Go workflows already cover the new backend/gateway/pkg code; a game.ListForAccount integration test and gateway transcode tests for the new ops were added.
    • UX polish (follow-up PR): a mobile-app app shell (growing nav bar, content pinned to the bottom) + a one-line announcement banner (client-side mock rotation now; server-driven channel later — §10); a mobile-OS tab bar and a reusable HoldConfirm press-and-hold control (MakeMove 🏁 + game-action confirms); board zoom reworked to a width-based zoom in a fixed viewport (real native scroll, double-tap; pinch/swipe dropped) with constant cqw labels, corner-letter tiles, contrasting grid lines, last-word dark-tile highlight, and a Settings bonus-label style (beginner/ classic/none); hint lays its tiles on the board (no spend when no move — a new no_hint_available result code); the history opens as an in-place slide-down (not a modal); word-check is alphabet/length-limited, cached and throttled. Design details live in the new docs/UI_DESIGN.md.
  • Stage 8 (interview + implementation):

    • Scope = vertical slice continued: the social/account/history operations were opened end-to-end (UI → gateway transcode → backend REST → existing domain services). The only new backend logic is lobby.ListInvitations, account.Store.GetStats, a game.SharedGame seam (self-join on game_players), the friend-code mechanism, and the friendships declined-status change.
    • Friends — two add paths (interview, a deliberate plan change): one-time friend codes (the player to be added issues a 6-digit numeric code, 12 h TTL, SHA-256-hashed like email codes, single active per issuer, single-use, redeem rate-limited) and a play-gated request (SendFriendRequest now requires a shared game — active or finished). An explicit decline is permanent (blocks re-send), an ignored request lazily expires after 30 days and may be re-sent, and a code from the same person bypasses a prior decline. This supersedes Stage 4's "declining/cancelling deletes the row" (cancel by the requester still deletes; decline now sets status='declined'). Migration 00006 widens friendships_status_chk and adds friend_codes (jetgen regen). No public ID or name search — discovery is codes + befriend-an-opponent.
    • Badges = poll + push (interview): a new generic notify push event (notify.KindNotification, sub-kinds friend_request/friend_added/invitation/ game_started) drives the lobby hamburger + "Friends" badge; emitted on friend- request and invitation create and on the invitation's game start. The client polls incoming requests + open invitations on lobby open and on focus (a missed push while hidden), and re-polls on the notify event. Cursor-resume stays deferred (single-instance MVP, §10).
    • Language single-control (interview): the Settings language control writes through to the durable account's preferred_language (profile.update); guests keep only the client preference. Seeding the language from the platform/client on first provider login is a Stage 9 forward-note.
    • Guests = durable-only (interview): friends/blocks/invitations/statistics and history management are durable-account-only; a guest sees a sign-in prompt. Binding an email to an existing guest (account linking) stays Stage 11.
    • GCG = finished-only + share (interview): game.ExportGCG refuses an active game (game.ErrGameActive) to avoid leaking the live journal mid-play; the client exports via the Web Share API where available, else a Blob download (game-<id>.gcg). Capacitor-native file save lands with the native wrapper.
    • IA = as the mockup (interview): Friends (friends + blocks) is its own screen from the lobby menu; Invitations is a lobby section + a "play with friends" mode in New game; Stats is a lobby tab-bar button; profile editing is on Profile; history + GCG stay in the game.
    • Wire/codegen: new fbs tables (friends/blocks/invitations/profile-update/email- bind/stats/gcg + NotificationEvent; Profile gained trailing away fields) in pkg/fbs, regenerated to committed Go + TS; ~21 new gateway transcode ops; new REST handlers under /api/v1/user/{friends,blocks,invitations,profile,email,stats} and …/games/:id/gcg. UI grows to ~82 KB gzip JS (budget 100 KB). No CI workflow change (the Go and UI workflows already cover the new code).
    • UI polish (owner review follow-up): a copyable friend code (📋 + toast); the lobby notification badge fixed (it had inherited the hamburger-bar style) and made a proper count dot; Safari flex inputs given min-width:0; profile-edit validation on both UI and backend — display-name format (letters + single /./_, ≤ 32 runes), a UTC-offset timezone picker (account.ResolveZone parses ±HH:MM or IANA; DST is traded for the simple picker), a 10-minute away grid capped at 12 h (wrap-aware), email format — with Save disabled and invalid fields red-bordered while any field is invalid; language stays in Settings; in a game, an "add to friends" item flips to a disabled "request sent"; chat send/nudge became ⬆️/🛎️ icons; a finished game drops its last-word highlight, hides Check word / Drop game, disables zoom, and draws an inert footer (greyed rack + tab bar) instead of hiding it. Two iPhone-simulator passes then made the chat and modals keyboard-aware (dvh plus a visualViewport listener that sizes the modal backdrop to the area above the keyboard), reserved the rack height so a finished footer does not collapse, and compacted the play-with-friends form (a searchable bounded-scroll friend list, a pinned invite, and an explicit, required game type — a smart default is TODO-6). On the owner's call, every profile / new-game picker is a native <select> (the away window as hour + 10-minute selects, the timezone as a UTC-offset select): native time/wheel inputs render differently per OS and can't be forced to match, and a select also avoids the iOS "clear" button that would empty a time field.
  • Stage 9 (interview + implementation):

    • Connector as its own container (interview): the Telegram side-service is a standalone module platform/telegram (binary cmd/telegram) holding the bot token only there; gateway and backend reach it by unauthenticated gRPC on the trusted internal network, and it egresses to api.telegram.org through a VPN sidecar (deploy/docker-compose.yml, mirroring ../15-puzzle). Bot library github.com/go-telegram/bot (one new dep), long-poll updates.
    • initData validation moved off the gateway (interview): the gateway's HMAC validator was relocated into the connector (internal/initdata, now also returning language_code); the gateway calls connector.ValidateInitData over gRPC during auth.telegram. The hop is negligible (loopback gRPC, once per login). GATEWAY_TELEGRAM_BOT_TOKEN is gone; GATEWAY_CONNECTOR_ADDR replaces it. The gateway/internal/auth package was deleted.
    • Connector gRPC API (pkg/proto/telegram/v1, service Telegram): the generic methods are platform-agnostic, keyed by the identity external_id (so a future VK/MAX connector reuses them); only ValidateInitData is Telegram-specific. Methods: ValidateInitData, Notify (the out-of-app push — renders a localized message + a Mini App deep-link button from the FlatBuffers payload), SendToUser and SendToGameChannel (arbitrary admin messages — built and unit-tested now, wired to the admin surface in Stage 10; the game channel id lives only in connector config).
    • Push = fallback, gateway-routed, de-dup by presence (interview): the gateway already consumes the firehose and knows in-app presence (push.Hub.HasSubscribers), so it decides in-app vs out-of-app atomically: for a recipient with no live in-app stream it fetches a new backend /internal/push-target ({external_id, language, notifications_in_app_only}) and calls connector.Notify only when they have a Telegram identity and have not set the new flag. Push set: your_turn, nudge, match_found, and the notify sub-kinds invitation/ friend_request (the connector skips the rest). Delivery runs in a goroutine so a slow connector never stalls the firehose; best-effort (no cursor resume — single instance, §10).
    • Profile flag notifications_in_app_only (interview, default true → push is opt-in): migration 00007 (+ jetgen), threaded through account.Profile/UpdateProfile, the REST DTOs, the fbs Profile/ UpdateProfileRequest (default true in the schema so an unset field reads conservatively), and a Profile-screen toggle. Flagged at review: the channel is silent until a user turns it off.
    • Language seeding from the platform (discharges the Stage 8 forward-note): account.ProvisionTelegram seeds a brand-new account's preferred_language from the Telegram language_code and its display name from first_name/ username (existing accounts untouched); the UI's adoptSession already adopts the server language when the user has not locked a locale, so no extra UI seeding was needed. The gateway forwards the fields from ValidateInitData.
    • Mini App = /telegram/ + guard (interview): the gateway serves the one SPA build under /telegram/ (Vite relative base; the hash router is path-agnostic). The UI detects a Telegram launch by Telegram.WebApp.initData, applies themeParams, authenticates via the existing auth.telegram op (UI authTelegram codec/client/transport/mock added), and routes the deep-link start_param (g/i/f → game / lobby-invitation / friend-code redeem). On the /telegram/ path without initData it redirects to the site root. The official telegram-web-app.js loads from index.html (harmless outside Telegram).
    • Deep-link scheme (shared Go platform/telegram/internal/deeplink ↔ TS ui/src/lib/deeplink.ts): g<game uuid> / i<invitation uuid> / f<6-digit code> / empty = lobby. A friend-code share-to-Telegram link is shown when VITE_TELEGRAM_LINK is configured (partially discharges TODO-5; QR still open). The Notify button and the bot /start reply both wrap the payload as <MiniAppURL>?startapp=<payload>.
    • Test environment (interview nuance): the Bot API base is overridable for Telegram's test environment — TELEGRAM_TEST_ENV=true suffixes the token with /test so the client hits /bot<token>/test/METHOD (TELEGRAM_API_BASE_URL overrides the host for a mock/self-hosted server).
    • Deploy groundwork (interview): platform/telegram/Dockerfile (builds the connector standalone — drops backend/gateway and the solver replace from a copy of go.work, validated with docker build) + the connector-scoped compose with the VPN sidecar; a root .dockerignore. No public ingress for the connector (long-poll + sidecar egress); the host reverse proxy routes only to the gateway port, which serves the Mini App. The full multi-service deploy is Stage 12.
    • Wire/codegen/CI: new proto pkg/proto/telegram/v1 (committed Go); fbs Profile/UpdateProfileRequest gained notifications_in_app_only (committed Go
      • TS). go.work gains use ./platform/telegram; deps via go mod edit + go work sync (no-tidy). go-unit.yaml gained the platform/** path filter and builds/vets/tests ./platform/telegram/.... UI grows to ~86 KB gzip JS (budget 100 KB). The connector's unit tests use an httptest fake Bot API; a Playwright smoke drives the Mini App launch + guard with an injected window.Telegram.
    • Stage 10 forward-note: the admin surface will wire connector.SendToUser/ SendToGameChannel (backend gains its own connector client) for operator broadcasts to a user and the game channel.
    • Verification-time fixes (caught by the CI gate): (1) the gateway transcode dropped notifications_in_app_only in four places (ProfileResp, encodeProfile, profileUpdateHandler, the UpdateProfile body) so the toggle never reached the backend — fixed, with a round-trip transcode test added. (2) The e2e suite was made hermetic (a shared ui/e2e/fixtures.ts blocks the real telegram-web-app.js): the render-blocking CDN <script> hung every page load on the CI runner, where telegram.org is unreachable, timing out all non-Telegram specs. (3) A pre-existing time-of-day flake in TestTimeoutSweep (the default 00:0007:00 away window made the sweeper skip when CI ran with now-1h inside it) was made deterministic by clearing the test account's away window.
  • Stage 10 (interview + implementation):

    • Admin console = backend-rendered /_gm, gateway Basic-Auth (interview, two rounds): the owner chose a dedicated web console but, pointing at ../galaxy-game and asking to keep it simple, the deliverable is server-rendered Go html/template + one embedded CSS (backend/internal/adminconsole: a framework-agnostic renderer + page view-models, //go:embed templates/assets, zero JS, no build step), not a SPA. It lives in the backend on its own route /_gm/*; the gateway (the project's built-in reverse proxy) gates /_gm/* with the existing GATEWAY_ADMIN_USER/PASSWORD Basic-Auth on its public listener and proxies verbatim to backend /_gm/* (mounted on the edge mux below the h2c wrap so Connect keeps working). This supersedes Stage 6's gateway-fronts- /api/v1/admin model: the separate admin port GATEWAY_ADMIN_ADDR is dropped (only the port — user/password stay), the backend /api/v1/admin group + ping are removed, and gateway/internal/admin is repurposed to the verbatim proxy. The backend keeps no operator identity and no admin_accounts table; CSRF on the console's POSTs is a same-origin check (Origin/Referer vs Host, the gateway preserves the inbound Host) — no token. Discharges Stage 1's "admin bootstrap" (it is config, not a DB seed).
    • Complaint resolution + dictionary pipeline (interview): migration 00008 (+ jetgen) adds disposition/resolution_note/resolved_at/applied_in_version to complaints and the deferred status CHECK (open|resolved) — discharges Stage 3's deferral (no resolved_by: operator identity is not tracked). Resolution sets a disposition (reject/accept_add/accept_remove); accepted complaints are derived by query into a pending dictionary-change list (no new table), stamped applied_in_version once a rebuilt version is loaded. New game reads ListComplaints/GetComplaint/CountComplaints/ResolveComplaint/ DictionaryChanges/MarkChangesApplied; admin list/count reads account.ListAccounts/CountAccounts/Identities and game.ListGames/CountGames/ GameByID.
    • Dictionary hot-reload = per-version subdir (interview): the launch version stays in the flat BACKEND_DICT_DIR (CI/dev untouched); a reloaded version X loads from BACKEND_DICT_DIR/X/ via the new Registry.LoadAvailable (present variants only), and boot re-loads every subdirectory via engine.OpenWithVersions so reloaded versions survive a restart. Partially addresses TODO-2 (the runtime reload contract; the offline DAWG generator stays future work).
    • Operator broadcasts (discharges Stage 9's forward-note): the backend gains its own connector gRPC client (backend/internal/connector, BACKEND_CONNECTOR_ADDR, nil when unset) over the existing pkg/proto/telegram/v1; the console messages a user by account_id (backend resolves the Telegram external_id) and posts to the game channel via SendToUser/SendToGameChannel.
    • Config/CI: backend adds BACKEND_CONNECTOR_ADDR; gateway drops GATEWAY_ADMIN_ADDR (keeps user/password). No new module and no fbs/proto/UI codegen (the console is server-rendered Go). The Go workflows already span ./backend/... ./gateway/... ./pkg/...; integration stays ./backend/....
  • Stage 11 (interview + implementation):

    • Scope = link-via-confirm + merge for email and Telegram (interview): the current account is the merge primary; a linked identity that already has its own account is merged into the current one and the secondary is retired as an audit tombstone (accounts.merged_into/merged_at, migration 00009
      • jetgen). Linkable this stage: email (the existing confirm-code) and Telegram via the Login Widget (the web sign-in). New internal/accountmerge (the single-transaction data merge) and internal/link (the orchestrator over account + accountmerge + session).
    • Tombstone, not delete (interview): the secondary row is kept so a shared finished game's no-cascade game_players/chat/complaints foreign keys stay valid; its seat in such a game is left in place. The merge is refused (ErrActiveGameConflict) only when the two share an active game.
    • Merge algorithm (one tx): stats summed (wins/losses/draws) + max kept; hint_balance summed; identities repointed; non-shared game_players transferred (shared kept); chat_messages/complaints reassigned; friendships/blocks repointed with self-edge drop and dedupe (friendships by status precedence accepted>pending>declined); invitations: secondary's as inviter deleted, invitee rows deduped; secondary's email_confirmations/friend_codes dropped; secondary tombstoned. Sessions are handled one layer up: session.Service.RevokeAllForAccount (+ Cache.RemoveByAccount) retires the secondary's sessions after the tx.
    • Primary direction + guest inversion (interview): primary = the current account, except when the initiator is a guest and the linked identity already has a durable owner — then the durable account wins, the guest's active games transfer into it, the guest is retired, and a fresh session for the durable account is minted and returned (the client adopts it). Binding a free identity to a guest is a plain upgrade (clear is_guest, same session). Discharges Stage 8's "guest email-binding is Stage 11".
    • API/UX = dedicated ops; reveal only after the code (interview): new edge ops link.email.request/confirm/merge (Email-rate-limited) and link.telegram.confirm/merge. request always mails a code (no pre-send "taken" signal, so a probe cannot enumerate registered addresses); a required merge is revealed only after the code is verified, gating an explicit irreversible merge step (the Profile screen's confirmation dialog). This supersedes Stage 8's email.bind.* ops (and their fbs EmailBindRequest/EmailConfirmRequest tables), which were retired from the gateway/UI for that reason; the backend EmailService.RequestCode/ConfirmCode primitives stay (still covered by inttest).
    • Field policy (interview): display_name = primary's; profile prefs/flags (language, timezone, away window, block toggles, notifications_in_app_only) = primary's; hint_balance = sum. A new service column paid_account (bool, default false; lifetime one-time-payment marker, no purchase flow yet) is added in 00009 and ORed on merge (true always wins). It is not user-editable and is shown read-only on the admin account-detail page.
    • Telegram Login Widget (interview, owner chose the broader scope): the connector validates it (internal/loginwidget, secret = SHA-256(bot_token), distinct from initData) via a new Telegram.ValidateLoginWidget RPC; the gateway validates the widget payload and passes the trusted external_id to the backend link route (same trust model as auth.telegram). The UI offers "Link Telegram" only in a plain web context (loginWidgetAvailable), driving the popup Telegram.Login.auth; it is inert in production until BotFather /setdomain registers the site domain and VITE_TELEGRAM_BOT_ID is configured (a deploy concern, Stage 12). e2e mocks the widget (telegram.org is blocked on CI).
    • Wire/CI: new fbs LinkEmailRequest/LinkEmailConfirm/LinkTelegramRequest/ LinkResult (committed Go + TS); new proto RPC (committed Go); new REST routes under /api/v1/user/link/*. The Go workflows already span ./backend/... ./gateway/... ./pkg/... ./platform/telegram/...; integration stays ./backend/.... UI ~90 KB gzip JS (budget 100 KB). New error code merge_active_game_conflict.
  • Stage 12 (interview + implementation):

    • Re-scoped & split (interview): the original "Polish (observability + perf + deploy)" was too large for one session, so it was split — Stage 12 = observability
      • performance + guest GC; Stage 13 = alphabet-on-the-wire (TODO-4); Stage 14 = CI & deploy (TODO-1, TODO-2, the collector + dashboards). The latter two were written into the plan now as the agreed baseline (each still re-interviews at its own start). (Stage 14 was itself later re-scoped to the solver/dictionary split alone; deploy + observability + the dual-bot idea split into Stages 1518.)
    • Shared telemetry (interview): a new pkg/telemetry owns the OTel provider bootstrap (exporter selection, W3C propagators, shutdown, Go runtime metrics); the backend internal/telemetry is now a thin facade over it (keeping its gin middleware), and the gateway and connector gained telemetry runtimes. A configurable otlp exporter was added alongside none/stdout; the default stays none, the OTLP endpoint comes from the standard OTEL_EXPORTER_OTLP_* env, and the collector + dashboards are Stage 15 (so CI needs none). otelgrpc instruments the backend push server, the gateway's backend + connector clients, and the connector's gRPC server. New config GATEWAY_SERVICE_NAME/GATEWAY_OTEL_* and TELEGRAM_SERVICE_NAME/ TELEGRAM_OTEL_*; the backend's existing BACKEND_OTEL_* gained the otlp value.
    • Metrics = operational, business-near (interview): histograms game_replay_duration and game_move_validate_duration; counters games_started_total, games_abandoned_total (a turn-timeout seat drop) and chat_messages_total (kind=message/nudge); an observable gauge game_cache_active; the gateway edge_request_duration (message_type/result); plus Go runtime/heap metrics. Game-scoped metrics carry a variant attribute (english/russian_scrabble/erudit — chosen over a coarser language, which it subsumes); the gateway edge metric is variant-agnostic. Optional wiring uses the established SetMetrics/SetNotifier setter pattern (default no-op meter), so existing constructors and tests are untouched. No speculative optimisation — there is no measured hotspot; the deliverable is the instrumentation (the standing "performance only with evidence" rule). pprof was not added (reframed away by the owner).
    • Guest GC (interview, TODO-3): age-based, no-seat-only — see the discharged TODO-3 below; new config BACKEND_GUEST_REAP_INTERVAL/BACKEND_GUEST_RETENTION.
    • Deps/CI: new OTel modules (the OTLP exporters, contrib/instrumentation/runtime, otelgrpc) added with the no-tidy pattern (go mod edit + go mod download + go work sync; pkg carries no bare-path dep, so it tidies cleanly). No workflow change — the Go workflows already span ./backend/... ./gateway/... ./pkg/... ./platform/telegram/..., integration stays ./backend/..., and the default none exporter keeps CI collector-free.
  • Stage 13 (interview + implementation, discharges TODO-4):

    • Scope = live play only (interview): indices ride the wire for StateView.rack (out) and SubmitPlay/Evaluate/Exchange/CheckWord (in). The board path is untouchedMoveRecord (history, move results, hint), formed words, ComplaintRequest.word (durable, admin-reviewed) and WordCheckResult.word (echo) stay decoded concrete characters, so the durable journal / GCG and the §9.1 invariant are unchanged. Hard cutover, no dual letter/index fields (single client; the fbs Go + TS regenerate in one PR; no external wire consumer). Exchange moved fully to indices, a blank = the shared sentinel index 255 (engine.BlankIndex).
    • Edge-mapping layering (engineering): the engine gained a cached per-variant codec — AlphabetTable (the (index, letter, value) table from the solver ruleset), LetterForIndex, EncodeRack, DecodeTiles, DecodeWord — and the backend server edge owns the index↔letter mapping. game.Service's domain methods, engine.Game and the robot keep a single letter-based play path (untouched); a new thin game.Service.GameVariant (a single-column SELECT variant, cheaper than GetGame) lets the inbound handlers resolve the variant without doubling the play-path read. The gateway carries no alphabet table — it passes indices through verbatim; check_word rides as repeated ?idx= query params.
    • include_alphabet flag (interview): StateRequest.include_alphabet gates the table so it is not resent on every poll; the client sets it only on a per-variant cache miss (first open of a variant), and the table then arrives with the index rack so the rack is always decodable. The client caches the table in memory by variant (ui/src/lib/alphabet.ts).
    • Letter case (discovered): the solver emits lower-case letters and the rest of the UI works in upper case. The wire and the journal stay lower case; the UI normalises display to upper case (the codec upper-cases decoded board tiles and words, and the alphabet cache upper-cases on ingest), so placement.ts / board.ts / checkword.ts are unchanged and the latent real-backend lower-case display is fixed.
    • Parity rework (interview): the real value/alphabet parity moved to a Go engine test (engine.AlphabetTable: EN/RU/Эрудит sizes, EN a=1/q=10, Эрудит ё=index 6, value 0); ui/src/lib/premiums.ts is now geometry only (its value tables, tileValue and alphabet were removed, its parity test trimmed to the premium grid); the codec test round-trips the index tiles + the alphabet table; the mock keeps a fixture table (relocated from premiums.ts) seeded into the client cache, so the mock-driven UI is alphabet-agnostic too.
    • Wire/codegen/CI: new fbs AlphabetEntry + PlayTile; StateView.rack[ubyte] + alphabet; StateRequest.include_alphabet; SubmitPlay/Eval tiles→[PlayTile]; Exchange tiles→[ubyte]; CheckWord.word[ubyte] (committed Go + TS regenerated). UI ~90 KB gzip JS (budget 100 KB). No CI workflow change — the Go workflows already span ./backend/... ./gateway/... ./pkg/... and the UI workflow runs check/unit/build + a chromium/webkit e2e. docs/FUNCTIONAL.md is untouched (no user-visible behaviour change — the UI looks and plays the same; like Stage 2). The index-drift caveat is handled by construction (the running backend produces the table, so client↔server cannot drift); the DAWG/solver build-time agreement remains Stage 14 / TODO-2.
  • Stage 14 (interview + implementation, re-scoped + discharges TODO-1/TODO-2):

    • Re-scoped to the split (interview): the original "CI & deploy" was several sessions of work, so it was cut to the solver/dictionary split (the dependency foundation) and the deploy + observability + the dual-bot idea were written into the plan as new Stages 1518. The deploy decisions taken at the interview are recorded there (embed the UI in the gateway via go:embed; full Collector+Prometheus+Tempo+Grafana stack; two contours — test = auto on feature-branch push on the local host, prod = manual SSH docker save/load after merge; TEST_/PROD_ secret prefixes since Gitea 1.26 has no environments — verified).
    • TODO-1 — publish solver (interview: "опубликовать и запинить"): scrabble-solver renamed to module gitea.iliadenisov.ru/developer/scrabble-solver, internal/{wordlist,dictdawg} de-internalised to public packages (so the dict repo imports one builder — no drift), the build pipeline (cmd/builddict, dictprep, the dictionaries submodule) moved out, internal/dict repointed at the committed dawg/*.dawg fixtures, tagged v1.0.0. scrabble-game pins it in backend/go.mod, drops the go.work replace + the CI clone, and sets GOPRIVATE=gitea.iliadenisov.ru/* (go fetches the module directly from Gitea — verified end-to-end). The solver hash lives in go.work.sum (workspace mode; the bare-path scrabble/pkg replace still blocks go mod tidy).
    • TODO-2 — dictionary repo (interview: "полный TODO-2, новый репо"): developer/scrabble-dictionary builds the three DAWGs against the published solver + pinned dafsa/alphabet v1.1.0, byte-identical to the solver fixtures; published as the release artifact scrabble-dawg-v1.0.0.tar.gz; both Go workflows download it for BACKEND_DICT_DIR instead of cloning the solver. English source vendored from kamilmielnik/scrabble-dictionaries; the Эрудит fold is committed as dictprep/russian/erudit.txt, so the build needs no python.
    • Bootstrap nuances (encountered): the dict repo was created empty with a protected master, so it was seeded once via an owner-authorised protection lift→push→restore (a subsequent CI-fix push correctly went through a PR, not another lift); it was made public (like the solver) so the Go workflows fetch the artifact anonymously. Its CI is a build-only validation gate — the auto-release step's ${{ github.* }} contexts failed the Gitea workflow compile, so releases are published manually for now (a logged follow-up).
  • Stage 15 (interview + implementation):

    • Re-framed service-agnostic (interview): the owner kept the two-bots-in-one-container model but generalised the language signal — the sign-in service returns a set of supported game languages (subset of {en, ru}, ≥ 1) on the validate response, and the UI gates the New Game variant choice by it. Two distinct scopes, deliberately not conflated: the gating set is per-session (rides the Session fbs, never persisted — so the same telegram_id logged in through the en- and ru-bot gates differently, which is correct), and the routing language is per-account.
    • Push routing resolved (interview, the original "which bot delivers" open detail): only the user-facing Notify carries the en/ru language from the user's last ValidateInitData, persisted as accounts.service_language (migration 00010, written every login — new and existing — last-login-wins, read by /internal/push-target with a preferred_language fallback). It is NOT the game's variant language. Correction mid-interview: the admin broadcasts SendToUser / SendToGameChannel are admin-panel-only and unrelated to ValidateInitData; they pick the bot by an operator-chosen language (a console <select>), so a language field was added to those two RPCs sourced from the form, not from service_language.
    • Gating = UI-only, creation-only (interview): the backend does not enforce (a valid game is harmless, not a trust boundary); only the New Game pickers (auto-match + friend invite) filter — there is no variant picker on accept/open/play, so those are inherently ungated. Non-Telegram logins (web/email/guest) carry the gateway default set (GATEWAY_DEFAULT_SUPPORTED_LANGUAGES, default all).
    • Wire/connector: ValidateInitDataResponse gained service_language + supported_languages; the fbs Session gained supported_languages:[string]; SendToUser/SendToGameChannel gained language (committed Go + TS regenerated via make -C pkg gen + pnpm -C ui codegen). The connector config moved to per-language bots (TELEGRAM_BOT_TOKEN_EN/_RU, TELEGRAM_GAME_CHANNEL_ID_EN/_RU; TELEGRAM_MINIAPP_URL shared; ≥ 1 token required — a breaking config change, no prod yet); the server hosts a bot map and routes by language. The push template language now follows the routing bot (was preferred_language) — a documented change. The deploy compose/Dockerfile env was updated to the per-language vars (the full deploy stack is Stage 16). No CI workflow change (the Go and UI workflows already span the touched modules).
  • Stage 16 (interview + implementation):

    • Branch model reshaped (interview, supersedes the Stage 0 feature/* → master): a long-lived development integration branch + master as the prod trunk. Feature branches are cut from development; a feature-branch commit triggers nothing. A single consolidated .gitea/workflows/ci.yaml (Gitea has no cross-workflow needs) runs unit+integration+ui on a PR into development/master and a gated deploy job (needs the three) that auto-rolls the test contour on a PR into — or a push to — development (owner's "и PR, и push"). A PR into master is test-only; prod is the manual Stage 18. The former go-unit/integration/ui-test workflows were folded in (no path filters — full CI on every PR, per the owner). Console kept plain (NO_COLOR, docker compose --ansi never, --progress plain).
    • Gateway serves the UI (interview, the §13 single-origin): a new gateway/internal/webui embeds dist via go:embed (a committed placeholder index so go build/CI compile without a UI build) and serves the SPA at / and /telegram/ (a path-stripping SPA handler, index.html fallback for the hash router), mounted in the edge mux below the h2c wrap; /_gm stays an explicit 404 when the local admin proxy is off so the catch-all does not leak the shell. The gateway/Dockerfile node stage builds the UI with the VITE_* build-args and copies it into the embed dir before go build.
    • Images (interview): multi-stage distroless backend/Dockerfile (a DAWG stage curls the scrabble-dawg release pinned to DICT_VERSION, GOPRIVATE fetches the solver) and gateway/Dockerfile (node UI stage + Go stage), both trimming go.work like platform/telegram/Dockerfile. Built and verified locally.
    • Contour = caddy-fronted (interview, "caddy всё равно нужен для https"): a new caddy service owns a single /_gm Basic-Auth and routes /_gm/grafana/* → Grafana (anonymous-admin + sub-path, no own accounts) and the rest of /_gm/* → the backend console; everything else → the gateway. This supersedes Stage 10's gateway-fronts-/_gm model in the deploy topology (the gateway's own /_gm proxy stays for a local non-caddy run). TLS: the host caddy terminates it for the test contour and forwards to scrabble:80; the in-compose caddy is parameterised (CADDY_SITE_ADDRESS) to own ACME on prod (Stage 18) where there is no host caddy.
    • Networks (engineering): inter-service traffic on a private internal network (project-scoped DNS, no name collisions on the shared edge); only caddy joins the external edge (alias scrabble). The connector keeps its VPN sidecar (the only egress that needs the tunnel). The connector-scoped platform/telegram/deploy/docker-compose.yml was retired (the root deploy/docker-compose.yml supersedes it; the connector Dockerfile stays).
    • Observability stack (interview): OTel Collector (OTLP/gRPC → a Prometheus scrape endpoint + Tempo OTLP) + Prometheus (15d) + Tempo (72h) + Grafana (provisioned Prometheus+Tempo datasources
      • four dashboards: Service overview, Edge/UX, Game domain, Users; Traces via the Tempo datasource + Explore, no fixed panels). The collector's prometheus exporter uses add_metric_suffixes:false + resource_to_telemetry_conversion so the dashboards' PromQL matches the in-code metric names and carries service_name. The three services export otlp in the contour (default stays none, so CI needs no collector). Loki/logs were left out of scope (container stdout / zap JSON).
    • User metrics (interview): a backend accounts_created_total{kind} counter (telegram/email/guest; robots excluded — they are a provisioned pool, not users) via the Stage-12 SetMetrics no-op pattern, and a gateway in-memory active_users{window=24h,7d} observable gauge (distinct authenticated edge actors). The owner chose the in-memory gauge over a DB last_seen_at (overkill); its single-instance / reset-on-restart limits are documented (a live gauge, not billing).
    • Owner actions before the contour is green (surfaced, not blockers): set the TEST_ Gitea secrets/variables (see deploy/.env.example) and add a host-caddy route <test domain> → scrabble:80 on the runner host. CI bootstrap nuance: the first PR introducing ci.yaml may first deploy on the post-merge push to development (depending on whether Gitea runs head/base workflows for a PR), after which PR-time deploys work.
    • Telegram test environment (post-deploy fix): the connector now selects Telegram's test env with the library's native tgbot.UseTestEnvironment() (was a token += "/test" hack — functionally identical, verified, but the option is idiomatic and now has a bot test asserting the /bot<token>/test/getMe path). The test contour pins TELEGRAM_TEST_ENV=true in ci.yaml (the contour is the test environment) rather than via a TEST_-prefixed variable — removing a confusing double-TEST operator knob and the secret-vs-variable footgun; prod (Stage 18) leaves it false.
  • Stage 17 (interview + implementation): the test-contour verification pass. The owner's collected caveats were classified (fix-now / verify-then-fix / discuss) and resolved in one session.

    • Russian Scrabble fixed (#6): the UI sent the variant id russian while the backend's canonical string (and StateView) is russian_scrabble, so lobby.enqueue/invite returned 400 (confirmed in the contour logs). The UI was aligned to russian_scrabble (the Variant type, variants.ts, Lobby.svelte, mock fixtures, premium/alphabet keys, tests); the backend label is unchanged (persisted games, GCG and the variant metric attribute keep it).
    • Nudge message (#3): social.ErrNudgeOnOwnTurn shared the not_your_turn result code with game.ErrNotYourTurn, so nudging on your own turn read "it is not your turn" — backwards. A distinct nudge_own_turn code + i18n message was added, and the UI disables the nudge control on your own turn.
    • Connector name sanitization (#2): account.ProvisionTelegram now cleans the platform name to the editable display-name format (sanitizeDisplayName) and falls back to Player/Игрок-NNNNN (by language) when nothing remains. A new account.ProvisionRobot lets system robot names bypass editor validation (e.g. "Peter J.").
    • Robot names (#5, interview): per-language composed pools — 32 full + 32 colloquial first names paired by index, plus a surname pool (gender-agreed for Russian) rendered in three forms (first only / first + surname initial / first + full surname), composed deterministically per pool slot (stable across restarts). Pick(variant) is variant-aware: a Russian game draws Russian names with ≤ ~20% Latin, an English game the Latin pool. Robot identities are keyed robot-<lang>-<index>.
    • Robot timing (#4, interview): the fixed 2 + 88·u^3.5 move delay became move-number-aware — the band interpolates from [3,10] min at the first move (raised from [1,5] in round 4, #14) to [10,90] min by ~28 moves, right-skewed by k=4, so early moves are quick and the endgame can be long. A daytime nudge pulls the reply toward the move's lower band.
    • Multi-device push (#7, interview): emitMove no longer skips the acting seat, so the mover's own other devices (and their lobby) refresh. opponent_moved stays in-app only (no out-of-app push to the actor), and the gateway already fans each event out to all of a user's live streams.
    • Move-duration analytics (#1, interview): a live game_move_duration{variant,phase} histogram (opening/middle/endgame) + a Grafana panel, plus offline per-user analytics in the admin console — min/avg/max columns in the user list and an inline-SVG chart of think-time by the player's move number, computed from the journal (game_moves.created_at deltas; no schema change). Per-user stays offline, not a Prometheus label, to avoid cardinality blow-up; the live histogram aggregates all seats (robots included), so the per-human admin view is authoritative.
    • CI (#9/#10, interview): unit/integration/ui are path-conditional behind a changes job; an always-running gate job aggregates them (success-or-skipped) and is the single branch-protection required check (CI / gate), so a skipped job never blocks a merge. The deploy job gained a Telegram-connector liveness probe (docker inspect: running, not restarting, stable restart count, with a VPN-handshake grace period) — closing the Stage 16 blind spot where a crash-looping connector was invisible to the gateway-only probe.
    • UI theming / UX: inside Telegram the colour scheme is forced from WebApp.colorScheme over the OS prefers-color-scheme (fixes the Telegram Desktop breakage, #12) and the theme switcher is hidden (#11); the nav bar takes Telegram's bg and the announcement banner a subtle --ad-bg accent (#14/#15); the reconnect banner is suppressed while backgrounded and the stream reconnects on return (#16); hint zoom scrolls to the placement (#17); the players plaque raises the active seat and sinks the others with a tap toggling history (#19/#20); history fixes the word-column jitter and pins its bottom shadow to the board (#21/#23); directional screen-slide transitions (#18a); a per-game in-memory cache renders instantly on re-entry and refreshes in the background (#13).
    • Grafana repeated password (#8) — not a server defect: verified live that caddy challenges /_gm and /_gm/grafana with one identical realm and Grafana serves anonymously, so the repeated prompt is a browser Basic-Auth scoping quirk (likely Safari/Desktop), not infra — left for the owner to re-verify, no server change. Multi-word history (#22) was already implemented (all formed words shown).
    • Contour-verification follow-ups (rounds 23, from live testing): the Grafana double-password was its Live WebSocket tripping caddy Basic-Auth — Grafana Live is disabled (GF_LIVE_MAX_CONNECTIONS=0) and the admin console links to Grafana; the move-duration panel was invisible because the deploy reseed (rm -rf) left the config-only services on a stale bind mount — the deploy now force-recreates caddy/otelcol/prometheus/tempo/grafana; the per-user rate limit was raised 120/40 → 300/80 and the UI no longer reloads on the echo of its own move; the iOS/Telegram reconnect banner gained a resume grace window (visibilitychange + pageshow/pagehide
      • Telegram activated/deactivated); Telegram Mini Apps polish was adopted — chrome colours (setHeaderColor/setBackgroundColor/setBottomBarColor), native BackButton, HapticFeedback, closing confirmation in a game, disableVerticalSwipes; the players-plaque highlight was inverted so the active seat pops; the make-move popover became a direct with a tab-bar ↩️ Reset; the hint button disables at zero hints; plus board-only vertical scroll (#9) and a keyboard-overlay check-word dialog (#10).
    • Contour-verification follow-ups (round 4, from live testing): the robot early-move band was raised [1,5] → [3,10] min so openings are less hasty (#14); the profile is edited inline (the Edit/Cancel toggle is gone, the form is always shown for durable accounts, hint balance stays read-only) and a single trailing "." is allowed in display names (#5/#6); a pending tile now recalls by double-tap or by dragging it back onto the rack (single-tap recall removed), and holding a dragged tile over a cell ~1 s auto-zooms there (#3/#10); 🔀 Shuffle is animated — tiles hop along a low parabola to their new slots, duration scaled by distance, with a haptic shake (#9); a lines-off board Settings toggle (default off) renders a gapless checkerboard with rounded, right-shadowed tiles, reclaiming ~14px of width (#12). Deferred (discussed): board pinch zoom (#2) — it fights both the native scroll and the new one-finger drag-back, so it stays dropped in favour of double-tap + hover-hold zoom; robot win% in the admin game detail (#16) — needs the seed-derived play-to-win decision exposed across the game/robot package boundary, to be picked up when that seam is added.
    • Contour-verification follow-ups (round 5, from live testing): resign on the opponent's turn now works — the engine gained ResignSeat(seat) and game.Resign bypasses the turn check to forfeit the actor's own seat (it no longer returned "not your turn"); quick-match cancel was a UI no-op (only stopped polling) — added the full path (REST /lobby/cancel → gateway → client) and clear the matchmaker's pending result on cancel, so a cancelled search is dequeued (no "already queued", no later robot-substituted game); lobby win/loss ranked by score, so a 0-0 resignation read as a win — result.ts now places the viewer below the winner (rank ≥ 2), matching the game detail; a friend request to a robot is accepted as pending and expires like a human ignore (robots no longer set BlockFriendRequests); the nudge cooldown error got its own nudge_too_soon code/message and the chat button disables for the hour, with the note reworded to "Waiting for your move!". UI polish: even zoom (interpolate the scroll toward a pre-clamped target as the real width grows/shrinks — no lurch-and-snap) that recentres only on the first zoom-in; a drag drop-target highlight; pinch zoom (two-finger, preventDefault only on two touches; a second finger aborts a drag); shuffle hop capped at 0.3 s and off under reduce-motion; a borderless make-move icon, disabled on a known- illegal pending word; variant display names (english & russian_scrabble → Scrabble/Скрэббл, erudit → Erudite/Эрудит) shown on the create-game controls and the in-game title. L2 done: the admin game card now shows each robot seat's per-game play-to-win intent + the ~40% target and, on the robot's turn, its deterministic next-move ETA. Open edge: a bilingual New Game (both en+ru offered) would show two "Scrabble" buttons — left as the owner's chosen naming; disambiguate if it bites.
    • Contour-verification follow-ups (round 6, from live testing) — shipped & deployed: profile drops the hint-balance line; no mobile tap-flash on a board cell (-webkit-tap-highlight-color); variant display names keyed by the game's alphabet, not the UI language (english → "Scrabble", russian_scrabble → "Скрэббл", both unlocalized so they never collide; erudit localized), and the in-game title shows the variant name; chat & nudge are mutually exclusive by turn (message field on your turn, nudge on the opponent's, grey "awaiting reply" caption during the cooldown), with chat enforced server-side to your own turn (ErrChatNotYourTurn); the nudge cooldown resets once the player has moved or chatted since the last nudge (game.LastMoveAt + last chat vs last nudge; the UI mirrors it); the About screen got localized titles + a rules link + the random/friends sections, and the app version comes from git describe (Vite define __APP_VERSION__ ← Docker build-arg in the deploy step, default "dev"); the quick-game buttons became lobby-style plaques — name + flag (🇺🇸/🇷🇺 + a bundled minimalist USSR-flag SVG) + a one-line rules summary (bag size, the ё rule, bonus differences from the engine rulesets) + the 24h move-limit beneath; two follow-up fixes (pin the nudge right when available; redraw the USSR emblem as a thin schematic hammer & sickle); #3 drag-reorder of rack tiles with a visual gap (the dragged tile lifts out, the rest slide to open a slot; reorderIndices unit-tested; only with no pending tiles); and the persistence backend foundation (#4/#5/#6): a game_drafts table (migration 00011) + raw-SQL store/service (GetDraft/SaveDraft) that, on every committed move, clears the actor's own draft and resets any opponent's board draft whose cell the play overlapped — 5 integration tests.
    • Stage 17 round 6 — final pass (#4/#5/#6 + #1620), shipped:
      1. Draft persistence — gateway slice + UI (#4/#5/#6, PR #20). FB DraftRequest{game_id, json} (save) + DraftView{json} (get reuses GameActionRequest); the client serializes {rack_order, board_tiles} itself (no FB tile array), the gateway forwards it as json.RawMessage both ways (no double-encode), and GET/PUT /games/:id/draft (a server draftDTOgame.Draft) is the only place that reads the shape. UI: debounced save of the rack order (#4) + board draft (#6) and restore on load (lib/draft.ts, reconciling against the committed board); #5 — tiles may be arranged on the opponent's turn (placement relaxed; the preview and Make-move stay your-turn-only, so an off-turn draft is position-only). Off-turn tiles keep the existing pending highlight — no caption, no new style (owner's call). The backend draft endpoint is sub-ms.
      2. Landing + /app/ move (#1620, this PR). One Vite build with two HTML entries — the game SPA (index.html) and a new lightweight landing (landing.htmlLanding.svelte, reusing the theme/i18n/aboutContent leaf modules, not the app store, so it stays small). The gateway serves the landing at / and the game SPA at /app/ and /telegram/ (webui.Handler(stripPrefix, indexName)); relative base keeps one build serving every mount with a shared dist/assets/ (the planned per-target base conditional proved unnecessary). Correction to the original note: the Telegram Mini App stays at /telegram/ — only the plain web app moved off / to /app/, so BotFather is untouched. The landing's "Play in Telegram" link is per-language via two new build vars VITE_TELEGRAM_LINK_EN / VITE_TELEGRAM_LINK_RU (test/prod bots differ → no hardcoding; the button hides when unset). Logo copied .claude/telegram-logo.svgui/public/ (source stays untracked).
      • Edge robustness (folded into the landing PR). (a) Static cache headers — the embedded http.FileServer over go:embed has a zero modtime, so it emitted no validators → the client re-downloaded the whole bundle every launch; now hash-named /assets/* are immutable (a relaunch is a cache hit) and the HTML shells are no-cache. (b) Live-stream 15 s abort — the Subscribe heartbeat only fired after the first 15 s tick, so the stream sat silent and raced a ~15 s edge idle timeout (constant reconnects in the caddy log); now an immediate heartbeat on open + a 10 s default interval. Both surfaced while diagnosing a reported "slow load in Telegram" that was actually the owner's external network (the server is sub-ms end-to-end) — not a regression.
    • Landing follow-up (owner review pass): reworked from the first cut — the per-language Telegram link var renamed VITE_TELEGRAM_LINK_EN/_RUVITE_TELEGRAM_GAME_CHANNEL_NAME_EN/_RU (it carries a channel username, the landing builds https://t.me/<name>; the connector keeps the matching ..._CHANNEL_ID_.. to post). Switchers became icons — a 🌐 language dropdown (saved, synced to the app) and a ☼/☾ theme toggle that is ephemeral (follows the system scheme, never persisted, no "auto"). The "Play in browser" CTA was dropped (no standalone-web onboarding yet).
    • Game/Telegram review-pass polish: the USSR flag emblem redrawn (canonical hammer & sickle, scaled down ×1.5 below the star, the star itself +25%); touch drag enlarges the drag ghost ×1.5 (touch only — the finger hides the tile) and suppresses the iOS tap-highlight that lingered on a rack tile sliding into a dragged tile's slot; a placed tile can be dragged to another board cell (it lifts off its origin for the drag, and touch-action:none lets the drag win over the board pan when zoomed) and the manual-select ring clears when a tile is recalled; and Telegram fullscreen no longer hides our header under its native nav — the whole header drops below the content-safe-area top inset (title and the right-aligned menu both clear the nav), via --tg-content-top from the SDK + a tg-fullscreen class. (Telegram's Mini App SDK exposes no way to set the native nav-bar title, move its buttons, or add items to its "⋯" menu, so we keep our own header and simply push it clear.)
    • Lobby sort + in-game friend state (review pass, PR C): the my-games lobby now groups games into your turn / opponent's turn / finished (empty sections hidden) and orders them by last activity — your-turn oldest-first (the longest-waiting on top), the other two newest-first — in a compact, line-separated list (the owner's density pick over bordered cards). gameDTO / FB GameView gained last_activity_unix (the turn start while active, the finish time once finished). The in-game "add to friends" item is now server-derived (new GET /user/friends/outgoing + friends.outgoing op, returning the addressees already requested — pending or declined, which both read as "request sent") so it is correct across reloads, shows a disabled "✓ in friends" once accepted, and live-updates when the opponent answers: RespondFriendRequest now publishes friend_added (accept) / friend_declined (a new notify sub-kind, decline) to the original requester, whose open game re-derives its friend state. Owner decisions: a declined request stays "request sent" (non-revealing); an accepted opponent reads "✓ in friends"; rack-tile reorder while tiles are placed stays disabled by design.
    • Admin "Messages" moderation section (#18, PR D): a new /_gm/messages console page lists posted chat messages (nudges excluded) newest-first — time · source (guest / robot / oldest identity kind) · sender (→ user card) · IP · body · game (→ game card) — searchable by sender name / external-id glob masks and pinnable to one game (?game=) or sender (?user=), linked from the game and user cards. Server-rendered (adminconsole MessagesView + messages.gohtml, 50/page via the shared pager); the list query lives in social (raw SQL, kind='message', the source via a SQL CASE), reusing the now-exported account.LikePattern glob helper. Owner decisions: messages only (no nudges), separate name/ext masks (matching the Users section), a top-level nav entry plus the card deep-links.
    • Round-6 follow-up — UX polish + client-IP fix (this PR):
      • Client IP through the edge. The compose caddy now sets trusted_proxies static private_ranges, so the real client IP survives the host-caddy hop (it was logging the docker-network caddy hop 172.18.0.x for chat moderation, and bucketing the gateway's per-IP rate limiter on it). Correct + spoof-safe in both contours (prod has no host caddy → public clients untrusted → real peer used). peerIP unit-tested.
      • Ad banner gated off behind a compile-time SHOW_AD_BANNER=false in Screen.svelte — the {#if} branch, the AdBanner import and banner.ts are tree-shaken out of the prod bundle (code kept for post-release polish).
      • Landing Telegram entry is now just the 64px logo (clickable, no button/caption).
      • TG-fullscreen header reworked again: title + menu are one centred pair (hamburger right of the title) pinned to the bottom of the TG nav band, lining up with Telegram's own controls.
      • Edge-swipe back (Screen.svelte): a left-edge rightward drag navigates to back (touch/pen only, armed only from ≤24px so it never fights the board's gestures; skipped inside Telegram, which has its own back).
      • Chat + word-check are now their own routed screens (/game/:id/chat, /game/:id/check, header back to the game, no tab-bar) so the soft keyboard simply resizes the visible viewport — mirrored into a --vvh CSS var the Screen height uses, since iOS doesn't shrink dvh for the keyboard — with the input pinned to the bottom: no modal relayout, no page jump (this superseded a first bottom-sheet-Modal attempt). New chat messages raise an unread badge on the in-game hamburger + the Chat menu row (per game, cleared on open), mirroring the lobby badge; the chat screen is routable for a future Telegram deep-link. The TG-fullscreen header was also finalised over a couple of review passes: title + menu as a centred pair inside Telegram's nav band (between --tg-safe-top and --tg-content-top), with a small padding bump so the native controls aren't flush.
      • Tests backfilled for the merged round-6 work: e2e for the in-game "✓ in friends" item and a board→board tile relocation; codec units for last_activity_unix + OutgoingRequestList.
      • Hide finished games (#5, shipped): a player can remove a finished game from their own my games list — per-account, finished-only and irreversible (the game stays for the other players; there is no un-hide). On a finished row a swipe-left (touch) or a tap on its kebab ⋮ (the desktop affordance) reveals a that hides it; active rows carry an inert chevron purely to keep the right-edge icons aligned. New table game_hidden(account_id, game_id) + migration 00012; ListGamesForAccount filters the hidden set; POST /api/v1/user/games/:id/hide behind the game.hide edge op (reusing GameActionRequest → an Ack); the lobby drops the card optimistically and keeps the cache in sync. Covered by an integration test (active→ErrGameActive, outsider→ErrNotAPlayer, per-account visibility, idempotent), a gateway transcode test, and a mock e2e (kebab → ).
      • Enriched out-of-app push (#4, shipped): the "your turn" Telegram notification now names the opponent and recaps their last move — voiced as the opponent, «{name}: my move — «WORD». Score 120:95» for a scoring play, or a short "swapped / passed, your turn" — and a new game-over notification reports the result + final score when a game ends (any path: closing play, all-pass, resign, timeout). Scores are recipient-first (the reader's score leads), 2- to 4-player (120:95:80). YourTurnEvent gained opponent_name/ last_action/last_word/score_line (appended, backward-compatible) and a new GameOverEvent carries result/score_line; both emit per-recipient from the game commit (emitMove), join the out-of-app whitelist, and render per language (en/ru) in the Telegram connector. The backend resolves the mover's display name (the score line and result are built per recipient). Covered by notify round-trip, emit, connector-render (en/ru) and routing tests.

Deferred TODOs (cross-stage)

  • TODO-1 — publish & version the solver. Done in Stage 14. scrabble-solver is published as module gitea.iliadenisov.ru/developer/scrabble-solver (tagged v1.0.0, with wordlist/dictdawg de-internalised to public packages); backend/go.mod pins it, the go.work replace and the CI sibling-clone are gone, and GOPRIVATE=gitea.iliadenisov.ru/* fetches it directly (no public proxy/checksum DB). Removes the floating master dependency accepted since Stage 2.
  • TODO-2 — split the solver into engine vs dictionary generator + versioned dictionary artifacts. Done in Stage 14. A new repo developer/scrabble-dictionary holds the word-list sources + cmd/builddict (moved out of the solver, with dictprep and the dictionaries submodule) and builds the three DAWGs against the published solver + pinned dafsa/alphabet v1.1.0 — the output is byte-identical to the solver's committed fixtures, so the index-drift caveat is handled by construction. Delivered as a Gitea release artifact scrabble-dawg-vX.Y.Z.tar.gz (not go get; DAWGs are data; one semver label for the whole set); the Go workflows download it for BACKEND_DICT_DIR. The runtime dynamic-reload contract (per-version BACKEND_DICT_DIR/<version>/ via Registry.LoadAvailable / engine.OpenWithVersions, Stage 10) is unchanged — a deploy drops a new set into the directory; a version is safe to retire once no active game pins it.
  • TODO-3 — garbage-collect abandoned guest accounts. Done in Stage 12. A periodic account.GuestReaper deletes guests (is_guest) with no game seat at all whose account age exceeds BACKEND_GUEST_RETENTION (default 30 d, swept every BACKEND_GUEST_REAP_INTERVAL, default 1 h). Two schema facts shaped this, narrowing the original sketch: (1) game_players/chat_messages/complaints reference accounts without ON DELETE CASCADE, and a finished game belongs to the other players' history, so a guest with any seat is retained (a delete would be blocked anyway) — hence "no seat", not "no active game"; (2) sessions are revoke-only with no maintained last_seen_at, so a lingering session never expires and account age is the abandonment trigger, not "last session gone". The reaped guest's sessions/identities/ account_stats fall away via their own ON DELETE CASCADE.
  • TODO-4 — put the per-game alphabet on the wire (owner's idea, Stage 7). Done in Stage 13. The client is now alphabet-agnostic: it caches each variant's (index, letter, value) table — sent by the backend behind StateRequest.include_alphabet on a per-variant cache miss — and live play exchanges letter indices both ways (rack, submit-play, evaluate, exchange, word-check; a blank rides as the sentinel index 255). The table is produced from the solver ruleset (engine.AlphabetTable), so it is pinned by the solver version and cannot drift from the running backend, and ui/src/lib/premiums.ts is now geometry only. The durable journal / history / GCG stay decoded concrete characters (§9.1, unchanged). The DAWG/solver build-time agreement (the original caveat, shared with TODO-2) was discharged in Stage 14: the dict repo builds against the published solver + pinned dafsa/alphabet, byte-identical to the fixtures.
  • TODO-5 — QR friend codes (owner's idea, Stage 8). Partially done in Stage 9: the deep-link scheme now exists (f<code>, shared Go ↔ TS), the bot redeems it on launch, and the UI shows a share-to-Telegram link for an issued code when VITE_TELEGRAM_LINK is configured. Still open: render the link as a QR so a friend can add you by scanning rather than tapping/typing. The code semantics (12 h TTL, single use, one active per issuer) stay as-is; only the delivery changes.
  • TODO-6 — smart default for the friend-game "game type" (owner's idea, Stage 8). The play-with-friends form has no preselected variant today (an empty, required pick). Default it from the player's history (the variant they play most, from account_stats or a games query), falling back to their interface language (en → English, ru → Russian/Эрудит). Until then the explicit pick avoids guessing wrong.