Files
scrabble-game/PLAN.md
T
Ilia Denisov cf66ed7e26
Tests · Go / test (push) Successful in 7s
Tests · Integration / integration (push) Successful in 12s
Tests · Go / test (pull_request) Successful in 6s
Tests · Integration / integration (pull_request) Successful in 11s
Tests · UI / test (pull_request) Successful in 19s
Stage 9: Telegram integration (connector side-service, Mini App, out-of-app push)
New platform/telegram connector (own container, bot token only there):
- go-telegram/bot long-poll loop: /start deep-links + Mini App launch button.
- gRPC API pkg/proto/telegram/v1 (Telegram service): ValidateInitData, Notify
  (renders a localized message + deep-link button), SendToUser/SendToGameChannel
  (admin, wired in Stage 10). Generic methods are platform-agnostic (external_id).
- Bot API base override for Telegram's test environment; Dockerfile + compose
  (VPN sidecar, no public ingress); README.

Gateway:
- initData validation relocated from the gateway into the connector; the gateway
  calls ValidateInitData over gRPC (GATEWAY_CONNECTOR_ADDR), drops the bot token,
  and deletes internal/auth.
- Out-of-app push: runPushPump routes events whose recipient has no live in-app
  stream to connector.Notify, gated by /internal/push-target + the in-app-only
  flag (race-free de-dup); HasSubscribers added to the push hub.

Backend:
- Migration 00007 accounts.notifications_in_app_only (default true) + jetgen.
- ProvisionTelegram seeds a new account's language/display name from the launch
  fields; IdentityExternalID reverse lookup; /internal/push-target handler.

UI:
- Telegram Mini App launch: detect initData, apply themeParams, authTelegram,
  route the deep-link start_param (g/i/f); /telegram/ guard redirects outside
  Telegram. Vite relative base + telegram-web-app.js. In-app-only profile toggle;
  share-to-Telegram link for a friend code. Vitest + Playwright coverage.

Wire/docs/CI: fbs Profile/UpdateProfileRequest gain notifications_in_app_only
(Go + TS); go.work uses ./platform/telegram; go-unit.yaml covers it; PLAN,
ARCHITECTURE, FUNCTIONAL (+ru), UI_DESIGN, READMEs updated.
2026-06-04 07:10:21 +02:00

50 KiB
Raw Blame History

Scrabble Game — implementation plan

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

Context

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

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

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

Stage tracker

# Stage Status
0 Scaffolding (go.work, backend skeleton, docs, CI) done
1 Backend foundation (config, server, Postgres+goose, sessions, accounts) done
2 Engine package over scrabble-solver done
3 Game domain (lifecycle, rules, hint, word-check, history+GCG, stats) done
4 Lobby & social (matchmaking, friends, block, chat, profile, nudge) done
5 Robot opponent done
6 Gateway edge (Connect/FB, platform auth, sessions, push bridge, admin) done
7 UI — playable slice + UX polish (Svelte+Vite, board, lobby, chat, hint/word-check, i18n) done
8 UI — social/account/history (friends, blocks, invitations, profile edit, stats, history/GCG) done
9 Telegram integration (bot side-service, deep-link, push) done
10 Admin & dictionary ops (complaint review, version reload) todo
11 Account linking & merge todo
12 Polish (observability, perf with evidence, deploy) todo

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

Stages

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

Stage 0 — Scaffolding (done)

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

Stage 1 — Backend foundation

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

Stage 2 — Engine package

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

Stage 3 — Game domain

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

Stage 4 — Lobby & social

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

Stage 5 — Robot opponent

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

Stage 6 — Gateway edge

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

Stage 7 — UI

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

Suggested layouts (lobby + game screen)

User note:

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

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

Stage 8 — UI: social, account & history surfaces

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

Stage 9 — Telegram integration

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

Stage 10 — Admin & dictionary ops

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

Stage 11 — Account linking & merge

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

Stage 12 — Polish

Scope: observability dashboards, evidence-based performance work, prod build/deploy. Open details: deployment target/host; dashboards; load expectations.

Refinements logged during implementation

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

  • Stage 1 (interview + implementation):

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

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

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

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

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

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

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

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

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

Deferred TODOs (cross-stage)

  • TODO-1 — publish & version the solver. Once scrabble-solver is stable, give it a real module URL and switch backend to a versioned dependency, dropping the go.work replace and the CI clone. Removes the floating master dependency accepted for now (Stage 2 interview).
  • TODO-2 — split the solver into engine vs dictionary generator + versioned dictionary artifacts. Owner's idea, with the caveats agreed at the Stage 2 interview: the split is sound (build-time wordlist→DAWG vs runtime load have different lifecycles and shrink the runtime dependency surface), but the generator must pin the same dafsa/alphabet versions and alphabet definitions as the runtime engine or the on-disk format / letter indexing drifts and silently corrupts validation. For delivery prefer Git LFS or an artifact store (Gitea releases / OCI artifact / object storage) over a raw git submodule (the ~0.50.7 MB DAWGs are regenerated wholesale and bloat git history); pin by tag/hash for a reproducible startup set. A submodule/LFS pull is a deploy-time way to populate the directory, not the runtime dynamic-reload mechanism (Stage 10) — keep the BACKEND_DICT_DIR directory as the runtime contract: a new .dawg appears in it and is loaded with dawg.Load.
  • TODO-3 — garbage-collect abandoned guest accounts. Stage 6 makes a guest a durable accounts row (no identity, is_guest), so an ephemeral guest leaves a row behind. Add a periodic reaper (or a finished-and-idle sweep) that deletes guest accounts with no active games once their last session is gone; the ON DELETE CASCADE foreign keys clean up the dependent rows.
  • TODO-4 — put the per-game alphabet on the wire (owner's idea, Stage 7). Today the client hardcodes each variant's letters/values (ported into ui/src/lib/premiums.ts from scrabble-solver/rules/rules.go) and the edge exchanges plays/hints by concrete letters. Consider extending game.state to carry the variant's (letter, index, value) table so the UI stops duplicating it, and optionally moving tile exchange to letter indices end-to-end. Caveat (as for the dictionaries, TODO-2): the wire table must stay pinned to the same rules.Alphabet the engine uses, or indices drift.
  • 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.