e16076c89e
CI / changes (pull_request) Successful in 2s
CI / unit (pull_request) Successful in 9s
CI / integration (pull_request) Successful in 11s
CI / ui (pull_request) Successful in 31s
CI / gate (pull_request) Successful in 0s
CI / deploy (pull_request) Successful in 55s
Close out Stage 17 round 6: - Landing page at / — one Vite build with two entries (index.html = game SPA, landing.html = a lightweight landing reusing the theme/i18n/ aboutContent leaf modules, not the app store). - Move the web game SPA to /app/; the Telegram Mini App stays at /telegram/ (gateway webui.Handler(stripPrefix, indexName): landing at /, SPA at /app/ + /telegram/). Per-language "Play in Telegram" link via new VITE_TELEGRAM_LINK_EN/_RU build vars (button hides when unset). - Cache headers: hash-named /assets/* immutable, HTML shells no-cache (the go:embed zero modtime emitted no validators, so the client re-downloaded the whole bundle every launch). - Live-stream 15s abort fix: an immediate heartbeat on open + a 10s default interval (the first tick at 15s raced the edge idle timeout -> reconnect storm). PLAN/ARCHITECTURE(§13)/FUNCTIONAL(+ru)/gateway+ui+deploy READMEs updated; round 6 closed. Tests: gateway webui/connectsrv units, ui landing unit + e2e, full e2e (60) green.
1403 lines
112 KiB
Markdown
1403 lines
112 KiB
Markdown
# 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`](CLAUDE.md).
|
||
The architecture/decision record is [`docs/ARCHITECTURE.md`](docs/ARCHITECTURE.md);
|
||
behaviour is [`docs/FUNCTIONAL.md`](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:00–07: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.
|
||
|
||
```text
|
||
┌────────────────────┐
|
||
│ 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 2–4 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 15–18** 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 serving** — **embedded** 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 save` → `scp`/ssh → `docker load` → `docker 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 3–4, so a "light touch" here would have duplicated or pre-empted them.
|
||
- **Stage 3** (interview + implementation):
|
||
- Scope, as in Stages 1–2: **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:00–07: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 1–3: **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 1–4: **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 2–10 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:00–07: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+move−opp)
|
||
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 `Makefile`s, 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`](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:00–07: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 15–18.)
|
||
- **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
|
||
untouched** — `MoveRecord` (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 15–18**. 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 `curl`s 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 2–3, 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 + #16–20), 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 `draftDTO` ↔ `game.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 (#16–20, this PR).** One Vite build with **two HTML entries** — the game
|
||
SPA (`index.html`) and a new lightweight landing (`landing.html` → `Landing.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.svg` → `ui/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.
|
||
|
||
## 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.
|