642 lines
43 KiB
Markdown
642 lines
43 KiB
Markdown
# Scrabble Game — implementation plan
|
||
|
||
Living plan and **stage tracker**. Each stage is implemented in its own session;
|
||
the rules for starting and finishing a stage are in [`CLAUDE.md`](CLAUDE.md).
|
||
The architecture/decision record is [`docs/ARCHITECTURE.md`](docs/ARCHITECTURE.md);
|
||
behaviour is [`docs/FUNCTIONAL.md`](docs/FUNCTIONAL.md). When a stage produces a
|
||
decision, bake it back here **and** into the affected docs/code in the same PR.
|
||
|
||
## Context
|
||
|
||
Greenfield multiplatform Scrabble. Players arrive from a platform (Telegram
|
||
first; later VK/MAX/iOS/Android) or standalone web (email / guest). Three
|
||
executables — `gateway`, `backend`, `ui` — plus per-platform side-services.
|
||
Deliberately simpler than the sibling `../galaxy-game` (idea donor, not a
|
||
template). The `../scrabble-solver` engine is embedded in-process as a library.
|
||
|
||
## Locked decisions (recap — full record in docs/ARCHITECTURE.md)
|
||
|
||
Stack: `go.work` monorepo, modules `scrabble/<name>`, Go 1.26.x, backend
|
||
gin+pgx+Postgres(schema `backend`)+goose+zap+OTel (deps added when first used).
|
||
Wire: Connect-RPC + FlatBuffers (client↔gateway), REST/JSON + `X-User-ID`
|
||
(gateway↔backend), gRPC server-stream for live events. Auth: platform-native,
|
||
thin opaque session token, no Ed25519/signing, likely no Redis. UI: pure
|
||
HTML5/CSS, plain Svelte + Vite, Capacitor for native. MVP surfaces: Telegram +
|
||
web (email + ephemeral guest) + link/merge. Variants: ru/en/Эрудит.
|
||
Legality: validate-at-submit. End: empty bag+rack / 6 scoreless / 24h timeout.
|
||
Hint: top-1. Word-check: unlimited + complaint. Robot: P(win)≈0.40, margin
|
||
targeting, [2,90]min skewed timing, sleep 00:00–07:00 opp-tz, nudge logic.
|
||
Dictionary: pin per game. History: structured + GCG export, dictionary-
|
||
independent (see ARCHITECTURE §9.1).
|
||
|
||
## Stage tracker
|
||
|
||
| # | Stage | Status |
|
||
|---|-------|--------|
|
||
| 0 | Scaffolding (go.work, backend skeleton, docs, CI) | **done** |
|
||
| 1 | Backend foundation (config, server, Postgres+goose, sessions, accounts) | **done** |
|
||
| 2 | Engine package over scrabble-solver | **done** |
|
||
| 3 | Game domain (lifecycle, rules, hint, word-check, history+GCG, stats) | **done** |
|
||
| 4 | Lobby & social (matchmaking, friends, block, chat, profile, nudge) | **done** |
|
||
| 5 | Robot opponent | **done** |
|
||
| 6 | Gateway edge (Connect/FB, platform auth, sessions, push bridge, admin) | **done** |
|
||
| 7 | UI — playable slice + UX polish (Svelte+Vite, board, lobby, chat, hint/word-check, i18n) | **done** |
|
||
| 8 | UI — social/account/history (friends, blocks, invitations, profile edit, stats, history/GCG) | **done** |
|
||
| 9 | Telegram integration (bot side-service, deep-link, push) | 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 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 `replace` deferred to Stage 2 (nothing imports it yet;
|
||
adding the path now would break CI, which checks out only this repo). Docker /
|
||
compose deferred to a stage that has something to deploy. Trunk is `master`
|
||
(owner preference); `feature/*` + PR from Stage 1; the genesis commit lands on
|
||
`master` by necessity.
|
||
- **Stage 1** (interview + implementation):
|
||
- Query layer: **go-jet** over `database/sql` (pgx stdlib) + otelsql; a
|
||
`cmd/jetgen` tool regenerates the **committed** code from a throwaway
|
||
container. Postgres **17** pinned for jetgen, tests and prod.
|
||
- Sessions: opaque token stored only as a **SHA-256 hash** (kept as hex
|
||
`text`, not `bytea` — avoids jet bytea-literal friction), **revoke-only**
|
||
(no TTL); revocation-audit table deferred. Backend keeps a warmed
|
||
write-through session cache that gates `/readyz`.
|
||
- Data model: **UUIDv7** PKs; one unified `identities` table
|
||
(`kind ∈ telegram|email`, widen to `vk`/`max` later); no soft-delete /
|
||
actor-audit columns yet.
|
||
- HTTP surface: **service/store/cache layer only**. `/api/v1/{public,user,
|
||
internal,admin}` groups + `X-User-ID` middleware are scaffolding (exposed via
|
||
`Server` group accessors); the session/account REST handlers land with the
|
||
gateway in **Stage 6**. Admin bootstrap deferred to **Stage 10**.
|
||
- Telemetry: providers + request-timing middleware + otelsql; exporters
|
||
`none` (default) / `stdout`; OTLP + dashboards deferred to **Stage 12**.
|
||
- Tests/CI: integration tests behind the `integration` build tag in
|
||
`backend/internal/inttest` + new `integration.yaml` (testcontainers, Ryuk
|
||
off, serial), firing on push and PR. Backend now **hard-depends on Postgres
|
||
at boot** (migrations at startup) — a deliberate contract change from
|
||
Stage 0, documented in both READMEs. All code stays in the existing
|
||
`backend` module under `internal/` (+ `cmd/jetgen`); `go.work` untouched.
|
||
- **Stage 2** (interview + implementation):
|
||
- Scope: `internal/engine` is a self-contained **library** (registry, bag,
|
||
`Game` state machine, decode/replay). No `config`/`main`/`server` wiring this
|
||
stage — there is no consumer yet; wiring lands in **Stage 3**, mirroring
|
||
Stage 1's deferred handlers.
|
||
- **Pure rules engine** (interview): the engine owns the in-memory `Game`,
|
||
pure transitions (play/pass/exchange/resign + draw) **and end-condition
|
||
detection**, including the standard **end-game rack-adjustment scoring** — a
|
||
deliberate slice of Stage 3's "scoring/end-conditions" that the pure-engine
|
||
boundary implies. Stage 3 keeps scheduling, the 24h timeout, persistence and
|
||
GCG.
|
||
- **Solver wiring**: `replace scrabble-solver => ../scrabble-solver` in
|
||
`go.work`; `backend/go.mod` requires `scrabble-solver` (placeholder version,
|
||
redirected by the replace) and `github.com/iliadenisov/dafsa` directly (for
|
||
`dawg.Load`). CI clones the **public** solver repo at **master HEAD**
|
||
anonymously into `../scrabble-solver` (no token); both Go workflows gained
|
||
the step (the engine's untagged tests run under the integration workflow too)
|
||
and set `BACKEND_DICT_DIR`.
|
||
- **Dictionaries**: registry loads the committed DAWGs from a directory
|
||
parameter; `dict_version` is an explicit string label; the latest version
|
||
per variant is tracked. Smoke tests validate a known word per variant
|
||
(English/Russian/Эрудит). **Эрудит is handled uniformly** — every real
|
||
difference is already in `rules.Erudit()`; the move.go "single orientation
|
||
per turn" note needs no special code (any single play is one-directional).
|
||
- **Bag/blanks/exchange**: own deterministic `Bag` (Draw + Return) because
|
||
`selfplay.Bag` cannot return tiles; exchange is legal only when the bag holds
|
||
at least a rack and draws replacements before returning the swapped tiles. A
|
||
blank is `Placement{Blank:true}` carrying its designated letter; the history
|
||
keeps the concrete letter plus a blank flag (decoded via `Alphabet.Character`
|
||
/ `Decode`). `ReplayBoard` reuses `scrabble.Apply`, so no `internal/encoding`
|
||
dependency.
|
||
- **Deviation from the approved plan**: `docs/FUNCTIONAL.md` (+`_ru`) was left
|
||
unchanged. Stage 2 adds no user-visible behaviour; the variant, per-game
|
||
dictionary and dictionary-independent-history user stories already live in
|
||
Stages 3–4, so a "light touch" here would have duplicated or pre-empted them.
|
||
- **Stage 3** (interview + implementation):
|
||
- Scope, as in Stages 1–2: **domain service/store layer + engine wiring, no
|
||
HTTP** (`internal/game`). The gateway↔backend REST surface lands in Stage 6;
|
||
the only active driver this stage is a background turn-timeout sweeper started
|
||
from `main`. The robot (Stage 5) will consume the same service API.
|
||
- **Persistence = event-sourcing + warm cache** (interview): durable state is
|
||
the `games` row plus an append-only decoded move journal (`game_moves`); the
|
||
live position is an `engine.Game` kept in an in-memory cache with a ~24h idle
|
||
TTL and rebuilt by replaying the journal on a miss (the seeded bag makes
|
||
replay exact). Each game is serialised by a per-game mutex; a persistence
|
||
failure evicts the live game so the next access rebuilds. §9 reworded from
|
||
"stored structurally" to this model.
|
||
- **Resign/timeout split** (interview): 2-player resign/timeout only this stage
|
||
(the other player wins); multiplayer drop-out-and-continue + resigned-tiles
|
||
disposition deferred to Stage 4. Per-game **turn-timeout duration** setting
|
||
(5/10/15/30 min, 1/2/3/6/12/24 h; default 24 h) and a per-user **away window**
|
||
(`accounts.away_start/away_end`, default 00:00–07:00 local, honoured by the
|
||
sweeper with midnight-cross handling) added now; profile editing of the away
|
||
window is Stage 4 and the robot's sleep (Stage 5) reuses it.
|
||
- **Engine `Resign` fix** (interview, in `internal/engine`): the resigner keeps
|
||
their accumulated score (no end-game rack adjustment) and never wins; `winner`
|
||
excludes the resigner, so a two-player resign/timeout gives the win to the
|
||
other player regardless of score. Timeout reuses `Resign`, so the game domain
|
||
needs no winner override.
|
||
- **Additive engine domain API**: `Direction`, `Game.SubmitPlay/SubmitExchange/
|
||
EvaluatePlay/HintView/Hand`, `MoveRecord.{Dir,MainRow,MainCol}`,
|
||
`Registry.Lookup`, `ParseVariant` — so `internal/game` never imports
|
||
`scrabble-solver` (keeps the §5 single-importer invariant).
|
||
- **Create = atomic with seats** (interview): `Create` seats all accounts and
|
||
starts; lobby seat-filling is Stage 4. **Sweeper = periodic goroutine**
|
||
(interview; default 60 s, `BACKEND_GAME_TIMEOUT_SWEEP_INTERVAL`).
|
||
- **Hint = settings + wallet** (interview): per-game `hints_allowed` +
|
||
`hints_per_player`, plus a profile wallet `accounts.hint_balance` (spent after
|
||
the allowance; purchases later). Category defaults (random 1 / tournament 0 /
|
||
friendly 1-or-0) are the caller's job (lobby/tournaments).
|
||
- **Stats** (interview): `account_stats` with **`draws`** added beyond §9's
|
||
wins/losses; `max_word_points` = best single **move** score; ties draw,
|
||
resign/timeout is a loss, guests get no stats.
|
||
- **Complaint** (interview): full payload with `game_id`; word-check is scoped
|
||
to the game's pinned `(variant, dict_version)`. Stage 10 owns the resolution
|
||
lifecycle, so the `status` column carries no value CHECK yet.
|
||
- **GCG** (interview): standard Poslfit dialect (UTF-8, `#player`/`#lexicon`
|
||
pragmas, `8G`/`H8` coordinates, lower-case blanks, `.` pass-throughs, `-TILES`
|
||
exchange) plus `#note` lines for resign/timeout; derived from the journal, so
|
||
dictionary-independent.
|
||
- **Engine wiring + config**: `main` loads the registry (`engine.Open`, a hard
|
||
boot dependency like migrations) and starts the sweeper. New config:
|
||
`BACKEND_DICT_DIR` (required), `BACKEND_DICT_VERSION` (default `v1`),
|
||
`BACKEND_GAME_TIMEOUT_SWEEP_INTERVAL` (60 s), `BACKEND_GAME_CACHE_TTL` (24 h).
|
||
No CI change — both Go workflows already clone the solver sibling and export
|
||
`BACKEND_DICT_DIR`. `accounts` gained `away_start`/`away_end`/`hint_balance`
|
||
and the `account` package gained `SpendHint` (it owns its table).
|
||
|
||
- **Stage 4** (interview + implementation):
|
||
- Scope, as in Stages 1–3: **domain service/store layer, no HTTP** — REST/stream
|
||
is Stage 6. Chat and nudges are **persisted** now; live delivery (push /
|
||
in-app stream) is Stage 6/8. New packages `internal/social` (friends, blocks,
|
||
chat+nudge) and `internal/lobby` (matchmaking + invitations); profile editing
|
||
and the email confirm-code extend `internal/account`. The services have no
|
||
active driver this stage, so `main` builds them and hands them to the server,
|
||
which exposes them via accessors (the Stage 1 scaffolding-accessor pattern) for
|
||
the Stage 6 handlers.
|
||
- **Friends** (interview): request → accept on a single `friendships` table;
|
||
decline/cancel delete the pending row; **blocking severs** any friendship.
|
||
- **Blocks** (interview): the existing global toggles **plus** a per-user
|
||
`blocks` table; block effects are **mutual** (a block either way suppresses
|
||
chat visibility and prevents requests/invitations between the pair).
|
||
- **Friend games** (interview): invitation → accept; the game starts only when
|
||
**all** invitees accept, any decline cancels it, and a pending invitation
|
||
**lazily expires after 7 days** (checked on access — no new sweeper).
|
||
- **Chat** (interview): ≤ **60 runes**, stored with the game forever, the
|
||
sender **IP** kept for moderation (as `text`, following Stage 1's no-`bytea`
|
||
precedent; the gateway forwards it in Stage 6), input **content-filtered**
|
||
(links/emails/phone numbers incl. obfuscated forms) via `mvdan.cc/xurls/v2`
|
||
plus a compact leet/separator normaliser and a ≥7-digit phone heuristic — the
|
||
one new dependency. **Nudge is a chat message** (`kind='nudge'`), rate-limited
|
||
to once per hour per game per sender.
|
||
- **Matchmaking** (interview): an **in-memory** FIFO pool keyed by **variant**
|
||
only (variant fixes the board language), pairing two humans (seat order
|
||
randomised). The 10 s wait and **robot substitution are deferred to Stage 5**.
|
||
The pool does **not** consult blocks (auto-match is anonymous) — a deliberate
|
||
simplification of the plan's optional block-skip that also avoids a DB call
|
||
under the pool lock.
|
||
- **Email confirm-code** (interview): 6-digit code, 15-min TTL, ≤ 5 attempts,
|
||
stored as a **SHA-256 hash**; a `Mailer` seam with an SMTP relay
|
||
(`BACKEND_SMTP_*`) and a default **log mailer**. It binds an email to the
|
||
current account; an email already confirmed by another account → `ErrEmailTaken`
|
||
(**merge is Stage 11**); email-as-login is Stage 6 and reuses this mechanism.
|
||
- **Multi-player drop-out** (interview; discharges the Stage 3 deferral): the
|
||
engine's `Resign` now drops a seat and the rest **play on** while ≥ 2 are
|
||
active, finishing (last-survivor wins) when one remains; `winner` excludes all
|
||
resigned seats. A per-game **`dropout_tiles`** setting (`remove` default |
|
||
`return`) governs the leaver's rack, which is **never revealed** to the others.
|
||
Timeout reuses `Resign`, so a multi-player timeout drops one seat and play
|
||
continues; `game.commit`/`timeoutGame` were already keyed on `g.Over()`, so they
|
||
only needed the setting threaded through create/replay.
|
||
- **Build/deps**: `go mod tidy` is not run — the bare-path `scrabble-solver`
|
||
replace lives only in `go.work`, so `tidy`/`go get` cannot resolve it; the
|
||
`xurls` dependency was added with `go mod edit -require` + `go mod download`,
|
||
its checksums recorded in the committed **`go.work.sum`**. No CI workflow change
|
||
(both Go workflows already clone the solver sibling and export
|
||
`BACKEND_DICT_DIR`).
|
||
|
||
- **Stage 5** (interview + implementation):
|
||
- Scope, as in Stages 1–4: **domain layer, no HTTP** — the robot consumes the
|
||
public game API as an ordinary seated player (`internal/robot`), so only
|
||
`internal/engine` still imports the solver. New: `engine.Candidates()` (decoded
|
||
ranked plays) and a thin `game.Service.Candidates` + `RobotTurns` read.
|
||
- **Account model** (interview): a pool of **durable accounts**, each a single
|
||
`identities` row `kind='robot'` (migration `00004` widens the kind CHECK — a
|
||
CHECK-only change, no jetgen). A curated ~16-name pool in code; `EnsurePool`
|
||
provisions them idempotently at boot (a hard dependency, like the registry) with
|
||
`block_chat`/`block_friend_requests` set, which is **all** the friend/DM blocking
|
||
needs (no special-casing).
|
||
- **Driver + state** (interview): a background sweeper goroutine
|
||
(`robot.Service.Run`/`Drive`, mirroring the timeout sweeper); **every per-game
|
||
and per-turn choice is derived deterministically from the game `seed`** (FNV-1a
|
||
mix, restart-stable — not `hash/maphash`), so the robot keeps **no extra state**.
|
||
`playToWin = mix(seed,"win")%100 < 40`; per-turn `delay`; sleep `drift`.
|
||
- **Timing** (interview): per-move delay `2 + 88·u^k` minutes, `u~U(0,1)`,
|
||
**k≈3.5 → median ~10 min**, clamped to [2,90]. A daytime nudge on the robot's
|
||
turn pulls the move into a 2–10 min reply window; the robot proactively nudges
|
||
after **12 h** idle on the human's turn (reusing `social.Nudge`'s once-per-hour
|
||
guard; `social.LastNudgeAt` added to detect the human's nudge).
|
||
- **Sleep** (interview — resolves the §7-vs-`account.go` mismatch): the robot
|
||
sleeps 00:00–07:00 in the **opponent's timezone shifted by a per-game drift ∈
|
||
[−3,+3]h** (so its night overlaps the human's rather than running anti-phase),
|
||
computed on the fly per game — **no profile mutation, no concurrency cap**. The
|
||
`account.go` away-window comment was corrected accordingly.
|
||
- **Margin** (interview): pick the candidate whose resulting margin (own+move−opp)
|
||
is closest to **[1,30]** when playing to win / **[−30,−1]** when playing to lose,
|
||
tie-broken toward the conservative edge; no legal play → exchange the full rack
|
||
when the bag can refill it, else pass.
|
||
- **Substitution** (interview): a matchmaker **reaper** (`Reap`/`RunReaper`)
|
||
substitutes a pooled robot after a **10 s** wait (`BACKEND_LOBBY_ROBOT_WAIT`),
|
||
`NewMatchmaker` now takes a `RobotProvider`. A waiter learns of a match — human
|
||
pairing **or** substitution — through a new `Poll` + results map; production
|
||
delivery is a **match-found notification** (session/in-app push + side-service),
|
||
Stage 6/8 — noted in §10.
|
||
- **Metrics** (interview, 1+2): robots are durable accounts, so `account_stats`
|
||
is the authoritative, complete balance ground-truth (target ~40% robot wins);
|
||
an OTel counter (`robot_games_finished_total`, exporter `none` today) and a
|
||
structured log cover robot-finished games for live observation.
|
||
- **Config**: `BACKEND_ROBOT_DRIVE_INTERVAL` (30 s), `BACKEND_LOBBY_ROBOT_WAIT`
|
||
(10 s), `BACKEND_LOBBY_REAPER_INTERVAL` (1 s). No CI change (both Go workflows
|
||
already clone the solver sibling and export `BACKEND_DICT_DIR`).
|
||
|
||
- **Stage 6** (interview + implementation):
|
||
- **Scope = framework + vertical slice** (interview): the *whole* edge mechanism
|
||
is built and the backend's REST surface + the live-event seam are opened for
|
||
the first time, but only a representative slice of operations is wired
|
||
end-to-end — auth (`auth.telegram`/`auth.guest`/`auth.email.request`/
|
||
`auth.email.login`), `profile.get`, `game.submit_play`/`game.state`,
|
||
`lobby.enqueue`/`lobby.poll`, `chat.post`, all five push events, and the admin
|
||
passthrough. The remaining domain operations reuse the identical transcode
|
||
pattern and are wired as the UI needs them: the play-loop ops (pass/exchange/
|
||
resign, hint, evaluate, word-check/complaint, history, my-games list, chat
|
||
list/nudge) in **Stage 7**; the social/account ops (friends, blocks,
|
||
invitations, profile editing, stats, GCG export) in **Stage 8**.
|
||
- **Wire contracts in a new shared `scrabble/pkg` module** (interview): the
|
||
backend push proto (`pkg/proto/push/v1`) and the FlatBuffers edge payloads
|
||
(`pkg/fbs`, one `scrabblefb` namespace) live here with **committed** generated
|
||
Go, imported by both backend and gateway. The Connect envelope proto lives in
|
||
`gateway/proto/edge/v1`. Codegen is dev-time (`buf generate` with **local**
|
||
plugins + `flatc`, driven by per-module `Makefile`s, mirroring `cmd/jetgen`);
|
||
CI only builds the committed output. `pkg` and `gateway` are bare-path modules
|
||
like `scrabble-solver`, so `go.work` carries `use ./pkg`, `use ./gateway` and a
|
||
`replace scrabble/pkg v0.0.0 => ./pkg` (the no-dot path is not VCS-fetchable);
|
||
deps were added with `go mod edit` + `go work sync` (the established no-tidy
|
||
pattern). `flatc` is pinned to **23.5.26** to match the `flatbuffers` Go runtime.
|
||
- **Guest = durable account + `is_guest`** (interview): migration `00005` adds
|
||
`accounts.is_guest`; a guest is a durable row with **no identity** (so the
|
||
`sessions`/`game_players` foreign keys hold) that is **excluded from statistics**
|
||
(the finish-time recompute skips guest seats) and from friends/history. The
|
||
earlier "guests never reach this table" comments and §3/§9 were softened to
|
||
"no profile/friends/stats persisted". Guest-row GC is a logged TODO (TODO-3).
|
||
- **Push = in-process `Publisher` + backend gRPC listener** (interview): a new
|
||
`internal/notify` hub (a `Publisher` interface defaulting to `Nop`, installed on
|
||
`game`/`social`/`lobby` via `SetNotifier` during boot — additive, existing tests
|
||
unchanged) is drained by a new backend gRPC server (`internal/pushgrpc`,
|
||
`BACKEND_GRPC_ADDR` default `:9090`) serving `Push.Subscribe`. Emission lives in
|
||
`game.commit` (so robot-driver and timeout-sweep moves emit `your_turn`/
|
||
`opponent_moved` too — the background sources a handler-only design would miss),
|
||
`social` (`chat_message`/`nudge`) and the matchmaker (`match_found`). Event
|
||
payloads are FlatBuffers-encoded **in the backend** (it imports `pkg/fbs`); the
|
||
gateway forwards them verbatim. Revoke/session-invalidation and cursor-resume are
|
||
**deferred** (single-instance MVP).
|
||
- **Edge envelope = minimal, token in header** (interview): the `Gateway` Connect
|
||
service is `Execute(message_type, payload, request_id)` + `Subscribe`; the
|
||
session token rides in `Authorization: Bearer`; auth ops are unauthenticated and
|
||
return the token in the FlatBuffers `Session`. Domain outcomes ride back in the
|
||
`ExecuteResponse.result_code` (HTTP 200); only edge failures (rate limit, missing
|
||
session, unknown type, internal) are Connect error codes. No Ed25519/signing
|
||
(the galaxy donor's crypto stack was dropped, per §3).
|
||
- **Admin = gateway validates Basic-Auth** (interview): the gateway checks
|
||
`GATEWAY_ADMIN_USER`/`_PASSWORD` and reverse-proxies to backend
|
||
`/api/v1/admin/*`; the backend admin surface is a single `ping` until Stage 10.
|
||
- **Rate-limit = 2 dimensions, 3 classes** (interview): public per-IP (30/min,
|
||
burst 10), authenticated per-user (120/min, burst 40), admin per-IP (60/min,
|
||
burst 20), plus an email-code per-IP sub-limit (5/10 min); token bucket
|
||
(`golang.org/x/time/rate`) with a lazy stale-bucket sweep.
|
||
- **Email-as-login** (discharges the Stage 4 deferral): `account.EmailService`
|
||
gained `RequestLoginCode`/`LoginWithCode`, reusing the confirm-code mechanism but
|
||
provisioning-or-finding the account by email identity (it does **not** refuse an
|
||
already-confirmed address — that is the returning user).
|
||
- **CI**: both Go workflows gained `gateway/**` (and `pkg/**` where backend depends
|
||
on it) path filters and now build/vet/test `./backend/... ./pkg/... ./gateway/...`
|
||
(unit) — integration stays `./backend/...` (the only module with tagged tests).
|
||
The solver clone + `BACKEND_DICT_DIR` steps are unchanged.
|
||
|
||
- **Stage 7** (interview + implementation):
|
||
- **Scope = playable slice** (interview): the *whole* UI shell plus the core play
|
||
loop end-to-end; the social/account/history surfaces were split out into a new
|
||
**Stage 8** and the later stages shifted +1 (Telegram→9, Admin→10, Linking→11,
|
||
Polish→12). Stage 7 wires only the operations the slice needs (the Stage 6
|
||
"as the UI needs them" pattern): the new gateway/transcode + backend-REST ops
|
||
`games.list`, `game.{pass,exchange,resign,hint,evaluate,check_word,complaint,
|
||
history}`, `chat.{list,nudge}`. The only new domain code is `game.ListForAccount`
|
||
(the "my games" query) and seat **`display_name`** resolution (server DTO layer);
|
||
`SeatView` gained a trailing `display_name`. Friends/blocks/invitations,
|
||
profile-editing, stats and the history/GCG viewer are Stage 8.
|
||
- **Stack** (interview): plain **Svelte 5 (runes) + TypeScript + Vite**, no
|
||
SvelteKit; `@connectrpc/connect-web` + the `flatbuffers` runtime, with the edge
|
||
TS bindings generated from the **same** `edge.proto` (`protoc-gen-es`) and
|
||
`scrabble.fbs` (`flatc --ts`) and **committed** under `ui/src/gen/` (dev-time
|
||
codegen, like `cmd/jetgen` / `pkg/Makefile`; CI builds the committed output).
|
||
- **No board on the wire** (discovered): `StateView` carries no grid, so the client
|
||
**replays the decoded move journal** (`game.history`, newly wired) onto an empty
|
||
board; premium squares + tile values are a client-side map **ported from
|
||
`scrabble-solver/rules/rules.go`** with a Vitest parity test.
|
||
- **Board UX** (interview): full-width, borderless; tiles placed by **Pointer-Events
|
||
drag or tap** (no HTML5 DnD — it has no touch support); a contextual **MakeMove**
|
||
control (short tap → make/reset popup, ~1 s press-and-hold → commit); per-tile
|
||
recall by tapping a pending tile; a **two-state zoom** (15↔9 cells) on touch only
|
||
(auto-zoom-in on placement, double-tap / pinch manual); a blank-letter chooser.
|
||
All board/tiles/effects are **pure HTML5/CSS + Unicode** — no image/font/SVG asset.
|
||
- **Theming** (interview): own **CSS custom-property tokens**, light/dark via
|
||
`prefers-color-scheme`, **Telegram-themeParams-ready** (a runtime hook can override
|
||
the tokens; the SDK is wired in the Telegram stage). **Navigation** (interview):
|
||
dependency-free **hash router**; session token in memory + **IndexedDB**, re-resolved
|
||
on reload (reopen Subscribe, refetch the open game); stream reconnect on focus.
|
||
**i18n** en/ru is a hand-rolled typed catalog (compile-time key parity + a test).
|
||
- **Mock transport** (owner request): a build-flagged in-memory fake (`VITE_MOCK`,
|
||
`pnpm start`) drives lobby → active game → board with no backend, tree-shaken out
|
||
of production; it is the same fixture the Playwright smoke uses.
|
||
- **Tests/CI** (interview): **Vitest** units (board replay, placement machine,
|
||
premium parity, i18n parity, FlatBuffers codec) + a **Playwright** smoke against
|
||
the mock; a new **`ui-test.yaml`** workflow (type-check, unit, build with a
|
||
**bundle-size budget** — prod is ~67 KB gzip JS — and a chromium e2e). The Go
|
||
workflows already cover the new backend/gateway/pkg code; a `game.ListForAccount`
|
||
integration test and gateway transcode tests for the new ops were added.
|
||
- **UX polish** (follow-up PR): a mobile-app **app shell** (growing nav bar, content
|
||
pinned to the bottom) + a one-line **announcement banner** (client-side mock
|
||
rotation now; server-driven channel later — §10); a mobile-OS **tab bar** and a
|
||
reusable **HoldConfirm** press-and-hold control (MakeMove 🏁 + game-action confirms);
|
||
board **zoom reworked** to a width-based zoom in a fixed viewport (real native
|
||
scroll, double-tap; pinch/swipe dropped) with constant `cqw` labels, corner-letter
|
||
tiles, contrasting grid lines, last-word dark-tile highlight, and a Settings
|
||
**bonus-label style** (beginner/
|
||
classic/none); **hint lays its tiles on the board** (no spend when no move — a new
|
||
`no_hint_available` result code); the history opens as an in-place **slide-down**
|
||
(not a modal); word-check is alphabet/length-limited, cached and throttled. Design
|
||
details live in the new [`docs/UI_DESIGN.md`](docs/UI_DESIGN.md).
|
||
|
||
- **Stage 8** (interview + implementation):
|
||
- **Scope = vertical slice continued**: the social/account/history operations were
|
||
opened end-to-end (UI → gateway transcode → backend REST → existing domain
|
||
services). The only new backend logic is `lobby.ListInvitations`,
|
||
`account.Store.GetStats`, a `game.SharedGame` seam (self-join on `game_players`),
|
||
the friend-code mechanism, and the friendships `declined`-status change.
|
||
- **Friends — two add paths** (interview, a deliberate plan change): **one-time
|
||
friend codes** (the player to be added issues a **6-digit numeric** code, 12 h TTL,
|
||
SHA-256-hashed like email codes, single active per issuer, single-use, redeem
|
||
rate-limited) and a **play-gated request** (`SendFriendRequest` now requires a
|
||
shared game — active or finished). An explicit **decline is permanent** (blocks
|
||
re-send), an **ignored request lazily expires after 30 days** and may be re-sent,
|
||
and a **code from the same person bypasses a prior decline**. This **supersedes
|
||
Stage 4's** "declining/cancelling deletes the row" (cancel by the requester still
|
||
deletes; decline now sets `status='declined'`). Migration **00006** widens
|
||
`friendships_status_chk` and adds **`friend_codes`** (jetgen regen). No public ID
|
||
or name search — discovery is codes + befriend-an-opponent.
|
||
- **Badges = poll + push** (interview): a new generic **`notify`** push event
|
||
(`notify.KindNotification`, sub-kinds friend_request/friend_added/invitation/
|
||
game_started) drives the lobby hamburger + "Friends" badge; emitted on friend-
|
||
request and invitation create and on the invitation's game start. The client polls
|
||
incoming requests + open invitations on lobby open and on focus (a missed push
|
||
while hidden), and re-polls on the `notify` event. Cursor-resume stays deferred
|
||
(single-instance MVP, §10).
|
||
- **Language single-control** (interview): the Settings language control writes
|
||
through to the durable account's `preferred_language` (`profile.update`); guests
|
||
keep only the client preference. Seeding the language from the platform/client on
|
||
first provider login is a **Stage 9** forward-note.
|
||
- **Guests = durable-only** (interview): friends/blocks/invitations/statistics and
|
||
history management are durable-account-only; a guest sees a sign-in prompt.
|
||
Binding an email to an existing guest (account linking) stays **Stage 11**.
|
||
- **GCG = finished-only + share** (interview): `game.ExportGCG` refuses an active
|
||
game (`game.ErrGameActive`) to avoid leaking the live journal mid-play; the client
|
||
exports via the **Web Share API** where available, else a **Blob download**
|
||
(`game-<id>.gcg`). Capacitor-native file save lands with the native wrapper.
|
||
- **IA = as the mockup** (interview): Friends (friends + blocks) is its own screen
|
||
from the lobby menu; Invitations is a lobby section + a "play with friends" mode in
|
||
New game; Stats is a lobby tab-bar button; profile editing is on Profile; history +
|
||
GCG stay in the game.
|
||
- **Wire/codegen**: new fbs tables (friends/blocks/invitations/profile-update/email-
|
||
bind/stats/gcg + `NotificationEvent`; `Profile` gained trailing away fields) in
|
||
`pkg/fbs`, regenerated to committed Go + TS; ~21 new gateway transcode ops; new
|
||
REST handlers under `/api/v1/user/{friends,blocks,invitations,profile,email,stats}`
|
||
and `…/games/:id/gcg`. UI grows to ~82 KB gzip JS (budget 100 KB). No CI workflow
|
||
change (the Go and UI workflows already cover the new code).
|
||
- **UI polish (owner review follow-up)**: a copyable friend code (📋 + toast); the
|
||
lobby notification badge fixed (it had inherited the hamburger-bar style) and made
|
||
a proper count dot; Safari flex inputs given `min-width:0`; **profile-edit
|
||
validation on both UI and backend** — display-name format (letters + single
|
||
`␠`/`.`/`_`, ≤ 32 runes), a **UTC-offset** timezone picker (`account.ResolveZone`
|
||
parses `±HH:MM` or IANA; DST is traded for the simple picker), a 10-minute away grid
|
||
capped at **12 h** (wrap-aware), email format — with Save disabled and invalid
|
||
fields red-bordered while any field is invalid; language stays in Settings; in a
|
||
game, an "add to friends" item flips to a disabled "request sent"; chat send/nudge
|
||
became ⬆️/🛎️ icons; a **finished game** drops its last-word highlight, hides Check
|
||
word / Drop game, disables zoom, and draws an **inert footer** (greyed rack + tab
|
||
bar) instead of hiding it. 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.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 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.
|