Merge pull request 'Promote development → master (docs finalize + UI tweaks + Telegram name fallback)' (#108) from development into master

This commit was merged in pull request #108.
This commit is contained in:
2026-06-22 07:27:40 +00:00
29 changed files with 257 additions and 2425 deletions
+89 -90
View File
@@ -1,96 +1,97 @@
# scrabble-game — project guide
Multiplatform Scrabble game. Read this first every session. The owner drives the
project **one stage per session** (tariff constraint), so the repository — not
conversation memory — is the source of continuity. Keep it that way.
Multiplatform Scrabble game, **in production** at `https://erudit-game.ru`. Read this
first every session. The repository — not conversation memory — is the source of
continuity; keep it that way.
## Sources of truth (read before changing behaviour)
- [`PLAN.md`](PLAN.md) — staged plan + **stage tracker** + per-stage *open
details to interview*.
- [`PRERELEASE.md`](PRERELEASE.md) — pre-release hardening tracker (phases R1R7
before Stage 18); same per-phase *interview + bake-back* discipline as `PLAN.md`.
- [`docs/ARCHITECTURE.md`](docs/ARCHITECTURE.md) — architecture, transport,
security, the decision record. Always describes current state.
- [`docs/FUNCTIONAL.md`](docs/FUNCTIONAL.md) (+ [`_ru`](docs/FUNCTIONAL_ru.md)
mirror) — per-domain user stories. English authoritative.
- [`docs/TESTING.md`](docs/TESTING.md) — test layers + the per-stage CI gate.
- [`docs/ARCHITECTURE.md`](docs/ARCHITECTURE.md) — architecture, transport, security,
the decision record. Always describes the current state.
- [`docs/FUNCTIONAL.md`](docs/FUNCTIONAL.md) (+ [`_ru`](docs/FUNCTIONAL_ru.md) mirror)
— per-domain user stories. English authoritative.
- [`docs/TESTING.md`](docs/TESTING.md) — test layers + the CI gate.
- [`docs/UI_DESIGN.md`](docs/UI_DESIGN.md) — the `ui` visual/interaction design system.
- [`deploy/README.md`](deploy/README.md) — the deploy contour + the production
rollout / rollback runbook.
## Mandatory per-stage workflow
## How we work
**Start of a stage**
1. Read `PLAN.md` (the stage's scope + *open details*) and the relevant `docs/`.
2. Analyse what the stage actually requires against the current code.
3. **Interview the owner** on every open detail and any fork not already fixed
in the plan — do not silently pick borderline decisions. Offer options with
brief pros/cons.
4. Only then implement, strictly within the stage's scope.
**End of a stage**
1. Bake every new agreement back into `PLAN.md`, `docs/ARCHITECTURE.md`,
`docs/FUNCTIONAL.md` (+ `_ru`), the affected service `README`, and Go Doc
comments — in the **same** PR. Correct earlier stages' docs/code if a new
decision changes them.
2. Update the stage tracker; add a line under *Refinements logged during
implementation* for any plan deviation.
3. Get CI green, then mark the stage done.
(The `stage-implementation` skill encodes this same loop and can be invoked.)
- Inspect the relevant code path and the docs above before changing behaviour.
- **Interview the owner on every fork** — do not silently pick borderline decisions;
offer options with brief pros/cons.
- Smallest correct diff. Prefer compact code; reuse before adding; do not add deps,
seams or knobs until they are needed.
- **Update or add tests for every functional change**, at the layers
`docs/TESTING.md` calls out.
- **Bake docs in the same PR**: update `docs/ARCHITECTURE.md`, `docs/FUNCTIONAL.md`
(+`_ru`), the affected service `README` and Go Doc comments alongside the change.
- Document added packages, types, funcs, consts and vars with Go Doc comments.
## Conventions
- All code, comments, identifiers, commits, docs, filenames in **English**.
- Chat with the owner follows the user-level `~/.claude/CLAUDE.md` (Russian,
the agreed persona and translation rules).
- Mirror every point edit of `docs/FUNCTIONAL.md` into `docs/FUNCTIONAL_ru.md`
in the same patch (translate only the touched paragraphs).
- Prefer compact code; do not add deps, seams or knobs until a stage needs them.
Reuse before adding. Document added packages/types/funcs with Go Doc comments.
- Update or add tests for every functional change.
- Chat with the owner follows the user-level `~/.claude/CLAUDE.md` (Russian, the
agreed persona and translation rules).
- Mirror every point edit of `docs/FUNCTIONAL.md` into `docs/FUNCTIONAL_ru.md` in the
same patch (translate only the touched paragraphs).
## Branching & CI
## Branching, CI & production
- **Two long-lived branches** (Stage 16 onward): **`development`** is the
integration branch; **`master`** is the production trunk. Cut `feature/*`
branches **from `development`** and PR them back into it. (Stages 015 used
`master` as the trunk with `feature/* → master`; the genesis Stage 0 commit is
on `master` by necessity.)
- A commit to a `feature/*` branch triggers **nothing**. The single workflow
`.gitea/workflows/ci.yaml` runs the full suite (`unit` + `integration` + `ui`)
on a PR into `development` or `master`, and the 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 + a `GET /` probe). A PR into
`master` is test-only.
- Merge `development → master` only when CI is green; the **prod** deploy is then a
**manual** workflow (Stage 18), never automatic. Secrets/variables are prefixed
`TEST_` / `PROD_` per contour (Gitea 1.26 has no deployment environments).
- After any push, watch the run to green before declaring a stage done — use the
ready-made watcher, never an inline poll loop:
`python3 ~/.claude/bin/gitea-ci-watch.py` (background). It reads `$GITEA_URL`
/ `$GITEA_TOKEN`; `gitea.iliadenisov.ru` is allow-listed in
`.claude/settings.json`. Remote: `origin git@gitea.iliadenisov.ru:developer/scrabble-game.git`.
- **Two long-lived branches**: **`development`** is the integration branch; **`master`**
is the production trunk. Cut `feature/*` from `development` and PR back into it;
promote `development → master` via PR when ready to release. Both branches require
one approval + the `CI / gate` check.
- A commit to a `feature/*` branch triggers nothing. The single workflow
`.gitea/workflows/ci.yaml` runs the full suite (`unit` + `integration` + `ui`) on a
PR into `development` or `master`, and the 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 + landing/SPA/backend probes). A
PR into `master` is test-only.
- **Production is live on two hosts** (main + the Telegram bot host) and deploys
**only manually** (`workflow_dispatch`), never automatically:
- **`.gitea/workflows/prod-deploy.yaml`** (`confirm=deploy`, from `master`) builds +
pushes the images to the registry, then SSH-deploys both hosts — rolling per
service in dependency order, health-gated, **auto-rollback to the previous tag**;
a schema migration adds a maintenance window + a consistent `pg_dump`. Four visible
jobs: build → deploy-main → deploy-bot → verify.
- **`.gitea/workflows/prod-rollback.yaml`** (`confirm=rollback`) re-deploys a prior
release (blank `target_version` = the previous deployed version) — image-only,
rolling, health-gated.
- **Releases are git tags `vX.Y.Z` on `master`**; the deploy stamps `git describe
--tags` into the image tag, every binary (`pkg/version` via `-ldflags` → the
`service.version` telemetry attribute) and the SPA About screen. Tag the release
before deploying.
- Hosts are provisioned idempotently by **`deploy/ansible/`**. Per-contour
secrets/variables use the `TEST_` / `PROD_` prefix (Gitea 1.26 has no deployment
environments). Migrations must be **expand-contract** (backward-compatible) so
image rollback stays DB-safe. Full runbook + variable list in `deploy/README.md`.
- After any push, merge or deploy, **watch the run to green** before declaring done —
use the ready-made watcher (run it in the background), never an inline poll loop:
`python3 ~/.claude/bin/gitea-ci-watch.py`. It reads `$GITEA_URL` / `$GITEA_TOKEN`;
`gitea.iliadenisov.ru` is allow-listed in `.claude/settings.json`. Remote:
`origin git@gitea.iliadenisov.ru:developer/scrabble-game.git`.
## Stack
Go 1.26.3, `go.work` monorepo, module paths `scrabble/<name>`. Dependencies are
added **when first used** (incremental): backend uses `gin` + `zap` +
`pgx`/`go-jet`/`goose`/OTel (added in Stage 1). Client↔gateway is Connect-RPC +
FlatBuffers (h2c); gateway↔backend is REST/JSON + `X-User-ID` plus a gRPC
server-stream for live events. UI is pure HTML5/CSS on plain Svelte + Vite,
packaged to native with Capacitor. Likely no Redis.
Go 1.26.3, `go.work` monorepo, module paths `scrabble/<name>`. Backend uses `gin` +
`zap` + `pgx`/`go-jet`/`goose`/OTel. Client↔gateway is Connect-RPC + FlatBuffers
(h2c); gateway↔backend is REST/JSON + `X-User-ID` plus a gRPC server-stream for live
events. UI is pure HTML5/CSS on plain Svelte + Vite, packaged to native with
Capacitor. No Redis.
## Reused engine: `../scrabble-solver` (module `scrabble-solver`, Go 1.26.3)
Embedded **in-process as a library** — there is no per-game container. Public
API to reuse (do not reimplement):
Embedded **in-process as a library** (`replace scrabble-solver => ../scrabble-solver`
in `go.work`; CI checks out the sibling from
`https://gitea.iliadenisov.ru/.../scrabble-solver.git`). There is no per-game
container. Public API to reuse (do not reimplement):
- `scrabble.NewSolver(rs, finder)``GenerateMoves(b, r, mode)` (ranked,
highest score first), `ValidatePlay(b, dir, tiles)`, `ScorePlay(...)`;
`scrabble.Apply(b, m)`; types `Move/Word/Placement/Direction/Mode`
- `scrabble.NewSolver(rs, finder)` → `GenerateMoves(b, r, mode)` (ranked, highest
score first), `ValidatePlay(b, dir, tiles)`, `ScorePlay(...)`; `scrabble.Apply(b, m)`;
types `Move/Word/Placement/Direction/Mode`
(`scrabble-solver/scrabble/{solver,move,apply}.go`).
- `rules.English() / RussianScrabble() / Erudit()`
(`scrabble-solver/rules/rules.go`).
- `rules.English() / RussianScrabble() / Erudit()` (`scrabble-solver/rules/rules.go`).
- `board.New / Parse / Clone / Transpose`; `rack.New / Add / Remove / Clone`;
`selfplay.NewBag / Draw / Len` (bag pattern).
- Load committed dictionaries with `dawg.Load(path)` from
@@ -99,20 +100,17 @@ API to reuse (do not reimplement):
Constraints:
- Words/tiles are **alphabet-index bytes**, meaningful only with the matching
`rules.Ruleset` (`Alphabet.Decode`); blank flag carried separately. **Decode
`rules.Ruleset` (`Alphabet.Decode`); the blank flag is carried separately. **Decode
to real characters before persisting history** (history must be
dictionary-independent — see `docs/ARCHITECTURE.md` §9.1).
- The solver's `internal/*` is NOT importable from this sibling module.
- **GCG is test-only** in the solver (no public writer) — we ship our own.
- Wiring: add `replace scrabble-solver => ../scrabble-solver` to `go.work` in
**Stage 2** (when `internal/engine` first imports it), and make CI check out
the solver sibling (`https://gitea.iliadenisov.ru/.../scrabble-solver.git`).
It uses published `github.com/iliadenisov/{alphabet,dafsa}` (no local replace).
- The solver uses published `github.com/iliadenisov/{alphabet,dafsa}` (no local replace).
## Repository layout
```
go.work # use the existing modules; grows per stage
go.work # the go.work monorepo
backend/ # module scrabble/backend
cmd/backend/ # main: telemetry -> db+migrate -> cache -> server
cmd/jetgen/ # dev tool: regenerate go-jet code (throwaway container)
@@ -123,12 +121,14 @@ backend/ # module scrabble/backend
internal/session/ # opaque tokens, sessions store, cache, service
internal/server/ # gin engine, /api/v1 groups, X-User-ID, probes
internal/inttest/ # //go:build integration Postgres-backed tests
docs/ .gitea/workflows/ PLAN.md CLAUDE.md README.md
gateway/ ui/ pkg/ # added by their stages
platform/telegram/ # Telegram side-service, two binaries (Stage 9; split in phase TX): cmd/validator (HMAC, no VPN) + cmd/bot (Bot API; dials gateway over reverse mTLS bot-link)
loadtest/ # module scrabble/loadtest: the pre-release stress harness (R2)
backend/Dockerfile gateway/Dockerfile platform/telegram/Dockerfile loadtest/Dockerfile # multi-stage distroless (Stage 16; loadtest R2); gateway/Dockerfile has the `landing` target (R3), platform/telegram/Dockerfile has `validator`+`bot` targets (TX)
deploy/ # docker-compose (per-service limits, R7) + caddy + landing + otelcol (OTLP + docker_stats per-container metrics) + prometheus/tempo/grafana + postgres_exporter
gateway/ # module scrabble/gateway: Connect-RPC edge, embeds the SPA
ui/ # Svelte + Vite SPA + landing (Node project, not in go.work)
pkg/ # shared: telemetry, version, wire/FlatBuffers, proto, mtls
platform/telegram/ # Telegram side-service: cmd/validator (HMAC, no VPN) + cmd/bot (Bot API; dials gateway over reverse mTLS bot-link)
loadtest/ # module scrabble/loadtest: the load/stress harness
docs/ .gitea/workflows/ CLAUDE.md README.md
backend/Dockerfile gateway/Dockerfile platform/telegram/Dockerfile loadtest/Dockerfile # multi-stage distroless; gateway/Dockerfile has the `landing` target, platform/telegram/Dockerfile has `validator`+`bot` targets
deploy/ # docker-compose (+ prod overlay + bot host) + ansible provisioning + caddy + landing + otelcol (OTLP + docker_stats) + prometheus/tempo/grafana + node_exporter + postgres_exporter; prod-deploy.sh
```
## Build & test
@@ -138,20 +138,19 @@ go build ./backend/... # per module ('./...' from the root won't span t
go vet ./backend/...
gofmt -l . # must print nothing
go test -count=1 ./backend/...
go build ./platform/telegram/... && go test ./platform/telegram/... # Telegram validator + bot (Stage 9; split in TX)
go build ./platform/telegram/... && go test ./platform/telegram/... # Telegram validator + bot
go run ./backend/cmd/backend # /healthz, /readyz on :8080
cd ui && pnpm install && pnpm check && pnpm test:unit && pnpm build # the UI (Stage 7+)
cd ui && pnpm install && pnpm check && pnpm test:unit && pnpm build # the UI
pnpm start # UI mock mode: lobby -> game, no backend
docker build -f backend/Dockerfile -t scrabble-backend . # images (Stage 16); gateway embeds the SPA
docker build -f backend/Dockerfile -t scrabble-backend . # images; gateway embeds the SPA
docker build -f gateway/Dockerfile --target gateway -t scrabble-gateway .
docker build -f gateway/Dockerfile --target landing -t scrabble-landing . # static landing (R3)
docker build -f gateway/Dockerfile --target landing -t scrabble-landing . # static landing
docker compose -f deploy/docker-compose.yml config # validate the full contour
```
The `ui` module is a Node project (pnpm), **not** in `go.work`; it is the `ui` job
of the single `.gitea/workflows/ci.yaml` (Stage 16 folded the former go-unit /
integration / ui-test workflows into it). Committed edge codegen under `ui/src/gen/`
The `ui` module is a Node project (pnpm), **not** in `go.work`; it is the `ui` job of
the single `.gitea/workflows/ci.yaml`. Committed edge codegen under `ui/src/gen/`
(regenerate with `pnpm codegen`); pnpm build-script approval lives in
`ui/pnpm-workspace.yaml` (`allowBuilds: esbuild: true`).
-1613
View File
File diff suppressed because it is too large Load Diff
-632
View File
@@ -1,632 +0,0 @@
# Pre-release plan — hardening before Stage 18
Living tracker for the pre-release hardening pass that runs **before Stage 18** (the
prod cutover). Same discipline as [`PLAN.md`](PLAN.md): one phase per session,
**interview the owner on the open details** at the start of each phase, bake every
decision back into `PLAN.md` / `docs/` / the affected `README`s / Go Doc comments in
the **same** PR, get CI green, then mark the phase done. Phases run as
`feature/* → development` PRs (the Stage 16 branch model); the owner approves+merges.
**Why now:** the system is feature-complete through Stage 17 and the test contour is
green, but there is **no prod data yet** — schema, wire labels and the dictionary
layout can still change for free. These phases spend that one-time freedom and harden
the edge before prod. Each phase maps back to the owner's raw pre-release TODO list
(numbers in the tracker).
## Phase tracker
| # | Phase | Raw TODOs | Status |
|---|-------|-----------|--------|
| R1 | Schema & naming reset | 1 + 10 | **done** |
| R2 | Stress harness + contour observability + early run | 9a | **done** |
| R3 | Edge hardening | 2 + 8 + 3 | **done** |
| R4 | Push enrichment + kill the last poll | 4 + 5 | **done** |
| R5 | Bundle slimming | 6 | **done** |
| R6 | Refactor + docs reconciliation + de-staging | 7 | **done** |
| R7 | Final stress run + tuning | 9b | **done** |
| UI | Tab-bar navigation redesign (drop the hamburger) | owner ad-hoc | **done** |
| MW | "Multiple words per turn" rule for Russian games (engine v1.1.0) | owner ad-hoc | **done** |
| MW2 | Single-word rule connectivity fix: the word must run along its own line through an existing tile (perpendicular-only contact no longer connects); single-tile direction picks the best legal word (engine v1.1.1) | owner ad-hoc | **done** |
| MW3 | Graceful replay degradation: a game whose journalled move became illegal under MW2 is closed as a draw (`end_reason='aborted'`) on open instead of erroring, with an impersonal organizer note in the history + GCG (migration `00002`) | owner ad-hoc | **done** |
| OW | Open auto-match: enter the game at once and wait inside it (robot after 90180 s) | owner ad-hoc | **done** |
| DA | Dictionary admin: online release-archive upload → word-diff preview → install/activate; versioned dict volume; active version persisted in DB; resident label = release tag | owner ad-hoc | **done** |
| AB | Manual account block (admin suspension): permanent/temporary with an editable en+ru reason picklist; a block forfeits the player's active games + cancels their open ones; a backend gate refuses a blocked account with **403 `account_blocked`**; the UI shows a terminal blocked screen and stops all push/poll; manual unblock; temporary blocks self-expire (migration `00003`) | owner ad-hoc | **done** |
| AI | Honest AI opponent in quick game: an explicit 🤖 AI / 👤 random selector (AI default); the robot is seated and moves at once; 7-day inactivity loss (the per-turn timeout reused); chat/nudge disabled, no statistics; the opponent is shown as 🤖 everywhere | owner ad-hoc | **done** |
| AD | Advertising banner ("ad network"): server-driven weighted campaigns (percent weight + validity window; the perpetual default fills the remainder up to 100%), bilingual messages shown by bot (`service_language`); eligibility = free account + empty hint wallet + no `no_banner` role (guests included); the resolved feed rides `profile.get` with a `notify` `banner` re-poll on eligibility change; `/_gm/banners` admin + global display timings; client smooth-weighted-round-robin rotation + fade-out/gap/fade-in UX. A single `app.load` bootstrap aggregator was considered and **deferred** (see ARCHITECTURE §10). | owner ad-hoc | **done** (PR1 backend+admin, PR2 UI rotation) |
| GL | Simultaneous quick-game cap (10): grey "New Game" + a lobby notice at the cap; backend gate on quick enqueue + invitation creation (409 `game_limit_reached`), accepting invitations exempt; `at_game_limit` rides `games.list` | owner ad-hoc | **done** |
| CR | In-game chat read receipts: per-message `unread_seats` bitmask (migration `00008`); a per-viewer unread **dot** in the lobby + game header (a nudge counts and clears when its recipient moves); reading = opening the move history (the 💬 fade-blinks twice) or the chat, acked (`chat.read`) only when unread; `chat_read_duration` + `chat_unread_messages` metrics + tracing + the **Scrabble — Messages** Grafana dashboard (follow-up PR); a message to a disguised robot opponent is born read; admin unread-only filter / read column / per-seat read card | owner ad-hoc | **done** |
| BX | Asymmetric per-user block + in-game controls: a block now silently suppresses everything **from** the blocked user (chat, nudge, friend requests, invitations are kept but never delivered/surfaced, born-read) while they notice nothing, **without** deleting the friendship (unblock restores it); auto-match excludes a block-related pair (either direction); in-game opponent card gains a ✖️ **block** control (mirroring 🤝, red "Block?" confirm, mutual-hide, struck name + hidden chat composer when blocked); optimistic apply + `user_blocked`/`user_unblocked` event confirm + rollback; admin user card gains **blocks / blocked-by / friends** cross-linked lists. Blocking a disguised-robot opponent is recorded per-game in a separate **`robot_blocks`** table (migration `00011`), keyed on game+seat with the seen name — never the shared robot account — so the matchmaker keeps giving robots; it shows in the blocked list and re-marks the in-game card | owner ad-hoc | **done** |
| FM | First-move tile draw (official rules): each seated player draws a tile, the one closest to "A" leads (a blank beats every letter), ties re-drawing until a single leader; **honest per-draw `crypto/rand` entropy**, not the bag seed, so the **record** (`game_setup_draws`, migration `00013`) — not a seed — is the only account of the outcome, kept for future **tournaments** (designed as a discrete per-tile "player N draws" step). Friend/AI draws at create; **auto-match draws at *open*** against a synthetic `uuid.Nil` opponent whose draw rows are back-filled on join, so the opener's seat is fixed up front and the existing open-game pre-move is preserved (no reseating, no play-gating). Admin `/_gm/games/:id` gains the recorded draw list + a simple **step-by-step board replay** (`ReplayTimeline`). | owner ad-hoc | **done** |
| SB | Single Telegram bot + per-user variant preferences: the two per-language bots collapse into **one** (drop `accounts.service_language`, `supported_languages`, the `*_EN`/`*_RU` env vars and game-language push routing — the single bot renders in the recipient's `preferred_language`); New Game variant gating moves to a profile **`variant_preferences`** set (default Erudit only, Erudit-first, server-enforced on the caller's auto-match/vs-AI/invitation-create paths, an invited friend may accept any variant); env vars collapse to unsuffixed `TELEGRAM_BOT_TOKEN`/`TELEGRAM_GAME_CHANNEL_ID`/`VITE_TELEGRAM_LINK`/`VITE_TELEGRAM_GAME_CHANNEL_NAME` and `GATEWAY_DEFAULT_SUPPORTED_LANGUAGES` is removed; wire drops `service_language`/`supported_languages` (Session, ValidateInitDataResponse) + the push `language` routing field and adds `variant_preferences` to Profile/UpdateProfile. | owner ad-hoc | **done** |
| DV | Dictionary version hygiene: CI + image/compose seed track the current release (`v1.2.1`); a **seed-drift guard** records the flat dir's seed in an authoritative `.seed_version` marker so a bumped build seed on a live volume is ignored (it can't relabel live bytes — which would mis-serve the dictionary + void games pinned to the prior label); `DICT_VERSION` is the fresh-volume seed only, a live contour migrates through the admin console | owner ad-hoc | **done** |
| TX | Telegram egress off the main host: split the connector into a home **validator** (Mini App / Login-Widget HMAC, no VPN, no Bot API — so game login no longer depends on Telegram being reachable) and a remote **bot** (Bot API long-poll + `sendMessage`) that holds **no inbound port** and dials the gateway over a reverse **mTLS bot-link** (`pkg/proto/botlink/v1`); the gateway funnels out-of-app push (fire-and-forget, at-most-once) and the backend admin broadcasts (a relay that awaits the bot's ack) down the link. The bot is Telegram-rate-limited; **one bot now**, with seams (a bot registry + `owns_updates` + command ids) for N later; **no webhook** (rejected: one URL per token, adds inbound + a static address). The **unified test contour** runs the split (the bot keeps its VPN sidecar and dials the gateway by its internal name; certs from `deploy/gen-certs.sh`). The **prod** wiring — the bot on a separate host (no VPN), the gateway bot-link port published, `PROD_` certs, an SSH deploy of both hosts together — is **built in Stage 18** (the two-host registry rollout; first cutover pending the `erudit-game.ru` DNS). | owner ad-hoc | **done** (code + test contour; prod wiring built — Stage 18) |
| AG | Anti-abuse IP ban + honeypot/honeytoken (prod-only): a fail2ban-style in-memory `ratelimit.Banlist` keyed by client IP, fed by sustained rate-limiter rejections (the IP-keyed public/email/admin classes — the user class stays the soft-flag's concern), a **honeypot** decoy path (the contour caddy tags `/.env`, `/.git`, `/wp-*`, … with `X-Scrabble-Honeypot` and routes them to the gateway), and a **honeytoken** (`GATEWAY_HONEYTOKEN`, a planted bearer). The `abuseGuard` edge middleware refuses a banned IP with **429** before any work — closing the R3 gap that the static SPA/landing was outside the token bucket. Off by default — it keys by the real client IP the shared-NAT test contour does not expose (detection still logs there); enabled in prod via `GATEWAY_ABUSE_BAN_ENABLED`. Operators see + lift bans on the console **Throttled** page; the gateway syncs its active set to the backend (`/api/v1/internal/bans/sync`, `internal/banview`) every 30 s and applies operator unbans. | owner ad-hoc | **done** (code + test contour; ban on in prod via Stage 18 — machinery built, cutover pending DNS) |
| CM | Channel-chat moderation + promo bot: a second standalone bot in the bot container answers `/start` with a localized message + a **URL** button into the **main** bot's Mini App (`?startapp`; a `web_app` button would sign initData with the promo token, which the main validator rejects). The **main** bot gates write access in a channel's linked discussion chat. The chat **allows sending by default** and the bot only restricts (Telegram intersects the chat default with the per-user permission, so a per-user grant cannot exceed a deny-by-default group): it **mutes** a member who is not registered or is admin-suspended or holding a new **`chat_muted`** role, and **un-mutes** an eligible one it had muted, for a member currently in the chat (a `getChatMember` guard, since bots cannot list members). Eligibility = `registered AND NOT suspended AND NOT chat_muted` (the game suspension dominates), resolved once in the backend and reached two ways: the bot's `ResolveChatEligibility` on a `chat_member` event over the existing mTLS bot-link, and a backend `chat_access_changed` event → gateway → `ChatGate` command (emitted on block/unblock, a `chat_muted` change, a first registration, or a temporary-block expiry via a sweeper; idempotent). No schema change — `chat_muted` reuses `account_roles`. | owner ad-hoc | **done** |
| → | Stage 18 — prod contour deploy | — | see [`PLAN.md`](PLAN.md) |
## Key findings (these reshaped the raw list — read before starting a phase)
- **R1 (TODO 1 + 10) is one cheap moment, now.** Squashing the 12 goose migrations is
safe precisely because there is no prod data and the contour DB is wiped. Folding the
new variant labels (`scrabble_ru`/`scrabble_en`/`erudit_ru`) into that single baseline
makes the rename need **no data migration and no back-compat mapping**. Today's labels
(`english`/`russian_scrabble`/`erudit`) are persisted in `games.variant`,
`game_invitations.variant`, in `pkg/fbs` and the UI — ~100 files, but a mechanical sweep
on a clean DB.
- **R4 (TODO 4 + 5): the app is already push-first.** Game state refreshes on
`your_turn`/`opponent_moved`, the lobby on `notify`, chat on `chat_message`. The **only**
genuine periodic server poll is `lobby.poll` (matchmaking, 2.5 s,
`ui/src/screens/NewGame.svelte`). What remains is killing that one poll **and** enriching
push events to carry payloads so the UI stops re-fetching after each signal.
- **R3 (TODO 2): identity forgery is already mitigated.** Identity is always derived from
the session (`Authorization: Bearer``X-User-ID`); the client cannot inject identity,
the backend re-validates resource ownership, Telegram initData is HMAC-checked. The real
gaps are a missing **request-body size limit** (cheap DoS) and **invisible rate-limit
rejections** (no log/metric/admin view — that is TODO 8). Static landing serving is **not**
covered by the gateway token bucket (it only guards `Execute`).
- **R6 (TODO 7) scale:** ~431 `Stage N` references across ~104 files (incl. the file name
`backend/internal/inttest/stage6_test.go`). Code is the source of truth; `docs/` describe
current state; `PLAN.md` keeps the decision history.
## Locked decisions (owner interview)
- **Stress test (TODO 9):** **early + final** runs. Driver = **edge protocol** (Connect/FB
through the gateway, moves generated by the solver) **plus a separate gateway-hammer**
saturation test. Pacing = **realistic (under limits) + saturation (ramp to the knee)**.
Resource metrics = **add cAdvisor + postgres_exporter to the contour** (today only
Go-runtime metrics exist). The harness stays in the repo for repeats.
- **Push (TODO 4 + 5):** **both** — kill `lobby.poll` (use the existing `match_found`, keep
poll as the ws-down fallback) **and** enrich push events with payloads.
- **Refactor (TODO 7):** **hygiene + structural changes by a reviewed list**
behaviour-preserving, test-gated, contentious items surfaced to the owner before applying.
- **Landing (TODO 3):** **separate static container** behind the project caddy
(`/` → landing, `/app/` + `/telegram/` → gateway); drop `landing.html` from the gateway
`go:embed`.
- **Rate-abuse (TODO 8):** metric + Grafana + admin view **plus a conservative auto-flag**
a *soft, reversible* "suspected high-rate" marker for operator review, tunable threshold,
**no auto-ban**.
- **Anti-abuse IP ban (AG, owner ad-hoc):** a honeypot was considered and rejected as a *DDoS*
defence — it detects/deceives but does not shed volumetric load, cannot cover the real
endpoints, and a tarpit backfires under flood; volumetric L3/L4 is an upstream/CDN concern,
out of scope. The effective layer is a **temporary IP ban** (fail2ban-style) that the honeypot
and honeytoken merely *feed*. This does **not** reverse the TODO-8 "no auto-ban": that decision
governs the **account** soft-flag (still never a gate); the IP ban is a separate, IP-keyed,
**prod-only** layer with an **operator unban** in the console. Decisions: banlist lives in the
existing `ratelimit` package (smallest surface); the decoy path list is a **single source of
truth in the caddy** (it tags requests with a header — the gateway keeps no second list);
bans are in-memory + single-instance (like `ratewatch`), auto-expiring, **plus** an admin
console view + manual unban over a bidirectional 30 s sync (operator control = owner's choice).
An active-bans Grafana **gauge** was trimmed (the console view + the `gateway_abuse_banned_total`
counter cover it) to keep the diff focused.
- **Open auto-match (owner ad-hoc):** a quick game **enters a real game at once and waits inside
it** (status `open`, the opponent seat empty); a second human searching the same variant+rule
joins it, or a robot fills it after a **90 s + random 090 s** wait, pushing the in-app
**opponent_joined** event. While open, the starter may move on their turn but resign, chat and
nudge are disabled, and the lobby + opponent card read "searching for opponent". Matchmaking is
now **DB-backed open games** — the in-memory pool, `lobby.poll` and `lobby.cancel` are gone. The
schema is edited in the baseline (no prod data); `game_players.account_id` is nullable for the
empty seat.
- **Telegram egress off-host (TX, owner ad-hoc):** the driver is **removing VPN/Telegram
traffic from the main host** (OPSEC / one fewer analysis vector), not only notification
resilience. Login is local HMAC, so it stays up regardless of the bot — confirmed in the
code and made structural by the split. **Unified topology in code** (validator + bot, the
bot dialing the gateway) in **both** contours, differing only in deployment; the test bot
keeps its VPN sidecar. Transport = a **reverse gRPC bidi stream, mTLS, bot-dials-gateway**
(no inbound/static IP on the bot), reusing the push-stream pattern; **webhook rejected**
(one URL per token, adds inbound + a static address). Delivery **at-most-once** (a dropped
nudge beats a duplicate). **One bot now**, seams (registry + `owns_updates` + command ids)
for N later. **Cert rotation** by a scheduled CI job from a long-lived CA. **Prod deploy by
SSH** (pull excluded), the bot rolled **together** with the main app (the bot-link protocol
kept back-compatible by one version as the non-atomic-two-host-deploy safety net). The bot
is monitored **from the gateway** (connection + ack metrics). The bot-host token-at-rest is
**accepted**.
## Phases
Each phase: read this tracker + the relevant `docs/`, **interview the owner on the open
details below**, implement within scope, then update the tracker + docs/code and get CI
green before marking it done.
### R1 — Schema & naming reset *(TODO 1 + 10)* — first
Squash `backend/internal/postgres/migrations/00001..00012` into one `00001_baseline.sql`
(method: `pg_dump --schema-only` from a fully-migrated DB → wrap as the goose baseline →
prove a fresh migrate yields a schema identical to the 12-migration chain via the
integration suite → delete the old files; keep goose). Bake the new variant labels into the
baseline. Propagate `scrabble_ru`/`scrabble_en`/`erudit_ru` through the backend
(`engine.Variant`/`ParseVariant`, `registry.dictFiles`, the CHECK values), the wire
(`pkg/fbs` `variant:string`, regenerate FB) and the UI (`lib/model.ts` union, `variants.ts`,
fixtures, premium/alphabet keys, tests); i18n display keys stay display-only. Tidy
`../scrabble-dictionary` to a single source→dawg build point and align the dawg artifact
names to the new labels (crosses into `../scrabble-solver`'s committed fixtures — keep them
byte-identical). After merge, **wipe the contour DB** (drop the volume) so it re-provisions
on the next deploy.
- Critical files: `backend/internal/postgres/migrations/`,
`backend/internal/engine/{engine,registry}.go`, `pkg/fbs/scrabble.fbs`,
`ui/src/lib/{model,variants}.ts`, `../scrabble-dictionary/{Makefile,cmd/builddict,…}`.
- Open details to interview: the exact dawg filename scheme; whether the dict-repo tidy is
one PR or split; how to script the contour DB wipe in the deploy.
### R2 — Stress harness + contour observability + early run *(TODO 9, part 1)*
Build the reusable load harness as a new `loadtest` module in `go.work` (reuses `pkg/fbs`,
`connect-go`, and `scrabble-solver` for legal-move generation): a seeder that inserts
**1000 guest + 10000 durable** accounts with pre-created sessions (token hashes) directly in
the DB and hands the plaintext tokens to the client; a driver that runs N virtual users,
each in 35 concurrent 24-player games, exercising submit-play / pass / exchange / nudge /
chat / check-word / draft-move / profile-save through the **edge protocol**, in
**realistic** (under rate limits) and **saturation** (ramp) modes; plus a separate
**gateway-hammer** that deliberately exceeds limits to verify the limiter holds and measure
its cost. Add **cAdvisor + postgres_exporter** to `deploy/docker-compose.yml` and a Grafana
resource dashboard. Run the **early pass** against the freshly-wiped contour; produce a
**trip report** (logic/concurrency bugs + a resource baseline) that feeds R3 and R6.
- Critical files: new `loadtest/`, `deploy/docker-compose.yml`, `deploy/observability/*`,
`docs/TESTING.md`.
- Open details: the scale ramp steps; the move-selection policy (a mid-ranked solver move
for realistic game progress); run duration; the pass/fail bar.
### R3 — Edge hardening *(TODO 2 + 8 + 3)*
Add a **request-body size cap** at the gateway h2c mux / `Execute` (e.g. ~1 MB). Add
**rate-limit observability**: a `gateway_rate_limited_total{class}` counter + a structured
log per rejection; an **aggregate** Grafana panel (request rate + rejection rate — spikes
visible without per-user label cardinality, honouring the Stage 12/17 discipline); an
**admin-console view** of recently throttled users/IPs (in-memory ring buffer, single-
instance, reset-on-restart, like the `active_users` gauge). Add the **conservative
auto-flag**: when a user is *sustained*-throttled past a tunable threshold, set a soft,
reversible `account.flagged_high_rate_at` marker (baked into the R1 baseline) surfaced in the
admin user list/detail — **no auto-ban**; the operator clears it. Split the **landing** into
its own static container (`deploy/` + a Caddyfile route `/` → landing) and drop
`landing.html` from the gateway `go:embed`.
- Critical files: `gateway/internal/connectsrv/server.go`, `gateway/internal/ratelimit/`,
`gateway/internal/connectsrv/metrics.go`, `backend/internal/adminconsole/`,
`deploy/caddy/Caddyfile`, `deploy/docker-compose.yml`, `gateway/internal/webui/`.
- Open details: the auto-flag threshold/window + whether the marker is persisted vs
in-memory; the landing image base (caddy vs nginx).
### R4 — Push enrichment + kill the last poll *(TODO 4 + 5)*
Replace `lobby.poll` with the existing `match_found` push (keep the poll as a ws-down
fallback). Enrich `your_turn`/`opponent_moved`/`notify` to carry the state payload so the UI
renders from the event without a follow-up `game.state` (removes the lobby↔game nav latency
the owner noticed). Wire-contract change: `pkg/fbs` event payloads → backend `notify` emit →
UI stream consumers (`ui/src/lib/app.svelte.ts`), with the per-game cache as the landing
spot; regenerate FB.
- Critical files: `pkg/fbs/scrabble.fbs`, `backend/internal/notify/events.go`,
`ui/src/lib/{app.svelte,transport}.ts`, `ui/src/screens/NewGame.svelte`.
- Open details: which events carry full vs delta payloads; the fallback-poll cadence when the
stream is down.
### R5 — Bundle slimming *(TODO 6)* — done
Analysed the bundle against the 100 KB-gzip budget; **no code slimming was warranted**, and the
budget metric was retargeted to measure the app correctly. The build already minifies +
tree-shakes; the dominant cost is the Connect/FlatBuffers transport runtime + generated bindings
+ the Svelte runtime (≈⅔ of `main`'s source is third-party/generated) — irreducible within scope.
**Lazy-loading was rejected**: `bundle-size.mjs` sums every emitted chunk, so code-splitting yields
no total-size win and adds request latency (+N gateway fetches on first navigation to a split
screen). i18n lazy-load was skipped (the catalogs are a sliver of a Svelte-runtime-dominated shared
chunk, and `en` must stay bundled as the `MessageKey` type source + fallback). Instead,
`bundle-size.mjs` now measures **per HTML entry**, with three independent gates on the natural chunk
boundaries — **app entry ≤ 100 KB, the Svelte+i18n shared chunk ≤ 30 KB, the landing's own chunk
≤ 5 KB** — since the app's real payload is its entry chunk plus the shared chunk (≈97 KB), while the
landing (≈24 KB) is reported separately and kept minimal. Same CLI + exit-code contract, so the CI
step is unchanged.
- Critical files: `ui/scripts/bundle-size.mjs`; no app code changed.
### R6 — Refactor + docs reconciliation + de-staging *(TODO 7)* — done
Behaviour-preserving only. Three separable, separately-committed passes: (a) mechanical
**de-staging** — remove `Stage N`/`TODO-N` references from code, comments and service
READMEs (rename `stage6_test.go`); (b) **docs↔code reconciliation** — reconcile
`docs/ARCHITECTURE.md` / `docs/FUNCTIONAL.md`(+`_ru`) against the code-as-truth, fixing drift
and Go Doc comments; (c) **structural changes by a reviewed list** — surface a list of
proposed optimizations / test-suite consolidations to the owner, apply only the approved,
behaviour-preserving, test-gated ones. The full suite + the final stress run (R7) are the
regression gate. Incorporates the early-run (R2) bug fixes not already shipped.
- Open details: the structural-changes list itself (owner-approved before applying); the test
consolidation targets.
### R7 — Final stress run + tuning *(TODO 9, part 2)* — done
Re-run the R2 harness against the final, refactored system on a clean contour; analyse
resource consumption across **all** components (gateway, backend, Postgres, the
metrics/observability stack, docker log volume) and agree the tuning (pool sizes, rate
limits, cache TTLs, container limits, GOMAXPROCS, log levels). Apply the agreed tuning; record
the methodology + results in the repo.
**Stage 18** (prod contour) then proceeds per [`PLAN.md`](PLAN.md).
## Sequencing rationale
`R1` first (cheapest now; everything builds on the final schema/naming and the stress test
must run against it). `R2` builds the harness and runs the **early** pass to surface bugs and
a resource baseline that feed `R3` and `R6`. `R3`/`R4`/`R5` harden and improve the system.
`R6` (de-stage + reconcile + structural) runs near the end so it sweeps settled code once and
benefits from all accumulated bug knowledge. `R7` validates the final system and tunes it.
Then Stage 18.
## Regression-safety discipline (cross-cutting)
- Every phase is a `feature/* → development` PR; CI (`unit` + `integration` + `ui` behind the
`CI / gate` check) must be green before the owner merges; watch the post-merge contour
deploy with `gitea-ci-watch.py`.
- `R6` structural changes are behaviour-preserving, test-gated, and split from the mechanical
sweeps; contentious items are owner-approved first.
- The two stress runs (`R2` early, `R7` final) are the system-level regression gate.
## Verification (per phase)
- `go build ./<module>/...`, `go vet`, `gofmt -l .` clean, `go test -count=1 ./<module>/...`;
UI: `pnpm check && pnpm test:unit && pnpm build`; the integration suite
(`-tags integration`) for DB/schema changes; `docker compose config` for deploy changes;
green CI on the PR + a healthy contour deploy.
- `R1`: prove the squashed baseline yields a schema identical to the 12-migration chain
(integration suite on a fresh DB) **before** deleting the old files.
- `R2`/`R7`: the harness runs end-to-end against the contour; the trip report lists concrete
defects + a resource profile from the Grafana cAdvisor/postgres_exporter panels.
## Refinements logged during implementation
- **R1** (interview + implementation):
- **Variant labels** `english`/`russian_scrabble`/`erudit`**`scrabble_en`/`scrabble_ru`/`erudit_ru`**
across the backend (`engine.Variant.String`/`ParseVariant`; the `games`/`game_invitations` `variant`
CHECK in the baseline; GCG `#lexicon` and the `variant` metric attribute both flow from `String`),
the wire (`pkg/fbs` `variant` is a `string` field — values change with **no FlatBuffers regen**) and
the UI (`model.ts` union, `variants.ts` records, `codec`/`premiums`/mocks/tests, the admin
`dictionary.gohtml`). **Kept:** the Go enum identifiers (`VariantEnglish`…, internal) and the i18n
display keys (`new.english`/`new.russian`/`new.erudit`, display-only). `complaints.variant` stays
free-text (no CHECK, as before).
- **dawg filenames kept descriptive** (`en_sowpods`/`ru_scrabble`/`ru_erudit`) — only the registry's
`Variant` key carries the rename, so `registry.go`, the published `scrabble-solver` fixtures and the
dictionary release artifact are untouched (decouples the three repos).
- **Migrations squashed** 12 → one hand-written `00001_baseline.sql`. Verified by a
`pg_dump --schema-only` diff (the chain vs the baseline are **identical** but for the two intended
variant-CHECK values) plus the green integration suite. **No data migration** (no production data).
- **Done (cross-repo + contour):** the **`scrabble-dictionary` tidy** merged (PR #2) and was re-cut as
the **byte-identical `v1.0.1`** release for clean provenance (the backend stays on `v1.0.0` — same
bytes, no rewire; the backend pulls a version-pinned release artifact, not master). Post-merge the
contour `backend` schema was wiped (`DROP SCHEMA backend CASCADE` + restart, not a volume drop) and
re-migrated to the baseline — verified the new variant CHECK (`scrabble_en/scrabble_ru/erudit_ru`),
`games`=0 and a clean boot.
- **R2** (interview + implementation):
- **Locked decisions:** game assembly via **invitations** (real path, no robots; not direct game-row
inserts); **moderate** ramp **50 → 200 → 500** at 10 min/step; **diagnostic** pass bar (no SLO gate);
run as a **one-shot container on `scrabble-internal`** in this PR.
- **Harness** = new `scrabble/loadtest` module (`use ./loadtest` + a `replace scrabble/gateway` for the
dot-free edge-proto import). It seeds 1000 guest + 10000 durable accounts + sessions **directly in
Postgres** (token hash mirrors `backend/internal/session`), drives players over the **edge protocol**,
generates **mid-ranked legal moves locally** with the embedded `scrabble-solver` by replaying
`game.history` (the edge carries no board — mirrors `engine.ReplayBoard` via the public API), and a
**gateway-hammer**. Compact CLI (`run` / `cleanup`), distroless Dockerfile (DAWGs baked), Go unit tests.
- **Adding the module broke the other images' builds** — backend/gateway/telegram Dockerfiles reduce the
workspace but still referenced `./loadtest` (not in their context); each now also
`-dropuse=./loadtest` (backend/telegram additionally `-dropreplace` the gateway replace). Caught by the
first deploy run; verified by building all four images.
- **Harness payload fixes found by the smoke pass:** the draft DTO's `rack_order` is a string (was sent
as `[]``bad_request`); the display-name validator forbids digits/colons, so the cleanup marker
became a letters-only `Zzloadtest` so `profile.update` resends the seeded name. `chat_not_your_turn` /
`nudge_own_turn` are **by-design** turn gates, correctly exercised.
- **Observability:** added **cAdvisor + postgres_exporter** + the **Scrabble — Resources** dashboard +
two Prometheus jobs. **Finding:** cAdvisor yields only the root cgroup on the contour host (separate
XFS `/var/lib/docker` breaks its layer-ID resolution — the existing galaxy deploy has the same limit),
so per-container CPU/RSS for the early pass was captured via `docker stats`. **R7:** adopt the otelcol
`docker_stats` receiver (already the contrib image) for per-container metrics in Grafana.
- **Early run (2026-06-09):** ramped clean to 500 players, no crash/deadlock, cleanup removed all 11000
accounts. 1.2 M edge calls, 48 870 plays, 2 798 games finished; the per-user limiter held under the
hammer (99.97 % rejected, p99 2 ms). **Top finding:** ~14 % `transport_error` on `game.state` at 500
players, under CPU saturation (backend/gateway/Postgres each ~1 core) and amplified by the harness's
single shared `http2.Transport`; the harness itself peaked at 86 % of a core on the same host, so the
figures are pessimistic. Full trip report in [`../loadtest/REPORT.md`](../loadtest/REPORT.md);
it feeds R3 (h2c `MaxConcurrentStreams`/timeouts, body-size cap), R6 and R7 (per-player transports,
separate hardware, pool/limit sizing).
- **CI:** `./loadtest/...` added to the path filter + vet/build/test; `go.work.sum` carries the new deps.
- **R3** (interview + implementation):
- **Locked decisions:** the flag column lands by **editing the R1 baseline** (+ a contour schema
wipe after merge — no migration chain accrues before prod); auto-flag defaults **1000 rejected /
10 min** (`BACKEND_HIGHRATE_FLAG_THRESHOLD`/`_WINDOW`, rolling window, set-once, operator clears,
no auto-ban); landing image = **caddy:2-alpine**; throttle data flows **gateway → backend** (a
30 s per-key summary POST to the new `/api/v1/internal/ratelimit/report`, the existing trusted
direction) with the episode window + flag rule in the backend (`internal/ratewatch`); rejection
logging = **Warn summary per key per window + Debug per rejection** — a deliberate deviation from
the phase's "structured log per rejection" (the R2 hammer would have logged ~522k lines in
minutes); all three R2-report tails included (explicit h2c sizing, the session-resolve failure
cause at Warn, reviving the admin limiter).
- **Body cap:** `GATEWAY_MAX_BODY_BYTES` (default 1 MiB) as both the Connect per-message read limit
and an `http.MaxBytesReader` wrap of the public mux; an oversized Execute is `resource_exhausted`.
- **Dead config found:** `AdminPerMinute`/`AdminBurst` were never wired — the gateway `/_gm` mount is
now 429-guarded per IP ahead of its Basic-Auth. The caddy-fronted contour path stays unlimited
(stock caddy has no limiter) — an accepted gap, recorded in `docs/ARCHITECTURE.md` §12.
- **Landing split:** a `landing` target in `gateway/Dockerfile` (the UI build stage is shared;
identical compose build args keep it one cached build); the gateway drops `landing.html` from the
embed and 308-redirects `/``/app/`; the contour caddy routes `/app/`, `/telegram/` and the
Connect path to the gateway and the catch-all to the landing container; the CI deploy probe now
checks both `/` (landing) and `/app/` (gateway).
- **Observability:** `gateway_rate_limited_total{class}` (user/public/email/admin, aggregate-only)
+ a rate-vs-rejections panel on the Edge/UX dashboard; the admin console gains the **Throttled**
page (the in-memory episode window, reset-on-restart like `active_users`, plus the flagged-account
queue) and the flag badge / clear action on the user list / card.
- The jet regen also restored the previously missing `game_drafts`/`game_hidden` generated models
(their tables were added after the last jetgen run; no behaviour change).
- **R4** (interview + implementation):
- **Locked decisions:** **delta-first**, not full snapshots — an event carries only the new move and
the UI applies it to its per-game cache, keyed on `move_count` (idempotent + gap-safe: a gap or the
actor's own move falls back to a `game.state` + `game.history` refetch). `match_found` /
`game_started` carry the recipient's **initial `StateView`** (instant lobby→game); the fallback
refetch stays the existing two calls (no merged endpoint); the matchmaking poll runs **only while
the stream is down** (2.5 s); **all** UI-state-changing events carry their payload (incl. lobby `notify`).
- **Enriched events** (`pkg/fbs` trailing fields — backward-compatible, no FB regen of *values*, only
the schema): `opponent_moved` (+`move`/`game`/`bag_len`), `your_turn` (+`move_count`), `match_found`
(+`state`), `game_over` (+`game`), `notify` (+`account`/`invitation`/`state`). The pre-R4
`opponent_moved` scalars (`seat`/`action`/`score`/`total`) stay for wire back-compat, now redundant
with `move`/`game` — slated for the R6 de-stage.
- **Encoding placement:** the `notify` package keeps ownership of the FlatBuffers encoding (a new
`encode.go` mirrors the gateway transcode but reads wire-agnostic `notify.*` input structs +
`engine.MoveRecord`); the game/lobby/social services map their domain types to those structs, so the
wire schema stays out of the domain. **Flagged for R6:** this partly duplicates the gateway encoders
(different source types) — a candidate consolidation.
- **Actor self-fetch killed too** (beyond literal "push"): the `submit_play`/`pass`/`exchange`/`resign`
**response** (`MoveResult`) now returns the actor's refilled rack + bag size, so the mover renders the
next turn from the response — `Game.svelte`'s `commit`/`pass`/`exchange`/`resign` drop their `await load()`.
- **`match_found` enrichment** needs a per-seat initial state: `lobby.GameCreator` gained `InitialState`,
and `game.Service.InitialState` builds the `notify.PlayerState` (rack re-encoded to wire indices, the
variant alphabet embedded for a first-seen variant).
- **UI:** a pure `lib/gamedelta.ts` reducer (`applyMoveDelta` / `applyGameOver` / `seedInitialState`,
unit-tested) advances the cache; `app.svelte` seeds it on `match_found` / `game_started`; `Game.svelte`
applies the delta (falling back to `load()` while composing, on a gap, or on its own move's new rack);
`NewGame.svelte` polls only when `app.streamAlive` is false and guards its teardown so a push-delivered
match is not cancelled.
- **notify (friends/invitations) scope:** the backend carries the full account / invitation payload on the
wire (per "all events → push"); the UI seeds the game cache from `game_started` but keeps its lightweight
**authoritative** badge refresh (`refreshNotifications`, on the rare `notify` event + on foreground) rather
than adding client-side friend/invitation caches — the per-move hot path is fully de-fetched, which was the
goal. Deeper lobby-cache consumption is an easy follow-up.
- **No schema change** (no migration); the contour needs no DB wipe. Tests: `notify` FB round-trips +
`emitMove` delta + the `gamedelta` reducer; the e2e mock now emits the enriched delta.
- **R5** (interview + implementation):
- **No code slimming — by analysis.** A gzip measure + sourcemap attribution of the real `dist` showed
the app bundle is already minified + tree-shaken and dominated by the Connect/FlatBuffers transport
runtime + generated FB/PB bindings (≈⅔ of `main`'s source) and the Svelte runtime — all
third-party/generated, irreducible within R5's scope. App-authored code carries no hand-trimmable fat.
- **Lazy-load rejected** (screens *and* i18n): `bundle-size.mjs` sums every emitted chunk, so
code-splitting moves bytes between chunks for **zero total-size win** while adding request latency (+N
gateway fetches on first navigation to a split screen). i18n lazy-load additionally buys ≤3 KB (en-only
users) at the cost of an async `t()`, and `en` must stay bundled (it is the `MessageKey` type source +
fallback). **Chunk-collapsing rejected** too — keeping the near-static Svelte runtime in its own
cacheable chunk is the recommended practice (an app deploy then re-busts only `main`, not the runtime),
and HTTP/2 makes the extra preload request negligible.
- **Metric retargeted to the app.** The two-entry build (`index.html` app + `landing.html`) makes Rollup
hoist the code shared by both (Svelte runtime + i18n + `aboutContent`) into one preloaded chunk, so the
app actually loads its entry chunk **+ the shared chunk** (≈74 + ≈23 = **≈97 KB**), never `landing.js`
(≈1.6 KB). The old script summed all three chunks (98.8 KB), over-counting the app by `landing.js`.
`bundle-size.mjs` now parses each built HTML for the JS it eagerly loads and gates three parts
independently — **app entry ≤ 100 KB, shared (Svelte+i18n) ≤ 30 KB, landing-own ≤ 5 KB** — reporting the
app total (≈97) and landing total (≈24.5). Same CLI + exit-code contract, so the CI step is unchanged.
- **No app/source/build change** (`App.svelte`, `lib/i18n/`, `vite.config.ts` untouched); no schema
change, no contour wipe. The stale "~82 KB" figure was corrected in `bundle-size.mjs` and `ui/README.md`.
- **R6** (interview + implementation):
- **Locked decisions:** apply **both** wire/code structural changes (**B** + **A**) and **only C1+C2** of
the test consolidation (not C3/C5); strip the `*(Stage N)*` tags from **all current-state docs**
(ARCHITECTURE / FUNCTIONAL+`_ru` / TESTING / UI_DESIGN), keeping PLAN.md / PRERELEASE.md / CLAUDE.md as
history; **split `stage6_test.go`** by domain. The `h2cMaxConcurrentStreams` sizing stays an **R7**
concern (tuning, not behaviour-preserving); the R2 early run forced no code fix, so nothing was carried in.
- **(a) De-staging:** removed the `Stage N` / `TODO-N` / `(RN)` references across code, comments, service
READMEs and the current-state docs, rewording narratives to present tense (no technical content lost).
Renamed the only stage-named identifiers (`registerStage8``registerSocialOps`,
`registerStage11``registerLinkOps`) and split `stage6_test.go` (`TestEmailLoginFlow``email_test.go`;
`TestGuestAutoMatchLeavesNoStats`+`provisionGuest``account_test.go`). De-staged the `.fbs`/`.proto`
comments and regenerated: only the `.proto`-derived Go docstrings (`*_grpc.pb.go`, `push.pb.go`) changed —
flatc strips schema comments, so the FB Go/TS bindings were untouched.
- **(b) Reconciliation:** the docs were accurate (each R-phase baked its own); the one drift was a stale
"guest-reaping deferred (TODO-3)" note in `ARCHITECTURE.md` §3 — guest reaping is implemented, so the
note was replaced with the current behaviour (FUNCTIONAL/TESTING already described it).
- **(c) B — dead `opponent_moved` scalars:** removed `seat/action/score/total` from `OpponentMovedEvent`
(`pkg/fbs/scrabble.fbs` + the `notify` emit + the round-trip test); regenerated FB Go + TS. No reader
used them (the UI codec/mock take `move`/`game`/`bag_len`; the gateway forwards the payload verbatim).
A pre-release wire-slot renumber — free with no prod data, no DB change.
- **(c) A — shared FB builders:** new `scrabble/pkg/wire` holds the single definition of the nested wire
tables (GameView / MoveRecord / StateView / AccountRef / Invitation) shared by the backend `notify`
encoder and the gateway `transcode`; both map their own source types to neutral `wire.*` structs and
delegate. **Honest tradeoff:** the verbose `Start/Add/End` + reverse-prepend boilerplate is now written
once, but the field *set* is still mapped per side, and the new package makes the change net **+~145 LOC**
— a single-source / anti-drift win for the fiddly mechanics rather than a line-count cut. Behaviour-
preserving: the two sides' field sets were verified identical and the round-trip tests pass unchanged.
- **(c) C1+C2 — inttest fixtures:** moved the cross-file service/game fixtures (`newGameService` was used by
10 files) into `backend/internal/inttest/helpers.go`; single-file helpers stay local. Pure relocation.
- **No schema change → no contour DB wipe.** Regression gate: the full unit + integration + UI suites plus
the R7 stress run.
- **R7** (interview + implementation):
- **Locked decisions:** run the harness **same-host** (one-shot container on `scrabble-internal`, capped
`--cpus=3` so the contour keeps spare cores); **apply container limits + `GOMAXPROCS` now** (not just a
prod recommendation); **replace cAdvisor with the otelcol `docker_stats` receiver** (it resolved only the
root cgroup on this host); keep rate-limit / h2c knobs **compiled-in** (change values only if the data
demands — it did not).
- **Harness refinements (pre-run):** each virtual player builds its **own `edge.Client`** (its own h2c
connection for its Subscribe stream + Execute calls) instead of all players sharing one `http2.Transport`
the R2 `transport_error` artifact; and `playTurn` now reports a **finished** game so the player drops it
from rotation. Effect, measured: `game.state` `transport_error` 14 % (R2) → **2.49 %**; `game_finished` on
chat ≈ 3 900 → **35**.
- **Observability:** added the `docker_stats` receiver to `otelcol` (`api_version: "1.44"` — the daemon's
minimum is 1.40; the receiver defaults to 1.25 and crash-looped until pinned), mounted the docker socket
read-only with `group_add` (the contrib image runs as UID 10001), dropped the cAdvisor service + its
Prometheus job, and retargeted the **Scrabble — Resources** dashboard to the docker_stats metric names
(`container_cpu_utilization`/100 == cores). Cross-checked against `docker stats` within sampling error.
- **Profile (final run, 500 players, limits in force):** the **gateway is the binding constraint** — with
one connection per player it bursts into its 2-core cap (the residual 2.49 % `transport_error`); backend
~0.85 core and postgres ~1.4 cores had headroom; **tempo reached its 1 GiB cap**; the backend pool sat at
its `MaxOpenConns=25` cap (28 backends); docker logs were unbounded (~14 MiB / 30 min on the backend at
info). Full write-up in [`../loadtest/REPORT.md`](../loadtest/REPORT.md). *(Superseded in part: a
later pass modelling the `game.evaluate` hot path traced the gateway's CPU appetite to
**gateway→backend connection churn** — the default 2-idle-connection HTTP transport — not proxying
work. Pooling the connections cut peak gateway CPU ~7× (~1.75 → ~0.26 cores at 500 players) and
removed the ephemeral-port-exhaustion cliff behind the residual `transport_error`, so the gateway is
no longer the binding constraint — postgres is. The 3-core gateway cap below is now generous headroom.)*
- **Round-2 tuning (owner-agreed, all in `deploy/docker-compose.yml`, no code change):** gateway **2 → 3
cores + `GOMAXPROCS=3`**; tempo memory **1 → 2 GiB**; backend `MAX_OPEN_CONNS` **25 → 40**; a json-file
**log-rotation** default (10m × 3) applied contour-wide via a YAML anchor (level stays info).
backend/postgres kept at 2 cores / 512 MiB (headroom is cheap on the shared host).
- **Validation:** the same gradual ramp on the tuned contour cut `game.state` `transport_error` to **0.72 %**
(gateway ~2 cores, now under the 3-core cap, no throttle; tempo ~1.27 GiB, under 2 GiB). A separate
**burst** run (a single 100 → 500 jump) pegged the gateway at 3 cores (≈296 % sustained, 9.27 % error),
confirming it is **connection-CPU-bound** — a true arrival spike is a **horizontal-scaling** lever, not
more cores per node (recorded in the prod-sizing recommendation).
- **No schema change → no contour DB wipe.** Bake-back: `loadtest/REPORT.md`, `loadtest/README.md`,
`docs/TESTING.md`, the telemetry/observability section of `docs/ARCHITECTURE.md`, the repo-layout line in `CLAUDE.md`.
- **UI — Tab-bar navigation redesign** (owner ad-hoc, not on the raw TODO list): drop the hamburger
`Menu.svelte` everywhere (it fought the Telegram-fullscreen layout, where it had to be re-centred).
- **Locked decisions (interview):** the in-Settings sub-nav is a **bottom TabBar with the active tab
highlighted** (icon-only); **Export GCG** moves to the left slot of the move-history header (free in a
finished game, where 🏁 *leave* does not apply); the lobby **⚙️ badge counts incoming friend requests
only** (invitations keep their own lobby section); unread chat is badged on **the score bar and the 💬**.
- **What shipped:** a ⚙️ **Settings hub** (`screens/SettingsHub.svelte`) over the existing
Settings/Profile/Friends/About bodies and an in-game **comms hub** (`game/CommsHub.svelte`) over
chat + dictionary, both with in-place tabs and a fixed back target; the game's menu items relocate into
the open move history (🏁 leave / 📤 export + 💬 comms header) and the player cards (🤝 add-friend); a
shared **TapConfirm** (`components/TapConfirm.svelte`, `lib/tapconfirm.ts`) — tap → fading ✅ → tap —
replaces the Skip/Hint press-and-hold popovers and drives the add-friend confirm. Fixed the move-history
"jump" bug (the slid board is now inert and the stage can't scroll, so a swipe up genuinely closes it).
`Menu.svelte` + `HoldConfirm.svelte` removed.
- **No schema/wire change → no contour DB wipe.** Bake-back: `docs/UI_DESIGN.md`, `docs/FUNCTIONAL.md`
(+`_ru`). Regression gate: UI `check` + unit (`tapconfirm`) + build + bundle budget + e2e (Chromium &
WebKit), all green.
- **UI — Merge Exchange/Pass; drop the dead Tournaments tab** (owner ad-hoc, not on the raw TODO
list): the lobby's 🏆 *Tournaments* tab was an inert `lobby.soon` toast — removed (the lobby is back
to three tabs, matching `docs/FUNCTIONAL.md`). In-game the separate 🥺 *Skip* (pass) tab folds into
the 🔄 tab, now **Exchange/Pass**, whose dialog passes when no tile is selected and exchanges when
tiles are.
- **Decision — a pass is NOT an exchange of zero (verified against the rules + GCG):** the merge is
**UI-only**. Pass and exchange stay distinct game actions end-to-end — wire (`GameActionRequest` vs
`ExchangeRequest`), engine (`ActionPass` vs `ActionExchange`), and the GCG Poslfit dialect (a pass is
a bare `-`, an exchange is `-TILES`). The engine forbids a zero-tile exchange (`ErrNothingToExchange`)
and allows an exchange only with a full rack left in the bag (`ErrNotEnoughTilesToExchange`), while a
pass is always legal — collapsing them would lose a real distinction. The dialog dispatches the
existing `gateway.pass` / `gateway.exchange`.
- **What shipped:** `Lobby.svelte` (tab removed); `Game.svelte` (one 🔄 Exchange/Pass tab no longer
gated on an empty bag; the dialog disables tile selection while the bag is below a full rack
(`bagLen >= RACK_SIZE`), its confirm button reading **Pass without exchanging** / **Exchange N**);
i18n (`game.draw` → Exchange/Pass, new `game.passNoExchange`, dropped `game.skip` /
`lobby.tournaments` / `lobby.soon`). No backend/wire/history/GCG change.
- **No schema/wire change → no contour DB wipe.** Bake-back: `docs/UI_DESIGN.md`, `docs/FUNCTIONAL.md`
(+`_ru`). Regression gate: UI `check` + unit + build + bundle budget + e2e (Chromium & WebKit).
- **AI — Honest AI opponent in quick game** (owner ad-hoc, not on the raw TODO list): a second quick-game
opponent the player *knowingly* chooses, distinct from the disguised robot of the random/open path
(which is kept as-is). New Game's quick-game mode replaces the "auto-match" subtitle with a two-button
selector **🤖 AI / 👤 Random player** (the `.seg`/`.opt` segmented style, AI the default); for AI the
move-clock line reads "Loss after 7 days of inactivity" and the "searching" hint is hidden.
- **Locked decisions (interview):** AI move is **event-driven** (the robot replies the instant the
player's move commits; the 30 s driver is the fallback); AI games **do not touch `account_stats`**
(practice, like guests); the **Stage 5 strength logic is reused unchanged** (`playToWin` 40 % from the
seed + margin band); **no per-move timeout — a 7-day inactivity loss** instead; the 7-day line lives on
the New Game screen (the in-game screen has no move-clock line); chat + nudge **disabled**, word-check
kept, add-friend never drawn, opponent shown as **🤖** everywhere.
- **The 7-day rule reuses the existing per-turn timeout:** an AI game is created with
`turn_timeout_secs = AIInactivityTimeout` (7 days) and the existing timeout sweeper resigns the overdue
seat — since the robot moves at once, only the human is ever on the clock, so the per-turn timeout *is*
the abandon rule (no new column, no new sweeper).
- **One game flag drives everything:** `games.vs_ai` (edited into the R1 baseline — pre-release, so a
contour DB wipe after merge). It is set **only** on AI-started games, so a robot-filled random game keeps
`vs_ai=false` and the disguised opponent is never revealed; the UI derives 🤖 / the gates **from the flag,
never from the opponent account**. New backend path `Matchmaker.StartVsAI` (picks a pooled robot via the
existing `Pick`, creates an **active** seated game via `game.Service.Create`, random seat order) — the AI
request never enters the open pool, so the open-game reaper never touches it. The robot driver gains a
`vs_ai` branch (no sleep, no proactive nudge, zero delay) and a focused `DriveGame`/`TriggerMove` fast
path wired from the game service's after-create/after-commit hook (`SetAITrigger`, a func value so the
game package never imports the robot package). Chat/nudge gated by a new `social` `VsAI` check
(`ErrGameVsAI` → 409 `ai_game`); statistics skipped in `commit` when `vs_ai`.
- **Wire:** `EnqueueRequest` += `vs_ai`, `GameView` += `vs_ai` (trailing FB fields, regenerated Go + TS),
threaded through the backend DTO, the gateway transcode and the `pkg/wire` + `notify` builders.
- **Tests:** `lobby` unit (StartVsAI seats a robot + flags the game; empty pool leaves no game); backend
integration (`ai_game_test.go`: active+seated+vs_ai+7-day clock, robot moves immediately, stats skipped,
7-day timeout resigns the human, chat/nudge rejected); UI codec round-trip (`vs_ai` on enqueue + game
view); e2e (an AI game shows 🤖, no "searching", chat disabled, the dictionary still works) + the
existing quick-match e2e updated to pick **Random player** (the default is now AI).
- **Schema/wire change → a contour DB wipe** after merge (`DROP SCHEMA backend CASCADE` + restart, the
R1/R3 pattern). Bake-back: `docs/ARCHITECTURE.md`, `docs/FUNCTIONAL.md` (+`_ru`), `docs/UI_DESIGN.md`,
`backend/README.md`, Go Doc comments.
- **Post-review refinements (owner, same PR):** (1) the **GCG export labels the robot seat "AI"** rather
than its human-like pool name (`ExportGCG` overrides the name via `accounts.IsRobot`; the in-app 🤖 is
unchanged); (2) honest-AI games **emit no `your_turn`** — the robot replies instantly, so the signal
would arrive with the move and be pointless; `opponent_moved` still advances the UI; (3) the **admin
console surfaces the AI flag** — a **🤖 column** in `/games` and an "AI game" line on the game card
(`GameRow`/`GameDetailView` gain `VsAI`); (4) `games_started_total` / `games_abandoned_total` gain a
**`vs_ai`** attribute and the Grafana *Game domain* dashboard splits started/abandoned into **human**
and **AI** panels.
- **Follow-up (separate PR — strategy deviation):** the robot now plays **≈20%** of opening/midgame moves
*against* its per-game `playToWin` intent (toward the opposite margin band — a winning robot eases off, a
losing one surges ahead), tapering linearly to **0 over the last 14 bag tiles** and **0 once the bag is
empty**, so the endgame follows the chosen strategy strictly while earlier outcomes can swing the human's
way. Deterministic from the seed (`mix(seed,"deviate",moveCount)`), applied to **both** robot paths via
the shared `selectMove`; the per-game intent (and the admin card) is unchanged. Tests: `robot` unit
(taper bounds + monotonicity, never-in-endgame, determinism, ~20% distribution). Bake-back:
`docs/ARCHITECTURE.md` §7, `docs/FUNCTIONAL.md` (+`_ru`), `backend/README.md`, `PLAN.md` Stage 5.
- **GL — Simultaneous quick-game cap** (owner ad-hoc, not on the raw TODO list): a player may hold at
most **10** active quick games; at the cap the lobby greys **New Game** and shows a plain notice
"Вы достигли лимита одновременных партий", both clearing automatically when an active game finishes.
- **Locked decisions (interview):** what counts = active **+** open (searching) quick games, **including
AI** (`vs_ai`); friend games (invitation-linked) **never** count. The backend gate refuses **all** new-game
creation at the cap — `lobby/enqueue` **and** `invitations` — with **409 `game_limit_reached`**; **accepting**
an invitation is never gated, so friend games are capped "from the other end". Delivery = a boolean
**`at_game_limit`** on the existing `games.list` (no per-event payload: a turn change does not move the count,
and the lobby already re-fetches `games.list` on entry + every game event); the first uncached lobby frame
defaults the button **enabled** (the backend gate is the authority).
- **What shipped:** `game.MaxActiveQuickGames` + `Store/Service.CountActiveQuickGames` (active/open seats, no
`game_invitations` row; hidden games still count → a dedicated count, not a filter over the lobby list);
`Server.atGameLimit`/`ensureUnderGameLimit` gating `handleEnqueue` + `handleCreateInvitation`;
`gameListDTO.at_game_limit`; the FB `GameList` trailing `at_game_limit` (regenerated Go + TS) threaded through
the gateway transcode + UI codec; `lib/model` + `lobbycache` snapshot + `Lobby.svelte` (disabled tab + a muted
`.limit` notice); i18n `lobby.limitReached` (en authoritative + ru).
- **Caveat (logged):** the gate is a pre-check, not transaction-atomic — concurrent creates from one account could
momentarily exceed by 12 (harmless soft cap; the UI disables the button regardless). Strict atomicity was judged
a disproportionate diff across the two create paths.
- **No schema change → no contour DB wipe** (only a trailing FB field, no migration). Tests: backend integration
(`game_limit_test.go`: count rule + HTTP gate 409 + accept bypass), server unit (error mapping), gateway
transcode round-trip, UI codec + lobbycache unit, e2e (`gamelimit.spec.ts`). Bake-back: `docs/FUNCTIONAL.md`
(+`_ru`), `docs/ARCHITECTURE.md` §8, `docs/UI_DESIGN.md`, `backend/README.md`.
- **CM — Channel-chat moderation + promo bot** (owner ad-hoc, not on the raw TODO list):
- **Locked decisions (interview):** the promo bot is a **goroutine in `cmd/bot`** (its own token, no
bot-link); the moderated chat's default-no-send is configured by a **human** in the group settings (the
bot only grants, never `setChatPermissions`); a non-eligible joiner is **left muted silently**; a
temporary-suspension expiry is handled by a **backend sweeper** that emits the re-evaluate event; and a new
**`chat_muted` role** is a chat-only mute with the **game suspension dominating**
(`eligible = registered AND NOT suspended AND NOT chat_muted`).
- **Bot API reality (verified against the docs):** a cross-bot Mini App launch must be a **URL button** to the
main bot's `t.me/<bot>?startapp` link — a `web_app` button signs initData with the *sending* bot's token,
which the main validator rejects — so the promo button reuses the UI's `VITE_TELEGRAM_LINK`. `chat_member`
updates arrive **only** when the bot is a chat **admin** with the "Ban users" right (the client label for the
Bot API `can_restrict_members`) and `chat_member` is in `allowed_updates`; bots cannot list members but can
`getChatMember` a single user, which is the membership guard on the block/unblock path.
- **Wire:** `pkg/proto/botlink/v1` gains a `ChatGateCommand` in the `Command` oneof and a unary
`ResolveChatEligibility`; the backend gains `notify.KindChatAccessChanged` (no payload, infra-only — never an
out-of-app message) and an internal `POST /api/v1/internal/chat-access` resolver; the gateway resolves the
join (by external_id) and the event (by user_id) through it and pushes the chat-gate command fire-and-forget
(at-most-once, recovered by the next moderation action or a re-join).
- **No schema change → no contour DB wipe:** `chat_muted` is a new `account.KnownRoles` entry (the
`account_roles` table is data-driven). The suspension-expiry sweeper is a new `account.SuspensionSweeper`
(a 1-minute window, idempotent) started in `cmd/backend`, alongside the guest reaper.
- **Deploy:** new `TEST_`/`PROD_` `TELEGRAM_PROMO_BOT_TOKEN` (secret), `TELEGRAM_BOT_USERNAME` and
`TELEGRAM_CHAT_ID` (variables); the promo link reuses the existing `*_VITE_TELEGRAM_LINK` variable as
`TELEGRAM_BOT_LINK`. The bot must be promoted to admin in the real discussion group, and the group default
set to no-send, as part of the Stage 18 prod cutover (the test contour exercises the code path).
- **Bake-back:** `docs/ARCHITECTURE.md`, `docs/FUNCTIONAL.md` (+`_ru`), `platform/telegram/README.md`,
`backend/README.md`, Go Doc comments. Tests: backend resolver truth table + publish on block/unblock/role +
the sweeper window (unit + integration); gateway hub `ResolveChatEligibility` + the chat-gate command; bot
`chat_member` grant + `ApplyChatGate` getChatMember-guard; promo `/start` localization + URL button; config
parsing.
- **Post-contour-test fixes (same PR):** a live test drove three corrections. (1) **Strategy
inversion (the key one)** — the original "group default no-send, bot grants the eligible" cannot
work: Telegram intersects the chat default with each user's permission, so a per-user grant never
exceeds a deny-by-default group (the bot set `can_send=true` yet the user still could not write).
The group now **allows sending by default** and the bot only **restricts** — it mutes an ineligible
member (unregistered / admin-suspended / `chat_muted`) and un-mutes an eligible one it had muted,
acting only when the current state differs (idempotent; the bot's own change is skipped by matching
the actor id to the bot). A present member in a default-allow group can appear as `restricted` with
`is_member`, so the gate reads both. (2) **Join-before-register** — a user who joins before
registering is covered by no `chat_member` event, so `ProvisionTelegram` now reports first contact
and the Telegram auth handler emits `chat_access_changed` on it. (3) **Observability** — a startup
self-check logs whether the bot is an admin-with-restrict in the chat (it caught a misconfigured
`TELEGRAM_CHAT_ID` set to a channel id, not the discussion-group id); the per-event trace is at
Debug, the actual mute/unmute and warnings at Info.
+2 -3
View File
@@ -22,9 +22,8 @@ supports English Scrabble, Russian Scrabble and Эрудит.
security, cross-service contracts.
- [`docs/FUNCTIONAL.md`](docs/FUNCTIONAL.md) (+ [`_ru`](docs/FUNCTIONAL_ru.md)) —
per-domain user stories.
- [`docs/TESTING.md`](docs/TESTING.md) — test layers and the per-stage CI gate.
- [`PLAN.md`](PLAN.md) — the staged implementation plan and stage tracker.
- [`CLAUDE.md`](CLAUDE.md) — project guide and the mandatory per-stage workflow.
- [`docs/TESTING.md`](docs/TESTING.md) — test layers and the CI gate.
- [`CLAUDE.md`](CLAUDE.md) — project guide and development workflow.
## Build & test
+2 -2
View File
@@ -3,8 +3,8 @@
// loads the dictionaries into the engine registry, warms the session cache,
// constructs the game domain and starts its turn-timeout sweeper, constructs the
// lobby and social domains, then serves the HTTP listener with the infrastructure
// probes and the /api/v1 route-group skeleton. Domain HTTP endpoints are added
// with the gateway in a later stage described in PLAN.md.
// probes and the /api/v1 route group, behind which the domains expose their HTTP
// endpoints to the gateway.
package main
import (
+12 -5
View File
@@ -207,10 +207,11 @@ type provisionSeed struct {
// telegramSeed derives the create-time seed from Telegram launch fields: a
// supported preferred language from languageCode (an ISO-639 code, possibly
// region-tagged like "ru-RU"), and a display name sanitized from firstName or,
// failing that, username (sanitizeDisplayName strips disallowed characters to the
// editable format). When neither yields any letters, it falls back to a generated
// placeholder in the seeded language (placeholderDisplayName).
// region-tagged like "ru-RU"), and a display name. The name precedence is the real
// name (firstName, sanitized to the editable format) → the @username taken verbatim
// (already a valid handle, only trimmed and length-capped, never character-stripped)
// → a generated placeholder in the seeded language (placeholderDisplayName), reached
// only when firstName has no usable letters and no username is set.
func telegramSeed(languageCode, username, firstName string) provisionSeed {
var seed provisionSeed
if lang, _, _ := strings.Cut(strings.ToLower(strings.TrimSpace(languageCode)), "-"); lang == "en" || lang == "ru" {
@@ -218,7 +219,13 @@ func telegramSeed(languageCode, username, firstName string) provisionSeed {
}
name := sanitizeDisplayName(firstName)
if name == "" {
name = sanitizeDisplayName(username)
// The real name yielded nothing usable: fall back to the @username verbatim
// (Telegram guarantees a valid handle), only trimmed and capped to the column
// width — never character-stripped like the real name.
name = strings.TrimSpace(username)
if r := []rune(name); len(r) > maxDisplayName {
name = strings.TrimRight(string(r[:maxDisplayName]), " ")
}
}
if name == "" {
name = placeholderDisplayName(seed.preferredLanguage)
+8 -6
View File
@@ -9,8 +9,9 @@ import (
// TestTelegramSeed covers the pure mapping from Telegram launch fields to the
// create-time account seed: supported-language detection (bare and region-tagged),
// the first-name / username display-name precedence, and the sanitization that
// strips disallowed characters (emoji, digits, punctuation) to the editable format.
// the real-name → @username (verbatim) → placeholder display-name precedence, and
// the sanitization of the real name (emoji, digits, punctuation stripped to the
// editable format). The username, when used, is kept verbatim.
func TestTelegramSeed(t *testing.T) {
cases := map[string]struct {
languageCode, username, firstName string
@@ -28,6 +29,7 @@ func TestTelegramSeed(t *testing.T) {
"punct to space": {"en", "user", "John❤Doe", "en", "John Doe"},
"digits dropped": {"ru", "user", "Маша123", "ru", "Маша"},
"garbage to username": {"en", "good", "123!@#", "en", "good"},
"username verbatim": {"en", "co_ol99", "🎮🎮", "en", "co_ol99"},
}
for name, tc := range cases {
t.Run(name, func(t *testing.T) {
@@ -49,10 +51,10 @@ func TestTelegramSeedPlaceholder(t *testing.T) {
languageCode, username, firstName string
wantRe string
}{
"en empty": {"en", "", "", `^Player-\d{5}$`},
"ru empty": {"ru", "", "", `^Игрок-\d{5}$`},
"default en": {"fr", "", "", `^Player-\d{5}$`},
"both garbage": {"ru", "123", "!!!", `^Игрок-\d{5}$`},
"en empty": {"en", "", "", `^Player-\d{5}$`},
"ru empty": {"ru", "", "", `^Игрок-\d{5}$`},
"default en": {"fr", "", "", `^Player-\d{5}$`},
"name garbage, no username": {"ru", "", "!!!", `^Игрок-\d{5}$`},
}
for name, tc := range cases {
t.Run(name, func(t *testing.T) {
+1 -1
View File
@@ -21,7 +21,7 @@ const (
// ActionResign abandons the game.
ActionResign
// ActionTimeout is the auto-resignation a missed turn becomes; recorded by
// the game domain in a later stage, never produced by the engine itself.
// the game domain, never produced by the engine itself.
ActionTimeout
)
+1 -1
View File
@@ -10,7 +10,7 @@
// characters (see decode.go and docs/ARCHITECTURE.md §9.1), so archived games
// replay independently of any dictionary. Second, the engine owns rules and
// scoring only: turn scheduling, the 24-hour timeout, persistence and transport
// belong to the game domain in a later stage.
// belong to the game domain.
package engine
import (
+1 -1
View File
@@ -31,7 +31,7 @@ type entry struct {
// Registry holds the dictionaries resident in memory, addressed by variant and
// dictionary version, and the solvers built over them. Several versions of a
// variant may be resident at once; a game pins the version it started on. The
// admin reload flow (a later stage) registers a new version through Load.
// admin reload flow registers a new version through Load.
// Registry is safe for concurrent use.
type Registry struct {
mu sync.RWMutex
+1 -1
View File
@@ -16,5 +16,5 @@
// word-check tool with complaint capture, per-player game state, history and GCG
// export, and the per-game turn-timeout sweeper that auto-resigns an overdue
// player (honouring their daily away window). The HTTP surface that fronts these
// operations is added with the gateway in a later stage.
// operations is exposed to the gateway.
package game
+1 -1
View File
@@ -105,7 +105,7 @@ const MaxActiveQuickGames = 10
const aiPlayerName = "AI"
// CreateParams describes a new game. Seats lists the seated accounts in turn
// order (seat 0 moves first); lobby/matchmaking assembles it in a later stage.
// order (seat 0 moves first); lobby/matchmaking assembles it.
type CreateParams struct {
Variant engine.Variant
Seats []uuid.UUID
+1 -1
View File
@@ -62,7 +62,7 @@ func TestEmailConfirmFlow(t *testing.T) {
}
// TestEmailAlreadyTakenByAnotherAccount refuses to bind an email confirmed by a
// different account (merge is a later stage).
// different account (combining two accounts is the separate link/merge flow).
func TestEmailAlreadyTakenByAnotherAccount(t *testing.T) {
ctx := context.Background()
store := account.NewStore(testDB)
+5 -5
View File
@@ -1,10 +1,10 @@
// Package server wires the backend's HTTP listener: the gin engine, its route
// groups, the per-request telemetry middleware and the start/stop lifecycle.
//
// The /api/v1 route groups (public, user, internal, admin) are created here so
// later stages attach their endpoints to a stable structure; the /user group
// requires the X-User-ID identity header. The probes /healthz (liveness) and
// /readyz (database + session-cache readiness) are unauthenticated.
// The /api/v1 route groups (public, user, internal, admin) attach their endpoints
// to a stable structure; the /user group requires the X-User-ID identity header.
// The probes /healthz (liveness) and /readyz (database + session-cache readiness)
// are unauthenticated.
package server
import (
@@ -245,7 +245,7 @@ func (s *Server) Invitations() *lobby.InvitationService { return s.invitations }
func (s *Server) Emails() *account.EmailService { return s.emails }
// Handler returns the underlying HTTP handler. It lets tests drive the server
// without binding a socket and lets later stages compose the backend behind
// without binding a socket and lets callers compose the backend behind
// another listener.
func (s *Server) Handler() http.Handler { return s.http.Handler }
+1 -2
View File
@@ -8,8 +8,7 @@ import (
)
// Service mints, resolves, and revokes sessions over the store and the
// write-through cache. The gateway is its only caller (from a later stage); the
// HTTP surface is wired then.
// write-through cache. The gateway is its only caller.
type Service struct {
store *Store
cache *Cache
+2 -2
View File
@@ -3,8 +3,8 @@
// in as a message kind. It owns the friendships, blocks and chat_messages tables,
// reads the account-level block toggles through account.Store, and gates chat and
// nudge on game state through a GameReader so it never imports the engine. The
// live delivery of chat and nudges (push / in-app stream) belongs to the gateway
// in a later stage; this package only persists and reads them.
// live delivery of chat and nudges (push / in-app stream) belongs to the gateway;
// this package only persists and reads them.
package social
import (
+1 -1
View File
@@ -1,4 +1,4 @@
# Prod host provisioning (Stage 18)
# Prod host provisioning
Idempotent Ansible that prepares the two production hosts. It installs Docker, a
non-sudo `deploy` service account, SSH hardening, a default-deny firewall,
+1 -1
View File
@@ -1,5 +1,5 @@
---
# Stage 18 host provisioning. Idempotent: safe to re-run after a host resize.
# Production host provisioning. Idempotent: safe to re-run after a host resize.
# Prepares hosts only (docker, hardening, service account, firewall); the
# application is deployed separately by .gitea/workflows/prod-deploy.yaml.
+2 -2
View File
@@ -3,7 +3,7 @@
#
# It (1) publishes caddy 80/443 — there is no host caddy in prod, so the contour caddy
# owns the edge and does its own ACME on CADDY_SITE_ADDRESS — and the gateway bot-link
# :9443 the remote bot dials in over mTLS; and (2) retunes the R7 limits down for the
# :9443 the remote bot dials in over mTLS; and (2) retunes the baseline limits down for the
# 2 vCPU / 1.9 GiB host (GOMAXPROCS=2, smaller memory caps, shorter Prometheus
# retention). The contour launches deliberately undersized at zero players; the added
# node_exporter + Grafana watch host memory so it can be resized at Selectel when
@@ -29,7 +29,7 @@ services:
ports:
- "9443:9443"
environment:
# 2 vCPU host: align the Go scheduler with the cgroup quota (R7's 3 needs 3 cores).
# 2 vCPU host: align the Go scheduler with the cgroup quota (the baseline's 3-core gateway needs 3 cores).
GOMAXPROCS: "2"
deploy:
resources:
+12 -12
View File
@@ -25,9 +25,9 @@
# backend admin relay reaches the gateway at `gateway:9092` (plaintext).
name: scrabble
# Bound every container's json-file logs. R7 measured the backend emitting a
# per-request latency line at info (~14 MiB / 30 min under the 500-player stress
# peak); without rotation the volume grows unbounded. 10 MiB x 3 files caps each
# Bound every container's json-file logs. The backend emits a per-request latency
# line at info (~14 MiB / 30 min under the 500-player peak); without rotation the
# volume grows unbounded. 10 MiB x 3 files caps each
# container at 30 MiB. Applied to every service via the *default-logging alias.
x-logging: &default-logging
driver: json-file
@@ -52,8 +52,8 @@ services:
retries: 30
volumes:
- postgres-data:/var/lib/postgresql/data
# R7 starting limits: 512M leaves headroom over the default 128 MB shared_buffers +
# per-connection memory (R2 peaked at 28 backends / 69 MiB RSS); tighten after the run.
# 512M leaves headroom over the default 128 MB shared_buffers + per-connection
# memory (the load harness peaked at 28 backends / 69 MiB RSS).
deploy:
resources:
limits:
@@ -81,8 +81,8 @@ services:
environment:
# search_path=backend matches the migrations (00001 creates the schema).
BACKEND_POSTGRES_DSN: postgres://${POSTGRES_USER:-scrabble}:${POSTGRES_PASSWORD}@postgres:5432/${POSTGRES_DB:-scrabble}?sslmode=disable&search_path=backend
# R7 tuned: the pool sat at its 25-conn cap (28 backends total) at 500 players;
# 40 gives headroom for bursts. Postgres (2 cores / 512 MiB) handles it.
# The pool caps at 25 conns (~28 backends) around 500 players; 40 gives headroom
# for bursts. Postgres (2 cores / 512 MiB) handles it.
BACKEND_POSTGRES_MAX_OPEN_CONNS: "40"
BACKEND_HTTP_ADDR: ":8080"
BACKEND_GRPC_ADDR: ":9090"
@@ -111,8 +111,8 @@ services:
- dawg-data:/opt/dawg
# No container healthcheck: the distroless image has no shell/wget. Readiness
# is covered by the CI post-deploy probe (GET / through caddy).
# R7 starting limits (generous over the R2 ~1-core / <=100 MiB peak); tightened to
# the agreed prod values after the final stress run. deploy.resources.limits is
# Generous over the ~1-core / <=100 MiB measured peak; the prod overlay trims these
# to the launch-host values. deploy.resources.limits is
# honoured by `docker compose up` (Compose v2), not only by swarm.
deploy:
resources:
@@ -175,7 +175,7 @@ services:
# deploy/gen-certs.sh for the test contour; supplied from PROD_ secrets in prod.
volumes:
- ${SCRABBLE_CONFIG_DIR:-.}/certs:/certs:ro
# R7 tuned: the gateway holds one h2c connection per player, so at 500 players it
# The gateway holds one h2c connection per player, so at 500 players it
# bursts into a 2-core cap (~2.49% transport_error on game.state); 3 cores absorbs
# the bursts. Per-connection overhead is the realistic prod cost — size for it.
deploy:
@@ -402,8 +402,8 @@ services:
volumes:
- ${SCRABBLE_CONFIG_DIR:-.}/tempo/tempo.yaml:/etc/tempo/tempo.yaml:ro
- tempo-data:/var/tempo
# R7 tuned: tempo reached the 1 GiB cap during the final run (446 MiB in R2);
# raised to 2 GiB for headroom against OOM under sustained tracing load.
# Tempo reached the 1 GiB cap under sustained load (446 MiB in earlier runs);
# raised to 2 GiB for headroom against OOM.
deploy:
resources:
limits:
+3 -5
View File
@@ -2,10 +2,8 @@
Source of truth for the platform architecture, transport, security model and
cross-service contracts. User-visible behaviour per domain lives in
[`FUNCTIONAL.md`](FUNCTIONAL.md); the staged build order lives in
[`../PLAN.md`](../PLAN.md). This document always describes the **current**
design, not the history of how it was reached. Sections describing
not-yet-implemented components are marked *(planned)*.
[`FUNCTIONAL.md`](FUNCTIONAL.md). This document always describes the **current**
design, not the history of how it was reached.
## 1. Overview
@@ -1107,7 +1105,7 @@ Two contours, two secret/variable prefixes (`TEST_` / `PROD_`):
**`prod-rollback`** workflow re-deploys any prior release tag (blank input = the previous
deployed version, tracked on the host) over the same rolling, health-gated path — image-only,
no DB migration. The main host is
intentionally **launch-sized** (2 vCPU / 1.9 GiB): the prod overlay trims the R7 limits
intentionally **launch-sized** (2 vCPU / 1.9 GiB): the prod overlay trims the baseline limits
(`GOMAXPROCS=2`, smaller caps, 7d Prometheus retention) and a **node_exporter** feeds
host-memory metrics to Grafana so it can be resized reactively as players arrive.
`GATEWAY_ABUSE_BAN_ENABLED=true` in prod (the per-IP ban is meaningful only with real
+5 -6
View File
@@ -121,7 +121,7 @@ tests or touching CI.
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** — a reusable `loadtest/` module
(`scrabble/loadtest`) is the pre-release stress harness. It **seeds** a large account
(`scrabble/loadtest`) is the stress/load 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 —
real games assembled via invitations, **mid-ranked** legal moves generated locally by
@@ -154,13 +154,12 @@ tests or touching CI.
- No network or real platform calls in unit tests; validate platform
credentials behind an interface seam and test with fixtures.
## Per-stage CI gate
## CI gate
Every completed stage is exercised on `gitea.iliadenisov.ru` before it is marked
done in [`../PLAN.md`](../PLAN.md):
Every change is exercised on `gitea.iliadenisov.ru` before it is merged:
1. Commit the stage on its `feature/*` branch.
1. Commit the change on its `feature/*` branch.
2. Push to `origin`.
3. Watch the run to completion — never hand-roll a poll loop:
`python3 ~/.claude/bin/gitea-ci-watch.py` (launch in the background).
4. Only after every workflow that fired is green may the stage be marked done.
4. Only after every workflow that fired is green may the change be merged.
+3 -3
View File
@@ -1,6 +1,6 @@
# loadtest — stress harness
Reusable load harness for the pre-release stress pass. It
Reusable load/stress harness. It
seeds a large account population with pre-created sessions, drives virtual players
through the **gateway edge protocol** in realistic games, hammers the rate limiter,
and prints a trip-report summary. It stays in the repo for repeats.
@@ -107,6 +107,6 @@ gateway→backend connection-pool fix, and the revised sizing — are written up
The harness shares the host CPU with the contour, so its own `scrabble-loadtest`
container series is read alongside the system under test; capping it with `--cpus`
keeps the contour's quota. Per-player transports (R7) removed the shared-transport
artifact that inflated R2's `transport_error`, so the figures reflect the system. A
keeps the contour's quota. Per-player transports removed the shared-transport
artifact that previously inflated `transport_error`, so the figures reflect the system. A
fully isolated ceiling on separate hardware remains future work.
+1 -1
View File
@@ -129,7 +129,7 @@ func cmdRun(ctx context.Context, log *slog.Logger, args []string) error {
drv.Hammer(ctx, pool.Durables[0], scenario.HammerConfig{Workers: *hammerWorkers, Duration: *hammerDur})
}
fmt.Println("\n==== R2 load-test report ====")
fmt.Println("\n==== load-test report ====")
fmt.Println(rec.Summary())
if *doCleanup {
+1 -1
View File
@@ -152,7 +152,7 @@ targets, `validator` and `bot`. In the test contour (`deploy/docker-compose.yml`
for Telegram egress and dials the gateway bot-link by its internal name. The bot-link
mTLS material is generated by `deploy/gen-certs.sh`. In prod the bot runs on a separate
host with native Telegram access and dials the gateway's published bot-link port with
`PROD_` certificates (the deferred final stage — see `PRERELEASE.md`).
`PROD_` certificates in production.
A real end-to-end Telegram smoke needs a BotFather bot, its token, a public HTTPS Mini
App origin, and the bot container; the unit tests cover the wire format, templates,
+3 -2
View File
@@ -257,6 +257,7 @@ export const en = {
'invitations.with': 'With {names}',
'invitations.accept': 'Accept',
'invitations.decline': 'Decline',
'invitations.declineConfirm': 'Decline invitation?',
'invitations.cancel': 'Cancel',
'invitations.waiting': 'Waiting for replies',
@@ -264,10 +265,10 @@ export const en = {
'new.withFriends': 'Play with friends',
'new.pickFriends': 'Choose who to invite',
'new.searchFriends': 'Search friends',
'new.gameType': 'Game type',
'new.gameType': 'Variant',
'new.invite': 'Send invitation',
'new.moveTime': 'Move time',
'new.hintsPerPlayer': 'Hints per player',
'new.hintsPerPlayer': 'Hints',
'new.multipleWordsPerTurn': 'Multiple words per turn',
'new.start': 'Start game',
'new.invited': 'Invitation sent.',
+3 -2
View File
@@ -258,6 +258,7 @@ export const ru: Record<MessageKey, string> = {
'invitations.with': 'С {names}',
'invitations.accept': 'Принять',
'invitations.decline': 'Отклонить',
'invitations.declineConfirm': 'Отклонить приглашение?',
'invitations.cancel': 'Отменить',
'invitations.waiting': 'Ожидаем ответы',
@@ -265,10 +266,10 @@ export const ru: Record<MessageKey, string> = {
'new.withFriends': 'Игра с друзьями',
'new.pickFriends': 'Кого пригласить',
'new.searchFriends': 'Поиск друзей',
'new.gameType': 'Тип игры',
'new.gameType': 'Вариант',
'new.invite': 'Отправить приглашение',
'new.moveTime': 'Время на ход',
'new.hintsPerPlayer': 'Подсказок на игрока',
'new.hintsPerPlayer': 'Подсказки',
'new.multipleWordsPerTurn': 'Несколько слов за ход',
'new.start': 'Начать игру',
'new.invited': 'Приглашение отправлено.',
+83 -20
View File
@@ -3,17 +3,19 @@
import { SvelteMap, SvelteSet } from 'svelte/reactivity';
import Screen from '../components/Screen.svelte';
import TabBar from '../components/TabBar.svelte';
import Modal from '../components/Modal.svelte';
import { app, handleError, refreshFeedbackBadge, seedChatUnread } from '../lib/app.svelte';
import { connection } from '../lib/connection.svelte';
import { gateway } from '../lib/gateway';
import { navigate } from '../lib/router.svelte';
import { t, type MessageKey } from '../lib/i18n/index.svelte';
import { t } from '../lib/i18n/index.svelte';
import { resultBadge } from '../lib/result';
import { badgeKind } from '../lib/unread';
import { getLobby, setLobby } from '../lib/lobbycache';
import { preloadGames } from '../lib/preload';
import { gamePhase, groupGames, orderedSeats, scoreStanding, shouldBlink, type LobbyPhase } from '../lib/lobbysort';
import type { AccountRef, GameView, Invitation } from '../lib/model';
import { VARIANT_FLAG, VARIANT_RULES } from '../lib/variants';
let games = $state<GameView[]>([]);
let invitations = $state<Invitation[]>([]);
@@ -209,11 +211,14 @@
}
}
const variantKey: Record<string, MessageKey> = {
scrabble_en: 'new.english',
scrabble_ru: 'new.russian',
erudit_ru: 'new.erudit',
};
// The invitation pending a decline confirmation: the ❌ opens a modal (mirroring the
// in-game resign confirmation) rather than declining on the first tap.
let declineTarget = $state<Invitation | null>(null);
function confirmDecline() {
const inv = declineTarget;
declineTarget = null;
if (inv) declineInvite(inv);
}
</script>
<Screen title={app.profile?.displayName ?? t('app.title')}>
@@ -230,16 +235,23 @@
<span class="sub">{t('invitations.waiting')}</span>
{:else}
<span class="who">{t('invitations.from', { name: inv.inviter.displayName })}</span>
<span class="sub">{t(variantKey[inv.variant] ?? 'new.english')}</span>
<span class="vrow">
{#if VARIANT_FLAG[inv.variant]}
<span class="vflag">{VARIANT_FLAG[inv.variant]}</span>
{:else}
<img class="vflag-img" src="flag-ussr.svg" alt="" />
{/if}
<span class="sub">{t(VARIANT_RULES[inv.variant])}</span>
</span>
{/if}
{#if !inv.multipleWordsPerTurn}<span class="sub">{t('game.oneWordRule')}</span>{/if}
</span>
<span class="acts">
{#if inv.inviter.accountId === myId}
<button class="ghost" onclick={() => cancelInvite(inv)}>{t('invitations.cancel')}</button>
<button class="iconbtn" onclick={() => cancelInvite(inv)} aria-label={t('invitations.cancel')}></button>
{:else}
<button class="btn" onclick={() => acceptInvite(inv)}>{t('invitations.accept')}</button>
<button class="ghost" onclick={() => declineInvite(inv)}>{t('invitations.decline')}</button>
<button class="iconbtn" onclick={() => acceptInvite(inv)} aria-label={t('invitations.accept')}></button>
<button class="iconbtn" onclick={() => (declineTarget = inv)} aria-label={t('invitations.decline')}></button>
{/if}
</span>
</div>
@@ -308,6 +320,15 @@
{/if}
</div>
{#if declineTarget}
<Modal title={t('invitations.declineConfirm')} onclose={() => (declineTarget = null)}>
<div class="confirm-row">
<button class="cancel" onclick={() => (declineTarget = null)}>{t('common.cancel')}</button>
<button class="danger" onclick={confirmDecline} disabled={!connection.online}>{t('invitations.decline')}</button>
</div>
</Modal>
{/if}
{#snippet tabbar()}
<TabBar>
<button class="tab" disabled={atGameLimit} onclick={() => navigate('/new')}>
@@ -365,6 +386,40 @@
border-radius: var(--radius);
user-select: none;
}
/* The middle column grows; the envelope and the action column stay at their natural width. */
.invite .info {
flex: 1;
}
/* The variant row in an invitation: flag + the rules summary (mirrors NewGame). */
.vrow {
display: flex;
align-items: baseline;
gap: 6px;
}
.vflag {
font-size: 1.1rem;
line-height: 1;
}
.vflag-img {
width: 1.3rem;
height: auto;
border-radius: 2px;
align-self: center;
}
/* Borderless icon action (✅ / ❌), like the in-game .hicon buttons. */
.iconbtn {
background: none;
border: none;
color: var(--text);
font-size: 1.3rem;
line-height: 1;
padding: 4px 6px;
border-radius: var(--radius-sm);
cursor: pointer;
}
.iconbtn:active {
background: var(--bg-elev);
}
/* Game rows are a compact, flat list: no per-card frame, a hairline divider between
consecutive rows. */
.list {
@@ -516,23 +571,31 @@
opacity: 0;
}
}
/* The ✅ / ❌ actions stack vertically with a small gap — a min-width right column. */
.acts {
display: flex;
gap: 8px;
flex-direction: column;
gap: 10px;
flex: 0 0 auto;
justify-content: center;
}
.btn {
padding: 8px 12px;
border: 1px solid var(--accent);
background: var(--accent);
color: var(--accent-text);
/* Decline-confirmation modal (mirrors the in-game resign confirm). */
.confirm-row {
display: flex;
gap: 8px;
}
.confirm-row button {
flex: 1;
padding: 11px;
border-radius: var(--radius-sm);
}
.ghost {
padding: 8px 12px;
border: 1px solid var(--border);
background: var(--surface);
color: var(--text);
border-radius: var(--radius-sm);
font-weight: 600;
}
.danger {
background: var(--danger) !important;
color: #fff !important;
border-color: var(--danger) !important;
}
</style>
+12 -3
View File
@@ -67,9 +67,12 @@
let friends = $state<AccountRef[]>([]);
let selected = $state<string[]>([]);
let friendFilter = $state('');
// No default game type yet — the player must pick one (a smarter default from play
// history / language would be a future refinement). '' renders the disabled placeholder option.
// A lone offered variant is pre-selected and its picker disabled (nothing else to choose);
// with several, the player picks. '' renders the disabled placeholder option.
let inviteVariant = $state<Variant | ''>('');
$effect(() => {
if (variants.length === 1 && !inviteVariant) inviteVariant = variants[0].id;
});
let timeoutSecs = $state(86400);
let hints = $state(1);
@@ -152,6 +155,7 @@
<input type="checkbox" bind:checked={multipleWords} />
</label>
{/if}
<div class="grow"></div>
<p class="movelimit">{opponent === 'ai' ? t('new.aiInactiveLimit') : t('new.moveLimit', { n: AUTO_MATCH_HOURS })}</p>
{#if opponent === 'random'}<p class="searchhint">{t('new.searchHint')}</p>{/if}
<button
@@ -179,7 +183,7 @@
<div class="settings-row">
<label class="field">
<span>{t('new.gameType')}</span>
<select bind:value={inviteVariant} class:placeholder={!inviteVariant}>
<select bind:value={inviteVariant} class:placeholder={!inviteVariant} disabled={variants.length === 1}>
<option value="" disabled></option>
{#each variants as v (v.id)}<option value={v.id}>{t(v.label)}</option>{/each}
</select>
@@ -387,6 +391,11 @@
color: var(--text-muted);
margin: 0;
}
/* Pushes the auto-match start cluster (move-limit hint + Start button) to the bottom,
mirroring the friend-game invite button pinned by the .fg scroll area. */
.grow {
flex: 1;
}
.invite {
flex: 0 0 auto;
padding: 14px;