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.
50 KiB
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: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) | 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 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 — 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
replacedeferred 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 ismaster(owner preference);feature/*+ PR from Stage 1; the genesis commit lands onmasterby necessity. -
Stage 1 (interview + implementation):
- Query layer: go-jet over
database/sql(pgx stdlib) + otelsql; acmd/jetgentool 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, notbytea— 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
identitiestable (kind ∈ telegram|email, widen tovk/maxlater); no soft-delete / actor-audit columns yet. - HTTP surface: service/store/cache layer only.
/api/v1/{public,user, internal,admin}groups +X-User-IDmiddleware are scaffolding (exposed viaServergroup 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
integrationbuild tag inbackend/internal/inttest+ newintegration.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 existingbackendmodule underinternal/(+cmd/jetgen);go.workuntouched.
- Query layer: go-jet over
-
Stage 2 (interview + implementation):
- Scope:
internal/engineis a self-contained library (registry, bag,Gamestate machine, decode/replay). Noconfig/main/serverwiring 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-solveringo.work;backend/go.modrequiresscrabble-solver(placeholder version, redirected by the replace) andgithub.com/iliadenisov/dafsadirectly (fordawg.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 setBACKEND_DICT_DIR. - Dictionaries: registry loads the committed DAWGs from a directory
parameter;
dict_versionis 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 inrules.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) becauseselfplay.Bagcannot return tiles; exchange is legal only when the bag holds at least a rack and draws replacements before returning the swapped tiles. A blank isPlacement{Blank:true}carrying its designated letter; the history keeps the concrete letter plus a blank flag (decoded viaAlphabet.Character/Decode).ReplayBoardreusesscrabble.Apply, so nointernal/encodingdependency. - 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.
- Scope:
-
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 frommain. The robot (Stage 5) will consume the same service API. - Persistence = event-sourcing + warm cache (interview): durable state is
the
gamesrow plus an append-only decoded move journal (game_moves); the live position is anengine.Gamekept 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
Resignfix (interview, ininternal/engine): the resigner keeps their accumulated score (no end-game rack adjustment) and never wins;winnerexcludes the resigner, so a two-player resign/timeout gives the win to the other player regardless of score. Timeout reusesResign, 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— sointernal/gamenever importsscrabble-solver(keeps the §5 single-importer invariant). - Create = atomic with seats (interview):
Createseats 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 walletaccounts.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_statswithdrawsadded 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 thestatuscolumn carries no value CHECK yet. - GCG (interview): standard Poslfit dialect (UTF-8,
#player/#lexiconpragmas,8G/H8coordinates, lower-case blanks,.pass-throughs,-TILESexchange) plus#notelines for resign/timeout; derived from the journal, so dictionary-independent. - Engine wiring + config:
mainloads the registry (engine.Open, a hard boot dependency like migrations) and starts the sweeper. New config:BACKEND_DICT_DIR(required),BACKEND_DICT_VERSION(defaultv1),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 exportBACKEND_DICT_DIR.accountsgainedaway_start/away_end/hint_balanceand theaccountpackage gainedSpendHint(it owns its table).
- Scope, as in Stages 1–2: domain service/store layer + engine wiring, no
HTTP (
-
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) andinternal/lobby(matchmaking + invitations); profile editing and the email confirm-code extendinternal/account. The services have no active driver this stage, somainbuilds 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
friendshipstable; decline/cancel delete the pending row; blocking severs any friendship. - Blocks (interview): the existing global toggles plus a per-user
blockstable; 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-byteaprecedent; the gateway forwards it in Stage 6), input content-filtered (links/emails/phone numbers incl. obfuscated forms) viamvdan.cc/xurls/v2plus 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
Mailerseam 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
Resignnow drops a seat and the rest play on while ≥ 2 are active, finishing (last-survivor wins) when one remains;winnerexcludes all resigned seats. A per-gamedropout_tilessetting (removedefault |return) governs the leaver's rack, which is never revealed to the others. Timeout reusesResign, so a multi-player timeout drops one seat and play continues;game.commit/timeoutGamewere already keyed ong.Over(), so they only needed the setting threaded through create/replay. - Build/deps:
go mod tidyis not run — the bare-pathscrabble-solverreplace lives only ingo.work, sotidy/go getcannot resolve it; thexurlsdependency was added withgo mod edit -require+go mod download, its checksums recorded in the committedgo.work.sum. No CI workflow change (both Go workflows already clone the solver sibling and exportBACKEND_DICT_DIR).
- 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
-
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 onlyinternal/enginestill imports the solver. New:engine.Candidates()(decoded ranked plays) and a thingame.Service.Candidates+RobotTurnsread. - Account model (interview): a pool of durable accounts, each a single
identitiesrowkind='robot'(migration00004widens the kind CHECK — a CHECK-only change, no jetgen). A curated ~16-name pool in code;EnsurePoolprovisions them idempotently at boot (a hard dependency, like the registry) withblock_chat/block_friend_requestsset, 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 gameseed(FNV-1a mix, restart-stable — nothash/maphash), so the robot keeps no extra state.playToWin = mix(seed,"win")%100 < 40; per-turndelay; sleepdrift. - Timing (interview): per-move delay
2 + 88·u^kminutes,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 (reusingsocial.Nudge's once-per-hour guard;social.LastNudgeAtadded to detect the human's nudge). - Sleep (interview — resolves the §7-vs-
account.gomismatch): 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. Theaccount.goaway-window comment was corrected accordingly. - Margin (interview): pick the candidate whose resulting margin (own+move−opp) is closest to [1,30] when playing to win / [−30,−1] when playing to lose, tie-broken toward the conservative edge; no legal play → exchange the full rack when the bag can refill it, else pass.
- Substitution (interview): a matchmaker reaper (
Reap/RunReaper) substitutes a pooled robot after a 10 s wait (BACKEND_LOBBY_ROBOT_WAIT),NewMatchmakernow takes aRobotProvider. A waiter learns of a match — human pairing or substitution — through a newPoll+ 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_statsis the authoritative, complete balance ground-truth (target ~40% robot wins); an OTel counter (robot_games_finished_total, exporternonetoday) 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 exportBACKEND_DICT_DIR).
- Scope, as in Stages 1–4: domain layer, no HTTP — the robot consumes the
public game API as an ordinary seated player (
-
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/pkgmodule (interview): the backend push proto (pkg/proto/push/v1) and the FlatBuffers edge payloads (pkg/fbs, onescrabblefbnamespace) live here with committed generated Go, imported by both backend and gateway. The Connect envelope proto lives ingateway/proto/edge/v1. Codegen is dev-time (buf generatewith local plugins +flatc, driven by per-moduleMakefiles, mirroringcmd/jetgen); CI only builds the committed output.pkgandgatewayare bare-path modules likescrabble-solver, sogo.workcarriesuse ./pkg,use ./gatewayand areplace scrabble/pkg v0.0.0 => ./pkg(the no-dot path is not VCS-fetchable); deps were added withgo mod edit+go work sync(the established no-tidy pattern).flatcis pinned to 23.5.26 to match theflatbuffersGo runtime. - Guest = durable account +
is_guest(interview): migration00005addsaccounts.is_guest; a guest is a durable row with no identity (so thesessions/game_playersforeign 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 newinternal/notifyhub (aPublisherinterface defaulting toNop, installed ongame/social/lobbyviaSetNotifierduring boot — additive, existing tests unchanged) is drained by a new backend gRPC server (internal/pushgrpc,BACKEND_GRPC_ADDRdefault:9090) servingPush.Subscribe. Emission lives ingame.commit(so robot-driver and timeout-sweep moves emityour_turn/opponent_movedtoo — 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 importspkg/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
GatewayConnect service isExecute(message_type, payload, request_id)+Subscribe; the session token rides inAuthorization: Bearer; auth ops are unauthenticated and return the token in the FlatBuffersSession. Domain outcomes ride back in theExecuteResponse.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/_PASSWORDand reverse-proxies to backend/api/v1/admin/*; the backend admin surface is a singlepinguntil 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.EmailServicegainedRequestLoginCode/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/**(andpkg/**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_DIRsteps are unchanged.
- 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 (
-
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 isgame.ListForAccount(the "my games" query) and seatdisplay_nameresolution (server DTO layer);SeatViewgained a trailingdisplay_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+ theflatbuffersruntime, with the edge TS bindings generated from the sameedge.proto(protoc-gen-es) andscrabble.fbs(flatc --ts) and committed underui/src/gen/(dev-time codegen, likecmd/jetgen/pkg/Makefile; CI builds the committed output). - No board on the wire (discovered):
StateViewcarries 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 fromscrabble-solver/rules/rules.gowith 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.yamlworkflow (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; agame.ListForAccountintegration 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
cqwlabels, 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 newno_hint_availableresult 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 newdocs/UI_DESIGN.md.
- 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
-
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, agame.SharedGameseam (self-join ongame_players), the friend-code mechanism, and the friendshipsdeclined-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 (
SendFriendRequestnow 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 setsstatus='declined'). Migration 00006 widensfriendships_status_chkand addsfriend_codes(jetgen regen). No public ID or name search — discovery is codes + befriend-an-opponent. - Badges = poll + push (interview): a new generic
notifypush 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 thenotifyevent. 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.ExportGCGrefuses 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;Profilegained trailing away fields) inpkg/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.ResolveZoneparses±HH:MMor 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 (dvhplus avisualViewportlistener 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.
- 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
-
Stage 9 (interview + implementation):
- Connector as its own container (interview): the Telegram side-service is a
standalone module
platform/telegram(binarycmd/telegram) holding the bot token only there; gateway and backend reach it by unauthenticated gRPC on the trusted internal network, and it egresses toapi.telegram.orgthrough a VPN sidecar (deploy/docker-compose.yml, mirroring../15-puzzle). Bot librarygithub.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 returninglanguage_code); the gateway callsconnector.ValidateInitDataover gRPC duringauth.telegram. The hop is negligible (loopback gRPC, once per login).GATEWAY_TELEGRAM_BOT_TOKENis gone;GATEWAY_CONNECTOR_ADDRreplaces it. Thegateway/internal/authpackage was deleted. - Connector gRPC API (
pkg/proto/telegram/v1, serviceTelegram): the generic methods are platform-agnostic, keyed by the identityexternal_id(so a future VK/MAX connector reuses them); onlyValidateInitDatais Telegram-specific. Methods:ValidateInitData,Notify(the out-of-app push — renders a localized message + a Mini App deep-link button from the FlatBuffers payload),SendToUserandSendToGameChannel(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 callsconnector.Notifyonly when they have a Telegram identity and have not set the new flag. Push set:your_turn,nudge,match_found, and thenotifysub-kindsinvitation/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): migration00007(+ jetgen), threaded throughaccount.Profile/UpdateProfile, the REST DTOs, the fbsProfile/UpdateProfileRequest(defaulttruein 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.ProvisionTelegramseeds a brand-new account'spreferred_languagefrom the Telegramlanguage_codeand its display name fromfirst_name/username(existing accounts untouched); the UI'sadoptSessionalready adopts the server language when the user has not locked a locale, so no extra UI seeding was needed. The gateway forwards the fields fromValidateInitData. - 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 byTelegram.WebApp.initData, appliesthemeParams, authenticates via the existingauth.telegramop (UIauthTelegramcodec/client/transport/mock added), and routes the deep-linkstart_param(g/i/f→ game / lobby-invitation / friend-code redeem). On the/telegram/path without initData it redirects to the site root. The officialtelegram-web-app.jsloads fromindex.html(harmless outside Telegram). - Deep-link scheme (shared Go
platform/telegram/internal/deeplink↔ TSui/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 whenVITE_TELEGRAM_LINKis configured (partially discharges TODO-5; QR still open). TheNotifybutton and the bot/startreply 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=truesuffixes the token with/testso the client hits/bot<token>/test/METHOD(TELEGRAM_API_BASE_URLoverrides 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 ofgo.work, validated withdocker 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); fbsProfile/UpdateProfileRequestgainednotifications_in_app_only(committed Go- TS).
go.workgainsuse ./platform/telegram; deps viago mod edit+go work sync(no-tidy).go-unit.yamlgained theplatform/**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 injectedwindow.Telegram.
- TS).
- 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.
- Connector as its own container (interview): the Telegram side-service is a
standalone module
Deferred TODOs (cross-stage)
- TODO-1 — publish & version the solver. Once
scrabble-solveris stable, give it a real module URL and switchbackendto a versioned dependency, dropping thego.workreplace and the CI clone. Removes the floatingmasterdependency 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/alphabetversions 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.5–0.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 theBACKEND_DICT_DIRdirectory as the runtime contract: a new.dawgappears in it and is loaded withdawg.Load. - TODO-3 — garbage-collect abandoned guest accounts. Stage 6 makes a guest a
durable
accountsrow (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; theON DELETE CASCADEforeign 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.tsfromscrabble-solver/rules/rules.go) and the edge exchanges plays/hints by concrete letters. Consider extendinggame.stateto 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 samerules.Alphabetthe 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 whenVITE_TELEGRAM_LINKis 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_statsor a games query), falling back to their interface language (en → English, ru → Russian/Эрудит). Until then the explicit pick avoids guessing wrong.