Files
scrabble-game/PLAN.md
T

642 lines
43 KiB
Markdown
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
# Scrabble Game — implementation plan
Living plan and **stage tracker**. Each stage is implemented in its own session;
the rules for starting and finishing a stage are in [`CLAUDE.md`](CLAUDE.md).
The architecture/decision record is [`docs/ARCHITECTURE.md`](docs/ARCHITECTURE.md);
behaviour is [`docs/FUNCTIONAL.md`](docs/FUNCTIONAL.md). When a stage produces a
decision, bake it back here **and** into the affected docs/code in the same PR.
## Context
Greenfield multiplatform Scrabble. Players arrive from a platform (Telegram
first; later VK/MAX/iOS/Android) or standalone web (email / guest). Three
executables — `gateway`, `backend`, `ui` — plus per-platform side-services.
Deliberately simpler than the sibling `../galaxy-game` (idea donor, not a
template). The `../scrabble-solver` engine is embedded in-process as a library.
## Locked decisions (recap — full record in docs/ARCHITECTURE.md)
Stack: `go.work` monorepo, modules `scrabble/<name>`, Go 1.26.x, backend
gin+pgx+Postgres(schema `backend`)+goose+zap+OTel (deps added when first used).
Wire: Connect-RPC + FlatBuffers (client↔gateway), REST/JSON + `X-User-ID`
(gateway↔backend), gRPC server-stream for live events. Auth: platform-native,
thin opaque session token, no Ed25519/signing, likely no Redis. UI: pure
HTML5/CSS, plain Svelte + Vite, Capacitor for native. MVP surfaces: Telegram +
web (email + ephemeral guest) + link/merge. Variants: ru/en/Эрудит.
Legality: validate-at-submit. End: empty bag+rack / 6 scoreless / 24h timeout.
Hint: top-1. Word-check: unlimited + complaint. Robot: P(win)≈0.40, margin
targeting, [2,90]min skewed timing, sleep 00: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) | todo |
| 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.
```text
┌────────────────────┐
│ Display_Name =│- Profile
├────────────────────┤- Settings
│ Invitations │- About
│ - list │
├────────────────────┤
│ Active games │
│ - list │
├────────────────────┤
│ Finished games │
│ - list │
│ │
│ │
│ │
│ │
│ │
│ │
│ │
├────────────────────┤
│ ┌───┐ ┌───┐ ┌───┐│
│ New │ Stats Tourn│
│ └───┘ └───┘ └───┘│
└────────────────────┘
┌────────────────────┐
Lobby│◄ ==│- History
├────────────────────┤- Chat
│You Ann Kaya Rick│- Check word
│136 700 179 39│- Drop game
├────────────────────┤
│ │
│ │
│ │
│ c │
│ words │
│ o │
│ s │
│ s │
│ │
│ │
├──┬──┬──┬──┬──┬──┬──┤ ┌──┐
│A │Q │Z │* │N │I │W │◄│ │MakeMove/Reset
├──┴──┴──┴──┴──┴──┴──┤ └──┘
│ ┌───┐ ┌───┐ ┌───┐ │
│ Draw│ Skip│ Shfl│ │
│ └───┘ └───┘ └───┘ │
└────────────────────┘
```
### Stage 8 — UI: social, account & history surfaces
Scope: the UI surfaces deferred from Stage 7's playable slice, wiring the matching
backend/gateway operations as each screen needs them (the Stage 6 vertical-slice
pattern): friends (request/accept/decline/list), per-user blocks, friend-game
invitations (create 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 `Makefile`s, mirroring `cmd/jetgen`);
CI only builds the committed output. `pkg` and `gateway` are bare-path modules
like `scrabble-solver`, so `go.work` carries `use ./pkg`, `use ./gateway` and a
`replace scrabble/pkg v0.0.0 => ./pkg` (the no-dot path is not VCS-fetchable);
deps were added with `go mod edit` + `go work sync` (the established no-tidy
pattern). `flatc` is pinned to **23.5.26** to match the `flatbuffers` Go runtime.
- **Guest = durable account + `is_guest`** (interview): migration `00005` adds
`accounts.is_guest`; a guest is a durable row with **no identity** (so the
`sessions`/`game_players` foreign keys hold) that is **excluded from statistics**
(the finish-time recompute skips guest seats) and from friends/history. The
earlier "guests never reach this table" comments and §3/§9 were softened to
"no profile/friends/stats persisted". Guest-row GC is a logged TODO (TODO-3).
- **Push = in-process `Publisher` + backend gRPC listener** (interview): a new
`internal/notify` hub (a `Publisher` interface defaulting to `Nop`, installed on
`game`/`social`/`lobby` via `SetNotifier` during boot — additive, existing tests
unchanged) is drained by a new backend gRPC server (`internal/pushgrpc`,
`BACKEND_GRPC_ADDR` default `:9090`) serving `Push.Subscribe`. Emission lives in
`game.commit` (so robot-driver and timeout-sweep moves emit `your_turn`/
`opponent_moved` too — the background sources a handler-only design would miss),
`social` (`chat_message`/`nudge`) and the matchmaker (`match_found`). Event
payloads are FlatBuffers-encoded **in the backend** (it imports `pkg/fbs`); the
gateway forwards them verbatim. Revoke/session-invalidation and cursor-resume are
**deferred** (single-instance MVP).
- **Edge envelope = minimal, token in header** (interview): the `Gateway` Connect
service is `Execute(message_type, payload, request_id)` + `Subscribe`; the
session token rides in `Authorization: Bearer`; auth ops are unauthenticated and
return the token in the FlatBuffers `Session`. Domain outcomes ride back in the
`ExecuteResponse.result_code` (HTTP 200); only edge failures (rate limit, missing
session, unknown type, internal) are Connect error codes. No Ed25519/signing
(the galaxy donor's crypto stack was dropped, per §3).
- **Admin = gateway validates Basic-Auth** (interview): the gateway checks
`GATEWAY_ADMIN_USER`/`_PASSWORD` and reverse-proxies to backend
`/api/v1/admin/*`; the backend admin surface is a single `ping` until Stage 10.
- **Rate-limit = 2 dimensions, 3 classes** (interview): public per-IP (30/min,
burst 10), authenticated per-user (120/min, burst 40), admin per-IP (60/min,
burst 20), plus an email-code per-IP sub-limit (5/10 min); token bucket
(`golang.org/x/time/rate`) with a lazy stale-bucket sweep.
- **Email-as-login** (discharges the Stage 4 deferral): `account.EmailService`
gained `RequestLoginCode`/`LoginWithCode`, reusing the confirm-code mechanism but
provisioning-or-finding the account by email identity (it does **not** refuse an
already-confirmed address — that is the returning user).
- **CI**: both Go workflows gained `gateway/**` (and `pkg/**` where backend depends
on it) path filters and now build/vet/test `./backend/... ./pkg/... ./gateway/...`
(unit) — integration stays `./backend/...` (the only module with tagged tests).
The solver clone + `BACKEND_DICT_DIR` steps are unchanged.
- **Stage 7** (interview + implementation):
- **Scope = playable slice** (interview): the *whole* UI shell plus the core play
loop end-to-end; the social/account/history surfaces were split out into a new
**Stage 8** and the later stages shifted +1 (Telegram→9, Admin→10, Linking→11,
Polish→12). Stage 7 wires only the operations the slice needs (the Stage 6
"as the UI needs them" pattern): the new gateway/transcode + backend-REST ops
`games.list`, `game.{pass,exchange,resign,hint,evaluate,check_word,complaint,
history}`, `chat.{list,nudge}`. The only new domain code is `game.ListForAccount`
(the "my games" query) and seat **`display_name`** resolution (server DTO layer);
`SeatView` gained a trailing `display_name`. Friends/blocks/invitations,
profile-editing, stats and the history/GCG viewer are Stage 8.
- **Stack** (interview): plain **Svelte 5 (runes) + TypeScript + Vite**, no
SvelteKit; `@connectrpc/connect-web` + the `flatbuffers` runtime, with the edge
TS bindings generated from the **same** `edge.proto` (`protoc-gen-es`) and
`scrabble.fbs` (`flatc --ts`) and **committed** under `ui/src/gen/` (dev-time
codegen, like `cmd/jetgen` / `pkg/Makefile`; CI builds the committed output).
- **No board on the wire** (discovered): `StateView` carries no grid, so the client
**replays the decoded move journal** (`game.history`, newly wired) onto an empty
board; premium squares + tile values are a client-side map **ported from
`scrabble-solver/rules/rules.go`** with a Vitest parity test.
- **Board UX** (interview): full-width, borderless; tiles placed by **Pointer-Events
drag or tap** (no HTML5 DnD — it has no touch support); a contextual **MakeMove**
control (short tap → make/reset popup, ~1 s press-and-hold → commit); per-tile
recall by tapping a pending tile; a **two-state zoom** (15↔9 cells) on touch only
(auto-zoom-in on placement, double-tap / pinch manual); a blank-letter chooser.
All board/tiles/effects are **pure HTML5/CSS + Unicode** — no image/font/SVG asset.
- **Theming** (interview): own **CSS custom-property tokens**, light/dark via
`prefers-color-scheme`, **Telegram-themeParams-ready** (a runtime hook can override
the tokens; the SDK is wired in the Telegram stage). **Navigation** (interview):
dependency-free **hash router**; session token in memory + **IndexedDB**, re-resolved
on reload (reopen Subscribe, refetch the open game); stream reconnect on focus.
**i18n** en/ru is a hand-rolled typed catalog (compile-time key parity + a test).
- **Mock transport** (owner request): a build-flagged in-memory fake (`VITE_MOCK`,
`pnpm start`) drives lobby → active game → board with no backend, tree-shaken out
of production; it is the same fixture the Playwright smoke uses.
- **Tests/CI** (interview): **Vitest** units (board replay, placement machine,
premium parity, i18n parity, FlatBuffers codec) + a **Playwright** smoke against
the mock; a new **`ui-test.yaml`** workflow (type-check, unit, build with a
**bundle-size budget** — prod is ~67 KB gzip JS — and a chromium e2e). The Go
workflows already cover the new backend/gateway/pkg code; a `game.ListForAccount`
integration test and gateway transcode tests for the new ops were added.
- **UX polish** (follow-up PR): a mobile-app **app shell** (growing nav bar, content
pinned to the bottom) + a one-line **announcement banner** (client-side mock
rotation now; server-driven channel later — §10); a mobile-OS **tab bar** and a
reusable **HoldConfirm** press-and-hold control (MakeMove 🏁 + game-action confirms);
board **zoom reworked** to a width-based zoom in a fixed viewport (real native
scroll, double-tap; pinch/swipe dropped) with constant `cqw` labels, corner-letter
tiles, contrasting grid lines, last-word dark-tile highlight, and a Settings
**bonus-label style** (beginner/
classic/none); **hint lays its tiles on the board** (no spend when no move — a new
`no_hint_available` result code); the history opens as an in-place **slide-down**
(not a modal); word-check is alphabet/length-limited, cached and throttled. Design
details live in the new [`docs/UI_DESIGN.md`](docs/UI_DESIGN.md).
- **Stage 8** (interview + implementation):
- **Scope = vertical slice continued**: the social/account/history operations were
opened end-to-end (UI → gateway transcode → backend REST → existing domain
services). The only new backend logic is `lobby.ListInvitations`,
`account.Store.GetStats`, a `game.SharedGame` seam (self-join on `game_players`),
the friend-code mechanism, and the friendships `declined`-status change.
- **Friends — two add paths** (interview, a deliberate plan change): **one-time
friend codes** (the player to be added issues a **6-digit numeric** code, 12 h TTL,
SHA-256-hashed like email codes, single active per issuer, single-use, redeem
rate-limited) and a **play-gated request** (`SendFriendRequest` now requires a
shared game — active or finished). An explicit **decline is permanent** (blocks
re-send), an **ignored request lazily expires after 30 days** and may be re-sent,
and a **code from the same person bypasses a prior decline**. This **supersedes
Stage 4's** "declining/cancelling deletes the row" (cancel by the requester still
deletes; decline now sets `status='declined'`). Migration **00006** widens
`friendships_status_chk` and adds **`friend_codes`** (jetgen regen). No public ID
or name search — discovery is codes + befriend-an-opponent.
- **Badges = poll + push** (interview): a new generic **`notify`** push event
(`notify.KindNotification`, sub-kinds friend_request/friend_added/invitation/
game_started) drives the lobby hamburger + "Friends" badge; emitted on friend-
request and invitation create and on the invitation's game start. The client polls
incoming requests + open invitations on lobby open and on focus (a missed push
while hidden), and re-polls on the `notify` event. Cursor-resume stays deferred
(single-instance MVP, §10).
- **Language single-control** (interview): the Settings language control writes
through to the durable account's `preferred_language` (`profile.update`); guests
keep only the client preference. Seeding the language from the platform/client on
first provider login is a **Stage 9** forward-note.
- **Guests = durable-only** (interview): friends/blocks/invitations/statistics and
history management are durable-account-only; a guest sees a sign-in prompt.
Binding an email to an existing guest (account linking) stays **Stage 11**.
- **GCG = finished-only + share** (interview): `game.ExportGCG` refuses an active
game (`game.ErrGameActive`) to avoid leaking the live journal mid-play; the client
exports via the **Web Share API** where available, else a **Blob download**
(`game-<id>.gcg`). Capacitor-native file save lands with the native wrapper.
- **IA = as the mockup** (interview): Friends (friends + blocks) is its own screen
from the lobby menu; Invitations is a lobby section + a "play with friends" mode in
New game; Stats is a lobby tab-bar button; profile editing is on Profile; history +
GCG stay in the game.
- **Wire/codegen**: new fbs tables (friends/blocks/invitations/profile-update/email-
bind/stats/gcg + `NotificationEvent`; `Profile` gained trailing away fields) in
`pkg/fbs`, regenerated to committed Go + TS; ~21 new gateway transcode ops; new
REST handlers under `/api/v1/user/{friends,blocks,invitations,profile,email,stats}`
and `…/games/:id/gcg`. UI grows to ~82 KB gzip JS (budget 100 KB). No CI workflow
change (the Go and UI workflows already cover the new code).
- **UI polish (owner review follow-up)**: a copyable friend code (📋 + toast); the
lobby notification badge fixed (it had inherited the hamburger-bar style) and made
a proper count dot; Safari flex inputs given `min-width:0`; **profile-edit
validation on both UI and backend** — display-name format (letters + single
``/`.`/`_`, ≤ 32 runes), a **UTC-offset** timezone picker (`account.ResolveZone`
parses `±HH:MM` or IANA; DST is traded for the simple picker), a 10-minute away grid
capped at **12 h** (wrap-aware), email format — with Save disabled and invalid
fields red-bordered while any field is invalid; language stays in Settings; in a
game, an "add to friends" item flips to a disabled "request sent"; chat send/nudge
became ⬆️/🛎️ icons; a **finished game** drops its last-word highlight, hides Check
word / Drop game, disables zoom, and draws an **inert footer** (greyed rack + tab
bar) instead of hiding it. A second **iPhone-simulator** pass then made the chat
and modals keyboard-aware (`dvh` sizing), returned the away window to a native
`<input type="time" step="600">` (the iOS wheel with 10-minute steps; the timezone
stays a native offset `<select>`), reserved the rack height so a finished footer
does not collapse, and compacted the play-with-friends form (a searchable
bounded-scroll friend list, native game-type / move-time / hints selects in one
row, a pinned invite).
## 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 / deep-link friend codes (owner's idea, Stage 8).** The one-time
friend code is entered by hand today. Once the Telegram/native deep-link scheme
exists (Stage 9), wrap a code in a deep link and render it as a QR so a friend can
add you by scanning rather than typing. The code semantics (12 h TTL, single use,
one active per issuer) stay as-is; only the delivery changes.