Files
scrabble-game/PLAN.md
T
Ilia Denisov 6d0dd4fb14
Tests · Go / test (push) Successful in 6s
Tests · Integration / integration (push) Successful in 7s
Stage 2: engine package over scrabble-solver (registry, bag, Game, replay)
backend/internal/engine wraps the sibling scrabble-solver library in-process:

- Registry: versioned DAWG load via dafsa.Load, keyed by (variant, dict_version),
  latest-per-variant; English / Russian / Эрудит handled uniformly.
- Bag: own deterministic, seeded tile bag with Draw + Return (for exchanges),
  since the solver's self-play bag cannot return tiles.
- Game: pure rules engine — deal, play/pass/exchange/resign, refill, per-move
  scoring, turn order, and end-condition detection (empty bag + empty rack, six
  scoreless turns, resignation) with end-game rack adjustment.
- decode/ReplayBoard: dictionary-independent MoveRecords and board replay via
  scrabble.Apply (no internal/encoding), realising ARCHITECTURE §9.1.

Wiring: go.work gains "replace scrabble-solver => ../scrabble-solver"; backend
requires scrabble-solver (placeholder) and github.com/iliadenisov/dafsa directly.
Both Go CI workflows clone the public solver sibling (master HEAD, no token) and
set BACKEND_DICT_DIR.

Docs: ARCHITECTURE §5/§14, TESTING engine layer, backend README, and PLAN
refinements + deferred TODOs (publish/version solver; split engine vs dictionary
generator).
2026-06-02 15:10:08 +02:00

232 lines
13 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 | todo |
| 3 | Game domain (lifecycle, rules, hint, word-check, history+GCG, stats) | todo |
| 4 | Lobby & social (matchmaking, friends, block, chat, profile, nudge) | todo |
| 5 | Robot opponent | todo |
| 6 | Gateway edge (Connect/FB, platform auth, sessions, push bridge, admin) | todo |
| 7 | UI (plain Svelte + Vite, board, lobby, chat, i18n) | todo |
| 8 | Telegram integration (bot side-service, deep-link, push) | todo |
| 9 | Admin & dictionary ops (complaint review, version reload) | todo |
| 10 | Account linking & merge | todo |
| 11 | 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 9.
### 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.
### Stage 8 — 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 9 — 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 10 — 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 11 — 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 9**.
- Telemetry: providers + request-timing middleware + otelsql; exporters
`none` (default) / `stdout`; OTLP + dashboards deferred to **Stage 11**.
- 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.
## 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 9) — keep the `BACKEND_DICT_DIR` directory as
the runtime contract: a new `.dawg` appears in it and is loaded with
`dawg.Load`.