diff --git a/CLAUDE.md b/CLAUDE.md index d0f7144..11f5b73 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -1,96 +1,97 @@ # scrabble-game — project guide -Multiplatform Scrabble game. Read this first every session. The owner drives the -project **one stage per session** (tariff constraint), so the repository — not -conversation memory — is the source of continuity. Keep it that way. +Multiplatform Scrabble game, **in production** at `https://erudit-game.ru`. Read this +first every session. The repository — not conversation memory — is the source of +continuity; keep it that way. ## Sources of truth (read before changing behaviour) -- [`PLAN.md`](PLAN.md) — staged plan + **stage tracker** + per-stage *open - details to interview*. -- [`PRERELEASE.md`](PRERELEASE.md) — pre-release hardening tracker (phases R1–R7 - before Stage 18); same per-phase *interview + bake-back* discipline as `PLAN.md`. -- [`docs/ARCHITECTURE.md`](docs/ARCHITECTURE.md) — architecture, transport, - security, the decision record. Always describes current state. -- [`docs/FUNCTIONAL.md`](docs/FUNCTIONAL.md) (+ [`_ru`](docs/FUNCTIONAL_ru.md) - mirror) — per-domain user stories. English authoritative. -- [`docs/TESTING.md`](docs/TESTING.md) — test layers + the per-stage CI gate. +- [`docs/ARCHITECTURE.md`](docs/ARCHITECTURE.md) — architecture, transport, security, + the decision record. Always describes the current state. +- [`docs/FUNCTIONAL.md`](docs/FUNCTIONAL.md) (+ [`_ru`](docs/FUNCTIONAL_ru.md) mirror) + — per-domain user stories. English authoritative. +- [`docs/TESTING.md`](docs/TESTING.md) — test layers + the CI gate. - [`docs/UI_DESIGN.md`](docs/UI_DESIGN.md) — the `ui` visual/interaction design system. +- [`deploy/README.md`](deploy/README.md) — the deploy contour + the production + rollout / rollback runbook. -## Mandatory per-stage workflow +## How we work -**Start of a stage** -1. Read `PLAN.md` (the stage's scope + *open details*) and the relevant `docs/`. -2. Analyse what the stage actually requires against the current code. -3. **Interview the owner** on every open detail and any fork not already fixed - in the plan — do not silently pick borderline decisions. Offer options with - brief pros/cons. -4. Only then implement, strictly within the stage's scope. - -**End of a stage** -1. Bake every new agreement back into `PLAN.md`, `docs/ARCHITECTURE.md`, - `docs/FUNCTIONAL.md` (+ `_ru`), the affected service `README`, and Go Doc - comments — in the **same** PR. Correct earlier stages' docs/code if a new - decision changes them. -2. Update the stage tracker; add a line under *Refinements logged during - implementation* for any plan deviation. -3. Get CI green, then mark the stage done. - -(The `stage-implementation` skill encodes this same loop and can be invoked.) +- Inspect the relevant code path and the docs above before changing behaviour. +- **Interview the owner on every fork** — do not silently pick borderline decisions; + offer options with brief pros/cons. +- Smallest correct diff. Prefer compact code; reuse before adding; do not add deps, + seams or knobs until they are needed. +- **Update or add tests for every functional change**, at the layers + `docs/TESTING.md` calls out. +- **Bake docs in the same PR**: update `docs/ARCHITECTURE.md`, `docs/FUNCTIONAL.md` + (+`_ru`), the affected service `README` and Go Doc comments alongside the change. +- Document added packages, types, funcs, consts and vars with Go Doc comments. ## Conventions - All code, comments, identifiers, commits, docs, filenames in **English**. -- Chat with the owner follows the user-level `~/.claude/CLAUDE.md` (Russian, - the agreed persona and translation rules). -- Mirror every point edit of `docs/FUNCTIONAL.md` into `docs/FUNCTIONAL_ru.md` - in the same patch (translate only the touched paragraphs). -- Prefer compact code; do not add deps, seams or knobs until a stage needs them. - Reuse before adding. Document added packages/types/funcs with Go Doc comments. -- Update or add tests for every functional change. +- Chat with the owner follows the user-level `~/.claude/CLAUDE.md` (Russian, the + agreed persona and translation rules). +- Mirror every point edit of `docs/FUNCTIONAL.md` into `docs/FUNCTIONAL_ru.md` in the + same patch (translate only the touched paragraphs). -## Branching & CI +## Branching, CI & production -- **Two long-lived branches** (Stage 16 onward): **`development`** is the - integration branch; **`master`** is the production trunk. Cut `feature/*` - branches **from `development`** and PR them back into it. (Stages 0–15 used - `master` as the trunk with `feature/* → master`; the genesis Stage 0 commit is - on `master` by necessity.) -- A commit to a `feature/*` branch triggers **nothing**. The single workflow - `.gitea/workflows/ci.yaml` runs the full suite (`unit` + `integration` + `ui`) - on a PR into `development` or `master`, and the gated **`deploy`** job auto-rolls - the **test contour** on a PR into — or a push to — `development` - (`docker compose up -d --build` on the runner host + a `GET /` probe). A PR into - `master` is test-only. -- Merge `development → master` only when CI is green; the **prod** deploy is then a - **manual** workflow (Stage 18), never automatic. Secrets/variables are prefixed - `TEST_` / `PROD_` per contour (Gitea 1.26 has no deployment environments). -- After any push, watch the run to green before declaring a stage done — use the - ready-made watcher, never an inline poll loop: - `python3 ~/.claude/bin/gitea-ci-watch.py` (background). It reads `$GITEA_URL` - / `$GITEA_TOKEN`; `gitea.iliadenisov.ru` is allow-listed in - `.claude/settings.json`. Remote: `origin git@gitea.iliadenisov.ru:developer/scrabble-game.git`. +- **Two long-lived branches**: **`development`** is the integration branch; **`master`** + is the production trunk. Cut `feature/*` from `development` and PR back into it; + promote `development → master` via PR when ready to release. Both branches require + one approval + the `CI / gate` check. +- A commit to a `feature/*` branch triggers nothing. The single workflow + `.gitea/workflows/ci.yaml` runs the full suite (`unit` + `integration` + `ui`) on a + PR into `development` or `master`, and the gated **`deploy`** job auto-rolls the + **test contour** on a PR into — or a push to — `development` + (`docker compose up -d --build` on the runner host + landing/SPA/backend probes). A + PR into `master` is test-only. +- **Production is live on two hosts** (main + the Telegram bot host) and deploys + **only manually** (`workflow_dispatch`), never automatically: + - **`.gitea/workflows/prod-deploy.yaml`** (`confirm=deploy`, from `master`) builds + + pushes the images to the registry, then SSH-deploys both hosts — rolling per + service in dependency order, health-gated, **auto-rollback to the previous tag**; + a schema migration adds a maintenance window + a consistent `pg_dump`. Four visible + jobs: build → deploy-main → deploy-bot → verify. + - **`.gitea/workflows/prod-rollback.yaml`** (`confirm=rollback`) re-deploys a prior + release (blank `target_version` = the previous deployed version) — image-only, + rolling, health-gated. + - **Releases are git tags `vX.Y.Z` on `master`**; the deploy stamps `git describe + --tags` into the image tag, every binary (`pkg/version` via `-ldflags` → the + `service.version` telemetry attribute) and the SPA About screen. Tag the release + before deploying. + - Hosts are provisioned idempotently by **`deploy/ansible/`**. Per-contour + secrets/variables use the `TEST_` / `PROD_` prefix (Gitea 1.26 has no deployment + environments). Migrations must be **expand-contract** (backward-compatible) so + image rollback stays DB-safe. Full runbook + variable list in `deploy/README.md`. +- After any push, merge or deploy, **watch the run to green** before declaring done — + use the ready-made watcher (run it in the background), never an inline poll loop: + `python3 ~/.claude/bin/gitea-ci-watch.py`. It reads `$GITEA_URL` / `$GITEA_TOKEN`; + `gitea.iliadenisov.ru` is allow-listed in `.claude/settings.json`. Remote: + `origin git@gitea.iliadenisov.ru:developer/scrabble-game.git`. ## Stack -Go 1.26.3, `go.work` monorepo, module paths `scrabble/`. Dependencies are -added **when first used** (incremental): backend uses `gin` + `zap` + -`pgx`/`go-jet`/`goose`/OTel (added in Stage 1). Client↔gateway is Connect-RPC + -FlatBuffers (h2c); gateway↔backend is REST/JSON + `X-User-ID` plus a gRPC -server-stream for live events. UI is pure HTML5/CSS on plain Svelte + Vite, -packaged to native with Capacitor. Likely no Redis. +Go 1.26.3, `go.work` monorepo, module paths `scrabble/`. Backend uses `gin` + +`zap` + `pgx`/`go-jet`/`goose`/OTel. Client↔gateway is Connect-RPC + FlatBuffers +(h2c); gateway↔backend is REST/JSON + `X-User-ID` plus a gRPC server-stream for live +events. UI is pure HTML5/CSS on plain Svelte + Vite, packaged to native with +Capacitor. No Redis. ## Reused engine: `../scrabble-solver` (module `scrabble-solver`, Go 1.26.3) -Embedded **in-process as a library** — there is no per-game container. Public -API to reuse (do not reimplement): +Embedded **in-process as a library** (`replace scrabble-solver => ../scrabble-solver` +in `go.work`; CI checks out the sibling from +`https://gitea.iliadenisov.ru/.../scrabble-solver.git`). There is no per-game +container. Public API to reuse (do not reimplement): -- `scrabble.NewSolver(rs, finder)` → `GenerateMoves(b, r, mode)` (ranked, - highest score first), `ValidatePlay(b, dir, tiles)`, `ScorePlay(...)`; - `scrabble.Apply(b, m)`; types `Move/Word/Placement/Direction/Mode` +- `scrabble.NewSolver(rs, finder)` → `GenerateMoves(b, r, mode)` (ranked, highest + score first), `ValidatePlay(b, dir, tiles)`, `ScorePlay(...)`; `scrabble.Apply(b, m)`; + types `Move/Word/Placement/Direction/Mode` (`scrabble-solver/scrabble/{solver,move,apply}.go`). -- `rules.English() / RussianScrabble() / Erudit()` - (`scrabble-solver/rules/rules.go`). +- `rules.English() / RussianScrabble() / Erudit()` (`scrabble-solver/rules/rules.go`). - `board.New / Parse / Clone / Transpose`; `rack.New / Add / Remove / Clone`; `selfplay.NewBag / Draw / Len` (bag pattern). - Load committed dictionaries with `dawg.Load(path)` from @@ -99,20 +100,17 @@ API to reuse (do not reimplement): Constraints: - Words/tiles are **alphabet-index bytes**, meaningful only with the matching - `rules.Ruleset` (`Alphabet.Decode`); blank flag carried separately. **Decode + `rules.Ruleset` (`Alphabet.Decode`); the blank flag is carried separately. **Decode to real characters before persisting history** (history must be dictionary-independent — see `docs/ARCHITECTURE.md` §9.1). - The solver's `internal/*` is NOT importable from this sibling module. - **GCG is test-only** in the solver (no public writer) — we ship our own. -- Wiring: add `replace scrabble-solver => ../scrabble-solver` to `go.work` in - **Stage 2** (when `internal/engine` first imports it), and make CI check out - the solver sibling (`https://gitea.iliadenisov.ru/.../scrabble-solver.git`). - It uses published `github.com/iliadenisov/{alphabet,dafsa}` (no local replace). +- The solver uses published `github.com/iliadenisov/{alphabet,dafsa}` (no local replace). ## Repository layout ``` -go.work # use the existing modules; grows per stage +go.work # the go.work monorepo backend/ # module scrabble/backend cmd/backend/ # main: telemetry -> db+migrate -> cache -> server cmd/jetgen/ # dev tool: regenerate go-jet code (throwaway container) @@ -123,12 +121,14 @@ backend/ # module scrabble/backend internal/session/ # opaque tokens, sessions store, cache, service internal/server/ # gin engine, /api/v1 groups, X-User-ID, probes internal/inttest/ # //go:build integration Postgres-backed tests -docs/ .gitea/workflows/ PLAN.md CLAUDE.md README.md -gateway/ ui/ pkg/ # added by their stages -platform/telegram/ # Telegram side-service, two binaries (Stage 9; split in phase TX): cmd/validator (HMAC, no VPN) + cmd/bot (Bot API; dials gateway over reverse mTLS bot-link) -loadtest/ # module scrabble/loadtest: the pre-release stress harness (R2) -backend/Dockerfile gateway/Dockerfile platform/telegram/Dockerfile loadtest/Dockerfile # multi-stage distroless (Stage 16; loadtest R2); gateway/Dockerfile has the `landing` target (R3), platform/telegram/Dockerfile has `validator`+`bot` targets (TX) -deploy/ # docker-compose (per-service limits, R7) + caddy + landing + otelcol (OTLP + docker_stats per-container metrics) + prometheus/tempo/grafana + postgres_exporter +gateway/ # module scrabble/gateway: Connect-RPC edge, embeds the SPA +ui/ # Svelte + Vite SPA + landing (Node project, not in go.work) +pkg/ # shared: telemetry, version, wire/FlatBuffers, proto, mtls +platform/telegram/ # Telegram side-service: cmd/validator (HMAC, no VPN) + cmd/bot (Bot API; dials gateway over reverse mTLS bot-link) +loadtest/ # module scrabble/loadtest: the load/stress harness +docs/ .gitea/workflows/ CLAUDE.md README.md +backend/Dockerfile gateway/Dockerfile platform/telegram/Dockerfile loadtest/Dockerfile # multi-stage distroless; gateway/Dockerfile has the `landing` target, platform/telegram/Dockerfile has `validator`+`bot` targets +deploy/ # docker-compose (+ prod overlay + bot host) + ansible provisioning + caddy + landing + otelcol (OTLP + docker_stats) + prometheus/tempo/grafana + node_exporter + postgres_exporter; prod-deploy.sh ``` ## Build & test @@ -138,20 +138,19 @@ go build ./backend/... # per module ('./...' from the root won't span t go vet ./backend/... gofmt -l . # must print nothing go test -count=1 ./backend/... -go build ./platform/telegram/... && go test ./platform/telegram/... # Telegram validator + bot (Stage 9; split in TX) +go build ./platform/telegram/... && go test ./platform/telegram/... # Telegram validator + bot go run ./backend/cmd/backend # /healthz, /readyz on :8080 -cd ui && pnpm install && pnpm check && pnpm test:unit && pnpm build # the UI (Stage 7+) +cd ui && pnpm install && pnpm check && pnpm test:unit && pnpm build # the UI pnpm start # UI mock mode: lobby -> game, no backend -docker build -f backend/Dockerfile -t scrabble-backend . # images (Stage 16); gateway embeds the SPA +docker build -f backend/Dockerfile -t scrabble-backend . # images; gateway embeds the SPA docker build -f gateway/Dockerfile --target gateway -t scrabble-gateway . -docker build -f gateway/Dockerfile --target landing -t scrabble-landing . # static landing (R3) +docker build -f gateway/Dockerfile --target landing -t scrabble-landing . # static landing docker compose -f deploy/docker-compose.yml config # validate the full contour ``` -The `ui` module is a Node project (pnpm), **not** in `go.work`; it is the `ui` job -of the single `.gitea/workflows/ci.yaml` (Stage 16 folded the former go-unit / -integration / ui-test workflows into it). Committed edge codegen under `ui/src/gen/` +The `ui` module is a Node project (pnpm), **not** in `go.work`; it is the `ui` job of +the single `.gitea/workflows/ci.yaml`. Committed edge codegen under `ui/src/gen/` (regenerate with `pnpm codegen`); pnpm build-script approval lives in `ui/pnpm-workspace.yaml` (`allowBuilds: esbuild: true`). diff --git a/PLAN.md b/PLAN.md deleted file mode 100644 index 81955d1..0000000 --- a/PLAN.md +++ /dev/null @@ -1,1613 +0,0 @@ -# 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/`, 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 (registry, two-host, rolling + auto-rollback; manual after merge) | machinery built; first cutover pending DNS | -| 19 | User feedback (in-app submit + attachment, admin review/reply, account roles) | **done** | - -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//`, `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`. - -> **Superseded (2026-06-20, pre-release phase SB):** the two bots collapsed into **one** and the -> language-gated variant choice moved to a per-user **`variant_preferences`** profile set (default Erudit -> only). `accounts.service_language`, `supported_languages`, the `*_EN`/`*_RU` env vars, -> `GATEWAY_DEFAULT_SUPPORTED_LANGUAGES` and game-language push routing are gone; the single bot renders in -> the recipient's `preferred_language`. Current state: `docs/ARCHITECTURE.md` §3/§10, `PRERELEASE.md` row SB. - -### 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 **two remote hosts** over SSH — main (full stack, `erudit-game.ru`) -and tg (the bot only). Resolved open details (re-interviewed): -- **Transport: a registry** (not export/import) — build + push to `docker.iliadenisov.ru`, the hosts pull by tag. -- **Cert: ACME** at the contour caddy (`CADDY_SITE_ADDRESS=erudit-game.ru www.erudit-game.ru`, no host caddy). -- **No prod VPN** — the bot host has native Bot API egress (verified `api.telegram.org` → 200). -- **Rollback** — rolling per-service deploy (least → most dependent), health-gated, auto-rollback to the - previous image tag; a maintenance window + consistent `pg_dump` only on a schema migration - (expand-contract keeps the auto-rollback image-only; the dump is a manual safety net). - -**Strictly manual** (`workflow_dispatch` from `master`, `confirm=deploy`) after `development → master` -is merged green. `TEST_`/`PROD_` prefixed Gitea secrets/variables (Gitea 1.26 has no deployment -environments — the `environments` API 404s). Hosts are provisioned by **`deploy/ansible/`** (docker, a -non-sudo `deploy` user with the CI key, key-only sshd, ufw, fail2ban). The main host is **launch-sized** -(2 vCPU / 1.9 GiB): `docker-compose.prod.yml` trims the R7 limits (`GOMAXPROCS=2`, smaller caps, 7d -Prometheus retention) and adds `node_exporter` for host-memory monitoring (launch undersized, resize at -Selectel reactively). `vpn`+`bot` are gated to a `telegram-local` compose profile (test only); the prod -bot runs standalone from `docker-compose.bot.yml`. `GATEWAY_ABUSE_BAN_ENABLED=true`. - -**Built:** `deploy/ansible/` (both hosts provisioned + verified), the compose split + `node_exporter`, -`.gitea/workflows/prod-deploy.yaml` + `deploy/prod-deploy.sh`, the full `PROD_` secret/variable set. -**Remaining (acceptance):** the **first live cutover** — waits on the `erudit-game.ru` DNS delegation -(`A`/`www` → the main host) that ACME requires; then run the workflow and verify the public site end-to-end. - -### Stage 19 — User feedback *(done)* -A user→operator feedback channel, sequenced after the numbered stages but shipped **before** the Stage 18 -prod deploy. Scope: a **Feedback** screen under Settings → Info where a registered player sends a message -(≤1024 runes) with an optional single attachment; the operator reviews and replies in the server-rendered -**admin console**; the reply is delivered back in-app with a Settings/Info badge. Introduces the project's -**first per-account role** (`account_roles`), the reusable replacement for per-feature flags: the -`feedback_banned` role blocks **only** feedback submission (not the whole account, unlike a suspension), -granted from the feedback section's delete-with-block and granted/revoked from `/users`. - -- **Data:** migration `00004_feedback` — `feedback_messages` (body, attachment `bytea`, sender_ip, channel, - read/archived/reply/reply-read timestamps) + `account_roles` (account_id, role). New backend domain - `internal/feedback` (store + service), modelled on `internal/social` + the admin chat-moderation surface. -- **Anti-spam gate:** a player with an unreviewed message cannot send another ("Ожидаем рассмотрения вашего - последнего обращения"); enforced server-side. This *is* the rate limit — no separate one. -- **Reply lifecycle:** the reply lives on the message row; shown on the feedback screen ("Ответ на ваше - последнее сообщение") for the player's latest replied message; it becomes read the moment the screen - fetches it (delivery = read) and is hidden one week after. The badge (lobby ⚙️, combined with friend - requests; Settings → Info "1") rides the existing `NotificationEvent` with a new `admin_reply` sub-kind — - no wire-schema change for the push. -- **Wire:** new FlatBuffers `feedback.submit` / `feedback.get` / `feedback.unread`; `feedback.submit` is - **guest-gated at the gateway** via a new `Op.NonGuest` flag (the session resolve now carries `is_guest`), - so a guest's submission never reaches the backend; the backend rejects guests too. -- **Security:** body + attachment limits enforced server-side (the UI limits are UX only); attachments - served with `X-Content-Type-Options: nosniff`, images inline-previewed via `` (which never - executes), everything else download-only; the UI gates the attachment by extension (allowed types not - listed) and the backend mirrors the allow-list + size as the trust boundary; the console renders user - content as auto-escaped text (`html/template`). -Open details settled at planning: attachment ≤1,000,000 B (keeps the 1 MiB edge cap); allowed types -(images + pdf/txt/log/doc/docx/rtf/zip/gz/7z); channel from the client (telegram/ios/android/web); guests -cannot submit; three-way admin filter. - -## 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. *(Later refined — separate PR: on ≈20% of - opening/midgame moves the robot flips its intent for that one move, the chance - tapering to 0 over the last 14 bag tiles and 0 at an empty bag, so the endgame - stays strict; deterministic from the seed.)* - - **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, 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-.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 ` {/if} +

{opponent === 'ai' ? t('new.aiInactiveLimit') : t('new.moveLimit', { n: AUTO_MATCH_HOURS })}

{#if opponent === 'random'}

{t('new.searchHint')}

{/if}