R6(a): de-stage code, docs, READMEs; split stage6_test

Mechanical, behaviour-preserving removal of Stage N / TODO-N / phase (RN)
references from comments, doc-comments, service READMEs, the current-state docs
(ARCHITECTURE, FUNCTIONAL+_ru, TESTING, UI_DESIGN), config-file comments, and the
.fbs/.proto schema comments. PLAN.md / PRERELEASE.md / CLAUDE.md keep the stage
history.

- Rename the only stage-named identifiers: registerStage8 -> registerSocialOps,
  registerStage11 -> registerLinkOps (gateway transcode).
- Split stage6_test.go: TestEmailLoginFlow -> email_test.go,
  TestGuestAutoMatchLeavesNoStats (+ provisionGuest) -> account_test.go.
- Regenerated proto bindings (push.pb.go, telegram_grpc.pb.go) from the de-staged
  .proto comments; FB Go/TS bindings unchanged (flatc strips schema comments).

go build/vet/gofmt clean across modules; integration typecheck and pnpm check green.
This commit is contained in:
Ilia Denisov
2026-06-10 16:56:03 +02:00
parent a372343797
commit 8881214213
156 changed files with 749 additions and 778 deletions
+74 -77
View File
@@ -28,10 +28,10 @@ Three executables plus per-platform side-services:
- **`ui`** — pure-HTML5 client (plain Svelte 5 + TypeScript + Vite, static build;
no SvelteKit). Talks to `backend` only through `gateway` over Connect-RPC +
FlatBuffers, with the edge TS bindings generated from the **same** `edge.proto`
and `scrabble.fbs` and committed under `ui/src/gen/`. The **playable slice**
(Stage 7) covers auth, "my games", auto-match, the board (play/pass/exchange/
and `scrabble.fbs` and committed under `ui/src/gen/`. The client covers auth,
"my games", auto-match, the board (play/pass/exchange/
resign), hint, word-check, chat/nudge, the live stream, i18n (en/ru) and a profile
view; the social/account/history surfaces follow in Stage 8. There is no board on
view, plus the social/account/history surfaces. There is no board on
the wire — the client **reconstructs the 15×15 board by replaying the move
journal** (§9.1) and renders board, tiles, premium squares and effects as pure
CSS + Unicode (no image/font/SVG assets). Tiles are placed by Pointer-Events drag
@@ -41,7 +41,7 @@ Three executables plus per-platform side-services:
Embeddable in platform webviews; packageable to native (iOS/Android) via Capacitor.
The client uses a mobile-app shell (a growing nav bar; content pinned to the bottom),
a one-line **announcement banner** under the nav (a client-side mock rotation, **gated off
in the build until polished after release**, Stage 17 — a server-driven channel later, §10),
in the build until polished after release** — a server-driven channel later, §10),
and a client **board-style** setting (bonus-label
mode). The visual/interaction design system is documented in
[`UI_DESIGN.md`](UI_DESIGN.md).
@@ -89,7 +89,7 @@ dropped). Horizontal scaling is explicit future work.
auth operations are unauthenticated and return the minted token. A unary
operation's domain outcome rides back in `ExecuteResponse.result_code` (HTTP
200); only edge failures (rate limit, missing session, unknown type, internal)
surface as Connect error codes. The client (Stage 17) treats a connectivity edge failure as
surface as Connect error codes. The client treats a connectivity edge failure as
**state, not a per-call toast**: a transport `unavailable` or a `rate_limited` flips a global
`online` signal that drives a header **"Connecting…"** spinner and softly disables proactive
actions, and the transport **auto-retries with capped exponential backoff** — every op on a
@@ -98,7 +98,7 @@ dropped). Horizontal scaling is explicit future work.
response was lost — its button is disabled while offline and the player re-issues it on
reconnect). A reachability watcher (a lightweight `profile.get` probe) clears the signal when no
other traffic is in flight; the live `Subscribe` stream's drop/recovery feeds the same signal.
**Edge hardening (R3):** every request body on the public listener is capped at
**Edge hardening:** every request body on the public listener is capped at
`GATEWAY_MAX_BODY_BYTES` (default 1 MiB — far above any legitimate payload), both at the HTTP
layer (`http.MaxBytesReader`) and as the Connect per-message read limit, so an oversized
`Execute` is refused (`resource_exhausted`) without buffering. The h2c server carries explicit
@@ -106,8 +106,7 @@ dropped). Horizontal scaling is explicit future work.
`Subscribe` stream plus a few unary calls) and a 3-minute connection `IdleTimeout` (a live
`Subscribe` stream keeps its connection active, so only abandoned connections are reaped); the
`http.Server` sets only `ReadHeaderTimeout` (10 s) — Read/WriteTimeout would kill the stream.
R7 revisits the exact values under load.
- **Alphabet on the wire (Stage 13)**: live play exchanges **alphabet indices**, not
- **Alphabet on the wire**: live play exchanges **alphabet indices**, not
concrete letters. The rack (`StateView.rack`), the `SubmitPlay`/`Evaluate` tiles, the
`Exchange` tiles and the `CheckWord` word are `ubyte` indices into the variant's alphabet
(a blank is the sentinel index **255**). The client is **alphabet-agnostic**: on a
@@ -139,7 +138,7 @@ arrive from a platform rather than completing a mandatory registration).
bootstrap — then mints a **thin opaque server session token** (`session_id`). First
Telegram contact seeds the new account's language (from the launch `language_code`)
and display name (§4).
- **Service language & variant gating (Stage 15).** The connector hosts **one bot per
- **Service language & variant gating.** The connector hosts **one bot per
service language** (`en`/`ru`), each its own token + game channel; the same Telegram
user id spans both. `ValidateInitData` tries each token in turn and returns the
validating bot's **service language** and its **supported-languages set**. The set
@@ -151,7 +150,7 @@ arrive from a platform rather than completing a mandatory registration).
**persisted** per account (`accounts.service_language`, updated on every Telegram
login — last-login-wins) and routes the user's out-of-app push back through the right
bot (§10) — **except a game event, which routes by the game's own language** (its variant →
en/ru, Stage 17), so a game's notification always comes from the game's bot rather than the
en/ru), so a game's notification always comes from the game's bot rather than the
recipient's latest login bot. The service language is distinct from `preferred_language` (the
interface language) and from a game's variant language. Non-Telegram logins (web / email / guest) carry the
gateway's default set (`GATEWAY_DEFAULT_SUPPORTED_LANGUAGES`, all variants by default).
@@ -170,7 +169,7 @@ arrive from a platform rather than completing a mandatory registration).
require one, the same way the robot pool is durable), not a profile: no
friends, statistics or history are kept for it, and it is restricted to
auto-match. Platform and email users are auto-provisioned **durable** accounts
with an identity. (Reaping abandoned guest rows is deferred — PLAN.md TODO-3.)
with an identity.
## 4. Accounts, identities, linking & merge
@@ -179,15 +178,15 @@ arrive from a platform rather than completing a mandatory registration).
a platform auto-provisions a durable account bound to that platform identity.
Concretely, platform and email identities share one `identities` table keyed by
a unique `(kind, external_id)`; email is an identity with `kind=email` and a
`confirmed` flag. A synthetic `kind='robot'` identity (Stage 5) backs each pooled
robot opponent (§7). The **email confirm-code flow** (Stage 4) binds an email to the
`confirmed` flag. A synthetic `kind='robot'` identity backs each pooled
robot opponent (§7). The **email confirm-code flow** binds an email to the
authenticated account: a 6-digit code (stored only as a SHA-256 hash, 15-minute
TTL, ≤ 5 attempts) is sent through a `Mailer` seam (an SMTP relay, or a
development log mailer when none is configured) and, once verified, attaches a
confirmed email identity. Accounts and identities use application-generated
**UUIDv7** primary keys. A service flag `paid_account` (lifetime one-time
payment; no purchase flow yet) is carried on the account and ORed on a merge.
- **Linking** (Stage 11) is initiated from an authenticated profile and proves
- **Linking** is initiated from an authenticated profile and proves
control of the identity before attaching it: **email** through the confirm-code
flow, **Telegram** through the web **Login Widget** (validated by the connector,
HMAC under `SHA-256(bot_token)` — distinct from Mini App initData; the gateway
@@ -210,7 +209,7 @@ arrive from a platform rather than completing a mandatory registration).
already has a **durable** owner: then the durable account wins, the guest's active
games move into it, the guest is retired, and a **fresh session** is minted for the
durable account (the client switches to it). The secondary's sessions are revoked
(§3). High blast-radius; an isolated, well-tested stage.
(§3). High blast-radius; isolated and well-tested.
## 5. Game engine integration (`scrabble-solver`)
@@ -241,8 +240,7 @@ Key points:
boot version plus each subdirectory). In-flight games keep their pinned version;
new games use the latest. (The solver is published as a versioned module and the
dictionaries ship as a separate versioned **release artifact** from the
`scrabble-dictionary` repo — TODO-1/TODO-2, Stage 14; the runtime contract above is
unchanged.)
`scrabble-dictionary` repo; the runtime contract above is unchanged.)
- Move generation/validation/scoring use `Solver.GenerateMoves` (ranked),
`Solver.ValidatePlay` and `Solver.ScorePlay`; board mutation uses
`scrabble.Apply`. The engine adds its own deterministic, seeded tile **bag**
@@ -258,7 +256,7 @@ Key points:
unconditionally the other player in a two-player game. A player may resign **on the
opponent's turn** (a forfeit is not a turn-scoped move): `engine.ResignSeat(seat)`
resigns that player's own seat whoever is to move, and the game domain skips the turn
check for resign (Stage 17). The engine exposes a
check for resign. The engine exposes a
decoded, solver-free API (`SubmitPlay`/`SubmitExchange`/`EvaluatePlay`/
`HintView`/`Hand`) so `internal/game` drives it without importing the solver.
- The **game domain** (`internal/game`) owns everything the engine does not —
@@ -326,7 +324,7 @@ behaviour on every scan and after a restart — the same philosophy as journal
replay. A pool of durable accounts — each a `kind='robot'` identity (§4), keyed
`robot-<lang>-<index>` and provisioned at startup with **chat blocked but friend
requests open** — a request to a robot is accepted as pending and expires unanswered
(the robot never responds), mirroring a human who ignores it (Stage 17); the chat
(the robot never responds), mirroring a human who ignores it; the chat
block backs the human-like names (there is no DM surface; chat is per-game). Names are
**composed per language** from a first-name pool (32 full + 32 colloquial forms) and
a surname pool (gender-agreed for Russian) in one of three forms (first only /
@@ -352,12 +350,12 @@ English game the Latin pool.
rather than running anti-phase; on a daytime nudge it replies near the move's lower
band; it proactively nudges the idle human on a **lengthening, randomized schedule** — the
first ~60-90 min into the turn, each later reminder spaced further out toward 1-6 h — so a long
wait gets a handful of increasingly-spaced nudges rather than an hourly stream (Stage 17).
wait gets a handful of increasingly-spaced nudges rather than an hourly stream.
- **Observability**: robot accounts accrue ordinary statistics (§9) — the
authoritative balance metric (target ≈ 40% robot wins) — and a
`robot_games_finished_total` OTel counter plus a per-finish log give a live view.
The **admin game card** surfaces each robot seat's per-game play-to-win intent (from
the seed) and, on the robot's turn, its deterministic **next-move ETA** (Stage 17).
the seed) and, on the robot's turn, its deterministic **next-move ETA**.
## 8. Lobby & social
@@ -371,8 +369,8 @@ English game the Latin pool.
`Poll` remains as a fallback for a client that is not currently streaming.
**Cancel** (`POST /lobby/cancel`) removes the player from the pool and drops any
pending matched result, so a cancelled quick-match is dequeued rather than left for
the reaper to robot-substitute (Stage 17).
- **Friends** (Stage 8): two add paths over one `friendships` table. A **one-time
the reaper to robot-substitute.
- **Friends**: two add paths over one `friendships` table. A **one-time
code** the to-be-added player issues (a `friend_codes` row: 6-digit numeric,
SHA-256-hashed, **12 h** TTL, one live code per issuer, single-use, redeem
rate-limited) is redeemed by the other player to become friends immediately.
@@ -382,7 +380,7 @@ English game the Latin pool.
remembered (`status='declined'`) and blocks further requests from that sender,
unless they hand them a code, which overrides it. The requester's own cancel still
deletes the row; blocking someone severs an existing friendship. (Discovery by
friend list or platform deep-link arrives with Stage 9 / TODO-5.)
friend list or platform deep-link is future work.)
- **Block**: two independent **global** account toggles (`block_chat`,
`block_friend_requests`) **plus** a **per-user block list**. A per-user block is
applied mutually: it hides the pair's chat from each other and refuses friend
@@ -395,25 +393,25 @@ English game the Latin pool.
and **validated on input** — links, email addresses and phone numbers (including
lightly obfuscated forms) are rejected, since the chat is for quick reactions,
not contact exchange. Each message stores the sender's IP (forwarded by the
gateway in Stage 6) for moderation. A sender who has disabled chat cannot post,
gateway) for moderation. A sender who has disabled chat cannot post,
and messages from a blocked sender are hidden from the viewer. The operator console
has a **Messages** section (Stage 17) that lists posted messages (nudges excluded)
has a **Messages** section that lists posted messages (nudges excluded)
newest-first with the sender's resolved name, **source** (guest / robot / oldest
identity kind), IP and game, searchable by sender name / external-id glob masks and
pinnable to one game or sender (linked from the game and user cards).
- **Nudge**: folded into the chat as a `nudge` message kind. The player awaiting
the opponent may nudge **once per hour per game**; it is not allowed on one's own
turn. The platform-native delivery is wired with the gateway / platform
side-service (Stage 6 / 8).
turn. The platform-native delivery runs through the gateway and the platform
side-service.
- **Profile**: `preferred_language` (en/ru, edited in Settings), display name, email
(confirm-code binding, see §4), **timezone**, the daily **away window** and the
block toggles — all editable through `account.UpdateProfile`, which validates them
(Stage 8): a display name is Unicode letters joined by single ` `/`.`/`_`
block toggles — all editable through `account.UpdateProfile`, which validates them:
a display name is Unicode letters joined by single ` `/`.`/`_`
separators (no leading/trailing/adjacent separators, ≤ 32 runes); the timezone is a
fixed `±HH:MM` **UTC offset** (or a legacy IANA name) resolved by `account.ResolveZone`
for the sweeper and the robot's sleep (a fixed offset trades DST for a simple
picker); the away window is at most **12 h** (midnight-wrap aware). Linked platform
accounts and merge are Stage 11.
accounts and merge are covered in §4.
## 9. Persistence
@@ -423,23 +421,22 @@ English game the Latin pool.
into `internal/postgres/jet` and committed, regenerated by `cmd/jetgen`).
Migrations are embedded SQL applied with `pressly/goose/v3` at startup. Primary
keys are application-generated **UUIDv7**.
- Tables: `accounts` (durable internal accounts; Stage 3 added the away-window
columns `away_start`/`away_end` and the hint wallet `hint_balance`; Stage 6's
migration `00005` added the `is_guest` flag for ephemeral guest rows; Stage 9's
migration `00007` added the `notifications_in_app_only` out-of-app push toggle;
Stage 11's migration `00009` added the `paid_account` service flag and the
merge-tombstone columns `merged_into`/`merged_at`),
`identities` (platform/email/robot identities, unique `(kind, external_id)`;
Stage 5's migration `00004` admits the `robot` kind),
`sessions` (revoke-only opaque-token hashes), the Stage 3 game tables
`games` (Stage 4 added the `dropout_tiles` disposition column), `game_players`,
- Tables: `accounts` (durable internal accounts, carrying the away-window
columns `away_start`/`away_end`, the hint wallet `hint_balance`, the `is_guest`
flag for ephemeral guest rows, the `notifications_in_app_only` out-of-app push
toggle, the `paid_account` service flag and the merge-tombstone columns
`merged_into`/`merged_at`),
`identities` (platform/email/robot identities, unique `(kind, external_id)`,
the `kind` admitting `robot`),
`sessions` (revoke-only opaque-token hashes), the game tables
`games` (carrying the `dropout_tiles` disposition column), `game_players`,
`game_moves` (the move journal), `complaints` and `account_stats`, and the
Stage 4 social/lobby tables `friendships` (the request/accept graph), `blocks`
social/lobby tables `friendships` (the request/accept graph, its status admitting
`declined`), `blocks`
(per-user blocks), `chat_messages` (per-game chat and nudges), `email_confirmations`
(pending confirm-codes) and `game_invitations` / `game_invitation_invitees`
(friend-game invitations). Stage 8's migration `00006` widened the `friendships`
status to admit `declined` and added `friend_codes` (one-time add-a-friend codes).
Stage 17 added `game_drafts` (a player's in-progress rack order + board composition per
(pending confirm-codes), `game_invitations` / `game_invitation_invitees`
(friend-game invitations), `friend_codes` (one-time add-a-friend codes),
`game_drafts` (a player's in-progress rack order + board composition per
game) and `game_hidden` (`(account_id, game_id)` rows that drop a finished game from one
account's own lobby list, leaving it visible to the other players — finished-only and
irreversible by design, so there is no un-hide).
@@ -479,18 +476,18 @@ exposes none): the standard Poslfit dialect (UTF-8, `#player`/`#lexicon`
pragmas, `8G`/`H8` coordinates, lower-case blanks, `.` pass-throughs, `-TILES`
exchanges), plus `#note` lines for resignations and timeouts, which the standard
does not cover. **GCG export is offered only on a finished game** (`game.ErrGameActive`
otherwise, Stage 8), so an in-progress journal is never leaked mid-play; the client
otherwise), so an in-progress journal is never leaked mid-play; the client
shares the `.gcg` file via the Web Share API where available, else downloads it.
The Stage 13 alphabet-on-the-wire change does **not** touch this invariant: the live edge
The alphabet-on-the-wire transport does **not** touch this invariant: the live edge
exchanges alphabet indices, but the persisted journal (and everything derived from it —
replay, history, GCG) keeps the decoded concrete letters described above, so an archived
game still replays with the variant's `rules.Alphabet` alone, independent of any dictionary.
## 10. Notifications
Two channels: the **in-app live stream** (delivered from Stage 6) and
**platform-native push** (out-of-app, via the platform side-service — Stage 9).
Two channels: the **in-app live stream** and
**platform-native push** (out-of-app, via the platform side-service).
The backend emits notification intents through an in-process hub
(`internal/notify`, a `Publisher` seam installed on the game, social and lobby
services); a single backend→gateway **gRPC server-stream** (`Push.Subscribe`,
@@ -501,16 +498,16 @@ robot-driver and timeout-sweeper moves emit too; opponent-moved goes to **every
including the mover**, so the mover's own other devices and their lobby refresh — it is
in-app only, so the actor gets no out-of-app push for their own move), **chat-message** and **nudge**
(from the social service), **match-found** (from the matchmaker, §8), and **notify**
(Stage 8 — a lightweight "re-poll" signal carrying a sub-kind: friend-request,
(a lightweight "re-poll" signal carrying a sub-kind: friend-request,
friend-added, friend-declined, invitation or game-started; emitted on a friend-request,
on answering one (accept → friend-added, decline → friend-declined — to the original
requester, so a game screen watching that opponent re-derives its "add to friends" state,
Stage 17), and on an invitation create or its game start). Stage 17 added **game-over** (emitted to every
requester, so a game screen watching that opponent re-derives its "add to friends" state),
and on an invitation create or its game start). **game-over** is emitted to every
seat from the same game commit when a game finishes — any path: a closing play, all-pass,
resign or timeout) and **enriched your-turn** so the out-of-app push reads in full: it now
resign or timeout and **your-turn** is enriched so the out-of-app push reads in full: it
also carries the mover's display name, their last action and the main word of a scoring play,
and a **recipient-first** running score line (e.g. `120:95:80`, the reader's score first).
**R4 enriched the in-app stream into a delta channel** so the client renders from the event
The in-app stream is a **delta channel** so the client renders from the event
without a follow-up `game.state`: **opponent-moved** carries the committed move plus the post-move
summary (per-seat scores, whose turn, move count, status) and the bag size, which the client
applies to its per-game cache keyed on the **move count** — idempotent (a re-delivered or own-move
@@ -526,12 +523,12 @@ verbatim. A client that is not currently streaming falls back to the matchmaker'
match-found — the client polls **only while the stream is down**, since a live stream delivers
match-found itself; for the lobby **notification badge** (incoming friend requests + open
invitations) the client re-polls on the `notify` event and on lobby open / focus, covering a push
missed while the app was hidden. **Out-of-app platform push** (Stage 9) is a fallback
missed while the app was hidden. **Out-of-app platform push** is a fallback
the **gateway** routes from the same firehose: for an event whose recipient has **no
live in-app stream** it resolves the backend `/internal/push-target` (their Telegram
`external_id`, the **service language** — the bot they last signed in through, falling
back to the interface language — and the `notifications_in_app_only` flag). A **game** event,
however, carries the **game's own language** on the push (Stage 17), and the gateway routes by
however, carries the **game's own language** on the push, and the gateway routes by
that instead of the service language — so a game's notification always comes from the game's bot,
not the recipient's latest-login bot. It then asks the **Telegram connector** to deliver a
localized message with a Mini App deep-link button — only when the recipient has a Telegram
@@ -560,19 +557,19 @@ promotions) is future work and would deliver short markdown messages (text + lin
and the gateway↔connector calls. The OTLP **Collector** (OTLP/gRPC → Prometheus
metrics + Tempo traces), **Prometheus** (15d), **Tempo** (72h) and **Grafana**
(provisioned datasources + dashboards, behind the caddy `/_gm/grafana` Basic-Auth)
are stood up with the deploy (`deploy/`, Stage 16); the default exporter stays
are stood up with the deploy (`deploy/`); the default exporter stays
`none`, so CI needs no collector. The contour also runs **cAdvisor** (per-container
CPU/memory/network) and **postgres_exporter** (connections, cache-hit ratio,
transactions, db size), scraped by Prometheus and surfaced on the **Scrabble —
Resources** Grafana dashboard (R2), so the pre-release stress runs capture a resource
Resources** Grafana dashboard, which captures a resource
baseline; these export directly in Prometheus format (not through the collector).
- Per-request server-side timing via gin middleware from day one (the access log
carries method, route, status, latency and the active trace id). A
client-measured RTT piggybacked on the next request is a later enhancement.
- Domain/operational metrics (Stage 12), recorded through the meter and invisible
- Domain/operational metrics, recorded through the meter and invisible
until an exporter is configured: histograms `game_replay_duration` (journal
rebuild on a cache miss), `game_move_validate_duration` and `game_move_duration`
(Stage 17 — a seat's think time per committed move, attributed by `variant` and a
(a seat's think time per committed move, attributed by `variant` and a
`phase` of opening/middle/endgame; it aggregates **all** seats including robots,
whose synthetic timing dominates the tail, so per-human analysis lives in the admin
console, below); counters `games_started_total`, `games_abandoned_total` (a
@@ -581,18 +578,18 @@ promotions) is future work and would deliver short markdown messages (text + lin
`edge_request_duration` (the UI-perceived roundtrip, by `message_type`/`result`);
and Go runtime/heap metrics. Game-scoped metrics carry a `variant` attribute
(scrabble_en/scrabble_ru/erudit_ru).
- Per-user move-time analytics (Stage 17) are **offline**, derived in the admin
- Per-user move-time analytics are **offline**, derived in the admin
console from the move journal (`game_moves.created_at` deltas, the first move from
the game's creation), not Prometheus labels (which an `account_id` would explode):
the user list shows each account's min/avg/max think time, and the user-detail page
draws a zero-JS inline-SVG chart of min/mean/max by the player's move number.
- User metrics (Stage 16): a backend counter `accounts_created_total` (`kind` =
- User metrics: a backend counter `accounts_created_total` (`kind` =
telegram/email/guest; robots are a provisioned pool, not users, and are excluded)
and a gateway **in-memory** observable gauge `active_users` (`window` = 24h/7d) —
distinct accounts that performed an authenticated edge action in the window. The
gauge is single-process by design (single-instance MVP, §10): it is correct for one
gateway, resets on restart, and is a live operational figure, not a billing count.
- **Rate-limit observability (R3):** every limiter rejection increments the gateway
- **Rate-limit observability:** every limiter rejection increments the gateway
counter `gateway_rate_limited_total` (`class` = user/public/email/admin — aggregate
only, honouring the no-per-user-label discipline above) and logs one **Debug** line;
a gateway reporter drains the per-key rejection tracker every 30 s, emits one **Warn**
@@ -617,19 +614,19 @@ promotions) is future work and would deliver short markdown messages (text + lin
| Concern | Enforced by |
| --- | --- |
| Public rate limiting / anti-abuse | gateway (per-IP public/email/admin classes, per-user authenticated class; a request body cap of `GATEWAY_MAX_BODY_BYTES`; rejections are metered, summarised to the backend and surfaced in the admin console with a conservative reversible auto-flag — R3, §11) |
| Public rate limiting / anti-abuse | gateway (per-IP public/email/admin classes, per-user authenticated class; a request body cap of `GATEWAY_MAX_BODY_BYTES`; rejections are metered, summarised to the backend and surfaced in the admin console with a conservative reversible auto-flag — §11) |
| Telegram initData validation (bot-token HMAC) | the Telegram connector; the gateway delegates it over gRPC, so the bot token lives only in the connector |
| Session minting; email-code / guest validation | gateway (with backend) |
| Session → `user_id` resolution, `X-User-ID` injection | gateway |
| Authorisation, ownership, state transitions | backend (`X-User-ID` is the sole identity input) |
| Admin authentication | a single Basic-Auth gate on `/_gm/*`, forwarded **verbatim** to the backend's server-rendered admin console (and, in the deployed contour, routing `/_gm/grafana/*` to Grafana). In the deploy the **caddy** owns this gate (§13); a local non-caddy run uses the gateway's own `GATEWAY_ADMIN_*` proxy, which the per-IP admin limiter class guards ahead of its Basic-Auth (R3) — the caddy-fronted path has no limiter (stock caddy), an accepted gap. The backend trusts the proxy (no admin principal) and guards its state-changing POSTs with a **same-origin** check — the console's CSRF defence. No operator identity is tracked |
| Admin authentication | a single Basic-Auth gate on `/_gm/*`, forwarded **verbatim** to the backend's server-rendered admin console (and, in the deployed contour, routing `/_gm/grafana/*` to Grafana). In the deploy the **caddy** owns this gate (§13); a local non-caddy run uses the gateway's own `GATEWAY_ADMIN_*` proxy, which the per-IP admin limiter class guards ahead of its Basic-Auth — the caddy-fronted path has no limiter (stock caddy), an accepted gap. The backend trusts the proxy (no admin principal) and guards its state-changing POSTs with a **same-origin** check — the console's CSRF defence. No operator identity is tracked |
| backend ↔ gateway ↔ connector trust | the network (only gateway may reach backend; the connector serves unauthenticated gRPC on the internal segment) |
This is an explicit, accepted MVP risk: compromise of the gateway↔backend
network segment defeats backend authentication. Mitigated by network isolation;
mutual auth is a future hardening step.
**Short numeric codes** (email confirm-codes and Stage 8 friend codes) are stored
**Short numeric codes** (email confirm-codes and friend codes) are stored
only as SHA-256 hashes and are short-lived and single-use. The unauthenticated
email path carries a tight per-IP sub-limit (5 / 10 min); the **friend-code redeem**
is authenticated, so it rides the per-user limit (300 / min) and is further bounded
@@ -645,7 +642,7 @@ Single public origin, path-routed. The Vite build has two entries: a lightweight
(`go:embed`, baked in by a node stage in `gateway/Dockerfile`) and serves it at
`/app/` (web) and `/telegram/` (the Telegram Mini App; outside Telegram that path
redirects to the root — the client-side guard); a stray hit on the gateway's `/`
308-redirects to `/app/`. The **landing** ships in its own static container (R3): the
308-redirects to `/app/`. The **landing** ships in its own static container: the
`landing` target of `gateway/Dockerfile` (caddy:2-alpine + the same Vite build,
`deploy/landing/Caddyfile`) serves it at `/`, so stray public traffic is absorbed by
static file serving and never reaches the Go edge. Hash-named `/assets/*` are served
@@ -671,23 +668,23 @@ network (project-scoped DNS); only caddy joins the shared external `edge` networ
(alias `scrabble`).
Two contours, two secret/variable prefixes (`TEST_` / `PROD_`):
- **Test** (Stage 16): auto-deploys on a PR into — or a push to — `development`
- **Test**: auto-deploys on a PR into — or a push to — `development`
(`.gitea/workflows/ci.yaml``docker compose up -d --build` on the Gitea runner
host, then `GET /` + `GET /app/` probes through caddy — the landing container and
the gateway, R3). The host caddy terminates TLS and
the gateway). The host caddy terminates TLS and
forwards the domain to `scrabble:80`, so the in-compose caddy serves plain HTTP
(`CADDY_SITE_ADDRESS=:80`). The in-compose caddy **trusts X-Forwarded-For from
private-range upstreams** (`trusted_proxies private_ranges`), so the real client IP —
used for chat-moderation logging and the gateway's per-IP rate limiting — survives the
host-caddy hop; in prod (no host caddy) public clients are untrusted and Caddy uses the
real peer, so the single config is correct and spoof-safe in both contours (Stage 17).
- **Prod** (Stage 18): a manual SSH deploy after `development → master`. There is no
real peer, so the single config is correct and spoof-safe in both contours.
- **Prod**: a manual SSH deploy after `development → master`. There is no
host caddy, so the contour ships its own caddy terminating TLS — set
`CADDY_SITE_ADDRESS` to the domain and the caddy does its own ACME.
## 14. CI & branches
- **Two long-lived branches** (Stage 16): **`development`** is the integration
- **Two long-lived branches**: **`development`** is the integration
trunk and **`master`** the production trunk; `feature/*` branches are cut from
`development` and PR back into it (the genesis commit necessarily landed on
`master`). A commit to a `feature/*` branch triggers nothing.
@@ -695,18 +692,18 @@ Two contours, two secret/variable prefixes (`TEST_` / `PROD_`):
suite on a PR into `development`/`master` and on a push to `development`. Its
`unit` (gofmt/vet/build/unit-test), `integration` (Postgres-backed `integration`
tag, testcontainers `postgres:17-alpine`, Ryuk off, serial) and `ui`
(check/unit/build/bundle-budget/e2e) jobs are **path-conditional** (Stage 17 — a
(check/unit/build/bundle-budget/e2e) jobs are **path-conditional** (a
`changes` job filters by changed paths), and an always-running **`gate`** job
aggregates them (passing when each succeeded or was **skipped**) and is the single
branch-protection required check (`CI / gate`), so a path-skipped job never blocks
a merge.
- A gated **`deploy`** job auto-rolls the **test contour** on a PR into — or a push
to — `development` (`docker compose up -d --build` on the runner host), then probes
the gateway (`GET /`) **and the Telegram connector's liveness** (Stage 17 —
the gateway (`GET /`) **and the Telegram connector's liveness** (via
`docker inspect`: running, not restarting, stable restart count, with a
VPN-handshake grace period, since the connector has no public ingress and a
crash-loop is otherwise invisible). A PR into `master` is test-only; the prod
deploy is the manual Stage 18 workflow. Secrets/variables are prefixed
deploy is the manual workflow. Secrets/variables are prefixed
`TEST_`/`PROD_` per contour.
- The engine consumes `scrabble-solver` as a **published, versioned module**
(`gitea.iliadenisov.ru/developer/scrabble-solver`, pinned in `backend/go.mod`); both Go
@@ -714,6 +711,6 @@ Two contours, two secret/variable prefixes (`TEST_` / `PROD_`):
(no public proxy/checksum DB, no sibling clone). The dictionaries ship as a **release
artifact** from the `scrabble-dictionary` repo; the workflows download
`scrabble-dawg-<DICT_VERSION>.tar.gz` and point the engine tests at it via
`BACKEND_DICT_DIR` (TODO-1/TODO-2 discharged in Stage 14).
`BACKEND_DICT_DIR`.
- After any push, the run is watched to green before a stage is declared done
(`python3 ~/.claude/bin/gitea-ci-watch.py`).
+19 -20
View File
@@ -4,18 +4,17 @@ Per-domain user stories: what each user-visible operation does. This is the
starting point for any change request that touches behaviour. The English
version is authoritative; [`FUNCTIONAL_ru.md`](FUNCTIONAL_ru.md) is a mirror for
the project owner — mirror every point edit in the same patch (translate only
the changed paragraphs). Sections deepen as stages land; *(Stage N)* marks where
the detail is authored.
the changed paragraphs).
## Domains
### Client app *(Stage 7 / 8)*
The web/app client (Svelte + Vite) realizes these stories. The **playable slice**
(Stage 7) covers signing in (guest or email), the "my games" lobby, starting an
### Client app
The web/app client (Svelte + Vite) realizes these stories. It
covers signing in (guest or email), the "my games" lobby, starting an
auto-match, playing the board (place tiles by drag or tap, pass, exchange, resign),
the top-1 hint, the unlimited word-check with complaint, per-game chat and nudge,
real-time in-app updates, switching interface language (en/ru) and theme, and a
read-only profile. **Stage 8** adds managing friends (including one-time friend
read-only profile. It also handles managing friends (including one-time friend
codes) and blocks, friend-game invitations, editing the profile and binding an
email, the statistics screen, and the in-game history viewer with GCG export.
Settings also pick the board's bonus-label style (beginner / classic / none). A hint **lays the suggested tiles on the board** for the player to confirm and
@@ -26,7 +25,7 @@ theme, and links to the matching per-language Telegram channel; the game itself
`/app/` (web) and `/telegram/` (the Telegram Mini App). The landing's theme is ephemeral
(it follows the system scheme, not the saved preference); its language choice is saved.
### Identity & sessions *(Stage 1 / 6 / 9 / 15)*
### Identity & sessions
A player arrives from a platform (Telegram first), via email login, or as an
ephemeral guest. The gateway validates the credential once and mints a thin
session token; the backend resolves it to an internal `user_id`. A **Telegram Mini
@@ -56,7 +55,7 @@ pause until it is back (a server-data screen still opens, with the spinner, and
reconnect), and pending reads resume on their own — the interface stays usable instead of
flashing a red banner each time.
### Accounts, linking & merge *(Stage 1 / 11)*
### Accounts, linking & merge
First platform contact auto-provisions a durable account. From the profile a player
links an email (via a confirm code) or their Telegram (via the web sign-in); a guest
who links their first identity becomes a durable account. The "already taken" status
@@ -68,12 +67,12 @@ when a guest links an identity that already has a durable account, where the dur
account is kept and the guest's games move into it. A merge is blocked only while the
two accounts share a game still in progress.
### Lobby & matchmaking *(Stage 4 / 15)*
### Lobby & matchmaking
Bottom tab menu: **my games**, **profile**. The **my games** list groups games into three
sections — *your turn*, *opponent's turn* and *finished* (empty sections are hidden) — and
orders them so the games awaiting your move come first, the longest-waiting on top, while
opponent-turn and finished games are most-recent first; it renders as a compact,
line-separated list (Stage 17). You can **remove a finished game from your own list**:
line-separated list. You can **remove a finished game from your own list**:
swipe a finished row left (or, on desktop, tap its **⋮**) to reveal a **❌**, then tap it.
The removal is per-account and permanent — the game disappears only from your list and stays
in the other players' lists, and there is no undo. The game types offered on **New Game** are
@@ -84,13 +83,13 @@ unrestricted). Variants are shown by their **display name** — both Scrabble va
the same name titles the in-game screen. This gates only **starting** a new game — both auto-match and a friend
invitation — so a player still sees and plays existing games of any language. Auto-match
(always 2 players) joins a per-variant pool and is paired with the next waiting human;
after 10 s with no human the robot substitutes (the robot arrives in Stage 5). Friend games (24) are
after 10 s with no human the robot substitutes. Friend games (24) are
formed by inviting players from the friend list (an invitation, like a friend code,
is shareable as a Telegram deep link that opens it directly): the inviter chooses the
settings and the game starts once every invitee has accepted — any decline cancels it, and an unanswered invitation
expires after seven days.
### Playing a game *(Stage 3)*
### Playing a game
Place tiles, pass, exchange, or resign. A play is validated against the game's
dictionary at submit time and scored; an unlimited preview reports what a
tentative move would score and whether it is legal. The dictionary check tool is
@@ -111,7 +110,7 @@ and restored on return (including on another device); a player may **arrange til
the opponent's turn**, but that draft is position-only — the score preview and submission
stay available only on the player's own turn.
### Robot opponent *(Stage 5)*
### Robot opponent
When auto-match finds no human within ten seconds, a robot opponent takes the empty
seat so the game starts without waiting. It is meant to feel like a person: it
decides once per game whether to play to win (about 40% of the time, so the human
@@ -123,7 +122,7 @@ carries a human-like, language-appropriate name (a Russian game draws mostly Rus
names); it does not chat, and **silently ignores friend requests** — a request to a
robot stays pending and expires, exactly like a human who never responds.
### Social: friends, block, chat, nudge *(Stage 4 / 8)*
### Social: friends, block, chat, nudge
Become friends in two ways: redeem a **one-time code** the other player issues (six
digits, valid for twelve hours), or send a **request to someone you have played
with** — they accept, ignore it (a request lapses after thirty days and can then be
@@ -132,7 +131,7 @@ a code). Cancelling your own pending request withdraws it; unfriending removes t
friendship. In a game, an **add to friends** item for each opponent mirrors the live
relationship: it reads *request sent* (disabled) while a request is pending or was
declined, and *in friends* once accepted — updating in place the moment the opponent
answers, and staying correct across reloads (Stage 17). Block globally — switch off incoming chat
answers, and staying correct across reloads. Block globally — switch off incoming chat
and/or friend requests — and block individual players (a per-user block hides that
person's chat and stops requests and game invitations both ways; it also ends any
existing friendship). Per-game chat is for quick reactions: messages are short
@@ -142,16 +141,16 @@ nudge is part of the game chat); the out-of-app push is delivered via the platfo
Chat and the word-check tool open as their **own screens** (with a back to the game), and a
new chat message raises an **unread badge** on the game's menu until the chat is opened.
### Profile & settings *(Stage 4 / 8)*
### Profile & settings
Edit the display name (letters joined by a single space / "." / "_" separator, with an
optional trailing ".", up to 32 characters and at most 5 special characters — the "." / "_"
punctuation, spaces aside), the timezone (chosen as a UTC offset), the
daily away window (on a 10-minute grid, at most 12 hours, wrapping midnight) and the
block toggles. The profile form is edited inline (no separate edit mode). Linking
an email or Telegram and merging accounts are covered under "Accounts, linking &
merge" (Stage 11).
merge".
### History & statistics *(Stage 3 / 8)*
### History & statistics
Finished games are archived in a dictionary-independent form and exportable to
GCG; the export is offered **only once a game is finished** (exporting a live game
would leak the move journal), and the client shares the `.gcg` file where the
@@ -159,7 +158,7 @@ platform supports it, otherwise downloads it. Statistics (durable accounts only)
wins, losses, draws, max points in a game, and max points for a single move (the
best play, which already includes every word it formed plus the all-tiles bonus).
### Administration *(Stage 10)*
### Administration
Operators reach a server-rendered admin console at `${DOMAIN}/_gm` — the backend
renders it; the gateway gates it with HTTP Basic Auth on its public listener and
proxies it verbatim. The console lists and inspects **users** (profile, statistics,
@@ -173,7 +172,7 @@ applied after a reload). When a Telegram connector is configured an operator can
State-changing actions are protected by a same-origin check; the console tracks no
operator identity.
The console also surfaces **rate-limit abuse** (R3): a **Throttled** page lists the
The console also surfaces **rate-limit abuse**: a **Throttled** page lists the
recently throttled users/IPs the gateway reported (an in-memory window — it resets on
a backend restart) and the accounts currently carrying the soft **high-rate flag**. An
account sustaining rejections past a tunable threshold is flagged automatically —
+19 -20
View File
@@ -3,18 +3,17 @@
Пользовательские сценарии по доменам: что делает каждая видимая пользователю
операция. Это зеркало [`FUNCTIONAL.md`](FUNCTIONAL.md) для владельца проекта;
**авторитетна английская версия**. Любую точечную правку переносим в том же
патче (переводим только изменённые абзацы). Разделы наполняются по мере этапов;
*(Stage N)* помечает, где пишется детализация.
патче (переводим только изменённые абзацы).
## Домены
### Клиентское приложение *(Stage 7 / 8)*
Веб/приложение-клиент (Svelte + Vite) воплощает эти истории. **Играбельный срез**
(Stage 7) покрывает вход (гость или email), лобби «мои игры», старт авто-подбора,
### Клиентское приложение
Веб/приложение-клиент (Svelte + Vite) воплощает эти истории. Он
покрывает вход (гость или email), лобби «мои игры», старт авто-подбора,
игру на доске (постановка фишек перетаскиванием или тапом, пас, обмен, сдача),
top-1 подсказку, безлимитную проверку слова с жалобой, чат и nudge в партии,
обновления в реальном времени, переключение языка интерфейса (en/ru) и темы и
профиль только для чтения. **Stage 8** добавляет управление друзьями (в т.ч.
профиль только для чтения. Он также включает управление друзьями (в т.ч.
одноразовые коды-приглашения) и блоками, дружеские приглашения в игру,
редактирование профиля и привязку email, экран статистики и просмотр истории
партии с экспортом GCG. В настройках также выбирается стиль подписей бонус-клеток
@@ -27,7 +26,7 @@ top-1 подсказку, безлимитную проверку слова с
`/app/` (веб) и `/telegram/` (Telegram Mini App). Тема на странице эфемерна (берётся из
системной настройки, а не из сохранённой), выбор языка сохраняется.
### Личность и сессии *(Stage 1 / 6 / 9 / 15)*
### Личность и сессии
Игрок приходит с платформы (сначала Telegram), через email-вход или как
эфемерный гость. Gateway один раз валидирует доступ и выдаёт тонкий
session-токен; backend сопоставляет его с внутренним `user_id`. Запуск **Telegram
@@ -58,7 +57,7 @@ nudge) приходят от бота **этой партии** — по язы
подгружается при реконнекте), а незавершённые чтения возобновляются сами — интерфейс остаётся
рабочим вместо красного баннера каждый раз.
### Аккаунты, привязка и слияние *(Stage 1 / 11)*
### Аккаунты, привязка и слияние
Первый контакт с платформы заводит постоянный аккаунт. Из профиля игрок
привязывает email (по confirm-коду) или свой Telegram (через веб-вход); гость,
привязавший первую личность, становится постоянным аккаунтом. Факт «личность уже
@@ -70,12 +69,12 @@ nudge) приходят от бота **этой партии** — по язы
тогда сохраняется постоянный аккаунт, а игры гостя переходят в него. Слияние
запрещено, только пока у аккаунтов есть общая незавершённая игра.
### Лобби и подбор *(Stage 4 / 15)*
### Лобби и подбор
Нижнее tab-меню: **мои игры**, **профиль**. Список **мои игры** разбит на три секции —
*твой ход*, *ход соперника* и *завершённые* (пустые секции скрыты) — и упорядочен так,
что игры, ждущие твоего хода, идут первыми, дольше всего ждущие сверху, а игры на ходу
соперника и завершённые — самые свежие сверху; отображается компактным списком с
линиями-разделителями (Stage 17). Завершённую партию можно **убрать из своего списка**:
линиями-разделителями. Завершённую партию можно **убрать из своего списка**:
проведи по строке завершённой партии влево (или, на десктопе, нажми её **⋮**), чтобы открыть
**❌**, и нажми её. Удаление действует только для твоего аккаунта и необратимо — партия
исчезает лишь из твоего списка и остаётся в списках других игроков, отмены нет. Типы партий
@@ -88,13 +87,13 @@ nudge) приходят от бота **этой партии** — по язы
приглашение друга, — поэтому игрок по-прежнему видит и играет существующие игры на
любом языке. Авто-подбор (всегда 2 игрока)
встаёт в пул по варианту и сводится со следующим ожидающим человеком; через 10 с
без человека подставляется робот (робот — в Stage 5). Игры с друзьями (2–4)
без человека подставляется робот. Игры с друзьями (2–4)
формируются приглашением игроков из списка друзей (приглашение, как и код друга,
можно отправить deep-link'ом в Telegram, который откроет его сразу): инициатор
выбирает настройки, и партия стартует, когда приняли все приглашённые — любой отказ отменяет приглашение, а без
ответа приглашение протухает через семь дней.
### Игровой процесс *(Stage 3)*
### Игровой процесс
Выкладывание фишек, пас, обмен или сдача. Ход проверяется по словарю партии при
сдаче и считается; безлимитный предпросмотр сообщает, сколько принёс бы
предполагаемый ход и легален ли он. Инструмент проверки слова безлимитный и
@@ -115,7 +114,7 @@ nudge) приходят от бота **этой партии** — по язы
**раскладывать фишки и в ход соперника**, но такой черновик только позиционный —
предпросмотр счёта и отправка доступны лишь в собственный ход.
### Робот-соперник *(Stage 5)*
### Робот-соперник
Если авто-подбор не находит человека за десять секунд, свободное место занимает
робот-соперник, и партия стартует без ожидания. Он задуман неотличимым от человека:
один раз за партию решает, играть ли на победу (примерно в 40% случаев, так что
@@ -127,7 +126,7 @@ nudge) приходят от бота **этой партии** — по язы
**молча игнорирует заявки в друзья** — заявка роботу остаётся в ожидании и истекает,
ровно как у человека, который не отвечает.
### Социальное: друзья, блок, чат, nudge *(Stage 4 / 8)*
### Социальное: друзья, блок, чат, nudge
Подружиться можно двумя способами: погасить **одноразовый код**, который выпускает
другой игрок (шесть цифр, действует двенадцать часов), либо отправить **заявку
тому, с кем вы играли** — он принимает, игнорирует (заявка истекает через тридцать
@@ -136,7 +135,7 @@ nudge) приходят от бота **этой партии** — по язы
снимает её; удаление расторгает дружбу. В партии пункт меню **в друзья** для каждого
соперника отражает живое отношение: он показывает *заявка отправлена* (неактивный),
пока заявка висит или была отклонена, и *в друзьях* после принятия — обновляясь на месте
в момент ответа соперника и оставаясь верным после перезагрузки (Stage 17). Глобальная блокировка — отключить входящие
в момент ответа соперника и оставаясь верным после перезагрузки. Глобальная блокировка — отключить входящие
чат и/или заявки —
и блокировка конкретного игрока (пер-юзер блок скрывает его чат и запрещает заявки
и приглашения в игру в обе стороны, а также расторгает уже имеющуюся дружбу). Чат
@@ -147,16 +146,16 @@ push доставляется через платформу.
Чат и инструмент проверки слова открываются **отдельными экранами** (с кнопкой «назад» в
партию), а новое сообщение в чате рисует **бейдж непрочитанного** на меню партии до открытия чата.
### Профиль и настройки *(Stage 4 / 8)*
### Профиль и настройки
Редактирование отображаемого имени (буквы, разделённые одиночным пробелом / «.» /
«_», с необязательной завершающей «.», до 32 символов и не более 5 спецсимволов —
пунктуации «.» / «_», пробелы не в счёт), таймзоны (выбор смещения от
UTC), суточного окна отсутствия (away; сетка по 10 минут, не более 12 часов, с
переходом через полночь) и переключателей блокировок. Форма профиля редактируется
сразу (без отдельного режима редактирования). Привязка email и Telegram, а также
слияние аккаунтов вынесены в раздел «Аккаунты, привязка и слияние» (Stage 11).
слияние аккаунтов вынесены в раздел «Аккаунты, привязка и слияние».
### История и статистика *(Stage 3 / 8)*
### История и статистика
Завершённые партии архивируются в независимом от словаря виде и экспортируются
в GCG; экспорт доступен **только после завершения партии** (экспорт идущей партии
раскрыл бы журнал ходов), и клиент делится файлом `.gcg` там, где платформа это
@@ -164,7 +163,7 @@ UTC), суточного окна отсутствия (away; сетка по 10
победы, поражения, ничьи, макс. очков за партию и макс. очков за один ход (лучший
ход, уже включающий все образованные им слова и бонус за все фишки).
### Администрирование *(Stage 10)*
### Администрирование
Оператор открывает серверную админ-консоль по адресу `${DOMAIN}/_gm` — её рендерит
backend; gateway закрывает её HTTP Basic Auth на публичном порту и проксирует
один-в-один. В консоли можно смотреть **пользователей** (профиль, статистика,
@@ -177,7 +176,7 @@ identity, их игры) и **игры** (сводка + места), разби
Telegram-identity) или **отправить пост в игровой канал**. Изменяющие действия
защищены проверкой same-origin; личность оператора не отслеживается.
Консоль также показывает **злоупотребление лимитами** (R3): страница **Throttled**
Консоль также показывает **злоупотребление лимитами**: страница **Throttled**
перечисляет недавно затроттленных пользователей/IP по отчётам gateway (окно в памяти —
сбрасывается при рестарте backend) и аккаунты с действующим мягким **high-rate
флагом**. Аккаунт, устойчиво превышающий настраиваемый порог отказов, помечается
+22 -22
View File
@@ -9,19 +9,19 @@ tests or touching CI.
Every functional change ships with regression coverage. Run:
`go test -count=1 ./backend/... ./pkg/... ./gateway/...` (the module list grows
with the workspace).
- **Integration** *(Stage 1+)* — Postgres-backed tests behind the `integration`
- **Integration** — Postgres-backed tests behind the `integration`
build tag spin a throwaway `postgres:17-alpine` via `testcontainers-go`. They
live in `backend/internal/inttest` and run with
`go test -tags=integration -count=1 -p=1 ./backend/...` (needs Docker), guarded
by a separate CI workflow (`integration.yaml`; Ryuk disabled, serial). Slow.
- **UI** *(introduced with the UI in Stage 7)* — Vitest (unit) + Playwright
(e2e), mirroring the chosen plain-Svelte + Vite toolchain. Stage 8 adds Vitest for
the new FlatBuffers codecs (friend list, invitation, stats), the win-rate
- **UI** — Vitest (unit) + Playwright
(e2e), mirroring the chosen plain-Svelte + Vite toolchain. Vitest covers
the FlatBuffers codecs (friend list, invitation, stats), the win-rate
derivation and the GCG share/download choice, plus Playwright specs against the
mock for the friends screen (code issue/redeem, accept a request), the lobby
invitations section, the stats screen, profile editing, and the GCG export's
finished-only visibility.
- **Engine** *(Stage 2+)* — correctness of scoring and move generation is owned
- **Engine** — correctness of scoring and move generation is owned
by `scrabble-solver`'s own GCG-backed tests. `backend/internal/engine` adds, on
top of the embedded solver: per-variant smoke tests (load all three committed
DAWGs and validate a known word, including Эрудит), bag draw/return determinism
@@ -32,33 +32,33 @@ tests or touching CI.
win/loss rule** (the resigner keeps their score yet loses). The engine tests
read the DAWGs from `BACKEND_DICT_DIR` (or the sibling `scrabble-solver`
checkout) and fail loudly when it is absent.
- **Game domain** *(Stage 3+)*`backend/internal/game` adds pure unit tests
- **Game domain** — `backend/internal/game` adds pure unit tests
(the GCG writer, the away-window / effective-deadline boundaries, the hint
budget, the live-game cache and per-game lock, payload round-trips) plus
Postgres-backed integration tests in `inttest` (full lifecycle to a natural
end, **journal-replay equivalence**, the turn-timeout sweep with away-window
grace, resign win/loss and statistics, the hint allowance-then-wallet policy,
word-check and complaint capture, and per-game-lock serialisation). Stage 4 adds
word-check and complaint capture, and per-game-lock serialisation). It also covers
the engine's **multi-player drop-out** cases (continue after one resign,
last-survivor win, the tile-disposition bag effect) and a domain integration test
for a 3-player **timeout that continues**. The engine also gains a `Candidates`
ranked/decoded test (Stage 5).
- **Social & lobby** *(Stage 4+)*`backend/internal/social` unit-tests the chat
for a 3-player **timeout that continues**, and the engine's `Candidates`
ranked/decoded test.
- **Social & lobby** — `backend/internal/social` unit-tests the chat
**content filter** (links/emails/phones plus obfuscated forms) and
`backend/internal/lobby` unit-tests the in-memory **matchmaker** (FIFO pairing,
cancel, per-variant pools, plus the Stage 5 **robot substitution** reaper and
cancel, per-variant pools, plus the **robot substitution** reaper and
`Poll` delivery) with fake game-creator and robot-provider seams. Postgres-backed
`inttest` covers the friend request/accept lifecycle with the block/toggle guards,
the per-user block (and its severing of friendships), chat post/list with the IP,
content and block-visibility rules, the nudge turn/rate-limit rules, the
invitation flow (all-accept starts the game, decline cancels, lazy expiry,
inviter-only cancel), and the email confirm-code flow (request/confirm, taken
email, expiry and attempt-cap) with a fixture mailer. Stage 8 adds the
email, expiry and attempt-cap) with a fixture mailer. It also covers the
**befriend-an-opponent** gate (a request needs a shared game), the **permanent
decline** and 30-day re-send rule, the **one-time friend code** (issue/redeem,
self/single-use, decline-bypass), `ListInvitations`, the zero-value `GetStats`, and
the GCG **finished-only** gate.
- **Robot** *(Stage 5+)*`backend/internal/robot` unit-tests the pure strategy:
- **Robot** — `backend/internal/robot` unit-tests the pure strategy:
the ≈ 40% play-to-win split over many seeds, the right-skewed move-delay
(bounds, ~10-min median, determinism), the margin selection (win/lose, in-band
and out-of-band fallbacks, no-play exchange/pass), the sleep window with drift
@@ -66,7 +66,7 @@ tests or touching CI.
drives a robot through a full auto-match to a natural end (asserting a robot
statistics row), the matchmaker substitution end-to-end (enqueue → reap →
`[human, robot]`, discoverable via `Poll`), and a proactive 12-hour nudge.
- **Gateway & contracts** *(Stage 6+)*`backend/internal/notify` unit-tests the
- **Gateway & contracts** — `backend/internal/notify` unit-tests the
hub fan-out (delivery, overflow drop, unsubscribe) and the FlatBuffers event
constructors (payload round-trip). `gateway/...` unit-tests are hermetic (no
real network — an `httptest` fake backend and fixtures): the Telegram initData
@@ -76,20 +76,20 @@ tests or touching CI.
unsubscribe), the transcode round-trips (FlatBuffers↔JSON, X-User-ID
forwarding, nested GameView, domain-code surfacing), the admin Basic-Auth
reverse proxy (401 / forward), and a full Connect `Execute` path end to end
(guest auth, unauthenticated rejection, unknown message type). **R3** adds the
(guest auth, unauthenticated rejection, unknown message type). The
edge-hardening cases: an oversized `Execute` payload is refused
(`resource_exhausted`, the `GATEWAY_MAX_BODY_BYTES` cap), a limiter rejection
lands in `gateway_rate_limited_total{class}` and the rejection tracker
(drain/aggregate unit tests), the report POST reaches
`/api/v1/internal/ratelimit/report` with the agreed JSON shape, the `/_gm`
mount is 429-guarded by the per-IP admin class, and the gateway's `/`
308-redirects to `/app/` (the landing left the embed). The backend gains
308-redirects to `/app/` (the landing left the embed). The backend covers
the **guest** lifecycle (a guest plays an auto-match to a natural end yet accrues
no statistics) and the **email-as-login** flow (request/verify, returning user)
in `inttest`. Stage 8 adds gateway transcode round-trips for the new social/account
in `inttest`. Gateway transcode round-trips cover the social/account
operations (friends list, friend code issue/redeem, invitation create, stats, GCG,
the profile-update away round-trip) and a `notify`-event constructor round-trip.
- **Admin & dictionary ops** *(Stage 10)*`backend/internal/adminconsole` unit-tests
- **Admin & dictionary ops** — `backend/internal/adminconsole` unit-tests
the template renderer over every page plus the embedded asset; `backend/internal/engine`
adds the **dictionary hot-reload** cases (`LoadAvailable` loads only the present
variants, `OpenWithVersions` scans version subdirectories, a reload registers a new
@@ -99,13 +99,13 @@ tests or touching CI.
404 when not). Postgres-backed `inttest` drives the **complaint resolution →
dictionary-change pipeline** (file → resolve with a disposition → pending change → mark
applied), the admin **list/count** read queries, and the **/_gm console over HTTP**
(pages render; a resolve POST needs a same-origin header). **R3** adds `ratewatch`
(pages render; a resolve POST needs a same-origin header). `ratewatch` has
unit tests (window accumulation, the auto-flag threshold + expiry, the bounded
episode map), the account-store **high-rate flag round-trip** (set-once / clear /
re-flag) and a console flow in `inttest`: a gateway report auto-flags the account,
the **Throttled** page shows the episode and the flagged queue, the user card
carries the marker and the CSRF-guarded **Clear** reverses it.
- **Observability & performance** *(Stage 12)*`pkg/telemetry` unit-tests the exporter
- **Observability & performance** — `pkg/telemetry` unit-tests the exporter
selection (`none`/`stdout`/`otlp` build providers; OTLP constructs with no collector;
the nil-runtime fallback). The domain metrics are exercised through a manual
`sdkmetric` reader: `backend/internal/game` and `…/social` assert the counters and
@@ -115,7 +115,7 @@ tests or touching CI.
`otlp` now accepted, an unsupported exporter rejected) and the guest-reaper knobs.
Postgres-backed `inttest` drives the **guest reaper** end to end (an abandoned guest is
reaped; a too-young guest, a seated guest and a durable account are kept).
- **Load test & resource baseline** *(R2)* — a reusable `loadtest/` module
- **Load test & resource baseline** — a reusable `loadtest/` module
(`scrabble/loadtest`) is the pre-release stress harness. It **seeds** a large account
population with pre-created sessions directly in Postgres (token hashes matching
`backend/internal/session`), **drives** virtual players through the edge protocol —
@@ -128,7 +128,7 @@ tests or touching CI.
engine tests do). It is **not** part of the per-PR suite's behavioural assertions: it
runs ad hoc as a one-shot container against the contour, producing a trip report (bugs
+ a resource baseline) read off the **cAdvisor + postgres_exporter** Grafana dashboard
added to the contour in R2. See [`../loadtest/README.md`](../loadtest/README.md).
on the contour. See [`../loadtest/README.md`](../loadtest/README.md).
## Principles
+11 -11
View File
@@ -5,7 +5,7 @@ Visual and interaction conventions for the `ui` client. Behaviour lives in
points this doc references) lives in [`ARCHITECTURE.md`](ARCHITECTURE.md). The client
is **pure HTML5/CSS + Unicode** — no image/font/SVG assets; icons are CSS shapes or
emoji glyphs. Tokens are CSS custom properties (`ui/src/app.css`), light/dark via
`prefers-color-scheme` or an explicit Settings choice, and **Telegram-themed** (Stage 9):
`prefers-color-scheme` or an explicit Settings choice, and **Telegram-themed**:
on a Telegram Mini App launch — the app is served under `/telegram/` and detects the
launch by `Telegram.WebApp.initData` — the SDK's `themeParams` override the tokens at
runtime; opened outside Telegram, the `/telegram/` path redirects to the site root.
@@ -33,14 +33,14 @@ Login uses `Screen`.
emoji icon over a tiny truncated label. A press highlights a rounded **square** behind
the icon (slightly larger than it) until release; spacing keeps adjacent labels from
touching. No text selection on nav / tab-bar / buttons (`user-select: none`).
- **Screen transitions** (Stage 17, `App.svelte`): navigation slides directionally — a
- **Screen transitions** (`App.svelte`): navigation slides directionally — a
screen entered from the lobby flies in from the right; returning to the lobby reveals it
from the left (back). Transitions are local (so they do not play on first load) and
collapse to nothing under reduce-motion. Per-game and lobby in-memory caches
(`lib/gamecache.ts`, `lib/lobbycache.ts`) render a re-opened game or the lobby instantly
and refresh in the background, removing the blank-loading flash and the lobby's "draw-in"
on lobby ↔ game navigation.
- **Telegram integration** (Stage 17, `lib/telegram.ts`): inside the Mini App the colour
- **Telegram integration** (`lib/telegram.ts`): inside the Mini App the colour
scheme is forced from `Telegram.WebApp.colorScheme` (over the OS `prefers-color-scheme`,
which leaks into the Telegram Desktop webview and otherwise fights it) and the Settings
theme switcher is hidden; the nav bar takes Telegram's background and `setHeaderColor` /
@@ -67,7 +67,7 @@ Login uses `Screen`.
preventDefault fires only for two touches, so one-finger scroll stays native, and a second
finger aborts an in-progress drag). The pan is **interpolated toward a pre-clamped target**
as the real width grows/shrinks, so it magnifies evenly A→B instead of lurching and snapping
back near the edges (Stage 17). It **recentres only on a zoom-in** — placing a 2nd+ tile or
back near the edges. It **recentres only on a zoom-in** — placing a 2nd+ tile or
hovering a dragged tile never jumps the board. On touch the first tile placement auto-zooms
in centred on the target, and **holding a dragged tile over a cell ~1 s** auto-zooms there
the first time. The swipe-to-open-history gesture stays dropped (it fought native scroll);
@@ -78,15 +78,15 @@ Login uses `Screen`.
cell is **highlighted as a drop target** (an accent ring). A pending tile is taken back by a
**double-tap** or by **dragging it back onto the rack** (unzoomed board only — when zoomed
the one-finger gesture scrolls). A single tap no longer recalls (too easy to trigger); a
recalled tile returns to its original rack slot (Stage 17).
- **Players plaque & history** (`Game.svelte`, Stage 17): the seats above the board share
recalled tile returns to its original rack slot.
- **Players plaque & history** (`Game.svelte`): the seats above the board share
the width evenly; the seat whose turn it is is **raised** (a drop shadow on its sides)
while the others read **sunk in** (an inset shadow). A tap anywhere on the plaque toggles
the **move history** — a fixed-height slide-down drawer whose bottom border (and its
shadow) pins to the board as the board slides down, instead of tracking the table as
moves accumulate; its scrollbar gutter is reserved so the centred word column does not
jitter. A move's row lists every word it formed (the main word first).
- **Vertical fit & keyboard** (Stage 17): when the game does not fit the viewport, only the
- **Vertical fit & keyboard**: when the game does not fit the viewport, only the
board area scrolls vertically (`Screen` `column` mode; the score bar, status, rack and tab
bar stay fixed), while zoom keeps its own scroll. The check-word dialog opens in
`Modal` keyboard-overlay mode — the small sheet is top-anchored and the soft keyboard
@@ -99,7 +99,7 @@ Login uses `Screen`.
- **Bonus-square labels** — a Settings choice (`boardlabels.ts`): `beginner` shows a
split `3×` / `word` (localized слово/буква), `classic` a single `3W` / `3С`, `none`
nothing. Default **beginner**.
- **Grid lines** — a Settings toggle, **default off** (Stage 17). Off: a **gapless
- **Grid lines** — a Settings toggle, **default off**. Off: a **gapless
checkerboard** — plain cells alternate two shades, and tiles get rounded corners with a
soft right-side shadow so adjacent gapless tiles still read apart, reclaiming ~14px of
board width. On: the classic lined grid, where the inter-cell gap shows a contrasting
@@ -110,7 +110,7 @@ Login uses `Screen`.
- **HoldConfirm** (`components/HoldConfirm.svelte`): the shared press-and-hold control. A
short tap opens a small popover above the button; a ~0.7 s hold runs the primary action
immediately. Used by the **Skip** and **Hint** tabs (each confirmed by an **Ok ✅** popover).
- **MakeMove / Reset** (Stage 17): when ≥1 tile is pending the rack collapses its used slots
- **MakeMove / Reset**: when ≥1 tile is pending the rack collapses its used slots
and shifts left, a **borderless ✅ icon button** (styled like a tab, not a filled accent
button) beside the rack commits the move — no popover, and disabled while the pending word
is known illegal; the 🔀 Shuffle tab is replaced by a **↩️ Reset** tab.
@@ -123,7 +123,7 @@ Login uses `Screen`.
## Announcement banner (`components/AdBanner.svelte`, `lib/banner.ts`)
A one-line inset strip under the nav bar, drawn on a dedicated `--ad-bg` token (Stage 17)
A one-line inset strip under the nav bar, drawn on a dedicated `--ad-bg` token —
a subtle accent, a touch darker than the surroundings in the light theme and a touch lighter
in the dark theme, mapped to Telegram's `secondary_bg_color` inside the Mini App. Content is
minimal markdown (text + links, escaped + linkified). A parameterised **rotator** drives messages: a fitting message
@@ -138,7 +138,7 @@ Lobby rows show two lines (opponents, then result + score) with a large place-ba
on the right: Victory 🏆 / Defeat 🥈 / Draw 🏅, and for 34-player games II 🥈 / III 🥉 /
IV 🏅; active games show Your move 🟢 / Opponent's move ⏳; invitations use 💌.
## Social, account & history surfaces (Stage 8)
## Social, account & history surfaces
- **Friends** (`screens/Friends.svelte`, from the lobby menu): an "add a friend" block
pairing a code **input** with a **Show my code** action that reveals a large 6-digit