diff --git a/.gitea/workflows/ci.yaml b/.gitea/workflows/ci.yaml index 00df876..e9f39ef 100644 --- a/.gitea/workflows/ci.yaml +++ b/.gitea/workflows/ci.yaml @@ -1,6 +1,6 @@ name: CI -# Single gated pipeline for the test contour (Stage 16/17). Gitea cannot express +# Single gated pipeline for the test contour. Gitea cannot express # cross-workflow `needs`, so the full test suite and the auto test-deploy live in # one workflow. # @@ -9,9 +9,9 @@ name: CI # `development` or `master` (the full test suite — the merge gate) and on a push # to `development` (after a merge). The deploy job runs only for `development` # (PR or merge), so a PR into `master` is test-only; the prod deploy is a manual -# workflow (Stage 18). +# workflow. # -# Path-conditional jobs (Stage 17): `unit`/`integration`/`ui` run only when their +# Path-conditional jobs: `unit`/`integration`/`ui` run only when their # code changed (the `changes` job decides). Because a skipped required check would # block a merge under branch protection, the always-running `gate` job aggregates # their results and is the ONLY required status check; it passes when every @@ -263,7 +263,7 @@ jobs: TELEGRAM_GAME_CHANNEL_ID_EN: ${{ vars.TEST_TELEGRAM_GAME_CHANNEL_ID_EN }} TELEGRAM_GAME_CHANNEL_ID_RU: ${{ vars.TEST_TELEGRAM_GAME_CHANNEL_ID_RU }} # The test contour always uses Telegram's test environment — pinned here, - # not an operator variable. Stage 18's prod workflow leaves it false. + # not an operator variable. The prod workflow leaves it false. TELEGRAM_TEST_ENV: "true" VITE_TELEGRAM_BOT_ID: ${{ vars.TEST_VITE_TELEGRAM_BOT_ID }} VITE_TELEGRAM_LINK: ${{ vars.TEST_VITE_TELEGRAM_LINK }} @@ -301,7 +301,7 @@ jobs: - name: Probe the landing and the gateway through caddy run: | set -u - # Two probes through the contour caddy (R3 split): "/" is the static + # Two probes through the contour caddy: "/" is the static # landing container, "/app/" is the gateway-served SPA shell. for i in $(seq 1 20); do if docker run --rm --network edge alpine:3.20 wget -q -T 5 -O /dev/null http://scrabble/ && diff --git a/PRERELEASE.md b/PRERELEASE.md index a515a7b..8f3fe12 100644 --- a/PRERELEASE.md +++ b/PRERELEASE.md @@ -22,7 +22,7 @@ the edge before prod. Each phase maps back to the owner's raw pre-release TODO l | 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 | todo | +| R6 | Refactor + docs reconciliation + de-staging | 7 | **done** | | R7 | Final stress run + tuning | 9b | todo | | → | Stage 18 — prod contour deploy | — | see [`PLAN.md`](PLAN.md) | @@ -156,7 +156,7 @@ landing (≈24 KB) is reported separately and kept minimal. Same CLI + exit-code step is unchanged. - Critical files: `ui/scripts/bundle-size.mjs`; no app code changed. -### R6 — Refactor + docs reconciliation + de-staging *(TODO 7)* — near last +### 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 @@ -348,3 +348,35 @@ Then Stage 18. 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. diff --git a/README.md b/README.md index b307133..bd33240 100644 --- a/README.md +++ b/README.md @@ -8,14 +8,13 @@ supports English Scrabble, Russian Scrabble and Эрудит. - **`gateway`** — the only public ingress: anti-abuse, platform authentication (resolves the player and injects `X-User-ID`), routing to `backend`, and an - admin surface behind Basic Auth. *(added in a later stage)* + admin surface behind Basic Auth. - **`backend`** — internal-only service that owns every domain concern and embeds the [`scrabble-solver`](../scrabble-solver) engine library in-process. - **`ui`** — pure-HTML5 client (plain Svelte 5 + TypeScript + Vite) over Connect-RPC + FlatBuffers, embeddable in platform webviews and packageable to native via Capacitor. See [`ui/README.md`](ui/README.md). - **`platform/*`** — per-platform side-services (e.g. the Telegram bot). - *(added in a later stage)* ## Documentation (sources of truth) @@ -98,6 +97,6 @@ docker compose -f deploy/docker-compose.yml config # validate (needs the CI auto-deploys the **test contour** on a PR into — or push to — `development` (`.gitea/workflows/ci.yaml`); the **prod contour** is a manual deploy after -`development → master` (Stage 18). Env reference: [`deploy/.env.example`](deploy/.env.example); +`development → master`. Env reference: [`deploy/.env.example`](deploy/.env.example); the topology and the two-contour model are in [`docs/ARCHITECTURE.md`](docs/ARCHITECTURE.md) §13. diff --git a/backend/Dockerfile b/backend/Dockerfile index 2a5ff90..9d06dae 100644 --- a/backend/Dockerfile +++ b/backend/Dockerfile @@ -2,7 +2,7 @@ # a golang-alpine builder yields a static binary shipped on distroless nonroot. # # The dictionary DAWGs are baked in from the scrabble-dictionary release artifact -# (Stage 14) — the same set the Go CI downloads — and BACKEND_DICT_DIR points the +# — the same set the Go CI downloads — and BACKEND_DICT_DIR points the # binary at them. The published solver module is fetched directly from Gitea # (GOPRIVATE), so the build stage needs git and network. # diff --git a/backend/README.md b/backend/README.md index e968a21..8ecff49 100644 --- a/backend/README.md +++ b/backend/README.md @@ -1,24 +1,24 @@ # backend Internal-only domain service for the Scrabble platform (module `scrabble/backend`). -It owns identity/sessions, accounts, and — in later stages — the lobby, game -runtime, robot, chat, history and administration. Its only network consumers are -the `gateway` and the platform side-services; it is never exposed publicly. +It owns identity/sessions, accounts, the lobby, game runtime, robot, chat, history +and administration. Its only network consumers are the `gateway` and the platform +side-services; it is never exposed publicly. -As of Stage 1 the backend provides the foundation: configuration, the HTTP -listener with the `/api/v1` route-group skeleton and probes, the Postgres pool -with embedded goose migrations, OpenTelemetry wiring, an in-memory session cache, -and the durable accounts / identities / sessions data model. The session and -account REST endpoints are added with the `gateway` (Stage 6); Stage 1 ships the -store/service layer they will call. +The backend provides the foundation: configuration, the HTTP listener with the +`/api/v1` route-group skeleton and probes, the Postgres pool with embedded goose +migrations, OpenTelemetry wiring, an in-memory session cache, and the durable +accounts / identities / sessions data model. The session and account REST +endpoints live in the `gateway`; the backend ships the store/service layer they +call. -Stage 2 adds `internal/engine`, the in-process bridge to the `scrabble-solver` +`internal/engine` is the in-process bridge to the `scrabble-solver` library: a versioned dictionary registry, a deterministic tile bag, and a pure rules `Game` (legal plays, passes, exchanges, resignations and end-condition detection) that emits dictionary-independent move records. It is a library only; -the game domain wires it into the process in Stage 3. +the game domain wires it into the process. -Stage 3 adds `internal/game`, the game domain over the engine. Active games are +`internal/game` is the game domain over the engine. Active games are event-sourced: a `games` row plus an append-only decoded move journal, with the live `engine.Game` kept warm in a cache and rebuilt by replay on a miss. It provides create, the play/pass/exchange/resign transitions, an unlimited @@ -26,10 +26,9 @@ score/legality preview, the hint (per-game allowance plus a profile wallet), the word-check tool with complaint capture, per-player game state, history and GCG export, per-account statistics on finish, and a background turn-timeout sweeper that auto-resigns overdue turns (honouring each player's daily away window). Like -Stages 1–2 it is a service/store layer; the HTTP surface lands with the -`gateway` (Stage 6). +the engine it is a service/store layer; the HTTP surface lives in the `gateway`. -Stage 4 adds the lobby and social fabric. `internal/lobby` holds an in-memory +The lobby and social fabric. `internal/lobby` holds an in-memory matchmaking pool (FIFO per variant, pairs two humans into an auto-match) and friend-game invitations (invite → accept, starting a 2–4 player game once every invitee accepts). `internal/social` owns the friend graph (request/accept), @@ -41,10 +40,10 @@ development log mailer). The engine now also handles **multi-player drop-out**: a 3–4 player game a resignation or timeout drops that seat and the rest play on (the tile disposition is a per-game setting), the game ending when one active seat remains. As before this is a service/store layer — chat and nudges are persisted -but their live delivery, and all REST endpoints, arrive with the `gateway` -(Stage 6); the services are exposed via `Server` accessors for those handlers. +but their live delivery, and all REST endpoints, live in the `gateway`; the +services are exposed via `Server` accessors for those handlers. -Stage 5 adds the robot opponent (`internal/robot`). A pool of durable accounts — +The robot opponent (`internal/robot`). A pool of durable accounts — each a `kind='robot'` identity, provisioned at startup with chat and friend requests blocked — backs human-like, per-language composed names. A background driver plays the robot's moves through the public game API as an ordinary seated player (so only @@ -53,28 +52,28 @@ win (≈ 40%), targets a small score margin, and times its moves with a move-num right-skewed delay (quick openings, long endgames), a night-sleep window anchored to the opponent's timezone, and nudge behaviour — all derived deterministically from the game seed, so it keeps no extra state. The matchmaker now substitutes a pooled robot (matching the game's language) after a 10-second wait and -exposes `Poll` so a waiting player can collect the started game — R4 made it the **stream-down +exposes `Poll` so a waiting player can collect the started game — it is the **stream-down fallback**: while the client is streaming, the live match-found push (enriched with the recipient's initial game state) drives it instead. -Stage 6 opens the backend to the edge. The route groups gain their first +The backend opens to the edge. The route groups gain their first handlers (`internal/server/handlers_*.go`): gateway-only session endpoints under `/api/v1/internal` (Telegram/guest/email login → mint, resolve, revoke) and a slice of authenticated `/api/v1/user` operations (profile, submit play, game -state, lobby enqueue/poll, chat). Stage 8 fills in the social/account/history -operations under `/api/v1/user`: `friends/*` (request/respond/cancel/unfriend, +state, lobby enqueue/poll, chat). The social/account/history operations under +`/api/v1/user`: `friends/*` (request/respond/cancel/unfriend, list/incoming, the one-time `code` issue/redeem), `blocks/*`, `invitations/*` (create/accept/decline/cancel/list), `PUT profile`, `email/{request,confirm}`, -`stats`, and `games/:id/gcg` (finished-only). A new `internal/notify` hub feeds a +`stats`, and `games/:id/gcg` (finished-only). The `internal/notify` hub feeds a second listener — `internal/pushgrpc`, a gRPC server (`BACKEND_GRPC_ADDR`) streaming live events (your-turn, opponent-moved, chat, nudge, match-found, notify) to the -gateway. Stage 9 adds the gateway-only `POST /api/v1/internal/push-target` (a user's -Telegram `external_id`, language and `notifications_in_app_only` flag) that the gateway -uses to route out-of-app push to the Telegram connector, extends the Telegram login to -seed a new account's language and display name from the launch fields, and adds -the `accounts.notifications_in_app_only` flag (default true). +gateway. The gateway-only `POST /api/v1/internal/push-target` (a user's +Telegram `external_id`, language and `notifications_in_app_only` flag) lets the gateway +route out-of-app push to the Telegram connector; the Telegram login +seeds a new account's language and display name from the launch fields, and the +`accounts.notifications_in_app_only` flag (default true). `accounts.is_guest` marks an ephemeral guest — a durable row -with no identity, excluded from statistics. **Stage 10** adds the server-rendered +with no identity, excluded from statistics. The server-rendered **admin console** at `/_gm` (`internal/adminconsole` + `internal/server/handlers_admin_console.go`; the gateway fronts it with Basic-Auth and a same-origin guard protects its POSTs), the **complaint resolution** lifecycle (the `complaints` `disposition`/`resolution_note`/ @@ -82,13 +81,13 @@ the gateway fronts it with Basic-Auth and a same-origin guard protects its POSTs pipeline, dictionary **hot-reload** from `BACKEND_DICT_DIR//` (`engine.OpenWithVersions` / `Registry.LoadAvailable`), and operator **broadcasts** via a backend Telegram-connector client (`internal/connector`, `BACKEND_CONNECTOR_ADDR`) — each -broadcast picks the delivering bot by an operator-chosen language. **Stage 15** adds -`accounts.service_language`: the language tag of the bot a Telegram +broadcast picks the delivering bot by an operator-chosen language. `accounts.service_language` +holds the language tag of the bot a Telegram user last signed in through, written on every login and returned by `/internal/push-target` (falling back to `preferred_language`) so out-of-app push routes to the right bot. The shared wire contracts live in the sibling [`../pkg`](../pkg) module. -Stage 11 adds **account linking & merge** (`/api/v1/user/link/*`). `internal/link` +**Account linking & merge** (`/api/v1/user/link/*`). `internal/link` orchestrates it: an email confirm-code or a gateway-validated Telegram identity is attached to the current account, and when the identity already has its own account the two are merged in one transaction (`internal/accountmerge`) — stats and the hint @@ -98,9 +97,9 @@ shared finished game's foreign keys hold); a shared **active** game blocks the m The current account is primary, except a guest initiator whose linked identity has a durable owner — then the durable account wins and a fresh session is minted for it. The `accounts.paid_account`/`merged_into`/`merged_at` columns back this. This supersedes the -Stage 8 `email.bind.*` edge surface (the `RequestCode`/`ConfirmCode` primitives stay). +former `email.bind.*` edge surface (the `RequestCode`/`ConfirmCode` primitives stay). -**R3** adds rate-limit observability: the gateway posts its periodic rejection +Rate-limit observability: the gateway posts its periodic rejection summaries to `POST /api/v1/internal/ratelimit/report`; `internal/ratewatch` keeps a bounded in-memory episode window for the console's **Throttled** page and applies the conservative auto-flag — an account sustaining `BACKEND_HIGHRATE_FLAG_THRESHOLD` @@ -119,8 +118,8 @@ internal/postgres/ # pgx-over-database/sql pool (otelsql), goose migrations migrations/ # embedded *.sql (goose), schema `backend` jet/ # generated go-jet models + table builders (committed) internal/account/ # durable accounts + platform/email identities (store) + email/identity link primitives -internal/accountmerge/ # single-transaction merge of a secondary account into a primary (Stage 11) -internal/link/ # link/merge orchestrator over account + accountmerge + session (Stage 11) +internal/accountmerge/ # single-transaction merge of a secondary account into a primary +internal/link/ # link/merge orchestrator over account + accountmerge + session internal/session/ # opaque tokens, sessions store, write-through cache, service (incl. RevokeAllForAccount) internal/server/ # gin engine, route groups, X-User-ID middleware, probes internal/engine/ # in-process scrabble-solver bridge: registry, bag, Game, replay @@ -130,7 +129,7 @@ internal/lobby/ # in-memory matchmaking pool (+ robot substitution) + frien internal/robot/ # human-like robot opponent: account pool, seed-derived strategy, move driver internal/adminconsole/ # server-rendered admin console (Go templates + embedded CSS, view models), served at /_gm internal/connector/ # backend gRPC client to the Telegram connector (operator broadcasts) -internal/ratewatch/ # gateway rate-limit reports: episode window for the console + the high-rate auto-flag (R3) +internal/ratewatch/ # gateway rate-limit reports: episode window for the console + the high-rate auto-flag ``` ## Configuration (environment) @@ -163,7 +162,7 @@ internal/ratewatch/ # gateway rate-limit reports: episode window for the consol | `BACKEND_CONNECTOR_ADDR` | — | Telegram connector gRPC address for admin-console operator broadcasts. Empty disables broadcasts. | | `BACKEND_GUEST_REAP_INTERVAL` | `1h` | How often the abandoned-guest reaper sweeps. | | `BACKEND_GUEST_RETENTION` | `720h` | Account age past which a guest with no game seat is deleted. | -| `BACKEND_HIGHRATE_FLAG_THRESHOLD` | `1000` | Gateway-reported rejected calls within the window past which an account is soft-flagged (R3). | +| `BACKEND_HIGHRATE_FLAG_THRESHOLD` | `1000` | Gateway-reported rejected calls within the window past which an account is soft-flagged. | | `BACKEND_HIGHRATE_FLAG_WINDOW` | `10m` | The rolling window those rejections accumulate over. | ## Run @@ -209,9 +208,8 @@ local solver co-development you may add a temporary replace — see `go.work`). (`en_sowpods.dawg`, `ru_scrabble.dawg`, `ru_erudit.dawg`) ship as a **release artifact** from the [`scrabble-dictionary`](https://gitea.iliadenisov.ru/developer/scrabble-dictionary) repo (one semver per set); the engine loads them by `(variant, dict_version)` from -`BACKEND_DICT_DIR`. Since Stage 3 the backend loads them at startup as a hard dependency -(a missing dictionary aborts the boot). See [`../PLAN.md`](../PLAN.md) Stage 14 -(TODO-1/TODO-2). +`BACKEND_DICT_DIR`. The backend loads them at startup as a hard dependency +(a missing dictionary aborts the boot). ## Tests diff --git a/backend/cmd/backend/main.go b/backend/cmd/backend/main.go index 0854508..63b0583 100644 --- a/backend/cmd/backend/main.go +++ b/backend/cmd/backend/main.go @@ -108,7 +108,7 @@ func run(ctx context.Context, cfg config.Config, logger *zap.Logger) error { zap.String("dir", cfg.Game.DictDir), zap.String("version", cfg.Game.DictVersion)) - // Stage 10 admin console: an optional backend client to the Telegram connector + // Admin console: an optional backend client to the Telegram connector // side-service for operator broadcasts. Unset (BACKEND_CONNECTOR_ADDR empty) // leaves broadcasts disabled — the console shows a "not configured" notice. var conn *connector.Client @@ -141,7 +141,7 @@ func run(ctx context.Context, cfg config.Config, logger *zap.Logger) error { logger.Info("game turn-timeout sweeper started", zap.Duration("interval", cfg.Game.TimeoutSweepInterval)) - // Stage 12 TODO-3: reap abandoned guest accounts (no game seat, account age past + // Reap abandoned guest accounts (no game seat, account age past // the retention window). Dependent rows fall away via ON DELETE CASCADE. guestReaper := account.NewGuestReaper(accounts, cfg.GuestRetention, logger) go guestReaper.Run(ctx, cfg.GuestReapInterval) @@ -149,19 +149,18 @@ func run(ctx context.Context, cfg config.Config, logger *zap.Logger) error { zap.Duration("interval", cfg.GuestReapInterval), zap.Duration("retention", cfg.GuestRetention)) - // Stage 4 lobby & social domains. Their REST and stream surface is added with - // the gateway in Stage 6, so they are handed to the server (like the route - // groups) for the handlers to come. + // Lobby & social domains. Their REST and stream surface lives in the gateway, + // so they are handed to the server (like the route groups) for the handlers. mailer := newMailer(cfg.SMTP, logger) emails := account.NewEmailService(accounts, mailer) - // Stage 11 account linking & merge: the orchestrator over the account, merge and + // Account linking & merge: the orchestrator over the account, merge and // session layers. Wired to the /api/v1/user/link REST surface below. links := link.NewService(emails, accounts, accountmerge.NewMerger(db), sessions) socialSvc := social.NewService(social.NewStore(db), accounts, games) socialSvc.SetNotifier(hub) socialSvc.SetMetrics(tel.MeterProvider().Meter("scrabble/backend/social")) - // Stage 5 robot opponent: provision its durable account pool (a hard startup + // Robot opponent: provision its durable account pool (a hard startup // dependency, like the dictionaries) and start its move driver. The matchmaker // substitutes a pooled robot for a missing human after the wait window. robots := robot.NewService(games, accounts, socialSvc, tel.MeterProvider().Meter("scrabble/backend/robot"), logger) @@ -178,7 +177,7 @@ func run(ctx context.Context, cfg config.Config, logger *zap.Logger) error { invitations.SetNotifier(hub) logger.Info("lobby and social domains ready", zap.Duration("robot_wait", cfg.Lobby.RobotWait)) - // R3 rate-limit observability: ingest the gateway's rejection reports for the + // Rate-limit observability: ingest the gateway's rejection reports for the // admin throttled view and the conservative high-rate auto-flag. rateWatch := ratewatch.New(cfg.RateWatch, accounts, logger) logger.Info("rate watch ready", diff --git a/backend/internal/account/account.go b/backend/internal/account/account.go index 975484a..a436da6 100644 --- a/backend/internal/account/account.go +++ b/backend/internal/account/account.go @@ -24,8 +24,8 @@ import ( // Identity kinds recognised by the backend. Email is modelled as an identity // alongside platform identities; its confirmed flag is driven by the email -// confirm-code flow in a later stage. Robot is a synthetic kind: each pooled -// robot opponent is a durable account bound to one robot identity (Stage 5). +// confirm-code flow. Robot is a synthetic kind: each pooled +// robot opponent is a durable account bound to one robot identity. const ( KindTelegram = "telegram" KindEmail = "email" @@ -66,19 +66,19 @@ type Account struct { IsGuest bool // NotificationsInAppOnly confines notifications to the in-app live stream when // true (the default): the platform side-service skips out-of-app push for the - // account (Stage 9). + // account. NotificationsInAppOnly bool // PaidAccount marks a lifetime one-time-payment account. It is a service field // (no purchase flow yet); an account linking & merge ORs it so a paid status is - // never lost when accounts are consolidated (Stage 11). + // never lost when accounts are consolidated. PaidAccount bool // MergedInto is the primary account a retired (merged) secondary points at, or // uuid.Nil for a live account. A tombstone keeps the row so the no-cascade - // foreign keys of a shared finished game stay valid (Stage 11). + // foreign keys of a shared finished game stay valid. MergedInto uuid.UUID // FlaggedHighRateAt is the soft, reversible "suspected high-rate" marker: the // zero time for an unflagged account, otherwise when the gateway-reported - // rate-limiter rejections first crossed the sustained threshold (R3). An + // rate-limiter rejections first crossed the sustained threshold. An // operator clears it in the admin console; it never gates any request. FlaggedHighRateAt time.Time CreatedAt time.Time @@ -430,7 +430,7 @@ func (s *Store) SpendHint(ctx context.Context, id uuid.UUID) (bool, error) { // FlagHighRate stamps the soft "suspected high-rate" marker with at, only when // the account is not already flagged — the first sustained episode wins, and a // re-flag after an operator clear starts a fresh timestamp. An infra marker, not -// a profile edit, so updated_at is untouched; it never gates any request (R3). +// a profile edit, so updated_at is untouched; it never gates any request. // It reports whether the flag was newly set. func (s *Store) FlagHighRate(ctx context.Context, id uuid.UUID, at time.Time) (bool, error) { stmt := table.Accounts. diff --git a/backend/internal/account/email.go b/backend/internal/account/email.go index ebbb775..4efbbfe 100644 --- a/backend/internal/account/email.go +++ b/backend/internal/account/email.go @@ -33,7 +33,7 @@ var ( // ErrInvalidEmail is returned for an unparseable email address. ErrInvalidEmail = errors.New("account: invalid email address") // ErrEmailTaken is returned when the email is already confirmed by another - // account; binding it would be a merge, which Stage 11 owns. + // account; binding it would be a merge, which the link/merge flow owns. ErrEmailTaken = errors.New("account: email already confirmed by another account") // ErrAlreadyConfirmed is returned when the email is already confirmed by the // requesting account. @@ -52,8 +52,8 @@ var ( // Mailer and verifies it, binding a confirmed email identity to the requesting // account. Only the SHA-256 hash of a code is stored (never the plaintext), // matching the session model. Binding an email already confirmed by a different -// account is refused (ErrEmailTaken) — merging two accounts is Stage 11 — and -// using an email as a login is Stage 6, which reuses this mechanism. +// account is refused (ErrEmailTaken) — merging two accounts is the link/merge flow — +// and using an email as a login reuses this mechanism. type EmailService struct { store *Store mailer Mailer @@ -128,7 +128,7 @@ func (s *EmailService) ConfirmCode(ctx context.Context, accountID uuid.UUID, ema // RequestLoginCode issues a login confirm-code to the account that owns email, // provisioning a fresh (unconfirmed) durable account when the email is new. It is -// the unauthenticated email-login entry point (Stage 6) and, unlike RequestCode, +// the unauthenticated email-login entry point and, unlike RequestCode, // does not refuse an already-confirmed email — that is the ordinary returning-user // login. The code is mailed to the address, so only its real owner can complete // the login. It returns the target account id for the subsequent LoginWithCode. diff --git a/backend/internal/account/link.go b/backend/internal/account/link.go index bcc18fb..4aa2f18 100644 --- a/backend/internal/account/link.go +++ b/backend/internal/account/link.go @@ -13,14 +13,14 @@ import ( ) // ErrIdentityTaken is returned when a platform identity being linked already -// belongs to another account; the caller turns it into a merge (Stage 11). +// belongs to another account; the caller turns it into a merge. var ErrIdentityTaken = errors.New("account: identity already linked to another account") // RequestLinkCode issues and mails a confirm-code for email to accountID, // replacing any prior pending code. Unlike RequestCode it never refuses up front // (taken or already-confirmed): possession of the address is the authorization for // a later link or merge, and the merge is only revealed once the code is verified, -// so a probe cannot learn whether an address is registered (Stage 11). +// so a probe cannot learn whether an address is registered. func (s *EmailService) RequestLinkCode(ctx context.Context, accountID uuid.UUID, email string) error { addr, err := normalizeEmail(email) if err != nil { @@ -94,7 +94,7 @@ func (s *EmailService) verifyPendingCode(ctx context.Context, accountID uuid.UUI // AccountIDByIdentity returns the account owning (kind, externalID) and true, or // (uuid.Nil, false) when the identity is free. It backs the platform-identity link -// flow (Stage 11). +// flow. func (s *Store) AccountIDByIdentity(ctx context.Context, kind, externalID string) (uuid.UUID, bool, error) { acc, err := s.findByIdentity(ctx, kind, externalID) if errors.Is(err, ErrNotFound) { @@ -109,7 +109,7 @@ func (s *Store) AccountIDByIdentity(ctx context.Context, kind, externalID string // AttachIdentity links a new (kind, externalID) identity to an existing account. // A unique-constraint violation means the identity was taken meanwhile, surfaced // as ErrIdentityTaken. It is used to attach a platform identity (e.g. Telegram) -// to the current account during linking (Stage 11). +// to the current account during linking. func (s *Store) AttachIdentity(ctx context.Context, accountID uuid.UUID, kind, externalID string, confirmed bool) error { id, err := uuid.NewV7() if err != nil { @@ -129,7 +129,7 @@ func (s *Store) AttachIdentity(ctx context.Context, accountID uuid.UUID, kind, e } // ClearGuest removes the is_guest flag from accountID, promoting an ephemeral guest -// to a durable account once it gains its first identity (Stage 11). It is a no-op +// to a durable account once it gains its first identity. It is a no-op // for an already-durable account. func (s *Store) ClearGuest(ctx context.Context, accountID uuid.UUID) error { upd := table.Accounts.UPDATE(table.Accounts.IsGuest, table.Accounts.UpdatedAt). diff --git a/backend/internal/account/profile.go b/backend/internal/account/profile.go index ec87974..019045b 100644 --- a/backend/internal/account/profile.go +++ b/backend/internal/account/profile.go @@ -25,16 +25,16 @@ const maxDisplayName = 32 // maxDisplayNameSpecials caps the total special characters (the "." / "_" separators — // every name rune that is neither a letter nor a space) an editable display name may -// carry, so a still-well-formed name cannot be made of mostly punctuation (Stage 17). +// carry, so a still-well-formed name cannot be made of mostly punctuation. const maxDisplayNameSpecials = 5 // maxAwayWindow bounds the daily away window's duration (midnight-wrap aware). const maxAwayWindow = 12 * time.Hour -// displayNameRe enforces the editable display-name format (Stage 8): Unicode letters +// displayNameRe enforces the editable display-name format: Unicode letters // joined by single space / "." / "_" separators, where a "." or "_" may be followed // by a single space. No leading separator and no two adjacent separators (except -// " "); a single trailing "." is allowed (Stage 17), so +// " "); a single trailing "." is allowed, so // "Name_P. Last" and "Anna B." are valid, "Name P._Last" is not. var displayNameRe = regexp.MustCompile(`^\p{L}+(?:(?:[._] ?| )\p{L}+)*\.?$`) diff --git a/backend/internal/account/profile_test.go b/backend/internal/account/profile_test.go index a0b7d5b..b8774f7 100644 --- a/backend/internal/account/profile_test.go +++ b/backend/internal/account/profile_test.go @@ -12,7 +12,7 @@ import ( // TestUpdateProfileValidation checks that bad fields are rejected before any // database access, so a nil-backed Store is enough to exercise the guards. It also -// confirms UpdateProfile wires the Stage 8 validators (name format, away window, +// confirms UpdateProfile wires the validators (name format, away window, // offset/IANA timezone), not just their unit tests in validate_test.go. func TestUpdateProfileValidation(t *testing.T) { s := &Store{} diff --git a/backend/internal/account/timezone.go b/backend/internal/account/timezone.go index 3158ab1..41f0663 100644 --- a/backend/internal/account/timezone.go +++ b/backend/internal/account/timezone.go @@ -7,7 +7,7 @@ import ( ) // offsetZoneRe matches a fixed UTC offset like "+03:00" or "-05:30" — the form the -// Stage 8 profile editor stores (an offset dropdown rather than an IANA name). +// profile editor stores (an offset dropdown rather than an IANA name). var offsetZoneRe = regexp.MustCompile(`^([+-])(\d{2}):(\d{2})$`) // parseOffsetZone parses a "±HH:MM" offset into a fixed-offset location, reporting diff --git a/backend/internal/account/userlist.go b/backend/internal/account/userlist.go index 6f2d877..f2c39a3 100644 --- a/backend/internal/account/userlist.go +++ b/backend/internal/account/userlist.go @@ -20,7 +20,7 @@ type UserListItem struct { IsGuest bool IsRobot bool // FlaggedHighRateAt is the soft high-rate marker (zero when unflagged), shown - // as a badge in the console list (R3). + // as a badge in the console list. FlaggedHighRateAt time.Time CreatedAt time.Time } @@ -105,7 +105,7 @@ type FlaggedAccount struct { const flaggedListCap = 200 // ListFlaggedHighRate returns the accounts carrying the high-rate flag, most -// recently flagged first (R3). +// recently flagged first. func (s *Store) ListFlaggedHighRate(ctx context.Context) ([]FlaggedAccount, error) { rows, err := s.db.QueryContext(ctx, `SELECT account_id, display_name, flagged_high_rate_at diff --git a/backend/internal/accountmerge/merge.go b/backend/internal/accountmerge/merge.go index f7fe606..21933f2 100644 --- a/backend/internal/accountmerge/merge.go +++ b/backend/internal/accountmerge/merge.go @@ -2,7 +2,7 @@ // transaction: it sums statistics and the hint wallet, ORs the paid flag, repoints // the secondary's identities, transfers its games/chat/complaints/invitations, // de-duplicates friends and blocks, and leaves the secondary as an audit tombstone -// (accounts.merged_into). It is the data core of Stage 11 account linking & merge +// (accounts.merged_into). It is the data core of account linking & merge // (ARCHITECTURE.md §4); session revocation and any session switch are orchestrated // one layer up (the link service), since the in-memory session cache lives there. package accountmerge diff --git a/backend/internal/adminconsole/views.go b/backend/internal/adminconsole/views.go index 8aef4c0..d63ca1e 100644 --- a/backend/internal/adminconsole/views.go +++ b/backend/internal/adminconsole/views.go @@ -60,7 +60,7 @@ type UsersView struct { // UserRow is one account row in the list. MoveMin/Avg/Max are the account's // pre-formatted move-duration summary (empty when it has no timed move); -// FlaggedHighRate marks the soft high-rate badge (R3). +// FlaggedHighRate marks the soft high-rate badge. type UserRow struct { ID string DisplayName string @@ -111,10 +111,10 @@ type UserDetailView struct { NotificationsInAppOnly bool PaidAccount bool // MergedInto is the primary account id when this account has been retired by a - // merge (Stage 11), or empty for a live account. + // merge, or empty for a live account. MergedInto string // FlaggedHighRateAt is the pre-formatted soft high-rate marker timestamp, - // empty for an unflagged account; the card shows it with the Clear action (R3). + // empty for an unflagged account; the card shows it with the Clear action. FlaggedHighRateAt string HintBalance int CreatedAt string diff --git a/backend/internal/config/config.go b/backend/internal/config/config.go index 063911f..10151e0 100644 --- a/backend/internal/config/config.go +++ b/backend/internal/config/config.go @@ -37,7 +37,7 @@ type Config struct { // Robot configures the robot opponent driver (scan cadence). Robot robot.Config // RateWatch tunes the conservative high-rate auto-flag applied to the - // gateway's rate-limiter rejection reports (R3). + // gateway's rate-limiter rejection reports. RateWatch ratewatch.Config // SMTP configures the email relay used for confirm-codes. An empty Host // selects the development log mailer (the code is logged, not sent). diff --git a/backend/internal/engine/alphabet.go b/backend/internal/engine/alphabet.go index f1f7492..df86694 100644 --- a/backend/internal/engine/alphabet.go +++ b/backend/internal/engine/alphabet.go @@ -7,7 +7,7 @@ import ( // AlphabetEntry is one letter of a variant's alphabet: its alphabet-index byte, the // concrete character and its tile point value. It is the dictionary-independent display -// table the edge sends to the client (Stage 13), produced from the variant's solver +// table the edge sends to the client, produced from the variant's solver // ruleset (its alphabet and value table) and so pinned by the solver version, not by any // dictionary. type AlphabetEntry struct { diff --git a/backend/internal/engine/alphabet_test.go b/backend/internal/engine/alphabet_test.go index afb3368..45a167a 100644 --- a/backend/internal/engine/alphabet_test.go +++ b/backend/internal/engine/alphabet_test.go @@ -8,7 +8,7 @@ import ( // TestAlphabetTableEnglish pins the English table against the solver ruleset: 26 letters, // contiguous indices, the concrete lower-case characters the solver emits and the standard -// tile values. This is the real parity check the UI no longer carries (Stage 13). +// tile values. This is the real parity check the UI no longer carries. func TestAlphabetTableEnglish(t *testing.T) { tab, err := AlphabetTable(VariantEnglish) if err != nil { diff --git a/backend/internal/engine/engine.go b/backend/internal/engine/engine.go index c28a396..965691d 100644 --- a/backend/internal/engine/engine.go +++ b/backend/internal/engine/engine.go @@ -49,7 +49,7 @@ func (v Variant) String() string { // Language returns the variant's interface/bot language tag: "en" for English Scrabble, "ru" for // the Russian variants (Russian Scrabble and Erudite). It routes a game's out-of-app push to the -// matching per-language Telegram bot — by the game, not the recipient's last-login bot (Stage 17). +// matching per-language Telegram bot — by the game, not the recipient's last-login bot. func (v Variant) Language() string { if v == VariantEnglish { return "en" diff --git a/backend/internal/engine/variant_test.go b/backend/internal/engine/variant_test.go index a175e00..72d8629 100644 --- a/backend/internal/engine/variant_test.go +++ b/backend/internal/engine/variant_test.go @@ -4,7 +4,7 @@ import "testing" // TestVariantLanguage checks the variant -> bot-language mapping that routes a game's out-of-app // push by the game itself (English -> en, the Russian variants -> ru), rather than the recipient's -// last-login bot (Stage 17). +// last-login bot. func TestVariantLanguage(t *testing.T) { cases := map[Variant]string{ VariantEnglish: "en", diff --git a/backend/internal/game/draft.go b/backend/internal/game/draft.go index 9114b86..49d9b20 100644 --- a/backend/internal/game/draft.go +++ b/backend/internal/game/draft.go @@ -21,7 +21,7 @@ type DraftTile struct { Blank bool `json:"blank"` } -// Draft is a player's persisted client-side composition for a game (Stage 17): the +// Draft is a player's persisted client-side composition for a game: the // preferred rack tile order and the board tiles laid but not yet submitted. The server // keeps it so a reload or a second device resumes the same arrangement. type Draft struct { @@ -101,7 +101,7 @@ func (s *Store) clearDraft(ctx context.Context, gameID, accountID uuid.UUID) err // resetConflictingBoardDrafts clears the board_tiles of every OTHER player's draft that has // a tile on one of the just-committed cells, since that draft can no longer be placed; the -// rack order is kept (Stage 17 #6). +// rack order is kept. func (s *Store) resetConflictingBoardDrafts(ctx context.Context, gameID, actorID uuid.UUID, cells []DraftTile) error { if len(cells) == 0 { return nil diff --git a/backend/internal/game/eventwire.go b/backend/internal/game/eventwire.go index 5ee04ca..189aa74 100644 --- a/backend/internal/game/eventwire.go +++ b/backend/internal/game/eventwire.go @@ -6,7 +6,7 @@ import ( ) // The mappers below project the game domain into the wire-agnostic notify.* input -// structs the enriched live events carry (R4). They keep the wire schema out of the +// structs the enriched live events carry. They keep the wire schema out of the // game package: notify owns the FlatBuffers encoding, this file only resolves the // values (seat display names, last-activity sort key) into its input shapes. diff --git a/backend/internal/game/service.go b/backend/internal/game/service.go index 90859dc..e6419cf 100644 --- a/backend/internal/game/service.go +++ b/backend/internal/game/service.go @@ -217,14 +217,13 @@ func (svc *Service) Resign(ctx context.Context, gameID, accountID uuid.UUID) (Mo // GameVariant returns just a game's variant. The edge layer uses it to map wire alphabet // indices to concrete letters before delegating to the letter-based play, exchange and -// word-check methods (Stage 13), keeping a single domain path shared with the robot. +// word-check methods, keeping a single domain path shared with the robot. func (svc *Service) GameVariant(ctx context.Context, gameID uuid.UUID) (engine.Variant, error) { return svc.store.GetGameVariant(ctx, gameID) } // GameLanguage returns the game's language tag ("en"/"ru"), derived from its variant, so a -// game push routes out-of-app to the game's own bot rather than the recipient's last-login bot -// (Stage 17). +// game push routes out-of-app to the game's own bot rather than the recipient's last-login bot. func (svc *Service) GameLanguage(ctx context.Context, gameID uuid.UUID) (string, error) { v, err := svc.GameVariant(ctx, gameID) if err != nil { @@ -241,7 +240,7 @@ func (svc *Service) RobotSchedule(ctx context.Context, gameID uuid.UUID) (seed i // LastMoveAt returns the time of an account's most recent move in a game (and whether it // has moved). The social service uses it to reset the nudge cooldown once a player has -// taken a turn (Stage 17). +// taken a turn. func (svc *Service) LastMoveAt(ctx context.Context, gameID, accountID uuid.UUID) (time.Time, bool, error) { return svc.store.LastMoveAt(ctx, gameID, accountID) } @@ -294,7 +293,7 @@ func (svc *Service) transition(ctx context.Context, gameID, accountID uuid.UUID, return MoveResult{Move: rec, Game: post, Rack: g.Hand(seat), BagLen: g.BagLen()}, nil } -// afterCommitDrafts maintains the Stage 17 drafts after a committed move: the actor's own +// afterCommitDrafts maintains the drafts after a committed move: the actor's own // composition is consumed, so clear it; a play's tiles may overlap an opponent's board // draft, which is then reset. Best-effort — the move is already committed, so a draft // cleanup failure is logged rather than failing the move. @@ -382,7 +381,7 @@ func (svc *Service) emitMove(ctx context.Context, post Game, rec engine.MoveReco intents = append(intents, notify.OpponentMoved(s.AccountID, post.ID, rec, summary, bagLen)) } // Game pushes are routed out-of-app by the game's own language, not the recipient's - // last-login bot (Stage 17). + // last-login bot. lang := post.Variant.Language() switch post.Status { case StatusActive: @@ -789,7 +788,7 @@ func (svc *Service) GameState(ctx context.Context, gameID, accountID uuid.UUID) } // InitialState returns accountID's full initial view of game gameID as the notify -// PlayerState carried by the match_found / game_started events (R4), so a client can +// PlayerState carried by the match_found / game_started events, so a client can // render a freshly started game from the event without a follow-up fetch. The variant // alphabet table is always embedded (the recipient may be seeing the variant for the // first time). It satisfies lobby.GameCreator. @@ -837,7 +836,7 @@ func (svc *Service) ListForAccount(ctx context.Context, accountID uuid.UUID) ([] // HideGame hides a finished game from accountID's own lobby (it stays visible to the other // players); it is irreversible by design. Only a player of a finished game may hide it -// (ErrNotAPlayer / ErrGameActive otherwise); hiding an already-hidden game is a no-op (Stage 17). +// (ErrNotAPlayer / ErrGameActive otherwise); hiding an already-hidden game is a no-op. func (svc *Service) HideGame(ctx context.Context, accountID, gameID uuid.UUID) error { g, err := svc.store.GetGame(ctx, gameID) if err != nil { diff --git a/backend/internal/game/store.go b/backend/internal/game/store.go index 8fce51f..d6709ed 100644 --- a/backend/internal/game/store.go +++ b/backend/internal/game/store.go @@ -136,7 +136,7 @@ func (s *Store) GetGame(ctx context.Context, id uuid.UUID) (Game, error) { } // GetGameVariant reads just a game's variant — a cheap single-column lookup the edge uses -// to map wire alphabet indices to concrete letters (Stage 13) without loading the whole +// to map wire alphabet indices to concrete letters without loading the whole // game and its seats. func (s *Store) GetGameVariant(ctx context.Context, id uuid.UUID) (engine.Variant, error) { stmt := postgres.SELECT(table.Games.Variant). @@ -186,7 +186,7 @@ func (s *Store) ListGamesForAccount(ctx context.Context, accountID uuid.UUID) ([ if len(grows) == 0 { return nil, nil } - // Drop games the account has hidden from its own lobby (Stage 17). + // Drop games the account has hidden from its own lobby. hidden, err := s.hiddenGameIDs(ctx, accountID) if err != nil { return nil, err @@ -233,7 +233,7 @@ func (s *Store) ListGamesForAccount(ctx context.Context, accountID uuid.UUID) ([ } // HideGame hides a game from the account's own lobby list (idempotent). The caller validates the -// game is finished and the account is a player (Stage 17). +// game is finished and the account is a player. func (s *Store) HideGame(ctx context.Context, accountID, gameID uuid.UUID) error { _, err := s.db.ExecContext(ctx, `INSERT INTO backend.game_hidden (account_id, game_id) VALUES ($1, $2) ON CONFLICT DO NOTHING`, @@ -700,7 +700,7 @@ func (s *Store) GameSeed(ctx context.Context, id uuid.UUID) (int64, error) { // LastMoveAt returns the time of the account's most recent move in the game and true, or // the zero time and false when it has not moved. The social service uses it to reset the -// nudge cooldown once the player has taken a turn (Stage 17). +// nudge cooldown once the player has taken a turn. func (s *Store) LastMoveAt(ctx context.Context, gameID, accountID uuid.UUID) (time.Time, bool, error) { var at sql.NullTime err := s.db.QueryRowContext(ctx, diff --git a/backend/internal/game/types.go b/backend/internal/game/types.go index e523899..53d40d2 100644 --- a/backend/internal/game/types.go +++ b/backend/internal/game/types.go @@ -15,8 +15,8 @@ const ( StatusFinished = "finished" ) -// Complaint lifecycle values. A complaint is filed StatusComplaintOpen (Stage 3) -// and closed StatusComplaintResolved by the admin review queue (Stage 10) with a +// Complaint lifecycle values. A complaint is filed StatusComplaintOpen +// and closed StatusComplaintResolved by the admin review queue with a // Disposition. The CHECK constraints live in migration 00008. const ( StatusComplaintOpen = "open" @@ -125,7 +125,7 @@ func (g Game) seatOf(accountID uuid.UUID) (int, bool) { // MoveResult is the outcome of a committed transition: the decoded move and the // post-move game, plus the actor's own refilled rack and the bag size after the draw -// (Rack/BagLen, R4), so the mover renders the next state from the response without a +// (Rack/BagLen), so the mover renders the next state from the response without a // follow-up game.state. type MoveResult struct { Move engine.MoveRecord diff --git a/backend/internal/inttest/account_test.go b/backend/internal/inttest/account_test.go index 286b171..86b1607 100644 --- a/backend/internal/inttest/account_test.go +++ b/backend/internal/inttest/account_test.go @@ -11,6 +11,8 @@ import ( "github.com/google/uuid" "scrabble/backend/internal/account" + "scrabble/backend/internal/engine" + "scrabble/backend/internal/game" ) // TestAccountProvisionByIdentity covers find-or-create semantics, distinct @@ -78,7 +80,7 @@ func TestAccountProvisionByIdentity(t *testing.T) { } // TestGetStatsZeroForFreshAccount checks that an account with no finished games -// reads back the zero statistics rather than an error (the Stage 8 stats screen). +// reads back the zero statistics rather than an error (the stats screen). func TestGetStatsZeroForFreshAccount(t *testing.T) { ctx := context.Background() store := account.NewStore(testDB) @@ -109,7 +111,7 @@ func identityConfirmed(t *testing.T, kind, externalID string) bool { // TestProvisionTelegramSeedsNewAccountOnly checks that Telegram first contact // seeds the new account's language and display name from the launch fields, // defaults the in-app-only flag on, and never overwrites an existing account on a -// later login (Stage 9 language seeding). +// later login (language seeding). func TestProvisionTelegramSeedsNewAccountOnly(t *testing.T) { ctx := context.Background() store := account.NewStore(testDB) @@ -196,7 +198,7 @@ func TestServiceLanguageRoundTrip(t *testing.T) { } } -// TestHighRateFlagRoundTrip covers the R3 soft high-rate marker: a fresh account +// TestHighRateFlagRoundTrip covers the soft high-rate marker: a fresh account // is unflagged, FlagHighRate stamps it exactly once (a second sustained episode // never moves the timestamp), ClearHighRateFlag reverses it, and a re-flag after // the operator clear takes a fresh timestamp. @@ -279,7 +281,7 @@ func TestIdentityExternalID(t *testing.T) { } } -// TestNotificationsInAppOnlyRoundTrip checks the Stage 9 profile flag persists +// TestNotificationsInAppOnlyRoundTrip checks the profile flag persists // through UpdateProfile and reads back through GetByID. func TestNotificationsInAppOnlyRoundTrip(t *testing.T) { ctx := context.Background() @@ -311,3 +313,59 @@ func TestNotificationsInAppOnlyRoundTrip(t *testing.T) { t.Error("GetByID still reports in-app-only after clearing") } } + +// TestGuestAutoMatchLeavesNoStats drives a guest through a full auto-match game +// against a robot to a natural end and checks the guest holds a seat (the +// game_players foreign key is satisfied) yet accrues no statistics, while the +// durable robot opponent does. +func TestGuestAutoMatchLeavesNoStats(t *testing.T) { + ctx := context.Background() + svc := newGameService() + robots := newRobotService(t, svc) + if err := robots.EnsurePool(ctx); err != nil { + t.Fatalf("ensure pool: %v", err) + } + robotID, err := robots.Pick(engine.VariantEnglish) + if err != nil { + t.Fatalf("pick: %v", err) + } + guest := provisionGuest(t) + seed := openingSeed(t) + + g, err := svc.Create(ctx, game.CreateParams{ + Variant: engine.VariantEnglish, Seats: []uuid.UUID{guest, robotID}, + TurnTimeout: 24 * time.Hour, Seed: seed, + }) + if err != nil { + t.Fatalf("create: %v", err) + } + const robotSeat = 1 // seats = [guest, robot] + + finished := false + for i := 0; i < 400 && !finished; i++ { + _, toMove, status, err := svc.Participants(ctx, g.ID) + if err != nil { + t.Fatalf("participants: %v", err) + } + if status != game.StatusActive { + finished = true + break + } + if toMove == robotSeat { + setTurnStarted(t, g.ID, daytime.Add(-2*time.Hour)) + robots.Drive(ctx, daytime) + continue + } + playHuman(t, ctx, svc, g.ID, guest) + } + if !finished { + t.Fatal("guest game did not finish within the move budget") + } + + if _, _, _, _, _, ok := readStats(t, guest); ok { + t.Error("a guest must not accrue a statistics row") + } + if _, _, _, _, _, ok := readStats(t, robotID); !ok { + t.Error("the durable robot opponent should have a statistics row") + } +} diff --git a/backend/internal/inttest/admin_test.go b/backend/internal/inttest/admin_test.go index 0171c54..0531685 100644 --- a/backend/internal/inttest/admin_test.go +++ b/backend/internal/inttest/admin_test.go @@ -169,7 +169,7 @@ func TestConsoleServesAndGuardsCSRF(t *testing.T) { } // TestConsoleGameDetailRobotSchedule checks the admin game card surfaces a robot seat's -// play-to-win intent and, while it is the robot's turn, its next-move ETA (Stage 17). +// play-to-win intent and, while it is the robot's turn, its next-move ETA. func TestConsoleGameDetailRobotSchedule(t *testing.T) { ctx := context.Background() svc := newGameService() @@ -207,7 +207,7 @@ func TestConsoleGameDetailRobotSchedule(t *testing.T) { } } -// TestConsoleThrottledViewAndFlagClear drives the R3 rate-limit surface end to +// TestConsoleThrottledViewAndFlagClear drives the rate-limit surface end to // end against real stores: a gateway report past the threshold auto-flags the // account, the throttled view shows the episode and the flagged account, the // user card carries the marker, and the operator clear (a same-origin POST) diff --git a/backend/internal/inttest/draft_test.go b/backend/internal/inttest/draft_test.go index 41bad24..4538629 100644 --- a/backend/internal/inttest/draft_test.go +++ b/backend/internal/inttest/draft_test.go @@ -5,36 +5,11 @@ package inttest import ( "context" "testing" - "time" - "github.com/google/uuid" - - "scrabble/backend/internal/engine" "scrabble/backend/internal/game" ) -// newDraftGame creates a started two-player English game on an opening seed and returns the -// service, game id, seats, and the opening play (from a mirror) used to drive a real commit. -func newDraftGame(t *testing.T) (*game.Service, uuid.UUID, []uuid.UUID, engine.MoveRecord) { - t.Helper() - ctx := context.Background() - svc := newGameService() - seats := []uuid.UUID{provisionAccount(t), provisionAccount(t)} - seed := openingSeed(t) - g, err := svc.Create(ctx, game.CreateParams{ - Variant: engine.VariantEnglish, Seats: seats, TurnTimeout: 24 * time.Hour, Seed: seed, - }) - if err != nil { - t.Fatalf("create: %v", err) - } - hint, ok := newMirror(t, seed, 2).HintView() - if !ok || len(hint.Tiles) == 0 { - t.Fatal("no opening move") - } - return svc, g.ID, seats, hint -} - -// TestDraftPersistAndConflictReset covers Stage 17 draft persistence: a round-trip of the +// TestDraftPersistAndConflictReset covers draft persistence: a round-trip of the // rack order + board tiles, the actor's own draft cleared on their move, and an opponent's // board draft reset when a committed play overlaps one of its cells (the rack order kept). func TestDraftPersistAndConflictReset(t *testing.T) { diff --git a/backend/internal/inttest/email_test.go b/backend/internal/inttest/email_test.go index 105290d..e30ccc6 100644 --- a/backend/internal/inttest/email_test.go +++ b/backend/internal/inttest/email_test.go @@ -159,7 +159,7 @@ func TestUpdateProfilePersists(t *testing.T) { } } -// TestUpdateProfileOffsetTimezone checks the Stage 8 UTC-offset timezone: it is +// TestUpdateProfileOffsetTimezone checks the UTC-offset timezone: it is // accepted, persisted verbatim, and resolved to the right fixed offset. func TestUpdateProfileOffsetTimezone(t *testing.T) { ctx := context.Background() @@ -181,3 +181,49 @@ func TestUpdateProfileOffsetTimezone(t *testing.T) { t.Fatalf("ResolveZone offset = %d, want 10800", off) } } + +// TestEmailLoginFlow covers the email-as-login path: a code is mailed to +// a new address, verifying it provisions and returns the owning account, and a +// second login for the same address resolves to that same account (a returning +// user), with the identity confirmed. +func TestEmailLoginFlow(t *testing.T) { + ctx := context.Background() + mailer := &capturingMailer{} + svc := account.NewEmailService(account.NewStore(testDB), mailer) + email := "login-" + uuid.NewString() + "@example.com" + + accountID, err := svc.RequestLoginCode(ctx, email) + if err != nil { + t.Fatalf("request login code: %v", err) + } + code := sixDigit.FindString(mailer.lastBody) + if code == "" { + t.Fatalf("no code in mail body %q", mailer.lastBody) + } + + acc, err := svc.LoginWithCode(ctx, email, code) + if err != nil { + t.Fatalf("login with code: %v", err) + } + if acc.ID != accountID { + t.Errorf("login account = %s, want %s", acc.ID, accountID) + } + if acc.IsGuest { + t.Error("an email account must be durable, not a guest") + } + if !identityConfirmed(t, account.KindEmail, email) { + t.Error("the email identity must be confirmed after login") + } + + // A second login for the same email is the returning user: same account. + if _, err := svc.RequestLoginCode(ctx, email); err != nil { + t.Fatalf("second request: %v", err) + } + acc2, err := svc.LoginWithCode(ctx, email, sixDigit.FindString(mailer.lastBody)) + if err != nil { + t.Fatalf("second login: %v", err) + } + if acc2.ID != accountID { + t.Errorf("returning login account = %s, want %s", acc2.ID, accountID) + } +} diff --git a/backend/internal/inttest/game_test.go b/backend/internal/inttest/game_test.go index 45bdbe5..18e72fc 100644 --- a/backend/internal/inttest/game_test.go +++ b/backend/internal/inttest/game_test.go @@ -4,88 +4,17 @@ package inttest import ( "context" - "database/sql" "errors" "sync" "testing" "time" "github.com/google/uuid" - "go.uber.org/zap" - "scrabble/backend/internal/account" "scrabble/backend/internal/engine" "scrabble/backend/internal/game" ) -// newGameService builds a game service over the shared pool and registry. -func newGameService() *game.Service { - return game.NewService( - game.NewStore(testDB), - account.NewStore(testDB), - testRegistry, - game.Config{ - DictDir: dictDir(), - DictVersion: testDictVersion, - TimeoutSweepInterval: time.Minute, - CacheTTL: time.Hour, - }, - zap.NewNop(), - ) -} - -// provisionAccount creates a fresh durable account and returns its id. -func provisionAccount(t *testing.T) uuid.UUID { - t.Helper() - acc, err := account.NewStore(testDB).ProvisionByIdentity(context.Background(), account.KindTelegram, "tg-"+uuid.NewString()) - if err != nil { - t.Fatalf("provision account: %v", err) - } - return acc.ID -} - -// openingSeed returns a seed whose fresh two-player English opening rack has a -// legal move, so a greedy mirror can drive a game. -func openingSeed(t *testing.T) int64 { - t.Helper() - for seed := int64(1); seed <= 200; seed++ { - g, err := engine.New(testRegistry, engine.Options{Variant: engine.VariantEnglish, Version: testDictVersion, Players: 2, Seed: seed}) - if err != nil { - t.Fatalf("engine new: %v", err) - } - if _, ok := g.HintView(); ok { - return seed - } - } - t.Fatal("no opening seed found") - return 0 -} - -// newMirror builds a parallel engine game with the same seed, used to compute -// legal moves to feed the service under test. -func newMirror(t *testing.T, seed int64, players int) *engine.Game { - t.Helper() - g, err := engine.New(testRegistry, engine.Options{Variant: engine.VariantEnglish, Version: testDictVersion, Players: players, Seed: seed}) - if err != nil { - t.Fatalf("mirror new: %v", err) - } - return g -} - -// readStats reads an account's statistics row. -func readStats(t *testing.T, id uuid.UUID) (wins, losses, draws, maxGame, maxWord int, found bool) { - t.Helper() - row := testDB.QueryRowContext(context.Background(), - `SELECT wins, losses, draws, max_game_points, max_word_points FROM backend.account_stats WHERE account_id = $1`, id) - if err := row.Scan(&wins, &losses, &draws, &maxGame, &maxWord); err != nil { - if errors.Is(err, sql.ErrNoRows) { - return 0, 0, 0, 0, 0, false - } - t.Fatalf("read stats: %v", err) - } - return wins, losses, draws, maxGame, maxWord, true -} - // TestListForAccount checks the lobby "my games" query: it returns exactly the // games the account is seated in (each with its seats), and nothing for an outsider. func TestListForAccount(t *testing.T) { @@ -299,7 +228,7 @@ func TestResignWinnerAndStats(t *testing.T) { } } -// TestResignOnOpponentTurn checks the Stage 17 fix: a player can forfeit on the +// TestResignOnOpponentTurn checks a player can forfeit on the // opponent's turn. Seat 0 plays (so it is seat 1's turn), then seat 0 resigns its own // seat while it is not its turn — no ErrNotYourTurn, the game ends, and seat 0 loses // despite leading on score. @@ -464,7 +393,7 @@ func TestHintPolicy(t *testing.T) { } } -// TestGameVariant covers the edge's lightweight variant lookup (Stage 13): it returns the +// TestGameVariant covers the edge's lightweight variant lookup: it returns the // created game's variant and ErrNotFound for an unknown id. func TestGameVariant(t *testing.T) { ctx := context.Background() @@ -619,7 +548,7 @@ func equalStrings(a, b []string) bool { return true } -// TestExportGCGRefusesActiveGame checks the Stage 8 finished-only gate: a GCG export +// TestExportGCGRefusesActiveGame checks the finished-only gate: a GCG export // is allowed only once the game is over, so an active game leaks nothing mid-play. func TestExportGCGRefusesActiveGame(t *testing.T) { ctx := context.Background() diff --git a/backend/internal/inttest/helpers.go b/backend/internal/inttest/helpers.go new file mode 100644 index 0000000..fc94a8f --- /dev/null +++ b/backend/internal/inttest/helpers.go @@ -0,0 +1,167 @@ +//go:build integration + +package inttest + +import ( + "context" + "database/sql" + "errors" + "testing" + "time" + + "github.com/google/uuid" + "go.opentelemetry.io/otel/metric/noop" + "go.uber.org/zap" + + "scrabble/backend/internal/account" + "scrabble/backend/internal/engine" + "scrabble/backend/internal/game" + "scrabble/backend/internal/lobby" + "scrabble/backend/internal/robot" + "scrabble/backend/internal/social" +) + +// Shared fixtures for the Postgres-backed integration suite: the service +// constructors over the shared pool/registry, account provisioning, game +// assembly, and the stats reader. Helpers used by a single test file stay in +// that file; everything reused across files lives here. + +// newGameService builds a game service over the shared pool and registry. +func newGameService() *game.Service { + return game.NewService( + game.NewStore(testDB), + account.NewStore(testDB), + testRegistry, + game.Config{ + DictDir: dictDir(), + DictVersion: testDictVersion, + TimeoutSweepInterval: time.Minute, + CacheTTL: time.Hour, + }, + zap.NewNop(), + ) +} + +// newSocialService builds a social service over the shared pool, reading game +// state through a real game service. +func newSocialService() *social.Service { + return social.NewService(social.NewStore(testDB), account.NewStore(testDB), newGameService()) +} + +// newRobotService builds a robot service over games (shared so its moves and the +// test's human moves use the same live-game cache and per-game locks), a fresh +// social service for nudges, and a no-op meter. +func newRobotService(t *testing.T, games *game.Service) *robot.Service { + t.Helper() + return robot.NewService(games, account.NewStore(testDB), newSocialService(), noop.NewMeterProvider().Meter("robot-test"), zap.NewNop()) +} + +// newMatchmaker builds a matchmaker starting real games and substituting from +// robots after wait. +func newMatchmaker(t *testing.T, robots lobby.RobotProvider, wait time.Duration) *lobby.Matchmaker { + t.Helper() + return lobby.NewMatchmaker(newGameService(), robots, wait, zap.NewNop()) +} + +// provisionAccount creates a fresh durable account and returns its id. +func provisionAccount(t *testing.T) uuid.UUID { + t.Helper() + acc, err := account.NewStore(testDB).ProvisionByIdentity(context.Background(), account.KindTelegram, "tg-"+uuid.NewString()) + if err != nil { + t.Fatalf("provision account: %v", err) + } + return acc.ID +} + +// provisionGuest creates a fresh ephemeral guest account and returns its id. +func provisionGuest(t *testing.T) uuid.UUID { + t.Helper() + acc, err := account.NewStore(testDB).ProvisionGuest(context.Background()) + if err != nil { + t.Fatalf("provision guest: %v", err) + } + if !acc.IsGuest { + t.Fatalf("provisioned account %s is not flagged guest", acc.ID) + } + return acc.ID +} + +// openingSeed returns a seed whose fresh two-player English opening rack has a +// legal move, so a greedy mirror can drive a game. +func openingSeed(t *testing.T) int64 { + t.Helper() + for seed := int64(1); seed <= 200; seed++ { + g, err := engine.New(testRegistry, engine.Options{Variant: engine.VariantEnglish, Version: testDictVersion, Players: 2, Seed: seed}) + if err != nil { + t.Fatalf("engine new: %v", err) + } + if _, ok := g.HintView(); ok { + return seed + } + } + t.Fatal("no opening seed found") + return 0 +} + +// newMirror builds a parallel engine game with the same seed, used to compute +// legal moves to feed the service under test. +func newMirror(t *testing.T, seed int64, players int) *engine.Game { + t.Helper() + g, err := engine.New(testRegistry, engine.Options{Variant: engine.VariantEnglish, Version: testDictVersion, Players: players, Seed: seed}) + if err != nil { + t.Fatalf("mirror new: %v", err) + } + return g +} + +// newGameWithSeats creates a started game seating n fresh accounts and returns the +// game id and the seated account ids in seat order. +func newGameWithSeats(t *testing.T, n int) (uuid.UUID, []uuid.UUID) { + t.Helper() + seats := make([]uuid.UUID, n) + for i := range seats { + seats[i] = provisionAccount(t) + } + g, err := newGameService().Create(context.Background(), game.CreateParams{ + Variant: engine.VariantEnglish, Seats: seats, TurnTimeout: 24 * time.Hour, Seed: openingSeed(t), + }) + if err != nil { + t.Fatalf("create game: %v", err) + } + return g.ID, seats +} + +// newDraftGame creates a started two-player English game on an opening seed and returns the +// service, game id, seats, and the opening play (from a mirror) used to drive a real commit. +func newDraftGame(t *testing.T) (*game.Service, uuid.UUID, []uuid.UUID, engine.MoveRecord) { + t.Helper() + ctx := context.Background() + svc := newGameService() + seats := []uuid.UUID{provisionAccount(t), provisionAccount(t)} + seed := openingSeed(t) + g, err := svc.Create(ctx, game.CreateParams{ + Variant: engine.VariantEnglish, Seats: seats, TurnTimeout: 24 * time.Hour, Seed: seed, + }) + if err != nil { + t.Fatalf("create: %v", err) + } + hint, ok := newMirror(t, seed, 2).HintView() + if !ok || len(hint.Tiles) == 0 { + t.Fatal("no opening move") + } + return svc, g.ID, seats, hint +} + +// readStats reads an account's statistics row. +func readStats(t *testing.T, id uuid.UUID) (wins, losses, draws, maxGame, maxWord int, found bool) { + t.Helper() + row := testDB.QueryRowContext(context.Background(), + `SELECT wins, losses, draws, max_game_points, max_word_points FROM backend.account_stats WHERE account_id = $1`, id) + if err := row.Scan(&wins, &losses, &draws, &maxGame, &maxWord); err != nil { + if errors.Is(err, sql.ErrNoRows) { + return 0, 0, 0, 0, 0, false + } + t.Fatalf("read stats: %v", err) + } + return wins, losses, draws, maxGame, maxWord, true +} diff --git a/backend/internal/inttest/hide_test.go b/backend/internal/inttest/hide_test.go index aaac244..a08ad78 100644 --- a/backend/internal/inttest/hide_test.go +++ b/backend/internal/inttest/hide_test.go @@ -12,7 +12,7 @@ import ( "scrabble/backend/internal/game" ) -// TestHideFinishedGame covers Stage 17 per-account game hiding: an active game cannot be +// TestHideFinishedGame covers per-account game hiding: an active game cannot be // hidden, a finished game is removed from the hider's own list while staying visible to the // other player, an outsider cannot hide it, and the action is idempotent. func TestHideFinishedGame(t *testing.T) { diff --git a/backend/internal/inttest/robot_test.go b/backend/internal/inttest/robot_test.go index e7160ca..738e4c7 100644 --- a/backend/internal/inttest/robot_test.go +++ b/backend/internal/inttest/robot_test.go @@ -8,31 +8,12 @@ import ( "time" "github.com/google/uuid" - "go.opentelemetry.io/otel/metric/noop" - "go.uber.org/zap" "scrabble/backend/internal/account" "scrabble/backend/internal/engine" "scrabble/backend/internal/game" - "scrabble/backend/internal/lobby" - "scrabble/backend/internal/robot" ) -// newRobotService builds a robot service over games (shared so its moves and the -// test's human moves use the same live-game cache and per-game locks), a fresh -// social service for nudges, and a no-op meter. -func newRobotService(t *testing.T, games *game.Service) *robot.Service { - t.Helper() - return robot.NewService(games, account.NewStore(testDB), newSocialService(), noop.NewMeterProvider().Meter("robot-test"), zap.NewNop()) -} - -// newMatchmaker builds a matchmaker starting real games and substituting from -// robots after wait. -func newMatchmaker(t *testing.T, robots lobby.RobotProvider, wait time.Duration) *lobby.Matchmaker { - t.Helper() - return lobby.NewMatchmaker(newGameService(), robots, wait, zap.NewNop()) -} - // setTurnStarted rewrites a game's turn clock so a robot turn can be made due (or // idle) at a chosen instant, independent of wall time. func setTurnStarted(t *testing.T, id uuid.UUID, at time.Time) { @@ -97,7 +78,7 @@ func TestRobotPoolProvisionsRobotAccounts(t *testing.T) { t.Fatalf("get robot account: %v", err) } // A robot blocks chat but NOT friend requests: a request to a robot stays pending and - // expires, mirroring a human who ignores it (Stage 17). + // expires, mirroring a human who ignores it. if acc.DisplayName == "" || !acc.BlockChat || acc.BlockFriendRequests { t.Errorf("robot profile wrong: name=%q chat-blocked=%v friends-blocked=%v (want chat blocked, friends open)", acc.DisplayName, acc.BlockChat, acc.BlockFriendRequests) diff --git a/backend/internal/inttest/social_test.go b/backend/internal/inttest/social_test.go index 666ed89..87a13d9 100644 --- a/backend/internal/inttest/social_test.go +++ b/backend/internal/inttest/social_test.go @@ -45,32 +45,9 @@ func (c *capturePublisher) notified(user uuid.UUID, sub string) bool { return false } -// newSocialService builds a social service over the shared pool, reading game -// state through a real game service. -func newSocialService() *social.Service { - return social.NewService(social.NewStore(testDB), account.NewStore(testDB), newGameService()) -} - -// newGameWithSeats creates a started game seating n fresh accounts and returns the -// game id and the seated account ids in seat order. -func newGameWithSeats(t *testing.T, n int) (uuid.UUID, []uuid.UUID) { - t.Helper() - seats := make([]uuid.UUID, n) - for i := range seats { - seats[i] = provisionAccount(t) - } - g, err := newGameService().Create(context.Background(), game.CreateParams{ - Variant: engine.VariantEnglish, Seats: seats, TurnTimeout: 24 * time.Hour, Seed: openingSeed(t), - }) - if err != nil { - t.Fatalf("create game: %v", err) - } - return g.ID, seats -} - // TestFriendRequestToRobotStaysPending checks a friend request to a robot is accepted as // pending rather than blocked: robots no longer block friend requests, so the request -// just sits unanswered and later expires — mirroring a human who ignores it (Stage 17). +// just sits unanswered and later expires — mirroring a human who ignores it. func TestFriendRequestToRobotStaysPending(t *testing.T) { ctx := context.Background() svc := newSocialService() @@ -342,7 +319,7 @@ func TestChatRejectsBadContent(t *testing.T) { } } -// TestChatOnlyOnYourTurn checks chat is allowed only on the sender's own turn (Stage 17): +// TestChatOnlyOnYourTurn checks chat is allowed only on the sender's own turn: // the player to move can post, the waiting player gets ErrChatNotYourTurn. func TestChatOnlyOnYourTurn(t *testing.T) { ctx := context.Background() @@ -383,7 +360,7 @@ func TestNudgeRulesAndRateLimit(t *testing.T) { } // TestNudgeCooldownResetsOnAction checks the nudge cooldown clears once the player has -// acted (moved or chatted) since their last nudge, even within the hour (Stage 17). +// acted (moved or chatted) since their last nudge, even within the hour. func TestNudgeCooldownResetsOnAction(t *testing.T) { ctx := context.Background() svc := newSocialService() @@ -413,7 +390,7 @@ func TestNudgeCooldownResetsOnAction(t *testing.T) { } // TestListOutgoingRequests checks the requester-side list that backs the in-game "add to -// friends" item (Stage 17): a pending request shows for the requester only; an accepted one +// friends" item: a pending request shows for the requester only; an accepted one // clears (it is a friendship now); a declined one stays (cannot be re-sent, so it reads as // still "sent"); a lazily expired pending one drops (it may be re-sent). func TestListOutgoingRequests(t *testing.T) { @@ -469,7 +446,7 @@ func TestListOutgoingRequests(t *testing.T) { } // TestRespondPublishesToRequester checks that answering a request notifies the original -// requester over the live channel (Stage 17): accept -> friend_added, decline -> +// requester over the live channel: accept -> friend_added, decline -> // friend_declined, so a game screen watching that opponent re-derives its friend state. func TestRespondPublishesToRequester(t *testing.T) { ctx := context.Background() @@ -503,7 +480,7 @@ func TestRespondPublishesToRequester(t *testing.T) { } // TestNudgeRoutedByGameLanguage checks a nudge's out-of-app push carries the game's language, so -// it is delivered by the game's bot rather than the recipient's last-login bot (Stage 17). +// it is delivered by the game's bot rather than the recipient's last-login bot. func TestNudgeRoutedByGameLanguage(t *testing.T) { ctx := context.Background() svc := newSocialService() @@ -528,7 +505,7 @@ func TestNudgeRoutedByGameLanguage(t *testing.T) { } } -// TestAdminListMessages checks the admin moderation list (Stage 17): real messages only +// TestAdminListMessages checks the admin moderation list: real messages only // (nudges excluded), the game / sender pins, the sender glob masks, and the source label. func TestAdminListMessages(t *testing.T) { ctx := context.Background() diff --git a/backend/internal/inttest/stage6_test.go b/backend/internal/inttest/stage6_test.go deleted file mode 100644 index 6c3ea48..0000000 --- a/backend/internal/inttest/stage6_test.go +++ /dev/null @@ -1,130 +0,0 @@ -//go:build integration - -package inttest - -import ( - "context" - "testing" - "time" - - "github.com/google/uuid" - - "scrabble/backend/internal/account" - "scrabble/backend/internal/engine" - "scrabble/backend/internal/game" -) - -// provisionGuest creates a fresh ephemeral guest account and returns its id. -func provisionGuest(t *testing.T) uuid.UUID { - t.Helper() - acc, err := account.NewStore(testDB).ProvisionGuest(context.Background()) - if err != nil { - t.Fatalf("provision guest: %v", err) - } - if !acc.IsGuest { - t.Fatalf("provisioned account %s is not flagged guest", acc.ID) - } - return acc.ID -} - -// TestGuestAutoMatchLeavesNoStats drives a guest through a full auto-match game -// against a robot to a natural end and checks the guest holds a seat (the -// game_players foreign key is satisfied) yet accrues no statistics, while the -// durable robot opponent does. -func TestGuestAutoMatchLeavesNoStats(t *testing.T) { - ctx := context.Background() - svc := newGameService() - robots := newRobotService(t, svc) - if err := robots.EnsurePool(ctx); err != nil { - t.Fatalf("ensure pool: %v", err) - } - robotID, err := robots.Pick(engine.VariantEnglish) - if err != nil { - t.Fatalf("pick: %v", err) - } - guest := provisionGuest(t) - seed := openingSeed(t) - - g, err := svc.Create(ctx, game.CreateParams{ - Variant: engine.VariantEnglish, Seats: []uuid.UUID{guest, robotID}, - TurnTimeout: 24 * time.Hour, Seed: seed, - }) - if err != nil { - t.Fatalf("create: %v", err) - } - const robotSeat = 1 // seats = [guest, robot] - - finished := false - for i := 0; i < 400 && !finished; i++ { - _, toMove, status, err := svc.Participants(ctx, g.ID) - if err != nil { - t.Fatalf("participants: %v", err) - } - if status != game.StatusActive { - finished = true - break - } - if toMove == robotSeat { - setTurnStarted(t, g.ID, daytime.Add(-2*time.Hour)) - robots.Drive(ctx, daytime) - continue - } - playHuman(t, ctx, svc, g.ID, guest) - } - if !finished { - t.Fatal("guest game did not finish within the move budget") - } - - if _, _, _, _, _, ok := readStats(t, guest); ok { - t.Error("a guest must not accrue a statistics row") - } - if _, _, _, _, _, ok := readStats(t, robotID); !ok { - t.Error("the durable robot opponent should have a statistics row") - } -} - -// TestEmailLoginFlow covers the Stage 6 email-as-login path: a code is mailed to -// a new address, verifying it provisions and returns the owning account, and a -// second login for the same address resolves to that same account (a returning -// user), with the identity confirmed. -func TestEmailLoginFlow(t *testing.T) { - ctx := context.Background() - mailer := &capturingMailer{} - svc := account.NewEmailService(account.NewStore(testDB), mailer) - email := "login-" + uuid.NewString() + "@example.com" - - accountID, err := svc.RequestLoginCode(ctx, email) - if err != nil { - t.Fatalf("request login code: %v", err) - } - code := sixDigit.FindString(mailer.lastBody) - if code == "" { - t.Fatalf("no code in mail body %q", mailer.lastBody) - } - - acc, err := svc.LoginWithCode(ctx, email, code) - if err != nil { - t.Fatalf("login with code: %v", err) - } - if acc.ID != accountID { - t.Errorf("login account = %s, want %s", acc.ID, accountID) - } - if acc.IsGuest { - t.Error("an email account must be durable, not a guest") - } - if !identityConfirmed(t, account.KindEmail, email) { - t.Error("the email identity must be confirmed after login") - } - - // A second login for the same email is the returning user: same account. - if _, err := svc.RequestLoginCode(ctx, email); err != nil { - t.Fatalf("second request: %v", err) - } - acc2, err := svc.LoginWithCode(ctx, email, sixDigit.FindString(mailer.lastBody)) - if err != nil { - t.Fatalf("second login: %v", err) - } - if acc2.ID != accountID { - t.Errorf("returning login account = %s, want %s", acc2.ID, accountID) - } -} diff --git a/backend/internal/link/service.go b/backend/internal/link/service.go index 3057a8b..bf0311f 100644 --- a/backend/internal/link/service.go +++ b/backend/internal/link/service.go @@ -1,4 +1,4 @@ -// Package link orchestrates account linking & merge (Stage 11, ARCHITECTURE.md §4). +// Package link orchestrates account linking & merge (ARCHITECTURE.md §4). // It sits above the account, accountmerge and session layers: it verifies the // caller's control of an identity (an email confirm-code or a gateway-validated // platform identity), binds a free identity to the current account, and — when the diff --git a/backend/internal/lobby/invitations.go b/backend/internal/lobby/invitations.go index f9b762a..97506e1 100644 --- a/backend/internal/lobby/invitations.go +++ b/backend/internal/lobby/invitations.go @@ -106,7 +106,7 @@ func (svc *InvitationService) SetNotifier(p notify.Publisher) { } // emitInvitation publishes the invitation notification to each invitee, carrying the invitation -// itself so the client adds it to its lobby list without a refetch (R4). +// itself so the client adds it to its lobby list without a refetch. func (svc *InvitationService) emitInvitation(ctx context.Context, inv Invitation, inviteeIDs []uuid.UUID) { if len(inviteeIDs) == 0 { return @@ -120,7 +120,7 @@ func (svc *InvitationService) emitInvitation(ctx context.Context, inv Invitation } // emitGameStarted publishes the game_started notification to each seated player, carrying their -// initial view of the started game so the client seeds its game cache without a refetch (R4). A +// initial view of the started game so the client seeds its game cache without a refetch. A // seat whose state cannot be read is skipped (it still sees the game on the next lobby load). func (svc *InvitationService) emitGameStarted(ctx context.Context, g game.Game, seats []uuid.UUID) { intents := make([]notify.Intent, 0, len(seats)) diff --git a/backend/internal/lobby/lobby.go b/backend/internal/lobby/lobby.go index 10065ea..b627b68 100644 --- a/backend/internal/lobby/lobby.go +++ b/backend/internal/lobby/lobby.go @@ -23,7 +23,7 @@ type GameCreator interface { Create(ctx context.Context, params game.CreateParams) (game.Game, error) // InitialState returns a seated player's full initial view of a started game, used // to enrich the match_found / game_started events so the client renders the new game - // without a follow-up fetch (R4). + // without a follow-up fetch. InitialState(ctx context.Context, gameID, accountID uuid.UUID) (notify.PlayerState, error) } diff --git a/backend/internal/lobby/matchmaker.go b/backend/internal/lobby/matchmaker.go index dc334c2..4f30942 100644 --- a/backend/internal/lobby/matchmaker.go +++ b/backend/internal/lobby/matchmaker.go @@ -76,7 +76,7 @@ func (m *Matchmaker) SetNotifier(p notify.Publisher) { // emitMatchFound pushes match_found to every seat of a freshly started game. // Emitting to a robot seat is harmless (no client subscription exists for it). func (m *Matchmaker) emitMatchFound(ctx context.Context, g game.Game) { - lang := g.Variant.Language() // route the push by the game's language, not the recipient's bot (Stage 17) + lang := g.Variant.Language() // route the push by the game's language, not the recipient's bot intents := make([]notify.Intent, 0, len(g.Seats)) for _, s := range g.Seats { state, err := m.games.InitialState(ctx, g.ID, s.AccountID) diff --git a/backend/internal/lobby/matchmaker_test.go b/backend/internal/lobby/matchmaker_test.go index e6403ef..89c41b1 100644 --- a/backend/internal/lobby/matchmaker_test.go +++ b/backend/internal/lobby/matchmaker_test.go @@ -249,7 +249,7 @@ func TestMatchmakerReaperSkipsCancelled(t *testing.T) { // TestMatchmakerCancelClearsPendingResult covers the race where the reaper substitutes a // robot just before the player cancels: Cancel must drop the pending result so the -// abandoned game never surfaces through Poll (Stage 17). +// abandoned game never surfaces through Poll. func TestMatchmakerCancelClearsPendingResult(t *testing.T) { creator := &fakeCreator{} mm := newTestMatchmaker(creator, uuid.New()) diff --git a/backend/internal/notify/encode.go b/backend/internal/notify/encode.go index 076d2ef..47a4fae 100644 --- a/backend/internal/notify/encode.go +++ b/backend/internal/notify/encode.go @@ -4,194 +4,114 @@ import ( flatbuffers "github.com/google/flatbuffers/go" "scrabble/backend/internal/engine" - fb "scrabble/pkg/fbs/scrabblefb" + "scrabble/pkg/wire" ) // The builders below encode the nested wire tables embedded in enriched event -// payloads (R4). They mirror the gateway's transcode encoders, but read the domain's -// already-resolved values (notify.* input structs and the decoded engine.MoveRecord) -// rather than the gateway's REST DTOs. Each returns the offset of the table it built; -// callers must build every nested table before opening the parent event table. +// payloads. They map the domain's already-resolved values (notify.* payload structs +// and the decoded engine.MoveRecord) to the neutral scrabble/pkg/wire structs and +// delegate the FlatBuffers construction to package wire — the single definition of the +// nested-table layout shared with the gateway transcoder. Each returns the offset of +// the table it built; callers must build every nested table before opening the parent. + +// toWireGame maps a GameSummary to the shared wire.GameView. +func toWireGame(g GameSummary) wire.GameView { + seats := make([]wire.SeatView, len(g.Seats)) + for i, s := range g.Seats { + seats[i] = wire.SeatView{ + Seat: s.Seat, + AccountID: s.AccountID, + Score: s.Score, + HintsUsed: s.HintsUsed, + IsWinner: s.IsWinner, + DisplayName: s.DisplayName, + } + } + return wire.GameView{ + ID: g.ID, + Variant: g.Variant, + DictVersion: g.DictVersion, + Status: g.Status, + Players: g.Players, + ToMove: g.ToMove, + TurnTimeoutSecs: g.TurnTimeoutSecs, + MoveCount: g.MoveCount, + EndReason: g.EndReason, + Seats: seats, + LastActivityUnix: g.LastActivityUnix, + } +} // buildGameView builds a GameView table from a GameSummary and returns its offset. func buildGameView(b *flatbuffers.Builder, g GameSummary) flatbuffers.UOffsetT { - seatOffs := make([]flatbuffers.UOffsetT, len(g.Seats)) - for i, s := range g.Seats { - aid := b.CreateString(s.AccountID) - dname := b.CreateString(s.DisplayName) - fb.SeatViewStart(b) - fb.SeatViewAddSeat(b, int32(s.Seat)) - fb.SeatViewAddAccountId(b, aid) - fb.SeatViewAddScore(b, int32(s.Score)) - fb.SeatViewAddHintsUsed(b, int32(s.HintsUsed)) - fb.SeatViewAddIsWinner(b, s.IsWinner) - fb.SeatViewAddDisplayName(b, dname) - seatOffs[i] = fb.SeatViewEnd(b) - } - fb.GameViewStartSeatsVector(b, len(seatOffs)) - for i := len(seatOffs) - 1; i >= 0; i-- { - b.PrependUOffsetT(seatOffs[i]) - } - seats := b.EndVector(len(seatOffs)) - - id := b.CreateString(g.ID) - variant := b.CreateString(g.Variant) - dictVer := b.CreateString(g.DictVersion) - status := b.CreateString(g.Status) - endReason := b.CreateString(g.EndReason) - - fb.GameViewStart(b) - fb.GameViewAddId(b, id) - fb.GameViewAddVariant(b, variant) - fb.GameViewAddDictVersion(b, dictVer) - fb.GameViewAddStatus(b, status) - fb.GameViewAddPlayers(b, int32(g.Players)) - fb.GameViewAddToMove(b, int32(g.ToMove)) - fb.GameViewAddTurnTimeoutSecs(b, int32(g.TurnTimeoutSecs)) - fb.GameViewAddMoveCount(b, int32(g.MoveCount)) - fb.GameViewAddEndReason(b, endReason) - fb.GameViewAddSeats(b, seats) - fb.GameViewAddLastActivityUnix(b, g.LastActivityUnix) - return fb.GameViewEnd(b) + return wire.BuildGameView(b, toWireGame(g)) } -// buildMoveRecord builds a MoveRecord table from a decoded engine move and returns -// its offset. The values match the move-result DTO (Count is the engine count: the -// number of tiles swapped on an exchange, zero otherwise). +// buildMoveRecord builds a MoveRecord table from a decoded engine move and returns its +// offset (Count is the engine count: the number of tiles swapped on an exchange, zero +// otherwise). func buildMoveRecord(b *flatbuffers.Builder, m engine.MoveRecord) flatbuffers.UOffsetT { - tileOffs := make([]flatbuffers.UOffsetT, len(m.Tiles)) + tiles := make([]wire.TileRecord, len(m.Tiles)) for i, t := range m.Tiles { - letter := b.CreateString(t.Letter) - fb.TileRecordStart(b) - fb.TileRecordAddRow(b, int32(t.Row)) - fb.TileRecordAddCol(b, int32(t.Col)) - fb.TileRecordAddLetter(b, letter) - fb.TileRecordAddBlank(b, t.Blank) - tileOffs[i] = fb.TileRecordEnd(b) + tiles[i] = wire.TileRecord{Row: t.Row, Col: t.Col, Letter: t.Letter, Blank: t.Blank} } - fb.MoveRecordStartTilesVector(b, len(tileOffs)) - for i := len(tileOffs) - 1; i >= 0; i-- { - b.PrependUOffsetT(tileOffs[i]) - } - tiles := b.EndVector(len(tileOffs)) - - wordOffs := make([]flatbuffers.UOffsetT, len(m.Words)) - for i, w := range m.Words { - wordOffs[i] = b.CreateString(w) - } - fb.MoveRecordStartWordsVector(b, len(wordOffs)) - for i := len(wordOffs) - 1; i >= 0; i-- { - b.PrependUOffsetT(wordOffs[i]) - } - words := b.EndVector(len(wordOffs)) - - action := b.CreateString(m.Action.String()) - dir := b.CreateString(m.Dir.String()) - fb.MoveRecordStart(b) - fb.MoveRecordAddPlayer(b, int32(m.Player)) - fb.MoveRecordAddAction(b, action) - fb.MoveRecordAddDir(b, dir) - fb.MoveRecordAddMainRow(b, int32(m.MainRow)) - fb.MoveRecordAddMainCol(b, int32(m.MainCol)) - fb.MoveRecordAddTiles(b, tiles) - fb.MoveRecordAddWords(b, words) - fb.MoveRecordAddCount(b, int32(m.Count)) - fb.MoveRecordAddScore(b, int32(m.Score)) - fb.MoveRecordAddTotal(b, int32(m.Total)) - return fb.MoveRecordEnd(b) -} - -// buildAlphabet builds the AlphabetEntry vector embedded in a StateView and returns -// its offset. -func buildAlphabet(b *flatbuffers.Builder, entries []AlphabetLetter) flatbuffers.UOffsetT { - offs := make([]flatbuffers.UOffsetT, len(entries)) - for i, e := range entries { - letter := b.CreateString(e.Letter) - fb.AlphabetEntryStart(b) - fb.AlphabetEntryAddIndex(b, byte(e.Index)) - fb.AlphabetEntryAddLetter(b, letter) - fb.AlphabetEntryAddValue(b, int32(e.Value)) - offs[i] = fb.AlphabetEntryEnd(b) - } - fb.StateViewStartAlphabetVector(b, len(offs)) - for i := len(offs) - 1; i >= 0; i-- { - b.PrependUOffsetT(offs[i]) - } - return b.EndVector(len(offs)) + return wire.BuildMoveRecord(b, wire.MoveRecord{ + Player: m.Player, + Action: m.Action.String(), + Dir: m.Dir.String(), + MainRow: m.MainRow, + MainCol: m.MainCol, + Tiles: tiles, + Words: m.Words, + Count: m.Count, + Score: m.Score, + Total: m.Total, + }) } // buildStateView builds a StateView table from a PlayerState and returns its offset. func buildStateView(b *flatbuffers.Builder, s PlayerState) flatbuffers.UOffsetT { - game := buildGameView(b, s.Game) - rackBytes := make([]byte, len(s.Rack)) - for i, v := range s.Rack { - rackBytes[i] = byte(v) + alphabet := make([]wire.AlphabetEntry, len(s.Alphabet)) + for i, e := range s.Alphabet { + alphabet[i] = wire.AlphabetEntry{Index: e.Index, Letter: e.Letter, Value: e.Value} } - rack := b.CreateByteVector(rackBytes) - hasAlphabet := len(s.Alphabet) > 0 - var alphabet flatbuffers.UOffsetT - if hasAlphabet { - alphabet = buildAlphabet(b, s.Alphabet) - } - fb.StateViewStart(b) - fb.StateViewAddGame(b, game) - fb.StateViewAddSeat(b, int32(s.Seat)) - fb.StateViewAddRack(b, rack) - fb.StateViewAddBagLen(b, int32(s.BagLen)) - fb.StateViewAddHintsRemaining(b, int32(s.HintsRemaining)) - if hasAlphabet { - fb.StateViewAddAlphabet(b, alphabet) - } - return fb.StateViewEnd(b) + return wire.BuildStateView(b, wire.StateView{ + Game: toWireGame(s.Game), + Seat: s.Seat, + Rack: s.Rack, + BagLen: s.BagLen, + HintsRemaining: s.HintsRemaining, + Alphabet: alphabet, + }) } // buildAccountRef builds an AccountRef table and returns its offset. func buildAccountRef(b *flatbuffers.Builder, a AccountRef) flatbuffers.UOffsetT { - aid := b.CreateString(a.AccountID) - name := b.CreateString(a.DisplayName) - fb.AccountRefStart(b) - fb.AccountRefAddAccountId(b, aid) - fb.AccountRefAddDisplayName(b, name) - return fb.AccountRefEnd(b) + return wire.BuildAccountRef(b, wire.AccountRef{AccountID: a.AccountID, DisplayName: a.DisplayName}) } // buildInvitation builds an Invitation table from an InvitationSummary and returns its offset. func buildInvitation(b *flatbuffers.Builder, inv InvitationSummary) flatbuffers.UOffsetT { - inviter := buildAccountRef(b, inv.Inviter) - inviteeOffs := make([]flatbuffers.UOffsetT, len(inv.Invitees)) + invitees := make([]wire.InvitationInvitee, len(inv.Invitees)) for i, iv := range inv.Invitees { - aid := b.CreateString(iv.AccountID) - name := b.CreateString(iv.DisplayName) - resp := b.CreateString(iv.Response) - fb.InvitationInviteeStart(b) - fb.InvitationInviteeAddAccountId(b, aid) - fb.InvitationInviteeAddDisplayName(b, name) - fb.InvitationInviteeAddSeat(b, int32(iv.Seat)) - fb.InvitationInviteeAddResponse(b, resp) - inviteeOffs[i] = fb.InvitationInviteeEnd(b) + invitees[i] = wire.InvitationInvitee{ + AccountID: iv.AccountID, + DisplayName: iv.DisplayName, + Seat: iv.Seat, + Response: iv.Response, + } } - fb.InvitationStartInviteesVector(b, len(inviteeOffs)) - for i := len(inviteeOffs) - 1; i >= 0; i-- { - b.PrependUOffsetT(inviteeOffs[i]) - } - invitees := b.EndVector(len(inviteeOffs)) - - id := b.CreateString(inv.ID) - variant := b.CreateString(inv.Variant) - dropout := b.CreateString(inv.DropoutTiles) - status := b.CreateString(inv.Status) - gameID := b.CreateString(inv.GameID) - fb.InvitationStart(b) - fb.InvitationAddId(b, id) - fb.InvitationAddInviter(b, inviter) - fb.InvitationAddInvitees(b, invitees) - fb.InvitationAddVariant(b, variant) - fb.InvitationAddTurnTimeoutSecs(b, int32(inv.TurnTimeoutSecs)) - fb.InvitationAddHintsAllowed(b, inv.HintsAllowed) - fb.InvitationAddHintsPerPlayer(b, int32(inv.HintsPerPlayer)) - fb.InvitationAddDropoutTiles(b, dropout) - fb.InvitationAddStatus(b, status) - fb.InvitationAddGameId(b, gameID) - fb.InvitationAddExpiresAtUnix(b, inv.ExpiresAtUnix) - return fb.InvitationEnd(b) + return wire.BuildInvitation(b, wire.Invitation{ + ID: inv.ID, + Inviter: wire.AccountRef{AccountID: inv.Inviter.AccountID, DisplayName: inv.Inviter.DisplayName}, + Invitees: invitees, + Variant: inv.Variant, + TurnTimeoutSecs: inv.TurnTimeoutSecs, + HintsAllowed: inv.HintsAllowed, + HintsPerPlayer: inv.HintsPerPlayer, + DropoutTiles: inv.DropoutTiles, + Status: inv.Status, + GameID: inv.GameID, + ExpiresAtUnix: inv.ExpiresAtUnix, + }) } diff --git a/backend/internal/notify/events.go b/backend/internal/notify/events.go index b88f4ab..4e41ced 100644 --- a/backend/internal/notify/events.go +++ b/backend/internal/notify/events.go @@ -15,11 +15,11 @@ import ( // the game/social/lobby services emit events without importing the wire schema. // YourTurn announces to userID that it is their turn in game gameID, with the turn's nominal -// deadline. opponentName, lastAction, lastWord and scoreLine enrich the out-of-app push (Stage -// 17): the player who just moved, their move kind, the main word of a scoring play (empty +// deadline. opponentName, lastAction, lastWord and scoreLine enrich the out-of-app push: +// the player who just moved, their move kind, the main word of a scoring play (empty // otherwise) and the recipient-first running score line. Empty strings render the plain "your // turn" text. moveCount is the post-move count, which the client compares against its cached -// game to detect a missed in-app move and fall back to a refetch (R4). +// game to detect a missed in-app move and fall back to a refetch. func YourTurn(userID, gameID uuid.UUID, deadline time.Time, opponentName, lastAction, lastWord, scoreLine string, moveCount int) Intent { b := flatbuffers.NewBuilder(128) gid := b.CreateString(gameID.String()) @@ -41,9 +41,9 @@ func YourTurn(userID, gameID uuid.UUID, deadline time.Time, opponentName, lastAc // GameOver announces to userID that game gameID finished. result is the outcome from userID's // own perspective ("won"/"lost"/"draw") and scoreLine is the recipient-first final score; both -// feed the out-of-app "game over" push (Stage 17). game is the final post-game summary (the +// feed the out-of-app "game over" push. game is the final post-game summary (the // adjusted scores after rack penalties and the winner flag), so an in-app client settles the -// finished game from the event without a refetch (R4). +// finished game from the event without a refetch. func GameOver(userID, gameID uuid.UUID, result, scoreLine string, game GameSummary) Intent { b := flatbuffers.NewBuilder(512) gid := b.CreateString(gameID.String()) @@ -60,22 +60,16 @@ func GameOver(userID, gameID uuid.UUID, result, scoreLine string, game GameSumma } // OpponentMoved tells userID that move was just committed in game gameID, carrying it as a delta -// the client applies to its cached game without a refetch (R4): move is the decoded play/pass/ +// the client applies to its cached game without a refetch: move is the decoded play/pass/ // exchange, game is the post-move summary (per-seat scores, to_move, move_count, status) and -// bagLen is the bag size after the draw. The seat/action/score/total scalars repeat the move's -// summary for pre-R4 wire back-compat. +// bagLen is the bag size after the draw. func OpponentMoved(userID, gameID uuid.UUID, move engine.MoveRecord, game GameSummary, bagLen int) Intent { b := flatbuffers.NewBuilder(512) gid := b.CreateString(gameID.String()) - act := b.CreateString(move.Action.String()) moveOff := buildMoveRecord(b, move) gameOff := buildGameView(b, game) fb.OpponentMovedEventStart(b) fb.OpponentMovedEventAddGameId(b, gid) - fb.OpponentMovedEventAddSeat(b, int32(move.Player)) - fb.OpponentMovedEventAddAction(b, act) - fb.OpponentMovedEventAddScore(b, int32(move.Score)) - fb.OpponentMovedEventAddTotal(b, int32(move.Total)) fb.OpponentMovedEventAddMove(b, moveOff) fb.OpponentMovedEventAddGame(b, gameOff) fb.OpponentMovedEventAddBagLen(b, int32(bagLen)) @@ -116,7 +110,7 @@ func Nudge(userID, gameID, fromUserID uuid.UUID) Intent { // MatchFound tells userID that game gameID, which they are seated in, has started (an auto-match // pairing or a robot substitution). state is the recipient's full initial view of the new game, -// so the client navigates straight in from the event with no follow-up fetch (R4). +// so the client navigates straight in from the event with no follow-up fetch. func MatchFound(userID, gameID uuid.UUID, state PlayerState) Intent { b := flatbuffers.NewBuilder(512) gid := b.CreateString(gameID.String()) @@ -131,7 +125,7 @@ func MatchFound(userID, gameID uuid.UUID, state PlayerState) Intent { // Notification is a lightweight "re-poll" signal to userID that something in their lobby // changed. kind is a sub-discriminator (NotifyFriendRequest, NotifyFriendAdded, // NotifyFriendDeclined, NotifyInvitation, NotifyGameStarted). It carries no payload; prefer the -// enriched constructors below, which let the client update its lobby without a refetch (R4). +// enriched constructors below, which let the client update its lobby without a refetch. func Notification(userID uuid.UUID, kind string) Intent { b := flatbuffers.NewBuilder(32) k := b.CreateString(kind) @@ -143,7 +137,7 @@ func Notification(userID uuid.UUID, kind string) Intent { // NotificationAccount builds a lobby notification of one of the friend_* kinds carrying the // account it concerns (the requester, the new friend or the decliner), so the client updates its -// requests/friends lists and the in-game "add friend" state without a refetch (R4). +// requests/friends lists and the in-game "add friend" state without a refetch. func NotificationAccount(userID uuid.UUID, kind string, acc AccountRef) Intent { b := flatbuffers.NewBuilder(128) k := b.CreateString(kind) @@ -157,7 +151,7 @@ func NotificationAccount(userID uuid.UUID, kind string, acc AccountRef) Intent { // NotificationGameStarted builds the NotifyGameStarted notification carrying the recipient's // initial view of the just-started invited game, so the client seeds its game cache and the -// lobby list without a refetch (R4). +// lobby list without a refetch. func NotificationGameStarted(userID uuid.UUID, state PlayerState) Intent { b := flatbuffers.NewBuilder(512) k := b.CreateString(NotifyGameStarted) @@ -170,7 +164,7 @@ func NotificationGameStarted(userID uuid.UUID, state PlayerState) Intent { } // NotificationInvitation builds the NotifyInvitation notification carrying the new invitation, -// so the client adds it to its lobby invitations list without a refetch (R4). +// so the client adds it to its lobby invitations list without a refetch. func NotificationInvitation(userID uuid.UUID, inv InvitationSummary) Intent { b := flatbuffers.NewBuilder(512) k := b.CreateString(NotifyInvitation) diff --git a/backend/internal/notify/notify.go b/backend/internal/notify/notify.go index e793bd2..d96887d 100644 --- a/backend/internal/notify/notify.go +++ b/backend/internal/notify/notify.go @@ -28,7 +28,7 @@ const ( // (incoming friend requests, invitations) that drives the lobby badge. KindNotification = "notify" // KindGameOver announces a finished game to each seated player, driving the - // out-of-app "game over" push (Stage 17). + // out-of-app "game over" push. KindGameOver = "game_over" ) @@ -52,7 +52,7 @@ type Intent struct { Kind string Payload []byte EventID string - // Language routes an out-of-app push to a specific per-language bot (Stage 17): for a + // Language routes an out-of-app push to a specific per-language bot: for a // game event it is the game's language ("en"/"ru"), so the notification comes from the // game's bot rather than the recipient's last-login bot. Empty falls back to the // recipient's service language at the gateway. diff --git a/backend/internal/notify/notify_test.go b/backend/internal/notify/notify_test.go index e17b1b2..0aec6be 100644 --- a/backend/internal/notify/notify_test.go +++ b/backend/internal/notify/notify_test.go @@ -109,12 +109,10 @@ func TestOpponentMovedPayloadRoundTrips(t *testing.T) { t.Fatalf("kind = %q", in.Kind) } ev := fb.GetRootAsOpponentMovedEvent(in.Payload, 0) - // The pre-R4 summary scalars repeat the move. - if string(ev.GameId()) != gid.String() || ev.Seat() != 1 || string(ev.Action()) != "play" || ev.Score() != 24 || ev.Total() != 130 { - t.Fatalf("scalars wrong: game=%q seat=%d action=%q score=%d total=%d", - ev.GameId(), ev.Seat(), ev.Action(), ev.Score(), ev.Total()) + if string(ev.GameId()) != gid.String() { + t.Fatalf("game id = %q", ev.GameId()) } - // The R4 delta: the move, the post-move summary and the bag size. + // The delta: the move, the post-move summary and the bag size. if ev.BagLen() != 42 { t.Fatalf("bag_len = %d, want 42", ev.BagLen()) } diff --git a/backend/internal/postgres/migrations/00001_baseline.sql b/backend/internal/postgres/migrations/00001_baseline.sql index f33592e..8e56ab0 100644 --- a/backend/internal/postgres/migrations/00001_baseline.sql +++ b/backend/internal/postgres/migrations/00001_baseline.sql @@ -35,7 +35,7 @@ CREATE TABLE accounts ( merged_into uuid REFERENCES accounts (account_id) ON DELETE SET NULL, merged_at timestamptz, service_language text CHECK (service_language IN ('en', 'ru')), - -- Soft, reversible "suspected high-rate" marker (R3): set once when the gateway + -- Soft, reversible "suspected high-rate" marker: set once when the gateway -- reports sustained rate-limiter rejections past the threshold; an operator -- clears it in the admin console. Never an automatic ban. flagged_high_rate_at timestamptz, diff --git a/backend/internal/ratewatch/ratewatch.go b/backend/internal/ratewatch/ratewatch.go index f236f78..f0e2c89 100644 --- a/backend/internal/ratewatch/ratewatch.go +++ b/backend/internal/ratewatch/ratewatch.go @@ -1,5 +1,5 @@ // Package ratewatch ingests the gateway's periodic rate-limiter rejection -// reports (R3). It keeps an in-memory window of recent throttle episodes for +// reports. It keeps an in-memory window of recent throttle episodes for // the admin console's view and applies the conservative high-rate auto-flag: // when one account's rejections within the rolling window cross the threshold, // the account store stamps the soft, reversible flagged_high_rate_at marker diff --git a/backend/internal/server/dto.go b/backend/internal/server/dto.go index f362cc2..09b6d88 100644 --- a/backend/internal/server/dto.go +++ b/backend/internal/server/dto.go @@ -93,13 +93,13 @@ type gameDTO struct { MoveCount int `json:"move_count"` EndReason string `json:"end_reason"` // LastActivityUnix is the lobby sort key: the current turn's start for an active - // game, the finish time once finished (Stage 17). + // game, the finish time once finished. LastActivityUnix int64 `json:"last_activity_unix"` Seats []seatDTO `json:"seats"` } // moveResultDTO is the outcome of a committed move. Rack carries the actor's refilled rack as -// wire alphabet indices and BagLen the bag size after the draw (R4), so the mover renders the +// wire alphabet indices and BagLen the bag size after the draw, so the mover renders the // next state from the response without a follow-up state fetch. type moveResultDTO struct { Move moveRecordDTO `json:"move"` @@ -109,15 +109,14 @@ type moveResultDTO struct { } // alphabetEntryDTO is one letter of a variant's alphabet (its index, concrete letter and -// tile value), embedded in the state view for display only when the client requests it -// (Stage 13). +// tile value), embedded in the state view for display only when the client requests it. type alphabetEntryDTO struct { Index int `json:"index"` Letter string `json:"letter"` Value int `json:"value"` } -// stateDTO is a player's view of a game. Rack carries wire alphabet indices (Stage 13; a +// stateDTO is a player's view of a game. Rack carries wire alphabet indices (a // blank is engine.BlankIndex). Alphabet is present only when the request asked for it. type stateDTO struct { Game gameDTO `json:"game"` @@ -236,7 +235,7 @@ func moveRecordDTOFrom(m engine.MoveRecord) moveRecordDTO { } // moveResultDTOFrom projects a committed move result into its DTO, encoding the actor's rack as -// wire alphabet indices (Stage 13; R4). +// wire alphabet indices. func moveResultDTOFrom(r game.MoveResult) (moveResultDTO, error) { rack, err := engine.EncodeRack(r.Game.Variant, r.Rack) if err != nil { @@ -251,7 +250,7 @@ func moveResultDTOFrom(r game.MoveResult) (moveResultDTO, error) { } // stateDTOFrom projects a player's state view into its DTO, encoding the rack as wire -// alphabet indices (Stage 13). When includeAlphabet is set it also embeds the variant's +// alphabet indices. When includeAlphabet is set it also embeds the variant's // display table, which the client caches per variant and renders the rack with. func stateDTOFrom(v game.StateView, includeAlphabet bool) (stateDTO, error) { rack, err := engine.EncodeRack(v.Game.Variant, v.Rack) diff --git a/backend/internal/server/handlers.go b/backend/internal/server/handlers.go index 374df86..7537f6e 100644 --- a/backend/internal/server/handlers.go +++ b/backend/internal/server/handlers.go @@ -18,11 +18,10 @@ import ( "scrabble/backend/internal/social" ) -// registerRoutes wires the Stage 6 REST handlers onto the /api/v1 groups. The +// registerRoutes wires the REST handlers onto the /api/v1 groups. The // internal group is gateway-only (the gateway authenticates and forwards); the // user group requires X-User-ID; the admin group is reached through the gateway's -// Basic-Auth proxy. This is the representative vertical slice — further domain -// operations follow the same pattern (PLAN.md Stage 6). +// Basic-Auth proxy. func (s *Server) registerRoutes() { if s.sessions != nil && s.accounts != nil { in := s.internal @@ -32,13 +31,13 @@ func (s *Server) registerRoutes() { in.POST("/sessions/email/login", s.handleEmailLogin) in.POST("/sessions/resolve", s.handleResolveSession) in.POST("/sessions/revoke", s.handleRevokeSession) - // Out-of-app push routing for the platform side-service (Stage 9): the + // Out-of-app push routing for the platform side-service: the // gateway resolves a recipient's Telegram chat + language + in-app-only flag // before delivering an out-of-app notification. in.POST("/push-target", s.handlePushTarget) } if s.ratewatch != nil { - // The gateway's periodic rate-limiter rejection summary (R3): feeds the + // The gateway's periodic rate-limiter rejection summary: feeds the // admin console's throttled view and the high-rate auto-flag. s.internal.POST("/ratelimit/report", s.handleRateLimitReport) } @@ -49,7 +48,7 @@ func (s *Server) registerRoutes() { u.GET("/stats", s.handleStats) } if s.links != nil { - // Account linking & merge (Stage 11). The request step always mails a code; + // Account linking & merge. The request step always mails a code; // a required merge is revealed only after the code is verified, and the // irreversible merge is an explicit second step. u.POST("/link/email/request", s.handleLinkEmailRequest) diff --git a/backend/internal/server/handlers_account.go b/backend/internal/server/handlers_account.go index 3da79d4..27a44ef 100644 --- a/backend/internal/server/handlers_account.go +++ b/backend/internal/server/handlers_account.go @@ -11,7 +11,7 @@ import ( ) // The /api/v1/user account handlers wire profile editing, email binding and the -// statistics read (Stage 8). They follow handlers_user.go: X-User-ID identity, a +// statistics read. They follow handlers_user.go: X-User-ID identity, a // domain call, a JSON DTO. Profile editing overwrites the full editable set, so the // client sends the complete desired profile. diff --git a/backend/internal/server/handlers_admin_console.go b/backend/internal/server/handlers_admin_console.go index e86302f..a75e4c7 100644 --- a/backend/internal/server/handlers_admin_console.go +++ b/backend/internal/server/handlers_admin_console.go @@ -560,7 +560,7 @@ func (s *Server) consolePostBroadcast(c *gin.Context) { // consoleThrottled renders the rate-limit observability page: the recent // gateway-reported throttle episodes (in-memory, reset on a backend restart) -// and the accounts currently carrying the soft high-rate flag (R3). +// and the accounts currently carrying the soft high-rate flag. func (s *Server) consoleThrottled(c *gin.Context) { ctx := c.Request.Context() var view adminconsole.ThrottledView @@ -595,7 +595,7 @@ func (s *Server) consoleThrottled(c *gin.Context) { } // consoleClearHighRateFlag clears the soft high-rate marker — the operator's -// reversible review action (R3). +// reversible review action. func (s *Server) consoleClearHighRateFlag(c *gin.Context) { id, ok := s.consoleUUID(c, "/_gm/users") if !ok { diff --git a/backend/internal/server/handlers_blocks.go b/backend/internal/server/handlers_blocks.go index 6791d19..a523f52 100644 --- a/backend/internal/server/handlers_blocks.go +++ b/backend/internal/server/handlers_blocks.go @@ -7,7 +7,7 @@ import ( "github.com/google/uuid" ) -// The /api/v1/user/blocks/* handlers wire the per-user block list (Stage 8). A block +// The /api/v1/user/blocks/* handlers wire the per-user block list. A block // is mutual in effect (the social checks apply it both ways) and severs any // friendship between the pair. They reuse the friend handlers' targetIDRequest and // account-ref resolution. diff --git a/backend/internal/server/handlers_friends.go b/backend/internal/server/handlers_friends.go index 6e650fb..636efc4 100644 --- a/backend/internal/server/handlers_friends.go +++ b/backend/internal/server/handlers_friends.go @@ -9,7 +9,7 @@ import ( "github.com/google/uuid" ) -// The /api/v1/user/friends/* handlers wire the social friend graph (Stage 8): the +// The /api/v1/user/friends/* handlers wire the social friend graph: the // befriend-an-opponent request flow, the one-time friend-code path, and the // friends/incoming lists. They follow handlers_user.go: X-User-ID identity, a domain // call, a JSON DTO. Account ids are projected to {id, display_name} refs resolved diff --git a/backend/internal/server/handlers_game.go b/backend/internal/server/handlers_game.go index 0235d01..e0af299 100644 --- a/backend/internal/server/handlers_game.go +++ b/backend/internal/server/handlers_game.go @@ -12,10 +12,9 @@ import ( "scrabble/backend/internal/game" ) -// The handlers below extend the Stage 6 vertical slice with the remaining game and -// chat operations the UI needs (PLAN.md Stage 7). They follow the same pattern as -// handlers_user.go: X-User-ID identity, the domain service call, a JSON DTO mapped -// from the result. +// The handlers below cover the game and chat operations the UI needs. They follow +// the same pattern as handlers_user.go: X-User-ID identity, the domain service +// call, a JSON DTO mapped from the result. // hintResultDTO is the top-ranked move plus the remaining hint budget. type hintResultDTO struct { @@ -53,7 +52,7 @@ type chatListDTO struct { } // exchangeRequest swaps the given rack tiles back into the bag. Tiles are wire alphabet -// indices (Stage 13); a blank is engine.BlankIndex. +// indices; a blank is engine.BlankIndex. type exchangeRequest struct { Tiles []int `json:"tiles"` } @@ -211,7 +210,7 @@ func (s *Server) handleEvaluate(c *gin.Context) { } // handleCheckWord looks a word up in the game's pinned dictionary. The word arrives as -// repeated ?idx= alphabet indices (Stage 13); the backend decodes them to the concrete +// repeated ?idx= alphabet indices; the backend decodes them to the concrete // word for the lookup and echoes that concrete word back for the client's result cache. func (s *Server) handleCheckWord(c *gin.Context) { _, gameID, ok := s.userGame(c) @@ -242,7 +241,7 @@ func (s *Server) handleCheckWord(c *gin.Context) { } // queryIndexes parses repeated integer query parameters (e.g. ?idx=2&idx=0) into a slice. -// It carries a word-check query as alphabet indices on a GET (Stage 13). +// It carries a word-check query as alphabet indices on a GET. func queryIndexes(c *gin.Context, key string) ([]int, error) { raw := c.QueryArray(key) out := make([]int, 0, len(raw)) @@ -326,7 +325,7 @@ type draftTileDTO struct { Blank bool `json:"blank"` } -// draftDTO is a player's persisted client-side composition for a game (Stage 17): the +// draftDTO is a player's persisted client-side composition for a game: the // preferred rack tile order (an opaque client string) and the board tiles laid but not yet // submitted. The gateway forwards the JSON verbatim; this layer owns its shape. type draftDTO struct { @@ -352,7 +351,7 @@ func (d draftDTO) toDomain() game.Draft { return game.Draft{RackOrder: d.RackOrder, BoardTiles: tiles} } -// handleGetDraft returns the player's saved composition for a game (Stage 17), or an empty +// handleGetDraft returns the player's saved composition for a game, or an empty // draft when none is stored. func (s *Server) handleGetDraft(c *gin.Context) { uid, gameID, ok := s.userGame(c) @@ -367,7 +366,7 @@ func (s *Server) handleGetDraft(c *gin.Context) { c.JSON(http.StatusOK, draftDTOFrom(d)) } -// handleSaveDraft upserts the player's composition for a game (Stage 17). The service +// handleSaveDraft upserts the player's composition for a game. The service // rejects a non-player with ErrNotAPlayer. func (s *Server) handleSaveDraft(c *gin.Context) { uid, gameID, ok := s.userGame(c) diff --git a/backend/internal/server/handlers_invitations.go b/backend/internal/server/handlers_invitations.go index 6f41f0e..7412e8f 100644 --- a/backend/internal/server/handlers_invitations.go +++ b/backend/internal/server/handlers_invitations.go @@ -12,7 +12,7 @@ import ( "scrabble/backend/internal/lobby" ) -// The /api/v1/user/invitations/* handlers wire friend-game invitations (Stage 8): +// The /api/v1/user/invitations/* handlers wire friend-game invitations: // create a 2-4 player invitation, accept/decline as an invitee, cancel as the // inviter, and list the open invitations touching the caller. Display names for the // inviter and invitees are resolved from the account store. diff --git a/backend/internal/server/handlers_link.go b/backend/internal/server/handlers_link.go index 1729cf2..27a318d 100644 --- a/backend/internal/server/handlers_link.go +++ b/backend/internal/server/handlers_link.go @@ -10,7 +10,7 @@ import ( "scrabble/backend/internal/link" ) -// The /api/v1/user/link handlers drive account linking & merge (Stage 11). The +// The /api/v1/user/link handlers drive account linking & merge. The // request step always mails a code (no pre-send "taken" signal, so a probe cannot // enumerate registered emails); confirm reveals a required merge only after the // code is verified; merge performs the irreversible consolidation behind an diff --git a/backend/internal/server/handlers_ratelimit.go b/backend/internal/server/handlers_ratelimit.go index 512a9ae..03bdefa 100644 --- a/backend/internal/server/handlers_ratelimit.go +++ b/backend/internal/server/handlers_ratelimit.go @@ -23,7 +23,7 @@ type rateLimitReportEntry struct { } // handleRateLimitReport ingests one gateway rejection report into the rate -// watch — the admin console's throttled view and the high-rate auto-flag (R3). +// watch — the admin console's throttled view and the high-rate auto-flag. // Internal, gateway-only: like sessions/resolve it trusts the network segment. // Malformed individual entries are skipped by the watch itself. func (s *Server) handleRateLimitReport(c *gin.Context) { diff --git a/backend/internal/server/handlers_test.go b/backend/internal/server/handlers_test.go index c8e086a..3ddfc1f 100644 --- a/backend/internal/server/handlers_test.go +++ b/backend/internal/server/handlers_test.go @@ -58,7 +58,7 @@ func TestResolveSessionRejectsEmptyToken(t *testing.T) { } } -// TestRateLimitReportEndpoint covers the internal R3 report route: a malformed +// TestRateLimitReportEndpoint covers the internal report route: a malformed // body is a 400, a valid report lands in the rate watch with 204. func TestRateLimitReportEndpoint(t *testing.T) { watch := ratewatch.New(ratewatch.DefaultConfig(), nil, nil) diff --git a/backend/internal/server/handlers_user.go b/backend/internal/server/handlers_user.go index 6fa661e..661258e 100644 --- a/backend/internal/server/handlers_user.go +++ b/backend/internal/server/handlers_user.go @@ -27,7 +27,7 @@ func (s *Server) handleProfile(c *gin.Context) { } // submitPlayRequest places tiles in a direction on the player's turn. Each tile's Letter -// is a wire alphabet index (Stage 13); for a blank it is the designated letter's index. +// is a wire alphabet index; for a blank it is the designated letter's index. type submitPlayRequest struct { Dir string `json:"dir"` Tiles []struct { @@ -39,7 +39,7 @@ type submitPlayRequest struct { } // tilesFromRequest maps a play/evaluate request's index-addressed tiles to engine tile -// records for the game's variant (Stage 13: a placed blank carries its designated letter's +// records for the game's variant (a placed blank carries its designated letter's // index with Blank set). An out-of-range index surfaces as engine.ErrIllegalPlay (HTTP 400). func tilesFromRequest(variant engine.Variant, req submitPlayRequest) ([]engine.TileRecord, error) { tiles := make([]engine.TileRecord, 0, len(req.Tiles)) @@ -94,7 +94,7 @@ func (s *Server) handleSubmitPlay(c *gin.Context) { } // handleGameState returns the player's view of a game. -// handleHideGame hides a finished game from the caller's own lobby list (Stage 17). +// handleHideGame hides a finished game from the caller's own lobby list. func (s *Server) handleHideGame(c *gin.Context) { uid, ok := userID(c) if !ok { diff --git a/backend/internal/server/server.go b/backend/internal/server/server.go index 618efdc..429a394 100644 --- a/backend/internal/server/server.go +++ b/backend/internal/server/server.go @@ -50,20 +50,20 @@ type Deps struct { // func skips the session-readiness check. SessionsReady func() bool // Sessions, Accounts and Games are the identity, account and game-domain - // services the Stage 6 REST handlers route to. + // services the REST handlers route to. Sessions *session.Service Accounts *account.Store Games *game.Service - // Social, Matchmaker, Invitations and Emails are the Stage 4 domain services - // the Stage 6 REST handlers route to. + // Social, Matchmaker, Invitations and Emails are the domain services + // the REST handlers route to. Social *social.Service Matchmaker *lobby.Matchmaker Invitations *lobby.InvitationService Emails *account.EmailService - // Links drives account linking & merge (Stage 11): the /api/v1/user/link + // Links drives account linking & merge: the /api/v1/user/link // endpoints. A nil Links disables them. Links *link.Service - // Registry holds the resident dictionaries; the admin console (Stage 10) reads + // Registry holds the resident dictionaries; the admin console reads // its versions and hot-reloads new ones. DictDir is the dictionary directory a // reload reads a version subdirectory from. A nil Registry disables the console. Registry *engine.Registry @@ -72,7 +72,7 @@ type Deps struct { // nil when BACKEND_CONNECTOR_ADDR is unset (broadcasts show a "not configured" // notice). Connector *connector.Client - // RateWatch ingests the gateway's rate-limiter rejection reports (R3): the + // RateWatch ingests the gateway's rate-limiter rejection reports: the // admin console's throttled view + the high-rate auto-flag. A nil RateWatch // disables the internal report endpoint and the console view. RateWatch *ratewatch.Watch @@ -196,16 +196,16 @@ func (s *Server) UserGroup() *gin.RouterGroup { return s.user } // InternalGroup returns the gateway-facing internal route group. func (s *Server) InternalGroup() *gin.RouterGroup { return s.internal } -// Social returns the social domain service for the handlers added in Stage 6. +// Social returns the social domain service for the handlers. func (s *Server) Social() *social.Service { return s.social } -// Matchmaker returns the in-memory matchmaking pool for the Stage 6 handlers. +// Matchmaker returns the in-memory matchmaking pool for the handlers. func (s *Server) Matchmaker() *lobby.Matchmaker { return s.matchmaker } -// Invitations returns the friend-game invitation service for the Stage 6 handlers. +// Invitations returns the friend-game invitation service for the handlers. func (s *Server) Invitations() *lobby.InvitationService { return s.invitations } -// Emails returns the email confirm-code service for the Stage 6 handlers. +// Emails returns the email confirm-code service for the handlers. func (s *Server) Emails() *account.EmailService { return s.emails } // Handler returns the underlying HTTP handler. It lets tests drive the server diff --git a/backend/internal/session/cache.go b/backend/internal/session/cache.go index f5d9490..1dd3109 100644 --- a/backend/internal/session/cache.go +++ b/backend/internal/session/cache.go @@ -97,8 +97,8 @@ func (c *Cache) Remove(tokenHash string) { } // RemoveByAccount evicts every cached session belonging to accountID. The -// account-merge flow uses it to drop a retired secondary account's sessions -// (Stage 11); a linear scan is adequate at the cache's size. +// account-merge flow uses it to drop a retired secondary account's sessions; +// a linear scan is adequate at the cache's size. func (c *Cache) RemoveByAccount(accountID uuid.UUID) { if c == nil { return diff --git a/backend/internal/session/service.go b/backend/internal/session/service.go index de339c3..ee90e6e 100644 --- a/backend/internal/session/service.go +++ b/backend/internal/session/service.go @@ -73,8 +73,8 @@ func (svc *Service) Revoke(ctx context.Context, token string) error { } // RevokeAllForAccount revokes every active session of accountID and evicts them -// from the cache. The account-merge flow calls it to retire a secondary account -// (Stage 11). It is idempotent. +// from the cache. The account-merge flow calls it to retire a secondary account. +// It is idempotent. func (svc *Service) RevokeAllForAccount(ctx context.Context, accountID uuid.UUID) error { if _, err := svc.store.RevokeAllForAccount(ctx, accountID, time.Now().UTC()); err != nil { return err diff --git a/backend/internal/session/store.go b/backend/internal/session/store.go index 35868ff..4f595bf 100644 --- a/backend/internal/session/store.go +++ b/backend/internal/session/store.go @@ -112,8 +112,8 @@ func (s *Store) RevokeByTokenHash(ctx context.Context, tokenHash string, at time // RevokeAllForAccount transitions every active session of accountID to revoked // and returns the post-update rows (so the caller can evict them from the cache). -// It backs the account-merge flow, which retires a secondary account's sessions -// (Stage 11). No matching rows is not an error. +// It backs the account-merge flow, which retires a secondary account's sessions. +// No matching rows is not an error. func (s *Store) RevokeAllForAccount(ctx context.Context, accountID uuid.UUID, at time.Time) ([]Session, error) { stmt := table.Sessions. UPDATE(table.Sessions.Status, table.Sessions.RevokedAt). diff --git a/backend/internal/social/adminchat.go b/backend/internal/social/adminchat.go index cfeb9f2..e35d7a3 100644 --- a/backend/internal/social/adminchat.go +++ b/backend/internal/social/adminchat.go @@ -10,7 +10,7 @@ import ( "scrabble/backend/internal/account" ) -// AdminMessage is one chat message in the admin moderation list (Stage 17): the message +// AdminMessage is one chat message in the admin moderation list: the message // plus its sender's resolved display name and source, for the operator console. type AdminMessage struct { ID uuid.UUID diff --git a/backend/internal/social/chat.go b/backend/internal/social/chat.go index ecad30c..703acb9 100644 --- a/backend/internal/social/chat.go +++ b/backend/internal/social/chat.go @@ -58,7 +58,7 @@ func (svc *Service) PostMessage(ctx context.Context, gameID, senderID uuid.UUID, return Message{}, ErrNotParticipant } // Chat is allowed only on the sender's own turn in an active game; the opponent's-turn - // control is the nudge (Stage 17). + // control is the nudge. if status != statusActive { return Message{}, ErrGameNotActive } @@ -115,7 +115,7 @@ func (svc *Service) Nudge(ctx context.Context, gameID, senderID uuid.UUID) (Mess } if ok && svc.now().Sub(last) < nudgeInterval { // The cooldown resets once the sender has acted (moved or chatted) since the last - // nudge — engagement clears the "don't spam" limit (Stage 17). + // nudge — engagement clears the "don't spam" limit. acted, err := svc.actedSince(ctx, gameID, senderID, last) if err != nil { return Message{}, err @@ -132,7 +132,7 @@ func (svc *Service) Nudge(ctx context.Context, gameID, senderID uuid.UUID) (Mess if toMove >= 0 && toMove < len(seats) { nudge := notify.Nudge(seats[toMove], gameID, senderID) if lang, err := svc.games.GameLanguage(ctx, gameID); err == nil { - nudge.Language = lang // route by the game's bot, not the recipient's last-login one (Stage 17) + nudge.Language = lang // route by the game's bot, not the recipient's last-login one } svc.pub.Publish(nudge) } @@ -140,7 +140,7 @@ func (svc *Service) Nudge(ctx context.Context, gameID, senderID uuid.UUID) (Mess } // actedSince reports whether senderID made a move or posted a chat message in the game -// after t — the events that reset the nudge cooldown (Stage 17). +// after t — the events that reset the nudge cooldown. func (svc *Service) actedSince(ctx context.Context, gameID, senderID uuid.UUID, t time.Time) (bool, error) { if mv, ok, err := svc.games.LastMoveAt(ctx, gameID, senderID); err != nil { return false, err @@ -291,7 +291,7 @@ func (s *Store) lastNudgeAt(ctx context.Context, gameID, senderID uuid.UUID) (ti // lastMessageAt returns the time of senderID's most recent non-nudge chat message in // gameID, if any. The nudge cooldown resets when the player chats (or moves), so a stale -// nudge no longer blocks a new one (Stage 17). +// nudge no longer blocks a new one. func (s *Store) lastMessageAt(ctx context.Context, gameID, senderID uuid.UUID) (time.Time, bool, error) { stmt := postgres.SELECT(table.ChatMessages.CreatedAt). FROM(table.ChatMessages). diff --git a/backend/internal/social/friends.go b/backend/internal/social/friends.go index d15257e..add0ec9 100644 --- a/backend/internal/social/friends.go +++ b/backend/internal/social/friends.go @@ -31,7 +31,7 @@ const friendRequestTTL = 30 * 24 * time.Hour // accountRef resolves accountID into a notify.AccountRef (the display name from the account // store, empty on a lookup failure), for enriching the friend_* live events so the client -// updates its requests/friends state without a refetch (R4). +// updates its requests/friends state without a refetch. func (svc *Service) accountRef(ctx context.Context, accountID uuid.UUID) notify.AccountRef { ref := notify.AccountRef{AccountID: accountID.String()} if acc, err := svc.accounts.GetByID(ctx, accountID); err == nil { diff --git a/backend/internal/social/social.go b/backend/internal/social/social.go index 2c3564d..d993b81 100644 --- a/backend/internal/social/social.go +++ b/backend/internal/social/social.go @@ -32,7 +32,7 @@ type GameReader interface { // has moved); the nudge cooldown resets once the player has taken a turn. LastMoveAt(ctx context.Context, gameID, accountID uuid.UUID) (time.Time, bool, error) // GameLanguage is the game's language tag ("en"/"ru"), so a nudge's out-of-app push routes - // to the game's bot rather than the recipient's last-login bot (Stage 17). + // to the game's bot rather than the recipient's last-login bot. GameLanguage(ctx context.Context, gameID uuid.UUID) (string, error) } @@ -75,7 +75,7 @@ var ( ErrGameNotActive = errors.New("social: game is not active") // ErrChatNotYourTurn is returned when a chat message is sent while it is not the // sender's turn — chat is allowed only on your own turn (the opponent's-turn control - // is the nudge, Stage 17). + // is the nudge). ErrChatNotYourTurn = errors.New("social: cannot chat while it is not your turn") ) diff --git a/deploy/.env.example b/deploy/.env.example index 851e36f..5ae3d1a 100644 --- a/deploy/.env.example +++ b/deploy/.env.example @@ -1,6 +1,6 @@ # Environment for deploy/docker-compose.yml. The CI deploy job (ci.yaml) maps the -# Gitea TEST_-prefixed secrets/variables onto these unprefixed names; Stage 18 -# maps the PROD_-prefixed set the same way. Copy to deploy/.env for a local run. +# Gitea TEST_-prefixed secrets/variables onto these unprefixed names; the prod +# deploy maps the PROD_-prefixed set the same way. Copy to deploy/.env for a local run. # # Full reference (required vs optional, defaults, secret-vs-variable): deploy/README.md. @@ -17,7 +17,7 @@ LOG_LEVEL=info # --- Edge / caddy ----------------------------------------------------------- # Test: ":80" (the host caddy terminates TLS and forwards to scrabble:80 on the -# external `edge` network). Prod (Stage 18): a domain so caddy does its own ACME. +# external `edge` network). Prod: a domain so caddy does its own ACME. CADDY_SITE_ADDRESS=:80 GM_BASICAUTH_USER=gm GM_BASICAUTH_HASH= # required; `caddy hash-password` bcrypt hash diff --git a/deploy/README.md b/deploy/README.md index 5caa0e5..8f6894b 100644 --- a/deploy/README.md +++ b/deploy/README.md @@ -13,7 +13,7 @@ operational reference for **every environment variable**. | --- | --- | --- | | `caddy` | `caddy:2-alpine` | Edge proxy (alias `scrabble` on `edge`): single `/_gm` Basic-Auth → admin console + Grafana; `/app/`, `/telegram/` + the Connect path → gateway; the catch-all (incl. `/`) → landing. TLS per `CADDY_SITE_ADDRESS`. | | `gateway` | built (`gateway/Dockerfile`, target `gateway`) | Public edge; serves the embedded game SPA at `/app/` + `/telegram/`; Connect-RPC edge. `/` redirects to `/app/`. | -| `landing` | built (`gateway/Dockerfile`, target `landing`) | Static landing page at `/` (caddy:2-alpine + the shared Vite build, `deploy/landing/Caddyfile`); absorbs stray public paths (R3). | +| `landing` | built (`gateway/Dockerfile`, target `landing`) | Static landing page at `/` (caddy:2-alpine + the shared Vite build, `deploy/landing/Caddyfile`); absorbs stray public paths. | | `backend` | built (`backend/Dockerfile`) | Domain service; bakes in the DAWG dictionaries; runs migrations at boot. | | `postgres` | `postgres:17-alpine` | Database (named volume, `pg_isready` healthcheck). | | `vpn` + `telegram` | sidecar + built (`platform/telegram/Dockerfile`) | Telegram connector; egresses through the AmneziaWG sidecar; internal gRPC at `telegram:9091`. | @@ -39,7 +39,7 @@ cd deploy && docker compose up -d --build **In CI** (the test contour) — `.gitea/workflows/ci.yaml`'s `deploy` job maps the Gitea **`TEST_`-prefixed** secrets/variables onto the unprefixed names below and -runs `docker compose up -d --build` on the runner host. Stage 18 (prod) maps the +runs `docker compose up -d --build` on the runner host. The prod deploy maps the **`PROD_`** set the same way. So a Gitea secret named `TEST_POSTGRES_PASSWORD` feeds the compose's `POSTGRES_PASSWORD`, etc. @@ -80,7 +80,7 @@ connector **fails at boot** if both are empty. | `GRAFANA_ADMIN_PASSWORD` | secret | `admin` | Grafana admin password. Low impact (the login form is disabled, access is anonymous-admin behind caddy) but set it anyway. | | `TELEGRAM_GAME_CHANNEL_ID_EN` | variable | _(empty)_ | English game-channel id; empty/`0` disables channel posts. | | `TELEGRAM_GAME_CHANNEL_ID_RU` | variable | _(empty)_ | Russian game-channel id; empty/`0` disables channel posts. | -| `TELEGRAM_TEST_ENV` | _pinned_ | `false` | `true` routes the bot through Telegram's test environment (`.../bot/test/METHOD`). **The CI test contour pins this to `true` in `ci.yaml`** (the contour is the test environment) — it is not a Gitea variable. Set it in `.env` for a local run; prod (Stage 18) leaves it `false`. | +| `TELEGRAM_TEST_ENV` | _pinned_ | `false` | `true` routes the bot through Telegram's test environment (`.../bot/test/METHOD`). **The CI test contour pins this to `true` in `ci.yaml`** (the contour is the test environment) — it is not a Gitea variable. Set it in `.env` for a local run; prod leaves it `false`. | | `TELEGRAM_API_BASE_URL` | variable | _(empty)_ | Override the Bot API host (a mock/self-hosted server); empty = `https://api.telegram.org`. | | `GATEWAY_DEFAULT_SUPPORTED_LANGUAGES` | variable | `en,ru` | Variant-gating set for non-Telegram logins (web/email/guest). | | `VITE_TELEGRAM_BOT_ID` | variable | _(empty)_ | UI build-arg: numeric bot id for the web Login Widget. | @@ -114,7 +114,7 @@ resolves both `otelcol` and `api.telegram.org`. `GATEWAY_ADMIN_*` is intentional - **Host caddy** route ` → scrabble:80` (the in-compose caddy serves HTTP in the test contour; the host caddy terminates TLS). Not needed on prod, where the contour caddy owns TLS (set `CADDY_SITE_ADDRESS` to the domain). -- **Branch protection** requires the single status check `CI / gate` (Stage 17). +- **Branch protection** requires the single status check `CI / gate`. The `unit` / `integration` / `ui` jobs are path-conditional (they skip when their code did not change), and the always-running `gate` job aggregates them (passing when each succeeded or was skipped), so a skipped job never blocks a merge. See diff --git a/deploy/caddy/Caddyfile b/deploy/caddy/Caddyfile index 8860263..3d9fced 100644 --- a/deploy/caddy/Caddyfile +++ b/deploy/caddy/Caddyfile @@ -2,11 +2,11 @@ # every operator surface under /_gm (the backend-rendered admin console and the # Grafana subpath); the game SPA (/app/, /telegram/) and the Connect edge go to # the gateway; the catch-all — notably the public landing at / — goes to the -# static landing container (R3), so stray traffic never reaches the Go edge. +# static landing container, so stray traffic never reaches the Go edge. # Mirrors ../galaxy-game's /_gm model. # # CADDY_SITE_ADDRESS is ":80" in the test contour (the host caddy terminates TLS -# and forwards); set it to a domain in prod (Stage 18) so this caddy does its own +# and forwards); set it to a domain in prod so this caddy does its own # ACME and the contour is self-contained. { admin off @@ -14,7 +14,7 @@ # (chat moderation + per-IP rate limiting in the gateway). Test contour: the host caddy # (a private IP) is trusted, so its forwarded client IP is preserved. Prod (no host caddy): # clients connect from public IPs, which are NOT trusted, so Caddy uses the real peer — - # the same config is correct (and spoof-safe) in both contours (Stage 17). + # the same config is correct (and spoof-safe) in both contours. servers { trusted_proxies static private_ranges } diff --git a/deploy/docker-compose.yml b/deploy/docker-compose.yml index 953825a..706c1a0 100644 --- a/deploy/docker-compose.yml +++ b/deploy/docker-compose.yml @@ -17,7 +17,7 @@ # - `edge` (external): the host caddy reaches this contour at `scrabble:80` # (the in-compose caddy's alias). The in-compose caddy terminates only HTTP in # the test contour; the host caddy terminates TLS and forwards. For prod -# (Stage 18, no host caddy) set CADDY_SITE_ADDRESS to the domain so the caddy +# (no host caddy) set CADDY_SITE_ADDRESS to the domain so the caddy # does its own ACME — the contour is then self-contained. # - The connector egresses to api.telegram.org through the `vpn` sidecar # (network_mode: service:vpn); it answers internal gRPC at `telegram:9091`. @@ -102,7 +102,7 @@ services: networks: [internal] # --- Landing (static) ------------------------------------------------------- - # The public landing page in its own caddy container (R3): the contour caddy + # The public landing page in its own caddy container: the contour caddy # routes the catch-all (notably /) here, the gateway keeps only /app/, # /telegram/ and the Connect edge. Shares the gateway Dockerfile's UI build # stage — identical build args keep that stage a single cached build. diff --git a/deploy/grafana/dashboards/edge-ux.json b/deploy/grafana/dashboards/edge-ux.json index 413a268..77dff1c 100644 --- a/deploy/grafana/dashboards/edge-ux.json +++ b/deploy/grafana/dashboards/edge-ux.json @@ -37,8 +37,8 @@ }, { "type": "timeseries", - "title": "Rate limiting — request rate vs rejections (R3)", - "description": "Aggregate only (no per-user labels, the Stage 12/17 discipline): total edge request rate against the limiter rejection rate by class. Per-key detail lives in the admin console's Throttled view.", + "title": "Rate limiting — request rate vs rejections", + "description": "Aggregate only (no per-user labels): total edge request rate against the limiter rejection rate by class. Per-key detail lives in the admin console's Throttled view.", "gridPos": { "h": 8, "w": 24, "x": 0, "y": 16 }, "fieldConfig": { "defaults": { "unit": "reqps" }, "overrides": [] }, "datasource": { "type": "prometheus", "uid": "prometheus" }, diff --git a/deploy/landing/Caddyfile b/deploy/landing/Caddyfile index f14b522..92ff744 100644 --- a/deploy/landing/Caddyfile +++ b/deploy/landing/Caddyfile @@ -1,4 +1,4 @@ -# Static landing container (R3). Serves the public landing page and the built +# Static landing container. Serves the public landing page and the built # assets it references at /; the game SPA (/app/, /telegram/) and the Connect # edge stay on the gateway. The contour caddy routes the catch-all here, so # stray public paths are absorbed by static file serving and never reach the Go diff --git a/docs/ARCHITECTURE.md b/docs/ARCHITECTURE.md index fcd7cf8..0e4e361 100644 --- a/docs/ARCHITECTURE.md +++ b/docs/ARCHITECTURE.md @@ -28,10 +28,10 @@ Three executables plus per-platform side-services: - **`ui`** — pure-HTML5 client (plain Svelte 5 + TypeScript + Vite, static build; no SvelteKit). Talks to `backend` only through `gateway` over Connect-RPC + FlatBuffers, with the edge TS bindings generated from the **same** `edge.proto` - and `scrabble.fbs` and committed under `ui/src/gen/`. The **playable slice** - (Stage 7) covers auth, "my games", auto-match, the board (play/pass/exchange/ + and `scrabble.fbs` and committed under `ui/src/gen/`. The client covers auth, + "my games", auto-match, the board (play/pass/exchange/ resign), hint, word-check, chat/nudge, the live stream, i18n (en/ru) and a profile - view; the social/account/history surfaces follow in Stage 8. There is no board on + view, plus the social/account/history surfaces. There is no board on the wire — the client **reconstructs the 15×15 board by replaying the move journal** (§9.1) and renders board, tiles, premium squares and effects as pure CSS + Unicode (no image/font/SVG assets). Tiles are placed by Pointer-Events drag @@ -41,7 +41,7 @@ Three executables plus per-platform side-services: Embeddable in platform webviews; packageable to native (iOS/Android) via Capacitor. The client uses a mobile-app shell (a growing nav bar; content pinned to the bottom), a one-line **announcement banner** under the nav (a client-side mock rotation, **gated off - in the build until polished after release**, Stage 17 — a server-driven channel later, §10), + in the build until polished after release** — a server-driven channel later, §10), and a client **board-style** setting (bonus-label mode). The visual/interaction design system is documented in [`UI_DESIGN.md`](UI_DESIGN.md). @@ -89,7 +89,7 @@ dropped). Horizontal scaling is explicit future work. auth operations are unauthenticated and return the minted token. A unary operation's domain outcome rides back in `ExecuteResponse.result_code` (HTTP 200); only edge failures (rate limit, missing session, unknown type, internal) - surface as Connect error codes. The client (Stage 17) treats a connectivity edge failure as + surface as Connect error codes. The client treats a connectivity edge failure as **state, not a per-call toast**: a transport `unavailable` or a `rate_limited` flips a global `online` signal that drives a header **"Connecting…"** spinner and softly disables proactive actions, and the transport **auto-retries with capped exponential backoff** — every op on a @@ -98,7 +98,7 @@ dropped). Horizontal scaling is explicit future work. response was lost — its button is disabled while offline and the player re-issues it on reconnect). A reachability watcher (a lightweight `profile.get` probe) clears the signal when no other traffic is in flight; the live `Subscribe` stream's drop/recovery feeds the same signal. - **Edge hardening (R3):** every request body on the public listener is capped at + **Edge hardening:** every request body on the public listener is capped at `GATEWAY_MAX_BODY_BYTES` (default 1 MiB — far above any legitimate payload), both at the HTTP layer (`http.MaxBytesReader`) and as the Connect per-message read limit, so an oversized `Execute` is refused (`resource_exhausted`) without buffering. The h2c server carries explicit @@ -106,8 +106,7 @@ dropped). Horizontal scaling is explicit future work. `Subscribe` stream plus a few unary calls) and a 3-minute connection `IdleTimeout` (a live `Subscribe` stream keeps its connection active, so only abandoned connections are reaped); the `http.Server` sets only `ReadHeaderTimeout` (10 s) — Read/WriteTimeout would kill the stream. - R7 revisits the exact values under load. -- **Alphabet on the wire (Stage 13)**: live play exchanges **alphabet indices**, not +- **Alphabet on the wire**: live play exchanges **alphabet indices**, not concrete letters. The rack (`StateView.rack`), the `SubmitPlay`/`Evaluate` tiles, the `Exchange` tiles and the `CheckWord` word are `ubyte` indices into the variant's alphabet (a blank is the sentinel index **255**). The client is **alphabet-agnostic**: on a @@ -139,7 +138,7 @@ arrive from a platform rather than completing a mandatory registration). bootstrap — then mints a **thin opaque server session token** (`session_id`). First Telegram contact seeds the new account's language (from the launch `language_code`) and display name (§4). -- **Service language & variant gating (Stage 15).** The connector hosts **one bot per +- **Service language & variant gating.** The connector hosts **one bot per service language** (`en`/`ru`), each its own token + game channel; the same Telegram user id spans both. `ValidateInitData` tries each token in turn and returns the validating bot's **service language** and its **supported-languages set**. The set @@ -151,7 +150,7 @@ arrive from a platform rather than completing a mandatory registration). **persisted** per account (`accounts.service_language`, updated on every Telegram login — last-login-wins) and routes the user's out-of-app push back through the right bot (§10) — **except a game event, which routes by the game's own language** (its variant → - en/ru, Stage 17), so a game's notification always comes from the game's bot rather than the + en/ru), so a game's notification always comes from the game's bot rather than the recipient's latest login bot. The service language is distinct from `preferred_language` (the interface language) and from a game's variant language. Non-Telegram logins (web / email / guest) carry the gateway's default set (`GATEWAY_DEFAULT_SUPPORTED_LANGUAGES`, all variants by default). @@ -169,8 +168,11 @@ arrive from a platform rather than completing a mandatory registration). row is a technical necessity (the `sessions` and `game_players` foreign keys require one, the same way the robot pool is durable), not a profile: no friends, statistics or history are kept for it, and it is restricted to - auto-match. Platform and email users are auto-provisioned **durable** accounts - with an identity. (Reaping abandoned guest rows is deferred — PLAN.md TODO-3.) + auto-match. A background **guest reaper** deletes an abandoned guest — flagged + `is_guest`, holding no game seat, older than `BACKEND_GUEST_RETENTION` — on a + `BACKEND_GUEST_REAP_INTERVAL` sweep, so transient guest rows do not accumulate. + Platform and email users are auto-provisioned **durable** accounts with an + identity. ## 4. Accounts, identities, linking & merge @@ -179,15 +181,15 @@ arrive from a platform rather than completing a mandatory registration). a platform auto-provisions a durable account bound to that platform identity. Concretely, platform and email identities share one `identities` table keyed by a unique `(kind, external_id)`; email is an identity with `kind=email` and a - `confirmed` flag. A synthetic `kind='robot'` identity (Stage 5) backs each pooled - robot opponent (§7). The **email confirm-code flow** (Stage 4) binds an email to the + `confirmed` flag. A synthetic `kind='robot'` identity backs each pooled + robot opponent (§7). The **email confirm-code flow** binds an email to the authenticated account: a 6-digit code (stored only as a SHA-256 hash, 15-minute TTL, ≤ 5 attempts) is sent through a `Mailer` seam (an SMTP relay, or a development log mailer when none is configured) and, once verified, attaches a confirmed email identity. Accounts and identities use application-generated **UUIDv7** primary keys. A service flag `paid_account` (lifetime one-time payment; no purchase flow yet) is carried on the account and ORed on a merge. -- **Linking** (Stage 11) is initiated from an authenticated profile and proves +- **Linking** is initiated from an authenticated profile and proves control of the identity before attaching it: **email** through the confirm-code flow, **Telegram** through the web **Login Widget** (validated by the connector, HMAC under `SHA-256(bot_token)` — distinct from Mini App initData; the gateway @@ -210,7 +212,7 @@ arrive from a platform rather than completing a mandatory registration). already has a **durable** owner: then the durable account wins, the guest's active games move into it, the guest is retired, and a **fresh session** is minted for the durable account (the client switches to it). The secondary's sessions are revoked - (§3). High blast-radius; an isolated, well-tested stage. + (§3). High blast-radius; isolated and well-tested. ## 5. Game engine integration (`scrabble-solver`) @@ -241,8 +243,7 @@ Key points: boot version plus each subdirectory). In-flight games keep their pinned version; new games use the latest. (The solver is published as a versioned module and the dictionaries ship as a separate versioned **release artifact** from the - `scrabble-dictionary` repo — TODO-1/TODO-2, Stage 14; the runtime contract above is - unchanged.) + `scrabble-dictionary` repo; the runtime contract above is unchanged.) - Move generation/validation/scoring use `Solver.GenerateMoves` (ranked), `Solver.ValidatePlay` and `Solver.ScorePlay`; board mutation uses `scrabble.Apply`. The engine adds its own deterministic, seeded tile **bag** @@ -258,7 +259,7 @@ Key points: unconditionally the other player in a two-player game. A player may resign **on the opponent's turn** (a forfeit is not a turn-scoped move): `engine.ResignSeat(seat)` resigns that player's own seat whoever is to move, and the game domain skips the turn - check for resign (Stage 17). The engine exposes a + check for resign. The engine exposes a decoded, solver-free API (`SubmitPlay`/`SubmitExchange`/`EvaluatePlay`/ `HintView`/`Hand`) so `internal/game` drives it without importing the solver. - The **game domain** (`internal/game`) owns everything the engine does not — @@ -326,7 +327,7 @@ behaviour on every scan and after a restart — the same philosophy as journal replay. A pool of durable accounts — each a `kind='robot'` identity (§4), keyed `robot--` and provisioned at startup with **chat blocked but friend requests open** — a request to a robot is accepted as pending and expires unanswered -(the robot never responds), mirroring a human who ignores it (Stage 17); the chat +(the robot never responds), mirroring a human who ignores it; the chat block backs the human-like names (there is no DM surface; chat is per-game). Names are **composed per language** from a first-name pool (32 full + 32 colloquial forms) and a surname pool (gender-agreed for Russian) in one of three forms (first only / @@ -352,12 +353,12 @@ English game the Latin pool. rather than running anti-phase; on a daytime nudge it replies near the move's lower band; it proactively nudges the idle human on a **lengthening, randomized schedule** — the first ~60-90 min into the turn, each later reminder spaced further out toward 1-6 h — so a long - wait gets a handful of increasingly-spaced nudges rather than an hourly stream (Stage 17). + wait gets a handful of increasingly-spaced nudges rather than an hourly stream. - **Observability**: robot accounts accrue ordinary statistics (§9) — the authoritative balance metric (target ≈ 40% robot wins) — and a `robot_games_finished_total` OTel counter plus a per-finish log give a live view. The **admin game card** surfaces each robot seat's per-game play-to-win intent (from - the seed) and, on the robot's turn, its deterministic **next-move ETA** (Stage 17). + the seed) and, on the robot's turn, its deterministic **next-move ETA**. ## 8. Lobby & social @@ -371,8 +372,8 @@ English game the Latin pool. `Poll` remains as a fallback for a client that is not currently streaming. **Cancel** (`POST /lobby/cancel`) removes the player from the pool and drops any pending matched result, so a cancelled quick-match is dequeued rather than left for - the reaper to robot-substitute (Stage 17). -- **Friends** (Stage 8): two add paths over one `friendships` table. A **one-time + the reaper to robot-substitute. +- **Friends**: two add paths over one `friendships` table. A **one-time code** the to-be-added player issues (a `friend_codes` row: 6-digit numeric, SHA-256-hashed, **12 h** TTL, one live code per issuer, single-use, redeem rate-limited) is redeemed by the other player to become friends immediately. @@ -382,7 +383,7 @@ English game the Latin pool. remembered (`status='declined'`) and blocks further requests from that sender, unless they hand them a code, which overrides it. The requester's own cancel still deletes the row; blocking someone severs an existing friendship. (Discovery by - friend list or platform deep-link arrives with Stage 9 / TODO-5.) + friend list or platform deep-link is future work.) - **Block**: two independent **global** account toggles (`block_chat`, `block_friend_requests`) **plus** a **per-user block list**. A per-user block is applied mutually: it hides the pair's chat from each other and refuses friend @@ -395,25 +396,25 @@ English game the Latin pool. and **validated on input** — links, email addresses and phone numbers (including lightly obfuscated forms) are rejected, since the chat is for quick reactions, not contact exchange. Each message stores the sender's IP (forwarded by the - gateway in Stage 6) for moderation. A sender who has disabled chat cannot post, + gateway) for moderation. A sender who has disabled chat cannot post, and messages from a blocked sender are hidden from the viewer. The operator console - has a **Messages** section (Stage 17) that lists posted messages (nudges excluded) + has a **Messages** section that lists posted messages (nudges excluded) newest-first with the sender's resolved name, **source** (guest / robot / oldest identity kind), IP and game, searchable by sender name / external-id glob masks and pinnable to one game or sender (linked from the game and user cards). - **Nudge**: folded into the chat as a `nudge` message kind. The player awaiting the opponent may nudge **once per hour per game**; it is not allowed on one's own - turn. The platform-native delivery is wired with the gateway / platform - side-service (Stage 6 / 8). + turn. The platform-native delivery runs through the gateway and the platform + side-service. - **Profile**: `preferred_language` (en/ru, edited in Settings), display name, email (confirm-code binding, see §4), **timezone**, the daily **away window** and the - block toggles — all editable through `account.UpdateProfile`, which validates them - (Stage 8): a display name is Unicode letters joined by single ` `/`.`/`_` + block toggles — all editable through `account.UpdateProfile`, which validates them: + a display name is Unicode letters joined by single ` `/`.`/`_` separators (no leading/trailing/adjacent separators, ≤ 32 runes); the timezone is a fixed `±HH:MM` **UTC offset** (or a legacy IANA name) resolved by `account.ResolveZone` for the sweeper and the robot's sleep (a fixed offset trades DST for a simple picker); the away window is at most **12 h** (midnight-wrap aware). Linked platform - accounts and merge are Stage 11. + accounts and merge are covered in §4. ## 9. Persistence @@ -423,23 +424,22 @@ English game the Latin pool. into `internal/postgres/jet` and committed, regenerated by `cmd/jetgen`). Migrations are embedded SQL applied with `pressly/goose/v3` at startup. Primary keys are application-generated **UUIDv7**. -- Tables: `accounts` (durable internal accounts; Stage 3 added the away-window - columns `away_start`/`away_end` and the hint wallet `hint_balance`; Stage 6's - migration `00005` added the `is_guest` flag for ephemeral guest rows; Stage 9's - migration `00007` added the `notifications_in_app_only` out-of-app push toggle; - Stage 11's migration `00009` added the `paid_account` service flag and the - merge-tombstone columns `merged_into`/`merged_at`), - `identities` (platform/email/robot identities, unique `(kind, external_id)`; - Stage 5's migration `00004` admits the `robot` kind), - `sessions` (revoke-only opaque-token hashes), the Stage 3 game tables - `games` (Stage 4 added the `dropout_tiles` disposition column), `game_players`, +- Tables: `accounts` (durable internal accounts, carrying the away-window + columns `away_start`/`away_end`, the hint wallet `hint_balance`, the `is_guest` + flag for ephemeral guest rows, the `notifications_in_app_only` out-of-app push + toggle, the `paid_account` service flag and the merge-tombstone columns + `merged_into`/`merged_at`), + `identities` (platform/email/robot identities, unique `(kind, external_id)`, + the `kind` admitting `robot`), + `sessions` (revoke-only opaque-token hashes), the game tables + `games` (carrying the `dropout_tiles` disposition column), `game_players`, `game_moves` (the move journal), `complaints` and `account_stats`, and the - Stage 4 social/lobby tables `friendships` (the request/accept graph), `blocks` + social/lobby tables `friendships` (the request/accept graph, its status admitting + `declined`), `blocks` (per-user blocks), `chat_messages` (per-game chat and nudges), `email_confirmations` - (pending confirm-codes) and `game_invitations` / `game_invitation_invitees` - (friend-game invitations). Stage 8's migration `00006` widened the `friendships` - status to admit `declined` and added `friend_codes` (one-time add-a-friend codes). - Stage 17 added `game_drafts` (a player's in-progress rack order + board composition per + (pending confirm-codes), `game_invitations` / `game_invitation_invitees` + (friend-game invitations), `friend_codes` (one-time add-a-friend codes), + `game_drafts` (a player's in-progress rack order + board composition per game) and `game_hidden` (`(account_id, game_id)` rows that drop a finished game from one account's own lobby list, leaving it visible to the other players — finished-only and irreversible by design, so there is no un-hide). @@ -479,18 +479,18 @@ exposes none): the standard Poslfit dialect (UTF-8, `#player`/`#lexicon` pragmas, `8G`/`H8` coordinates, lower-case blanks, `.` pass-throughs, `-TILES` exchanges), plus `#note` lines for resignations and timeouts, which the standard does not cover. **GCG export is offered only on a finished game** (`game.ErrGameActive` -otherwise, Stage 8), so an in-progress journal is never leaked mid-play; the client +otherwise), so an in-progress journal is never leaked mid-play; the client shares the `.gcg` file via the Web Share API where available, else downloads it. -The Stage 13 alphabet-on-the-wire change does **not** touch this invariant: the live edge +The alphabet-on-the-wire transport does **not** touch this invariant: the live edge exchanges alphabet indices, but the persisted journal (and everything derived from it — replay, history, GCG) keeps the decoded concrete letters described above, so an archived game still replays with the variant's `rules.Alphabet` alone, independent of any dictionary. ## 10. Notifications -Two channels: the **in-app live stream** (delivered from Stage 6) and -**platform-native push** (out-of-app, via the platform side-service — Stage 9). +Two channels: the **in-app live stream** and +**platform-native push** (out-of-app, via the platform side-service). The backend emits notification intents through an in-process hub (`internal/notify`, a `Publisher` seam installed on the game, social and lobby services); a single backend→gateway **gRPC server-stream** (`Push.Subscribe`, @@ -501,16 +501,16 @@ robot-driver and timeout-sweeper moves emit too; opponent-moved goes to **every including the mover**, so the mover's own other devices and their lobby refresh — it is in-app only, so the actor gets no out-of-app push for their own move), **chat-message** and **nudge** (from the social service), **match-found** (from the matchmaker, §8), and **notify** -(Stage 8 — a lightweight "re-poll" signal carrying a sub-kind: friend-request, +(a lightweight "re-poll" signal carrying a sub-kind: friend-request, friend-added, friend-declined, invitation or game-started; emitted on a friend-request, on answering one (accept → friend-added, decline → friend-declined — to the original -requester, so a game screen watching that opponent re-derives its "add to friends" state, -Stage 17), and on an invitation create or its game start). Stage 17 added **game-over** (emitted to every +requester, so a game screen watching that opponent re-derives its "add to friends" state), +and on an invitation create or its game start). **game-over** is emitted to every seat from the same game commit when a game finishes — any path: a closing play, all-pass, -resign or timeout) and **enriched your-turn** so the out-of-app push reads in full: it now +resign or timeout — and **your-turn** is enriched so the out-of-app push reads in full: it also carries the mover's display name, their last action and the main word of a scoring play, and a **recipient-first** running score line (e.g. `120:95:80`, the reader's score first). -**R4 enriched the in-app stream into a delta channel** so the client renders from the event +The in-app stream is a **delta channel** so the client renders from the event without a follow-up `game.state`: **opponent-moved** carries the committed move plus the post-move summary (per-seat scores, whose turn, move count, status) and the bag size, which the client applies to its per-game cache keyed on the **move count** — idempotent (a re-delivered or own-move @@ -526,12 +526,12 @@ verbatim. A client that is not currently streaming falls back to the matchmaker' match-found — the client polls **only while the stream is down**, since a live stream delivers match-found itself; for the lobby **notification badge** (incoming friend requests + open invitations) the client re-polls on the `notify` event and on lobby open / focus, covering a push -missed while the app was hidden. **Out-of-app platform push** (Stage 9) is a fallback +missed while the app was hidden. **Out-of-app platform push** is a fallback the **gateway** routes from the same firehose: for an event whose recipient has **no live in-app stream** it resolves the backend `/internal/push-target` (their Telegram `external_id`, the **service language** — the bot they last signed in through, falling back to the interface language — and the `notifications_in_app_only` flag). A **game** event, -however, carries the **game's own language** on the push (Stage 17), and the gateway routes by +however, carries the **game's own language** on the push, and the gateway routes by that instead of the service language — so a game's notification always comes from the game's bot, not the recipient's latest-login bot. It then asks the **Telegram connector** to deliver a localized message with a Mini App deep-link button — only when the recipient has a Telegram @@ -560,19 +560,19 @@ promotions) is future work and would deliver short markdown messages (text + lin and the gateway↔connector calls. The OTLP **Collector** (OTLP/gRPC → Prometheus metrics + Tempo traces), **Prometheus** (15d), **Tempo** (72h) and **Grafana** (provisioned datasources + dashboards, behind the caddy `/_gm/grafana` Basic-Auth) - are stood up with the deploy (`deploy/`, Stage 16); the default exporter stays + are stood up with the deploy (`deploy/`); the default exporter stays `none`, so CI needs no collector. The contour also runs **cAdvisor** (per-container CPU/memory/network) and **postgres_exporter** (connections, cache-hit ratio, transactions, db size), scraped by Prometheus and surfaced on the **Scrabble — - Resources** Grafana dashboard (R2), so the pre-release stress runs capture a resource + Resources** Grafana dashboard, which captures a resource baseline; these export directly in Prometheus format (not through the collector). - Per-request server-side timing via gin middleware from day one (the access log carries method, route, status, latency and the active trace id). A client-measured RTT piggybacked on the next request is a later enhancement. -- Domain/operational metrics (Stage 12), recorded through the meter and invisible +- Domain/operational metrics, recorded through the meter and invisible until an exporter is configured: histograms `game_replay_duration` (journal rebuild on a cache miss), `game_move_validate_duration` and `game_move_duration` - (Stage 17 — a seat's think time per committed move, attributed by `variant` and a + (a seat's think time per committed move, attributed by `variant` and a `phase` of opening/middle/endgame; it aggregates **all** seats including robots, whose synthetic timing dominates the tail, so per-human analysis lives in the admin console, below); counters `games_started_total`, `games_abandoned_total` (a @@ -581,18 +581,18 @@ promotions) is future work and would deliver short markdown messages (text + lin `edge_request_duration` (the UI-perceived roundtrip, by `message_type`/`result`); and Go runtime/heap metrics. Game-scoped metrics carry a `variant` attribute (scrabble_en/scrabble_ru/erudit_ru). -- Per-user move-time analytics (Stage 17) are **offline**, derived in the admin +- Per-user move-time analytics are **offline**, derived in the admin console from the move journal (`game_moves.created_at` deltas, the first move from the game's creation), not Prometheus labels (which an `account_id` would explode): the user list shows each account's min/avg/max think time, and the user-detail page draws a zero-JS inline-SVG chart of min/mean/max by the player's move number. -- User metrics (Stage 16): a backend counter `accounts_created_total` (`kind` = +- User metrics: a backend counter `accounts_created_total` (`kind` = telegram/email/guest; robots are a provisioned pool, not users, and are excluded) and a gateway **in-memory** observable gauge `active_users` (`window` = 24h/7d) — distinct accounts that performed an authenticated edge action in the window. The gauge is single-process by design (single-instance MVP, §10): it is correct for one gateway, resets on restart, and is a live operational figure, not a billing count. -- **Rate-limit observability (R3):** every limiter rejection increments the gateway +- **Rate-limit observability:** every limiter rejection increments the gateway counter `gateway_rate_limited_total` (`class` = user/public/email/admin — aggregate only, honouring the no-per-user-label discipline above) and logs one **Debug** line; a gateway reporter drains the per-key rejection tracker every 30 s, emits one **Warn** @@ -617,19 +617,19 @@ promotions) is future work and would deliver short markdown messages (text + lin | Concern | Enforced by | | --- | --- | -| Public rate limiting / anti-abuse | gateway (per-IP public/email/admin classes, per-user authenticated class; a request body cap of `GATEWAY_MAX_BODY_BYTES`; rejections are metered, summarised to the backend and surfaced in the admin console with a conservative reversible auto-flag — R3, §11) | +| Public rate limiting / anti-abuse | gateway (per-IP public/email/admin classes, per-user authenticated class; a request body cap of `GATEWAY_MAX_BODY_BYTES`; rejections are metered, summarised to the backend and surfaced in the admin console with a conservative reversible auto-flag — §11) | | Telegram initData validation (bot-token HMAC) | the Telegram connector; the gateway delegates it over gRPC, so the bot token lives only in the connector | | Session minting; email-code / guest validation | gateway (with backend) | | Session → `user_id` resolution, `X-User-ID` injection | gateway | | Authorisation, ownership, state transitions | backend (`X-User-ID` is the sole identity input) | -| Admin authentication | a single Basic-Auth gate on `/_gm/*`, forwarded **verbatim** to the backend's server-rendered admin console (and, in the deployed contour, routing `/_gm/grafana/*` to Grafana). In the deploy the **caddy** owns this gate (§13); a local non-caddy run uses the gateway's own `GATEWAY_ADMIN_*` proxy, which the per-IP admin limiter class guards ahead of its Basic-Auth (R3) — the caddy-fronted path has no limiter (stock caddy), an accepted gap. The backend trusts the proxy (no admin principal) and guards its state-changing POSTs with a **same-origin** check — the console's CSRF defence. No operator identity is tracked | +| Admin authentication | a single Basic-Auth gate on `/_gm/*`, forwarded **verbatim** to the backend's server-rendered admin console (and, in the deployed contour, routing `/_gm/grafana/*` to Grafana). In the deploy the **caddy** owns this gate (§13); a local non-caddy run uses the gateway's own `GATEWAY_ADMIN_*` proxy, which the per-IP admin limiter class guards ahead of its Basic-Auth — the caddy-fronted path has no limiter (stock caddy), an accepted gap. The backend trusts the proxy (no admin principal) and guards its state-changing POSTs with a **same-origin** check — the console's CSRF defence. No operator identity is tracked | | backend ↔ gateway ↔ connector trust | the network (only gateway may reach backend; the connector serves unauthenticated gRPC on the internal segment) | This is an explicit, accepted MVP risk: compromise of the gateway↔backend network segment defeats backend authentication. Mitigated by network isolation; mutual auth is a future hardening step. -**Short numeric codes** (email confirm-codes and Stage 8 friend codes) are stored +**Short numeric codes** (email confirm-codes and friend codes) are stored only as SHA-256 hashes and are short-lived and single-use. The unauthenticated email path carries a tight per-IP sub-limit (5 / 10 min); the **friend-code redeem** is authenticated, so it rides the per-user limit (300 / min) and is further bounded @@ -645,7 +645,7 @@ Single public origin, path-routed. The Vite build has two entries: a lightweight (`go:embed`, baked in by a node stage in `gateway/Dockerfile`) and serves it at `/app/` (web) and `/telegram/` (the Telegram Mini App; outside Telegram that path redirects to the root — the client-side guard); a stray hit on the gateway's `/` -308-redirects to `/app/`. The **landing** ships in its own static container (R3): the +308-redirects to `/app/`. The **landing** ships in its own static container: the `landing` target of `gateway/Dockerfile` (caddy:2-alpine + the same Vite build, `deploy/landing/Caddyfile`) serves it at `/`, so stray public traffic is absorbed by static file serving and never reaches the Go edge. Hash-named `/assets/*` are served @@ -671,23 +671,23 @@ network (project-scoped DNS); only caddy joins the shared external `edge` networ (alias `scrabble`). Two contours, two secret/variable prefixes (`TEST_` / `PROD_`): -- **Test** (Stage 16): auto-deploys on a PR into — or a push to — `development` +- **Test**: auto-deploys on a PR into — or a push to — `development` (`.gitea/workflows/ci.yaml` → `docker compose up -d --build` on the Gitea runner host, then `GET /` + `GET /app/` probes through caddy — the landing container and - the gateway, R3). The host caddy terminates TLS and + the gateway). The host caddy terminates TLS and forwards the domain to `scrabble:80`, so the in-compose caddy serves plain HTTP (`CADDY_SITE_ADDRESS=:80`). The in-compose caddy **trusts X-Forwarded-For from private-range upstreams** (`trusted_proxies private_ranges`), so the real client IP — used for chat-moderation logging and the gateway's per-IP rate limiting — survives the host-caddy hop; in prod (no host caddy) public clients are untrusted and Caddy uses the - real peer, so the single config is correct and spoof-safe in both contours (Stage 17). -- **Prod** (Stage 18): a manual SSH deploy after `development → master`. There is no + real peer, so the single config is correct and spoof-safe in both contours. +- **Prod**: a manual SSH deploy after `development → master`. There is no host caddy, so the contour ships its own caddy terminating TLS — set `CADDY_SITE_ADDRESS` to the domain and the caddy does its own ACME. ## 14. CI & branches -- **Two long-lived branches** (Stage 16): **`development`** is the integration +- **Two long-lived branches**: **`development`** is the integration trunk and **`master`** the production trunk; `feature/*` branches are cut from `development` and PR back into it (the genesis commit necessarily landed on `master`). A commit to a `feature/*` branch triggers nothing. @@ -695,18 +695,18 @@ Two contours, two secret/variable prefixes (`TEST_` / `PROD_`): suite on a PR into `development`/`master` and on a push to `development`. Its `unit` (gofmt/vet/build/unit-test), `integration` (Postgres-backed `integration` tag, testcontainers `postgres:17-alpine`, Ryuk off, serial) and `ui` - (check/unit/build/bundle-budget/e2e) jobs are **path-conditional** (Stage 17 — a + (check/unit/build/bundle-budget/e2e) jobs are **path-conditional** (a `changes` job filters by changed paths), and an always-running **`gate`** job aggregates them (passing when each succeeded or was **skipped**) and is the single branch-protection required check (`CI / gate`), so a path-skipped job never blocks a merge. - A gated **`deploy`** job auto-rolls the **test contour** on a PR into — or a push to — `development` (`docker compose up -d --build` on the runner host), then probes - the gateway (`GET /`) **and the Telegram connector's liveness** (Stage 17 — + the gateway (`GET /`) **and the Telegram connector's liveness** (via `docker inspect`: running, not restarting, stable restart count, with a VPN-handshake grace period, since the connector has no public ingress and a crash-loop is otherwise invisible). A PR into `master` is test-only; the prod - deploy is the manual Stage 18 workflow. Secrets/variables are prefixed + deploy is the manual workflow. Secrets/variables are prefixed `TEST_`/`PROD_` per contour. - The engine consumes `scrabble-solver` as a **published, versioned module** (`gitea.iliadenisov.ru/developer/scrabble-solver`, pinned in `backend/go.mod`); both Go @@ -714,6 +714,6 @@ Two contours, two secret/variable prefixes (`TEST_` / `PROD_`): (no public proxy/checksum DB, no sibling clone). The dictionaries ship as a **release artifact** from the `scrabble-dictionary` repo; the workflows download `scrabble-dawg-.tar.gz` and point the engine tests at it via - `BACKEND_DICT_DIR` (TODO-1/TODO-2 discharged in Stage 14). + `BACKEND_DICT_DIR`. - After any push, the run is watched to green before a stage is declared done (`python3 ~/.claude/bin/gitea-ci-watch.py`). diff --git a/docs/FUNCTIONAL.md b/docs/FUNCTIONAL.md index bdbf102..eeb367c 100644 --- a/docs/FUNCTIONAL.md +++ b/docs/FUNCTIONAL.md @@ -4,18 +4,17 @@ Per-domain user stories: what each user-visible operation does. This is the starting point for any change request that touches behaviour. The English version is authoritative; [`FUNCTIONAL_ru.md`](FUNCTIONAL_ru.md) is a mirror for the project owner — mirror every point edit in the same patch (translate only -the changed paragraphs). Sections deepen as stages land; *(Stage N)* marks where -the detail is authored. +the changed paragraphs). ## Domains -### Client app *(Stage 7 / 8)* -The web/app client (Svelte + Vite) realizes these stories. The **playable slice** -(Stage 7) covers signing in (guest or email), the "my games" lobby, starting an +### Client app +The web/app client (Svelte + Vite) realizes these stories. It +covers signing in (guest or email), the "my games" lobby, starting an auto-match, playing the board (place tiles by drag or tap, pass, exchange, resign), the top-1 hint, the unlimited word-check with complaint, per-game chat and nudge, real-time in-app updates, switching interface language (en/ru) and theme, and a -read-only profile. **Stage 8** adds managing friends (including one-time friend +read-only profile. It also handles managing friends (including one-time friend codes) and blocks, friend-game invitations, editing the profile and binding an email, the statistics screen, and the in-game history viewer with GCG export. Settings also pick the board's bonus-label style (beginner / classic / none). A hint **lays the suggested tiles on the board** for the player to confirm and @@ -26,7 +25,7 @@ theme, and links to the matching per-language Telegram channel; the game itself `/app/` (web) and `/telegram/` (the Telegram Mini App). The landing's theme is ephemeral (it follows the system scheme, not the saved preference); its language choice is saved. -### Identity & sessions *(Stage 1 / 6 / 9 / 15)* +### Identity & sessions A player arrives from a platform (Telegram first), via email login, or as an ephemeral guest. The gateway validates the credential once and mints a thin session token; the backend resolves it to an internal `user_id`. A **Telegram Mini @@ -56,7 +55,7 @@ pause until it is back (a server-data screen still opens, with the spinner, and reconnect), and pending reads resume on their own — the interface stays usable instead of flashing a red banner each time. -### Accounts, linking & merge *(Stage 1 / 11)* +### Accounts, linking & merge First platform contact auto-provisions a durable account. From the profile a player links an email (via a confirm code) or their Telegram (via the web sign-in); a guest who links their first identity becomes a durable account. The "already taken" status @@ -68,12 +67,12 @@ when a guest links an identity that already has a durable account, where the dur account is kept and the guest's games move into it. A merge is blocked only while the two accounts share a game still in progress. -### Lobby & matchmaking *(Stage 4 / 15)* +### Lobby & matchmaking Bottom tab menu: **my games**, **profile**. The **my games** list groups games into three sections — *your turn*, *opponent's turn* and *finished* (empty sections are hidden) — and orders them so the games awaiting your move come first, the longest-waiting on top, while opponent-turn and finished games are most-recent first; it renders as a compact, -line-separated list (Stage 17). You can **remove a finished game from your own list**: +line-separated list. You can **remove a finished game from your own list**: swipe a finished row left (or, on desktop, tap its **⋮**) to reveal a **❌**, then tap it. The removal is per-account and permanent — the game disappears only from your list and stays in the other players' lists, and there is no undo. The game types offered on **New Game** are @@ -84,13 +83,13 @@ unrestricted). Variants are shown by their **display name** — both Scrabble va the same name titles the in-game screen. This gates only **starting** a new game — both auto-match and a friend invitation — so a player still sees and plays existing games of any language. Auto-match (always 2 players) joins a per-variant pool and is paired with the next waiting human; -after 10 s with no human the robot substitutes (the robot arrives in Stage 5). Friend games (2–4) are +after 10 s with no human the robot substitutes. Friend games (2–4) are formed by inviting players from the friend list (an invitation, like a friend code, is shareable as a Telegram deep link that opens it directly): the inviter chooses the settings and the game starts once every invitee has accepted — any decline cancels it, and an unanswered invitation expires after seven days. -### Playing a game *(Stage 3)* +### Playing a game Place tiles, pass, exchange, or resign. A play is validated against the game's dictionary at submit time and scored; an unlimited preview reports what a tentative move would score and whether it is legal. The dictionary check tool is @@ -111,7 +110,7 @@ and restored on return (including on another device); a player may **arrange til the opponent's turn**, but that draft is position-only — the score preview and submission stay available only on the player's own turn. -### Robot opponent *(Stage 5)* +### Robot opponent When auto-match finds no human within ten seconds, a robot opponent takes the empty seat so the game starts without waiting. It is meant to feel like a person: it decides once per game whether to play to win (about 40% of the time, so the human @@ -123,7 +122,7 @@ carries a human-like, language-appropriate name (a Russian game draws mostly Rus names); it does not chat, and **silently ignores friend requests** — a request to a robot stays pending and expires, exactly like a human who never responds. -### Social: friends, block, chat, nudge *(Stage 4 / 8)* +### Social: friends, block, chat, nudge Become friends in two ways: redeem a **one-time code** the other player issues (six digits, valid for twelve hours), or send a **request to someone you have played with** — they accept, ignore it (a request lapses after thirty days and can then be @@ -132,7 +131,7 @@ a code). Cancelling your own pending request withdraws it; unfriending removes t friendship. In a game, an **add to friends** item for each opponent mirrors the live relationship: it reads *request sent* (disabled) while a request is pending or was declined, and *in friends* once accepted — updating in place the moment the opponent -answers, and staying correct across reloads (Stage 17). Block globally — switch off incoming chat +answers, and staying correct across reloads. Block globally — switch off incoming chat and/or friend requests — and block individual players (a per-user block hides that person's chat and stops requests and game invitations both ways; it also ends any existing friendship). Per-game chat is for quick reactions: messages are short @@ -142,16 +141,16 @@ nudge is part of the game chat); the out-of-app push is delivered via the platfo Chat and the word-check tool open as their **own screens** (with a back to the game), and a new chat message raises an **unread badge** on the game's menu until the chat is opened. -### Profile & settings *(Stage 4 / 8)* +### Profile & settings Edit the display name (letters joined by a single space / "." / "_" separator, with an optional trailing ".", up to 32 characters and at most 5 special characters — the "." / "_" punctuation, spaces aside), the timezone (chosen as a UTC offset), the daily away window (on a 10-minute grid, at most 12 hours, wrapping midnight) and the block toggles. The profile form is edited inline (no separate edit mode). Linking an email or Telegram and merging accounts are covered under "Accounts, linking & -merge" (Stage 11). +merge". -### History & statistics *(Stage 3 / 8)* +### History & statistics Finished games are archived in a dictionary-independent form and exportable to GCG; the export is offered **only once a game is finished** (exporting a live game would leak the move journal), and the client shares the `.gcg` file where the @@ -159,7 +158,7 @@ platform supports it, otherwise downloads it. Statistics (durable accounts only) wins, losses, draws, max points in a game, and max points for a single move (the best play, which already includes every word it formed plus the all-tiles bonus). -### Administration *(Stage 10)* +### Administration Operators reach a server-rendered admin console at `${DOMAIN}/_gm` — the backend renders it; the gateway gates it with HTTP Basic Auth on its public listener and proxies it verbatim. The console lists and inspects **users** (profile, statistics, @@ -173,7 +172,7 @@ applied after a reload). When a Telegram connector is configured an operator can State-changing actions are protected by a same-origin check; the console tracks no operator identity. -The console also surfaces **rate-limit abuse** (R3): a **Throttled** page lists the +The console also surfaces **rate-limit abuse**: a **Throttled** page lists the recently throttled users/IPs the gateway reported (an in-memory window — it resets on a backend restart) and the accounts currently carrying the soft **high-rate flag**. An account sustaining rejections past a tunable threshold is flagged automatically — diff --git a/docs/FUNCTIONAL_ru.md b/docs/FUNCTIONAL_ru.md index 57f1e19..4fb65aa 100644 --- a/docs/FUNCTIONAL_ru.md +++ b/docs/FUNCTIONAL_ru.md @@ -3,18 +3,17 @@ Пользовательские сценарии по доменам: что делает каждая видимая пользователю операция. Это зеркало [`FUNCTIONAL.md`](FUNCTIONAL.md) для владельца проекта; **авторитетна английская версия**. Любую точечную правку переносим в том же -патче (переводим только изменённые абзацы). Разделы наполняются по мере этапов; -*(Stage N)* помечает, где пишется детализация. +патче (переводим только изменённые абзацы). ## Домены -### Клиентское приложение *(Stage 7 / 8)* -Веб/приложение-клиент (Svelte + Vite) воплощает эти истории. **Играбельный срез** -(Stage 7) покрывает вход (гость или email), лобби «мои игры», старт авто-подбора, +### Клиентское приложение +Веб/приложение-клиент (Svelte + Vite) воплощает эти истории. Он +покрывает вход (гость или email), лобби «мои игры», старт авто-подбора, игру на доске (постановка фишек перетаскиванием или тапом, пас, обмен, сдача), top-1 подсказку, безлимитную проверку слова с жалобой, чат и nudge в партии, обновления в реальном времени, переключение языка интерфейса (en/ru) и темы и -профиль только для чтения. **Stage 8** добавляет управление друзьями (в т.ч. +профиль только для чтения. Он также включает управление друзьями (в т.ч. одноразовые коды-приглашения) и блоками, дружеские приглашения в игру, редактирование профиля и привязку email, экран статистики и просмотр истории партии с экспортом GCG. В настройках также выбирается стиль подписей бонус-клеток @@ -27,7 +26,7 @@ top-1 подсказку, безлимитную проверку слова с `/app/` (веб) и `/telegram/` (Telegram Mini App). Тема на странице эфемерна (берётся из системной настройки, а не из сохранённой), выбор языка сохраняется. -### Личность и сессии *(Stage 1 / 6 / 9 / 15)* +### Личность и сессии Игрок приходит с платформы (сначала Telegram), через email-вход или как эфемерный гость. Gateway один раз валидирует доступ и выдаёт тонкий session-токен; backend сопоставляет его с внутренним `user_id`. Запуск **Telegram @@ -58,7 +57,7 @@ nudge) приходят от бота **этой партии** — по язы подгружается при реконнекте), а незавершённые чтения возобновляются сами — интерфейс остаётся рабочим вместо красного баннера каждый раз. -### Аккаунты, привязка и слияние *(Stage 1 / 11)* +### Аккаунты, привязка и слияние Первый контакт с платформы заводит постоянный аккаунт. Из профиля игрок привязывает email (по confirm-коду) или свой Telegram (через веб-вход); гость, привязавший первую личность, становится постоянным аккаунтом. Факт «личность уже @@ -70,12 +69,12 @@ nudge) приходят от бота **этой партии** — по язы тогда сохраняется постоянный аккаунт, а игры гостя переходят в него. Слияние запрещено, только пока у аккаунтов есть общая незавершённая игра. -### Лобби и подбор *(Stage 4 / 15)* +### Лобби и подбор Нижнее tab-меню: **мои игры**, **профиль**. Список **мои игры** разбит на три секции — *твой ход*, *ход соперника* и *завершённые* (пустые секции скрыты) — и упорядочен так, что игры, ждущие твоего хода, идут первыми, дольше всего ждущие сверху, а игры на ходу соперника и завершённые — самые свежие сверху; отображается компактным списком с -линиями-разделителями (Stage 17). Завершённую партию можно **убрать из своего списка**: +линиями-разделителями. Завершённую партию можно **убрать из своего списка**: проведи по строке завершённой партии влево (или, на десктопе, нажми её **⋮**), чтобы открыть **❌**, и нажми её. Удаление действует только для твоего аккаунта и необратимо — партия исчезает лишь из твоего списка и остаётся в списках других игроков, отмены нет. Типы партий @@ -88,13 +87,13 @@ nudge) приходят от бота **этой партии** — по язы приглашение друга, — поэтому игрок по-прежнему видит и играет существующие игры на любом языке. Авто-подбор (всегда 2 игрока) встаёт в пул по варианту и сводится со следующим ожидающим человеком; через 10 с -без человека подставляется робот (робот — в Stage 5). Игры с друзьями (2–4) +без человека подставляется робот. Игры с друзьями (2–4) формируются приглашением игроков из списка друзей (приглашение, как и код друга, можно отправить deep-link'ом в Telegram, который откроет его сразу): инициатор выбирает настройки, и партия стартует, когда приняли все приглашённые — любой отказ отменяет приглашение, а без ответа приглашение протухает через семь дней. -### Игровой процесс *(Stage 3)* +### Игровой процесс Выкладывание фишек, пас, обмен или сдача. Ход проверяется по словарю партии при сдаче и считается; безлимитный предпросмотр сообщает, сколько принёс бы предполагаемый ход и легален ли он. Инструмент проверки слова безлимитный и @@ -115,7 +114,7 @@ nudge) приходят от бота **этой партии** — по язы **раскладывать фишки и в ход соперника**, но такой черновик только позиционный — предпросмотр счёта и отправка доступны лишь в собственный ход. -### Робот-соперник *(Stage 5)* +### Робот-соперник Если авто-подбор не находит человека за десять секунд, свободное место занимает робот-соперник, и партия стартует без ожидания. Он задуман неотличимым от человека: один раз за партию решает, играть ли на победу (примерно в 40% случаев, так что @@ -127,7 +126,7 @@ nudge) приходят от бота **этой партии** — по язы **молча игнорирует заявки в друзья** — заявка роботу остаётся в ожидании и истекает, ровно как у человека, который не отвечает. -### Социальное: друзья, блок, чат, nudge *(Stage 4 / 8)* +### Социальное: друзья, блок, чат, nudge Подружиться можно двумя способами: погасить **одноразовый код**, который выпускает другой игрок (шесть цифр, действует двенадцать часов), либо отправить **заявку тому, с кем вы играли** — он принимает, игнорирует (заявка истекает через тридцать @@ -136,7 +135,7 @@ nudge) приходят от бота **этой партии** — по язы снимает её; удаление расторгает дружбу. В партии пункт меню **в друзья** для каждого соперника отражает живое отношение: он показывает *заявка отправлена* (неактивный), пока заявка висит или была отклонена, и *в друзьях* после принятия — обновляясь на месте -в момент ответа соперника и оставаясь верным после перезагрузки (Stage 17). Глобальная блокировка — отключить входящие +в момент ответа соперника и оставаясь верным после перезагрузки. Глобальная блокировка — отключить входящие чат и/или заявки — и блокировка конкретного игрока (пер-юзер блок скрывает его чат и запрещает заявки и приглашения в игру в обе стороны, а также расторгает уже имеющуюся дружбу). Чат @@ -147,16 +146,16 @@ push доставляется через платформу. Чат и инструмент проверки слова открываются **отдельными экранами** (с кнопкой «назад» в партию), а новое сообщение в чате рисует **бейдж непрочитанного** на меню партии до открытия чата. -### Профиль и настройки *(Stage 4 / 8)* +### Профиль и настройки Редактирование отображаемого имени (буквы, разделённые одиночным пробелом / «.» / «_», с необязательной завершающей «.», до 32 символов и не более 5 спецсимволов — пунктуации «.» / «_», пробелы не в счёт), таймзоны (выбор смещения от UTC), суточного окна отсутствия (away; сетка по 10 минут, не более 12 часов, с переходом через полночь) и переключателей блокировок. Форма профиля редактируется сразу (без отдельного режима редактирования). Привязка email и Telegram, а также -слияние аккаунтов вынесены в раздел «Аккаунты, привязка и слияние» (Stage 11). +слияние аккаунтов вынесены в раздел «Аккаунты, привязка и слияние». -### История и статистика *(Stage 3 / 8)* +### История и статистика Завершённые партии архивируются в независимом от словаря виде и экспортируются в GCG; экспорт доступен **только после завершения партии** (экспорт идущей партии раскрыл бы журнал ходов), и клиент делится файлом `.gcg` там, где платформа это @@ -164,7 +163,7 @@ UTC), суточного окна отсутствия (away; сетка по 10 победы, поражения, ничьи, макс. очков за партию и макс. очков за один ход (лучший ход, уже включающий все образованные им слова и бонус за все фишки). -### Администрирование *(Stage 10)* +### Администрирование Оператор открывает серверную админ-консоль по адресу `${DOMAIN}/_gm` — её рендерит backend; gateway закрывает её HTTP Basic Auth на публичном порту и проксирует один-в-один. В консоли можно смотреть **пользователей** (профиль, статистика, @@ -177,7 +176,7 @@ identity, их игры) и **игры** (сводка + места), разби Telegram-identity) или **отправить пост в игровой канал**. Изменяющие действия защищены проверкой same-origin; личность оператора не отслеживается. -Консоль также показывает **злоупотребление лимитами** (R3): страница **Throttled** +Консоль также показывает **злоупотребление лимитами**: страница **Throttled** перечисляет недавно затроттленных пользователей/IP по отчётам gateway (окно в памяти — сбрасывается при рестарте backend) и аккаунты с действующим мягким **high-rate флагом**. Аккаунт, устойчиво превышающий настраиваемый порог отказов, помечается diff --git a/docs/TESTING.md b/docs/TESTING.md index 02dc510..051d42e 100644 --- a/docs/TESTING.md +++ b/docs/TESTING.md @@ -9,19 +9,19 @@ tests or touching CI. Every functional change ships with regression coverage. Run: `go test -count=1 ./backend/... ./pkg/... ./gateway/...` (the module list grows with the workspace). -- **Integration** *(Stage 1+)* — Postgres-backed tests behind the `integration` +- **Integration** — Postgres-backed tests behind the `integration` build tag spin a throwaway `postgres:17-alpine` via `testcontainers-go`. They live in `backend/internal/inttest` and run with `go test -tags=integration -count=1 -p=1 ./backend/...` (needs Docker), guarded by a separate CI workflow (`integration.yaml`; Ryuk disabled, serial). Slow. -- **UI** *(introduced with the UI in Stage 7)* — Vitest (unit) + Playwright - (e2e), mirroring the chosen plain-Svelte + Vite toolchain. Stage 8 adds Vitest for - the new FlatBuffers codecs (friend list, invitation, stats), the win-rate +- **UI** — Vitest (unit) + Playwright + (e2e), mirroring the chosen plain-Svelte + Vite toolchain. Vitest covers + the FlatBuffers codecs (friend list, invitation, stats), the win-rate derivation and the GCG share/download choice, plus Playwright specs against the mock for the friends screen (code issue/redeem, accept a request), the lobby invitations section, the stats screen, profile editing, and the GCG export's finished-only visibility. -- **Engine** *(Stage 2+)* — correctness of scoring and move generation is owned +- **Engine** — correctness of scoring and move generation is owned by `scrabble-solver`'s own GCG-backed tests. `backend/internal/engine` adds, on top of the embedded solver: per-variant smoke tests (load all three committed DAWGs and validate a known word, including Эрудит), bag draw/return determinism @@ -32,33 +32,33 @@ tests or touching CI. win/loss rule** (the resigner keeps their score yet loses). The engine tests read the DAWGs from `BACKEND_DICT_DIR` (or the sibling `scrabble-solver` checkout) and fail loudly when it is absent. -- **Game domain** *(Stage 3+)* — `backend/internal/game` adds pure unit tests +- **Game domain** — `backend/internal/game` adds pure unit tests (the GCG writer, the away-window / effective-deadline boundaries, the hint budget, the live-game cache and per-game lock, payload round-trips) plus Postgres-backed integration tests in `inttest` (full lifecycle to a natural end, **journal-replay equivalence**, the turn-timeout sweep with away-window grace, resign win/loss and statistics, the hint allowance-then-wallet policy, - word-check and complaint capture, and per-game-lock serialisation). Stage 4 adds + word-check and complaint capture, and per-game-lock serialisation). It also covers the engine's **multi-player drop-out** cases (continue after one resign, last-survivor win, the tile-disposition bag effect) and a domain integration test - for a 3-player **timeout that continues**. The engine also gains a `Candidates` - ranked/decoded test (Stage 5). -- **Social & lobby** *(Stage 4+)* — `backend/internal/social` unit-tests the chat + for a 3-player **timeout that continues**, and the engine's `Candidates` + ranked/decoded test. +- **Social & lobby** — `backend/internal/social` unit-tests the chat **content filter** (links/emails/phones plus obfuscated forms) and `backend/internal/lobby` unit-tests the in-memory **matchmaker** (FIFO pairing, - cancel, per-variant pools, plus the Stage 5 **robot substitution** reaper and + cancel, per-variant pools, plus the **robot substitution** reaper and `Poll` delivery) with fake game-creator and robot-provider seams. Postgres-backed `inttest` covers the friend request/accept lifecycle with the block/toggle guards, the per-user block (and its severing of friendships), chat post/list with the IP, content and block-visibility rules, the nudge turn/rate-limit rules, the invitation flow (all-accept starts the game, decline cancels, lazy expiry, inviter-only cancel), and the email confirm-code flow (request/confirm, taken - email, expiry and attempt-cap) with a fixture mailer. Stage 8 adds the + email, expiry and attempt-cap) with a fixture mailer. It also covers the **befriend-an-opponent** gate (a request needs a shared game), the **permanent decline** and 30-day re-send rule, the **one-time friend code** (issue/redeem, self/single-use, decline-bypass), `ListInvitations`, the zero-value `GetStats`, and the GCG **finished-only** gate. -- **Robot** *(Stage 5+)* — `backend/internal/robot` unit-tests the pure strategy: +- **Robot** — `backend/internal/robot` unit-tests the pure strategy: the ≈ 40% play-to-win split over many seeds, the right-skewed move-delay (bounds, ~10-min median, determinism), the margin selection (win/lose, in-band and out-of-band fallbacks, no-play exchange/pass), the sleep window with drift @@ -66,7 +66,7 @@ tests or touching CI. drives a robot through a full auto-match to a natural end (asserting a robot statistics row), the matchmaker substitution end-to-end (enqueue → reap → `[human, robot]`, discoverable via `Poll`), and a proactive 12-hour nudge. -- **Gateway & contracts** *(Stage 6+)* — `backend/internal/notify` unit-tests the +- **Gateway & contracts** — `backend/internal/notify` unit-tests the hub fan-out (delivery, overflow drop, unsubscribe) and the FlatBuffers event constructors (payload round-trip). `gateway/...` unit-tests are hermetic (no real network — an `httptest` fake backend and fixtures): the Telegram initData @@ -76,20 +76,20 @@ tests or touching CI. unsubscribe), the transcode round-trips (FlatBuffers↔JSON, X-User-ID forwarding, nested GameView, domain-code surfacing), the admin Basic-Auth reverse proxy (401 / forward), and a full Connect `Execute` path end to end - (guest auth, unauthenticated rejection, unknown message type). **R3** adds the + (guest auth, unauthenticated rejection, unknown message type). The edge-hardening cases: an oversized `Execute` payload is refused (`resource_exhausted`, the `GATEWAY_MAX_BODY_BYTES` cap), a limiter rejection lands in `gateway_rate_limited_total{class}` and the rejection tracker (drain/aggregate unit tests), the report POST reaches `/api/v1/internal/ratelimit/report` with the agreed JSON shape, the `/_gm` mount is 429-guarded by the per-IP admin class, and the gateway's `/` - 308-redirects to `/app/` (the landing left the embed). The backend gains + 308-redirects to `/app/` (the landing left the embed). The backend covers the **guest** lifecycle (a guest plays an auto-match to a natural end yet accrues no statistics) and the **email-as-login** flow (request/verify, returning user) - in `inttest`. Stage 8 adds gateway transcode round-trips for the new social/account + in `inttest`. Gateway transcode round-trips cover the social/account operations (friends list, friend code issue/redeem, invitation create, stats, GCG, the profile-update away round-trip) and a `notify`-event constructor round-trip. -- **Admin & dictionary ops** *(Stage 10)* — `backend/internal/adminconsole` unit-tests +- **Admin & dictionary ops** — `backend/internal/adminconsole` unit-tests the template renderer over every page plus the embedded asset; `backend/internal/engine` adds the **dictionary hot-reload** cases (`LoadAvailable` loads only the present variants, `OpenWithVersions` scans version subdirectories, a reload registers a new @@ -99,13 +99,13 @@ tests or touching CI. 404 when not). Postgres-backed `inttest` drives the **complaint resolution → dictionary-change pipeline** (file → resolve with a disposition → pending change → mark applied), the admin **list/count** read queries, and the **/_gm console over HTTP** - (pages render; a resolve POST needs a same-origin header). **R3** adds `ratewatch` + (pages render; a resolve POST needs a same-origin header). `ratewatch` has unit tests (window accumulation, the auto-flag threshold + expiry, the bounded episode map), the account-store **high-rate flag round-trip** (set-once / clear / re-flag) and a console flow in `inttest`: a gateway report auto-flags the account, the **Throttled** page shows the episode and the flagged queue, the user card carries the marker and the CSRF-guarded **Clear** reverses it. -- **Observability & performance** *(Stage 12)* — `pkg/telemetry` unit-tests the exporter +- **Observability & performance** — `pkg/telemetry` unit-tests the exporter selection (`none`/`stdout`/`otlp` build providers; OTLP constructs with no collector; the nil-runtime fallback). The domain metrics are exercised through a manual `sdkmetric` reader: `backend/internal/game` and `…/social` assert the counters and @@ -115,7 +115,7 @@ tests or touching CI. `otlp` now accepted, an unsupported exporter rejected) and the guest-reaper knobs. Postgres-backed `inttest` drives the **guest reaper** end to end (an abandoned guest is reaped; a too-young guest, a seated guest and a durable account are kept). -- **Load test & resource baseline** *(R2)* — a reusable `loadtest/` module +- **Load test & resource baseline** — a reusable `loadtest/` module (`scrabble/loadtest`) is the pre-release stress harness. It **seeds** a large account population with pre-created sessions directly in Postgres (token hashes matching `backend/internal/session`), **drives** virtual players through the edge protocol — @@ -128,7 +128,7 @@ tests or touching CI. engine tests do). It is **not** part of the per-PR suite's behavioural assertions: it runs ad hoc as a one-shot container against the contour, producing a trip report (bugs + a resource baseline) read off the **cAdvisor + postgres_exporter** Grafana dashboard - added to the contour in R2. See [`../loadtest/README.md`](../loadtest/README.md). + on the contour. See [`../loadtest/README.md`](../loadtest/README.md). ## Principles diff --git a/docs/UI_DESIGN.md b/docs/UI_DESIGN.md index 3ba425a..d2e98ee 100644 --- a/docs/UI_DESIGN.md +++ b/docs/UI_DESIGN.md @@ -5,7 +5,7 @@ Visual and interaction conventions for the `ui` client. Behaviour lives in points this doc references) lives in [`ARCHITECTURE.md`](ARCHITECTURE.md). The client is **pure HTML5/CSS + Unicode** — no image/font/SVG assets; icons are CSS shapes or emoji glyphs. Tokens are CSS custom properties (`ui/src/app.css`), light/dark via -`prefers-color-scheme` or an explicit Settings choice, and **Telegram-themed** (Stage 9): +`prefers-color-scheme` or an explicit Settings choice, and **Telegram-themed**: on a Telegram Mini App launch — the app is served under `/telegram/` and detects the launch by `Telegram.WebApp.initData` — the SDK's `themeParams` override the tokens at runtime; opened outside Telegram, the `/telegram/` path redirects to the site root. @@ -33,14 +33,14 @@ Login uses `Screen`. emoji icon over a tiny truncated label. A press highlights a rounded **square** behind the icon (slightly larger than it) until release; spacing keeps adjacent labels from touching. No text selection on nav / tab-bar / buttons (`user-select: none`). -- **Screen transitions** (Stage 17, `App.svelte`): navigation slides directionally — a +- **Screen transitions** (`App.svelte`): navigation slides directionally — a screen entered from the lobby flies in from the right; returning to the lobby reveals it from the left (back). Transitions are local (so they do not play on first load) and collapse to nothing under reduce-motion. Per-game and lobby in-memory caches (`lib/gamecache.ts`, `lib/lobbycache.ts`) render a re-opened game or the lobby instantly and refresh in the background, removing the blank-loading flash and the lobby's "draw-in" on lobby ↔ game navigation. -- **Telegram integration** (Stage 17, `lib/telegram.ts`): inside the Mini App the colour +- **Telegram integration** (`lib/telegram.ts`): inside the Mini App the colour scheme is forced from `Telegram.WebApp.colorScheme` (over the OS `prefers-color-scheme`, which leaks into the Telegram Desktop webview and otherwise fights it) and the Settings theme switcher is hidden; the nav bar takes Telegram's background and `setHeaderColor` / @@ -67,7 +67,7 @@ Login uses `Screen`. preventDefault fires only for two touches, so one-finger scroll stays native, and a second finger aborts an in-progress drag). The pan is **interpolated toward a pre-clamped target** as the real width grows/shrinks, so it magnifies evenly A→B instead of lurching and snapping - back near the edges (Stage 17). It **recentres only on a zoom-in** — placing a 2nd+ tile or + back near the edges. It **recentres only on a zoom-in** — placing a 2nd+ tile or hovering a dragged tile never jumps the board. On touch the first tile placement auto-zooms in centred on the target, and **holding a dragged tile over a cell ~1 s** auto-zooms there the first time. The swipe-to-open-history gesture stays dropped (it fought native scroll); @@ -78,15 +78,15 @@ Login uses `Screen`. cell is **highlighted as a drop target** (an accent ring). A pending tile is taken back by a **double-tap** or by **dragging it back onto the rack** (unzoomed board only — when zoomed the one-finger gesture scrolls). A single tap no longer recalls (too easy to trigger); a - recalled tile returns to its original rack slot (Stage 17). -- **Players plaque & history** (`Game.svelte`, Stage 17): the seats above the board share + recalled tile returns to its original rack slot. +- **Players plaque & history** (`Game.svelte`): the seats above the board share the width evenly; the seat whose turn it is is **raised** (a drop shadow on its sides) while the others read **sunk in** (an inset shadow). A tap anywhere on the plaque toggles the **move history** — a fixed-height slide-down drawer whose bottom border (and its shadow) pins to the board as the board slides down, instead of tracking the table as moves accumulate; its scrollbar gutter is reserved so the centred word column does not jitter. A move's row lists every word it formed (the main word first). -- **Vertical fit & keyboard** (Stage 17): when the game does not fit the viewport, only the +- **Vertical fit & keyboard**: when the game does not fit the viewport, only the board area scrolls vertically (`Screen` `column` mode; the score bar, status, rack and tab bar stay fixed), while zoom keeps its own scroll. The check-word dialog opens in `Modal` keyboard-overlay mode — the small sheet is top-anchored and the soft keyboard @@ -99,7 +99,7 @@ Login uses `Screen`. - **Bonus-square labels** — a Settings choice (`boardlabels.ts`): `beginner` shows a split `3×` / `word` (localized слово/буква), `classic` a single `3W` / `3С`, `none` nothing. Default **beginner**. -- **Grid lines** — a Settings toggle, **default off** (Stage 17). Off: a **gapless +- **Grid lines** — a Settings toggle, **default off**. Off: a **gapless checkerboard** — plain cells alternate two shades, and tiles get rounded corners with a soft right-side shadow so adjacent gapless tiles still read apart, reclaiming ~14px of board width. On: the classic lined grid, where the inter-cell gap shows a contrasting @@ -110,7 +110,7 @@ Login uses `Screen`. - **HoldConfirm** (`components/HoldConfirm.svelte`): the shared press-and-hold control. A short tap opens a small popover above the button; a ~0.7 s hold runs the primary action immediately. Used by the **Skip** and **Hint** tabs (each confirmed by an **Ok ✅** popover). -- **MakeMove / Reset** (Stage 17): when ≥1 tile is pending the rack collapses its used slots +- **MakeMove / Reset**: when ≥1 tile is pending the rack collapses its used slots and shifts left, a **borderless ✅ icon button** (styled like a tab, not a filled accent button) beside the rack commits the move — no popover, and disabled while the pending word is known illegal; the 🔀 Shuffle tab is replaced by a **↩️ Reset** tab. @@ -123,7 +123,7 @@ Login uses `Screen`. ## Announcement banner (`components/AdBanner.svelte`, `lib/banner.ts`) -A one-line inset strip under the nav bar, drawn on a dedicated `--ad-bg` token (Stage 17) — +A one-line inset strip under the nav bar, drawn on a dedicated `--ad-bg` token — a subtle accent, a touch darker than the surroundings in the light theme and a touch lighter in the dark theme, mapped to Telegram's `secondary_bg_color` inside the Mini App. Content is minimal markdown (text + links, escaped + linkified). A parameterised **rotator** drives messages: a fitting message @@ -138,7 +138,7 @@ Lobby rows show two lines (opponents, then result + score) with a large place-ba on the right: Victory 🏆 / Defeat 🥈 / Draw 🏅, and for 3–4-player games II 🥈 / III 🥉 / IV 🏅; active games show Your move 🟢 / Opponent's move ⏳; invitations use 💌. -## Social, account & history surfaces (Stage 8) +## Social, account & history surfaces - **Friends** (`screens/Friends.svelte`, from the lobby menu): an "add a friend" block pairing a code **input** with a **Show my code** action that reveals a large 6-digit diff --git a/gateway/Dockerfile b/gateway/Dockerfile index 092fd6c..2adccd5 100644 --- a/gateway/Dockerfile +++ b/gateway/Dockerfile @@ -42,7 +42,7 @@ COPY ui ./ RUN pnpm build # --- landing ------------------------------------------------------------------- -# The public landing page as its own static container (R3): the same Vite build +# The public landing page as its own static container: the same Vite build # served by caddy at /, so stray public traffic is absorbed by static file # serving and never reaches the Go edge. FROM caddy:2-alpine AS landing @@ -58,7 +58,7 @@ COPY gateway ./gateway # Replace the committed placeholder with the freshly built UI before compiling, so # go:embed bakes the real bundle into the binary. The landing shell ships in the -# landing image, not in the gateway (R3). +# landing image, not in the gateway. RUN rm -rf gateway/internal/webui/dist COPY --from=ui /ui/dist gateway/internal/webui/dist RUN rm gateway/internal/webui/dist/landing.html diff --git a/gateway/README.md b/gateway/README.md index 656102c..f2202b2 100644 --- a/gateway/README.md +++ b/gateway/README.md @@ -23,7 +23,7 @@ proto/edge/v1/ # Connect envelope contract (committed generated Go) internal/config/ # GATEWAY_* env config internal/backendclient/ # typed REST client (+ X-User-ID) and push gRPC client internal/session/ # in-memory session cache (LRU/TTL, backend fallback) -internal/ratelimit/ # token-bucket limiter (golang.org/x/time/rate) + the rejection tracker (R3) +internal/ratelimit/ # token-bucket limiter (golang.org/x/time/rate) + the rejection tracker internal/connector/ # gRPC client to the Telegram connector (initData validate, out-of-app push) + routing internal/push/ # live-event fan-out hub (per-user client streams) internal/transcode/ # FlatBuffers<->REST bridge + message_type registry @@ -50,21 +50,21 @@ failures become Connect error codes. out-of-app push to that connector for recipients with no live in-app stream (ARCHITECTURE.md §10). When `GATEWAY_CONNECTOR_ADDR` is unset, both are disabled. -The Stage 6 message-type slice: `auth.telegram`, `auth.guest`, +The message-type catalog: `auth.telegram`, `auth.guest`, `auth.email.request`, `auth.email.login`, `profile.get`, `game.submit_play`, -`game.state`, `lobby.enqueue`, `lobby.poll`, `chat.post`; live events -`your_turn`, `opponent_moved`, `chat_message`, `nudge`, `match_found` (R4 enriched the game events — -and `game_over`/`notify` — to carry the state delta the client applies without a `game.state` -refetch). Stage 7 -added the play-loop ops; **Stage 8** added the social/account/history ops — +`game.state`, `lobby.enqueue`, `lobby.poll`, `chat.post` and the play-loop ops; +live events +`your_turn`, `opponent_moved`, `chat_message`, `nudge`, `match_found` (the game events — +and `game_over`/`notify` — carry the state delta the client applies without a `game.state` +refetch). The social/account/history ops — `friends.*` (list/incoming/request/respond/cancel/unfriend/code.issue/code.redeem), `blocks.*`, `invitation.*` (list/create/accept/decline/cancel), `profile.update`, -`stats.get`, `game.gcg`, and the `notify` live event — all via the identical -transcode pattern (`transcode_social.go`). **Stage 11** added account linking & merge +`stats.get`, `game.gcg`, and the `notify` live event — go through the identical +transcode pattern (`transcode_social.go`). Account linking & merge — `link.email.request/confirm/merge` and `link.telegram.confirm/merge` (`transcode_link.go`); the telegram ops validate the **Login Widget** payload via the connector (`ValidateLoginWidget`) and forward the trusted `external_id`. These -**superseded** the Stage 8 `email.bind.*` ops, which were removed. +**superseded** the former `email.bind.*` ops, which were removed. ## Configuration @@ -81,13 +81,13 @@ connector (`ValidateLoginWidget`) and forward the trusted `external_id`. These | `GATEWAY_SESSION_TTL` | `10m` | cached session lifetime | | `GATEWAY_SESSION_CACHE_MAX` | `50000` | cached session cap | | `GATEWAY_PUSH_HEARTBEAT_INTERVAL` | `10s` | live-stream keep-alive (an immediate heartbeat also fires on open, under the ~15s edge idle timeout) | -| `GATEWAY_MAX_BODY_BYTES` | `1048576` | caps one request body and one Connect message read; an oversized Execute is refused with `resource_exhausted` (R3) | +| `GATEWAY_MAX_BODY_BYTES` | `1048576` | caps one request body and one Connect message read; an oversized Execute is refused with `resource_exhausted` | | `GATEWAY_SERVICE_NAME` | `scrabble-gateway` | OpenTelemetry `service.name` | | `GATEWAY_OTEL_TRACES_EXPORTER` | `none` | `none`, `stdout` or `otlp` (gRPC; endpoint from `OTEL_EXPORTER_OTLP_*`) | | `GATEWAY_OTEL_METRICS_EXPORTER` | `none` | `none`, `stdout` or `otlp` | Rate-limit defaults (built-in): public 30/min·IP (burst 10), authenticated -300/min·user (burst 80, raised in Stage 17 for multi-device play), admin +300/min·user (burst 80, sized for multi-device play), admin 60/min·IP (burst 20, guarding the `/_gm` mount ahead of its Basic-Auth), email-code 5/10 min·IP (burst 2). @@ -95,7 +95,7 @@ Every rejection increments `gateway_rate_limited_total{class}` (`user`/`public`/`email`/`admin`) and logs one Debug line; a reporter drains the per-key rejection tracker every 30 s, emits a Warn summary per throttled key and posts the report to the backend (`/api/v1/internal/ratelimit/report`), feeding -the admin console's throttled view and the high-rate auto-flag (R3). +the admin console's throttled view and the high-rate auto-flag. ## Run diff --git a/gateway/cmd/gateway/main.go b/gateway/cmd/gateway/main.go index d6439f0..1dbc951 100644 --- a/gateway/cmd/gateway/main.go +++ b/gateway/cmd/gateway/main.go @@ -42,10 +42,10 @@ const ( // readHeaderTimeout bounds reading one request's headers on the public // listener (a slowloris guard). Bodies and long-lived streams are governed by // the h2c settings in connectsrv — Read/WriteTimeout stay unset on purpose, - // they would kill the Subscribe stream (R3). + // they would kill the Subscribe stream. readHeaderTimeout = 10 * time.Second // throttleReportInterval is the cadence of the rate-limiter rejection - // summary: the Warn log per throttled key and the report to the backend (R3). + // summary: the Warn log per throttled key and the report to the backend. throttleReportInterval = 30 * time.Second ) @@ -281,7 +281,7 @@ func deliverOutOfApp(ctx context.Context, backend *backendclient.Client, conn *c return } // A game event carries its own language, so the push comes from the game's bot rather than - // the recipient's last-login bot (Stage 17); other events fall back to the service language. + // the recipient's last-login bot; other events fall back to the service language. lang := target.Language if gameLang != "" { lang = gameLang diff --git a/gateway/internal/backendclient/api.go b/gateway/internal/backendclient/api.go index fde7358..1d3e858 100644 --- a/gateway/internal/backendclient/api.go +++ b/gateway/internal/backendclient/api.go @@ -35,7 +35,7 @@ type ProfileResp struct { NotificationsInAppOnly bool `json:"notifications_in_app_only"` } -// LinkResultResp is the result of an account link/merge step (Stage 11). Status is +// LinkResultResp is the result of an account link/merge step. Status is // "linked", "merge_required" (the secondary_* fields summarise the other account) or // "merged". Token is a switched-session token (a guest initiator's durable // counterpart won); Profile is the surviving/active account's profile. @@ -50,7 +50,7 @@ type LinkResultResp struct { } // TileJSON is one tile in a decoded move response (history, move result, hint); its Letter -// is a concrete character (Stage 13 keeps the move journal in letters). +// is a concrete character (the move journal is kept in letters). type TileJSON struct { Row int `json:"row"` Col int `json:"col"` @@ -58,7 +58,7 @@ type TileJSON struct { Blank bool `json:"blank"` } -// PlayTileJSON is one inbound tile to place, addressed by alphabet index (Stage 13). For a +// PlayTileJSON is one inbound tile to place, addressed by alphabet index. For a // blank, Letter is the designated letter's index and Blank is true. type PlayTileJSON struct { Row int `json:"row"` @@ -107,7 +107,7 @@ type GameResp struct { } // MoveResultResp is the outcome of a committed move. Rack carries the actor's refilled rack as -// wire alphabet indices and BagLen the bag size after the draw (R4). +// wire alphabet indices and BagLen the bag size after the draw. type MoveResultResp struct { Move MoveRecordResp `json:"move"` Game GameResp `json:"game"` @@ -116,14 +116,14 @@ type MoveResultResp struct { } // AlphabetEntryJSON is one letter of a variant's alphabet (its index, concrete letter and -// tile value), present in StateResp only when the client requested it (Stage 13). +// tile value), present in StateResp only when the client requested it. type AlphabetEntryJSON struct { Index int `json:"index"` Letter string `json:"letter"` Value int `json:"value"` } -// StateResp is a player's view of a game. Rack carries wire alphabet indices (Stage 13); +// StateResp is a player's view of a game. Rack carries wire alphabet indices; // Alphabet is present only when the request asked for it. type StateResp struct { Game GameResp `json:"game"` @@ -224,7 +224,7 @@ func (c *Client) Profile(ctx context.Context, userID string) (ProfileResp, error } // SubmitPlay commits a placement on the player's turn. The tiles are addressed by alphabet -// index (Stage 13). +// index. func (c *Client) SubmitPlay(ctx context.Context, userID, gameID, dir string, tiles []PlayTileJSON) (MoveResultResp, error) { var out MoveResultResp body := map[string]any{"dir": dir, "tiles": tiles} @@ -233,7 +233,7 @@ func (c *Client) SubmitPlay(ctx context.Context, userID, gameID, dir string, til } // GameState returns the player's view of a game. When includeAlphabet is set the backend -// embeds the variant's alphabet table (Stage 13); the client asks for it on a per-variant +// embeds the variant's alphabet table; the client asks for it on a per-variant // cache miss only. func (c *Client) GameState(ctx context.Context, userID, gameID string, includeAlphabet bool) (StateResp, error) { var out StateResp @@ -320,7 +320,7 @@ func (c *Client) Pass(ctx context.Context, userID, gameID string) (MoveResultRes } // Exchange swaps the chosen rack tiles back into the bag. Tiles are wire alphabet indices -// (Stage 13; a blank is engine.BlankIndex). +// (a blank is engine.BlankIndex). func (c *Client) Exchange(ctx context.Context, userID, gameID string, tiles []int) (MoveResultResp, error) { var out MoveResultResp err := c.do(ctx, http.MethodPost, c.gamePath(gameID, "/exchange"), userID, "", @@ -342,7 +342,7 @@ func (c *Client) Hint(ctx context.Context, userID, gameID string) (HintResultRes return out, err } -// GetDraft returns the player's saved composition for a game (Stage 17) as the backend's +// GetDraft returns the player's saved composition for a game as the backend's // raw JSON body. The gateway forwards it verbatim, never interpreting its shape. func (c *Client) GetDraft(ctx context.Context, userID, gameID string) (json.RawMessage, error) { var out json.RawMessage @@ -350,21 +350,21 @@ func (c *Client) GetDraft(ctx context.Context, userID, gameID string) (json.RawM return out, err } -// SaveDraft upserts the player's composition for a game (Stage 17). body is the client's +// SaveDraft upserts the player's composition for a game. body is the client's // {rack_order, board_tiles} JSON, forwarded verbatim — a json.RawMessage marshals as-is, so // there is no double-encode. func (c *Client) SaveDraft(ctx context.Context, userID, gameID string, body json.RawMessage) error { return c.do(ctx, http.MethodPut, c.gamePath(gameID, "/draft"), userID, "", body, nil) } -// HideGame hides a finished game from the caller's own games list (Stage 17). The action is +// HideGame hides a finished game from the caller's own games list. The action is // per-account and irreversible; the game stays visible to the other players. func (c *Client) HideGame(ctx context.Context, userID, gameID string) error { return c.do(ctx, http.MethodPost, c.gamePath(gameID, "/hide"), userID, "", struct{}{}, nil) } // Evaluate previews a tentative play's legality and score. The tiles are addressed by -// alphabet index (Stage 13). +// alphabet index. func (c *Client) Evaluate(ctx context.Context, userID, gameID, dir string, tiles []PlayTileJSON) (EvalResultResp, error) { var out EvalResultResp err := c.do(ctx, http.MethodPost, c.gamePath(gameID, "/evaluate"), userID, "", @@ -373,7 +373,7 @@ func (c *Client) Evaluate(ctx context.Context, userID, gameID, dir string, tiles } // CheckWord looks a word up in the game's pinned dictionary. The word is carried as -// repeated ?idx= alphabet indices (Stage 13); the backend echoes the decoded concrete word. +// repeated ?idx= alphabet indices; the backend echoes the decoded concrete word. func (c *Client) CheckWord(ctx context.Context, userID, gameID string, word []int) (WordCheckResp, error) { var out WordCheckResp q := url.Values{} diff --git a/gateway/internal/backendclient/api_social.go b/gateway/internal/backendclient/api_social.go index 34609dc..36599e7 100644 --- a/gateway/internal/backendclient/api_social.go +++ b/gateway/internal/backendclient/api_social.go @@ -6,7 +6,7 @@ import ( "net/url" ) -// The Stage 8 response structs and client methods mirror the backend's social, +// The response structs and client methods mirror the backend's social, // account and history JSON DTOs. The transcode layer maps them to FlatBuffers. // AccountRefResp is a referenced account with its display name resolved. @@ -249,7 +249,7 @@ func (c *Client) LinkEmailRequest(ctx context.Context, userID, email string) err } // LinkEmailConfirm verifies the code and binds a free email or reports a required -// merge (Stage 11). +// merge. func (c *Client) LinkEmailConfirm(ctx context.Context, userID, email, code string) (LinkResultResp, error) { var out LinkResultResp err := c.do(ctx, http.MethodPost, "/api/v1/user/link/email/confirm", userID, "", @@ -257,7 +257,7 @@ func (c *Client) LinkEmailConfirm(ctx context.Context, userID, email, code strin return out, err } -// LinkEmailMerge re-verifies the code and performs the merge (Stage 11). +// LinkEmailMerge re-verifies the code and performs the merge. func (c *Client) LinkEmailMerge(ctx context.Context, userID, email, code string) (LinkResultResp, error) { var out LinkResultResp err := c.do(ctx, http.MethodPost, "/api/v1/user/link/email/merge", userID, "", @@ -266,7 +266,7 @@ func (c *Client) LinkEmailMerge(ctx context.Context, userID, email, code string) } // LinkTelegram attaches a gateway-validated Telegram identity to the caller or -// reports a required merge (Stage 11). +// reports a required merge. func (c *Client) LinkTelegram(ctx context.Context, userID, externalID string) (LinkResultResp, error) { var out LinkResultResp err := c.do(ctx, http.MethodPost, "/api/v1/user/link/telegram", userID, "", @@ -275,7 +275,7 @@ func (c *Client) LinkTelegram(ctx context.Context, userID, externalID string) (L } // LinkTelegramMerge merges the account owning a gateway-validated Telegram identity -// into the caller's (Stage 11). +// into the caller's. func (c *Client) LinkTelegramMerge(ctx context.Context, userID, externalID string) (LinkResultResp, error) { var out LinkResultResp err := c.do(ctx, http.MethodPost, "/api/v1/user/link/telegram/merge", userID, "", diff --git a/gateway/internal/backendclient/client.go b/gateway/internal/backendclient/client.go index d1caeac..56653de 100644 --- a/gateway/internal/backendclient/client.go +++ b/gateway/internal/backendclient/client.go @@ -129,7 +129,7 @@ func (c *Client) SubscribePush(ctx context.Context, gatewayID string) (grpc.Serv // ReportRateLimited posts the gateway's periodic rate-limiter rejection summary // to the backend, which feeds the admin console's throttled view and the // high-rate auto-flag. The endpoint carries no user identity: like -// sessions/resolve it rides the trusted internal segment (R3). +// sessions/resolve it rides the trusted internal segment. func (c *Client) ReportRateLimited(ctx context.Context, windowSeconds int, entries []ratelimit.Rejection) error { body := struct { WindowSeconds int `json:"window_seconds"` diff --git a/gateway/internal/config/config.go b/gateway/internal/config/config.go index 3800be6..482284a 100644 --- a/gateway/internal/config/config.go +++ b/gateway/internal/config/config.go @@ -76,13 +76,13 @@ const ( defaultBackendTimeout = 5 * time.Second defaultSessionTTL = 10 * time.Minute defaultSessionCacheMax = 50000 - defaultPushHeartbeatInterval = 10 * time.Second // under the ~15 s edge idle timeout (Stage 17) + defaultPushHeartbeatInterval = 10 * time.Second // under the ~15 s edge idle timeout defaultServiceName = "scrabble-gateway" ) // DefaultMaxBodyBytes is the default request-body cap (GATEWAY_MAX_BODY_BYTES): // 1 MiB — far above any legitimate edge payload (drafts and chat are a few KB) -// yet small enough to stop a cheap memory-amplification upload (R3). +// yet small enough to stop a cheap memory-amplification upload. const DefaultMaxBodyBytes = 1 << 20 // supportedLanguages is the set of game languages a service may declare for the @@ -98,8 +98,8 @@ func DefaultRateLimit() RateLimitConfig { PublicPerMinute: 30, PublicBurst: 10, // Per-user (not per-IP): one user may run several devices, each holding a // Subscribe stream and reloading state on every live event, so the authenticated - // budget is generous (a per-user cap cannot DoS the service). Raised in Stage 17 - // after multi-device play tripped the old 120/40. + // budget is generous (a per-user cap cannot DoS the service). It is raised + // because multi-device play tripped the old 120/40. UserPerMinute: 300, UserBurst: 80, AdminPerMinute: 60, AdminBurst: 20, EmailPer10Min: 5, EmailBurst: 2, diff --git a/gateway/internal/connector/client.go b/gateway/internal/connector/client.go index 1104ca7..bbb0677 100644 --- a/gateway/internal/connector/client.go +++ b/gateway/internal/connector/client.go @@ -84,7 +84,7 @@ func (c *Client) ValidateInitData(ctx context.Context, initData string) (User, e // ValidateLoginWidget verifies Telegram Login Widget data and returns the user // identity, mapping a connector InvalidArgument to ErrInvalidLoginWidget. It backs -// the link.telegram edge operation (Stage 11). +// the link.telegram edge operation. func (c *Client) ValidateLoginWidget(ctx context.Context, data string) (User, error) { resp, err := c.c.ValidateLoginWidget(ctx, &telegramv1.ValidateLoginWidgetRequest{Data: data}) if err != nil { diff --git a/gateway/internal/connectsrv/peerip_test.go b/gateway/internal/connectsrv/peerip_test.go index f54b1cb..10135b1 100644 --- a/gateway/internal/connectsrv/peerip_test.go +++ b/gateway/internal/connectsrv/peerip_test.go @@ -7,7 +7,7 @@ import ( // TestPeerIP covers the client-IP extraction the chat-moderation IP and the per-IP rate // limiter both rely on: the first X-Forwarded-For hop (the real client, once Caddy is -// configured to trust its upstream), falling back to the connection peer (Stage 17). +// configured to trust its upstream), falling back to the connection peer. func TestPeerIP(t *testing.T) { tests := []struct { name string diff --git a/gateway/internal/connectsrv/server.go b/gateway/internal/connectsrv/server.go index f1880d3..bce39a6 100644 --- a/gateway/internal/connectsrv/server.go +++ b/gateway/internal/connectsrv/server.go @@ -35,7 +35,7 @@ import ( const heartbeatKind = "heartbeat" // Limiter classes, the `class` attribute of gateway_rate_limited_total and the -// class field of the periodic rejection report (R3). +// class field of the periodic rejection report. const ( classUser = "user" classPublic = "public" @@ -43,13 +43,13 @@ const ( classAdmin = "admin" ) -// Explicit h2c server sizing (R3, after the R2 stress run questioned the -// implicit defaults). +// Explicit h2c server sizing, made explicit rather than relying on the +// implicit defaults. const ( // h2cMaxConcurrentStreams bounds the open streams per client connection — the // x/net default made explicit. A real client holds one Subscribe stream plus a // few unary calls; only a synthetic load multiplexing many players over one - // transport approaches it. R7 revisits the sizing. + // transport approaches it. h2cMaxConcurrentStreams = 250 // h2cIdleTimeout closes a connection with no open streams. A live Subscribe // stream keeps its connection active, so long-lived clients are unaffected; @@ -151,7 +151,7 @@ func (s *Server) HTTPHandler() http.Handler { // working over h2c (docs/ARCHITECTURE.md §12). In the deployed contour the // front caddy owns the /_gm Basic-Auth and Grafana routing; this mount serves // a non-caddy (local) setup. The per-IP admin limiter class guards it — - // notably a Basic-Auth brute force (R3). + // notably a Basic-Auth brute force. mux.Handle("/_gm/", s.limitAdmin(s.adminProxy)) } else { // With the console disabled here, keep /_gm a 404 so the SPA catch-all below @@ -162,14 +162,14 @@ func (s *Server) HTTPHandler() http.Handler { // Mini App) — the single-origin model (docs/ARCHITECTURE.md §13). Both sit below // the h2c wrap so the Connect edge (a more specific prefix) keeps priority, and // each mount falls back to the app shell (index.html) for the hash router. The - // public landing moved to its own static container behind the contour caddy - // (R3), so the catch-all redirects a stray root hit to the app shell — which + // public landing lives in its own static container behind the contour caddy, + // so the catch-all redirects a stray root hit to the app shell — which // keeps a local no-caddy run usable. mux.Handle("/telegram/", webui.Handler("/telegram/", "index.html")) mux.Handle("/app/", webui.Handler("/app/", "index.html")) mux.Handle("/", http.RedirectHandler("/app/", http.StatusPermanentRedirect)) // Every request body on the public listener is capped (the admin proxy POSTs - // included); the h2c server carries explicit stream/idle sizing (R3). + // included); the h2c server carries explicit stream/idle sizing. return h2c.NewHandler(maxBodyHandler(s.maxBodyBytes, mux), &http2.Server{ MaxConcurrentStreams: h2cMaxConcurrentStreams, IdleTimeout: h2cIdleTimeout, @@ -264,7 +264,7 @@ func (s *Server) Subscribe(ctx context.Context, req *connect.Request[edgev1.Subs // Send an immediate heartbeat so the stream's first byte flushes through the proxy chain // right away and resets edge/client idle timers, instead of the connection sitting silent // until the first tick — which otherwise raced a ~15 s idle timeout and forced a reconnect - // every interval (Stage 17). + // every interval. if err := stream.Send(&edgev1.Event{Kind: heartbeatKind}); err != nil { return err } @@ -294,7 +294,7 @@ func (s *Server) Subscribe(ctx context.Context, req *connect.Request[edgev1.Subs // noteRateLimited accounts one limiter rejection: the aggregate counter, the // per-rejection Debug line and the periodic-report tracker. The operational // signal is the reporter's Warn summary; per-rejection logging stays at Debug so -// a rejection flood cannot flood the log (R3). +// a rejection flood cannot flood the log. func (s *Server) noteRateLimited(ctx context.Context, class, key, msgType string) { s.metrics.recordRateLimited(ctx, class) s.tracker.Add(class, key) @@ -315,7 +315,7 @@ func (s *Server) rejectRateLimited(ctx context.Context, class, key, msgType stri // of its Basic-Auth check (a credential brute force is exactly what it bounds). // It covers the gateway-fronted /_gm mount; in the deployed contour /_gm reaches // the backend through caddy, whose Basic-Auth has no limiter (stock caddy) — see -// docs/ARCHITECTURE.md §12 (R3). +// docs/ARCHITECTURE.md §12. func (s *Server) limitAdmin(next http.Handler) http.Handler { return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { ip := peerIP(r.RemoteAddr, r.Header) @@ -340,8 +340,8 @@ func (s *Server) resolve(ctx context.Context, h http.Header) (string, error) { // An unknown or expired token (a backend 4xx) is the client's problem and // stays silent; anything else — a resolve timeout, a refused connection, a // backend 5xx — is an infra failure misread as "unauthenticated" by the - // client, so surface the cause (the transient resolves seen under load in - // the R2 stress run). The token itself is never logged. + // client, so surface the cause (the transient resolves seen under load). + // The token itself is never logged. var apiErr *backendclient.APIError if !errors.As(err, &apiErr) || apiErr.Status >= http.StatusInternalServerError { s.log.Warn("session resolve failed", zap.Error(err)) diff --git a/gateway/internal/connectsrv/server_test.go b/gateway/internal/connectsrv/server_test.go index d5c8f52..0a4f605 100644 --- a/gateway/internal/connectsrv/server_test.go +++ b/gateway/internal/connectsrv/server_test.go @@ -85,7 +85,7 @@ func TestExecuteAuthedRequiresSession(t *testing.T) { // TestExecuteRateLimitedTracked verifies a limiter rejection returns // ResourceExhausted and lands in the rejection tracker under the public class, -// keyed by the client IP (R3). +// keyed by the client IP. func TestExecuteRateLimitedTracked(t *testing.T) { backendSrv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { _, _ = w.Write([]byte(`{"token":"tok","user_id":"u-1","is_guest":true,"display_name":"Guest"}`)) @@ -135,7 +135,7 @@ func TestExecuteRateLimitedTracked(t *testing.T) { } // TestAdminMountRateLimited verifies the /_gm mount is guarded by the per-IP -// admin limiter class ahead of the proxy's Basic-Auth (R3). +// admin limiter class ahead of the proxy's Basic-Auth. func TestAdminMountRateLimited(t *testing.T) { backendSrv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {})) defer backendSrv.Close() @@ -181,7 +181,7 @@ func TestAdminMountRateLimited(t *testing.T) { // TestExecuteOversizedPayloadRejected verifies the request-body cap: an Execute // message above GATEWAY_MAX_BODY_BYTES is refused at the edge without reaching -// the backend (R3). +// the backend. func TestExecuteOversizedPayloadRejected(t *testing.T) { client, cleanup := newEdge(t, func(w http.ResponseWriter, r *http.Request) { t.Error("backend must not be called for an oversized payload") @@ -198,7 +198,7 @@ func TestExecuteOversizedPayloadRejected(t *testing.T) { } // TestRootRedirectsToApp verifies the gateway no longer serves a landing at "/" -// (it lives in the landing container since R3): a stray root hit is redirected +// (it lives in the landing container): a stray root hit is redirected // to the app shell. func TestRootRedirectsToApp(t *testing.T) { front := httptest.NewServer(connectsrv.NewServer(connectsrv.Deps{}).HTTPHandler()) diff --git a/gateway/internal/transcode/encode.go b/gateway/internal/transcode/encode.go index cd2b249..584a925 100644 --- a/gateway/internal/transcode/encode.go +++ b/gateway/internal/transcode/encode.go @@ -5,6 +5,7 @@ import ( "scrabble/gateway/internal/backendclient" fb "scrabble/pkg/fbs/scrabblefb" + "scrabble/pkg/wire" ) // The encoders build the FlatBuffers response payloads from the backend's typed @@ -80,7 +81,7 @@ func encodeProfile(p backendclient.ProfileResp) []byte { return b.FinishedBytes() } -// encodeLinkResult builds a LinkResult payload (Stage 11). A switched-session token +// encodeLinkResult builds a LinkResult payload. A switched-session token // (a guest initiator whose durable counterpart won) is carried as a nested Session // for the client to adopt; it is omitted otherwise. supportedLangs is the variant // gating set for that switched session — the link flows run on the web, so it is the @@ -138,50 +139,28 @@ func encodeMoveResult(r backendclient.MoveResultResp) []byte { } // encodeState builds a StateView payload. The rack is a vector of alphabet indices and the -// alphabet display table is included only when the backend returned it (Stage 13: the +// alphabet display table is included only when the backend returned it (the // client requests it on a per-variant cache miss). func encodeState(s backendclient.StateResp) []byte { b := flatbuffers.NewBuilder(512) - game := buildGameView(b, s.Game) - rackBytes := make([]byte, len(s.Rack)) - for i, v := range s.Rack { - rackBytes[i] = byte(v) - } - rack := b.CreateByteVector(rackBytes) - hasAlphabet := len(s.Alphabet) > 0 - var alphabet flatbuffers.UOffsetT - if hasAlphabet { - alphabet = buildAlphabet(b, s.Alphabet) - } - fb.StateViewStart(b) - fb.StateViewAddGame(b, game) - fb.StateViewAddSeat(b, int32(s.Seat)) - fb.StateViewAddRack(b, rack) - fb.StateViewAddBagLen(b, int32(s.BagLen)) - fb.StateViewAddHintsRemaining(b, int32(s.HintsRemaining)) - if hasAlphabet { - fb.StateViewAddAlphabet(b, alphabet) - } - b.Finish(fb.StateViewEnd(b)) + b.Finish(wire.BuildStateView(b, toWireState(s))) return b.FinishedBytes() } -// buildAlphabet builds the AlphabetEntry vector for a StateView and returns its offset. -func buildAlphabet(b *flatbuffers.Builder, entries []backendclient.AlphabetEntryJSON) flatbuffers.UOffsetT { - offs := make([]flatbuffers.UOffsetT, len(entries)) - for i, e := range entries { - letter := b.CreateString(e.Letter) - fb.AlphabetEntryStart(b) - fb.AlphabetEntryAddIndex(b, byte(e.Index)) - fb.AlphabetEntryAddLetter(b, letter) - fb.AlphabetEntryAddValue(b, int32(e.Value)) - offs[i] = fb.AlphabetEntryEnd(b) +// toWireState maps a StateResp to the shared wire.StateView. +func toWireState(s backendclient.StateResp) wire.StateView { + alphabet := make([]wire.AlphabetEntry, len(s.Alphabet)) + for i, e := range s.Alphabet { + alphabet[i] = wire.AlphabetEntry{Index: e.Index, Letter: e.Letter, Value: e.Value} } - fb.StateViewStartAlphabetVector(b, len(offs)) - for i := len(offs) - 1; i >= 0; i-- { - b.PrependUOffsetT(offs[i]) + return wire.StateView{ + Game: toWireGame(s.Game), + Seat: s.Seat, + Rack: s.Rack, + BagLen: s.BagLen, + HintsRemaining: s.HintsRemaining, + Alphabet: alphabet, } - return b.EndVector(len(offs)) } // encodeMatch builds a MatchResult payload. @@ -328,80 +307,55 @@ func encodeChatList(r backendclient.ChatListResp) []byte { // buildGameView builds a GameView table and returns its offset. func buildGameView(b *flatbuffers.Builder, g backendclient.GameResp) flatbuffers.UOffsetT { - seatOffs := make([]flatbuffers.UOffsetT, len(g.Seats)) + return wire.BuildGameView(b, toWireGame(g)) +} + +// toWireGame maps a GameResp to the shared wire.GameView. +func toWireGame(g backendclient.GameResp) wire.GameView { + seats := make([]wire.SeatView, len(g.Seats)) for i, s := range g.Seats { - aid := b.CreateString(s.AccountID) - dname := b.CreateString(s.DisplayName) - fb.SeatViewStart(b) - fb.SeatViewAddSeat(b, int32(s.Seat)) - fb.SeatViewAddAccountId(b, aid) - fb.SeatViewAddScore(b, int32(s.Score)) - fb.SeatViewAddHintsUsed(b, int32(s.HintsUsed)) - fb.SeatViewAddIsWinner(b, s.IsWinner) - fb.SeatViewAddDisplayName(b, dname) - seatOffs[i] = fb.SeatViewEnd(b) + seats[i] = wire.SeatView{ + Seat: s.Seat, + AccountID: s.AccountID, + Score: s.Score, + HintsUsed: s.HintsUsed, + IsWinner: s.IsWinner, + DisplayName: s.DisplayName, + } } - fb.GameViewStartSeatsVector(b, len(seatOffs)) - for i := len(seatOffs) - 1; i >= 0; i-- { - b.PrependUOffsetT(seatOffs[i]) + return wire.GameView{ + ID: g.ID, + Variant: g.Variant, + DictVersion: g.DictVersion, + Status: g.Status, + Players: g.Players, + ToMove: g.ToMove, + TurnTimeoutSecs: g.TurnTimeoutSecs, + MoveCount: g.MoveCount, + EndReason: g.EndReason, + Seats: seats, + LastActivityUnix: g.LastActivityUnix, } - seats := b.EndVector(len(seatOffs)) - - id := b.CreateString(g.ID) - variant := b.CreateString(g.Variant) - dictVer := b.CreateString(g.DictVersion) - status := b.CreateString(g.Status) - endReason := b.CreateString(g.EndReason) - - fb.GameViewStart(b) - fb.GameViewAddId(b, id) - fb.GameViewAddVariant(b, variant) - fb.GameViewAddDictVersion(b, dictVer) - fb.GameViewAddStatus(b, status) - fb.GameViewAddPlayers(b, int32(g.Players)) - fb.GameViewAddToMove(b, int32(g.ToMove)) - fb.GameViewAddTurnTimeoutSecs(b, int32(g.TurnTimeoutSecs)) - fb.GameViewAddMoveCount(b, int32(g.MoveCount)) - fb.GameViewAddEndReason(b, endReason) - fb.GameViewAddSeats(b, seats) - fb.GameViewAddLastActivityUnix(b, g.LastActivityUnix) - return fb.GameViewEnd(b) } // buildMoveRecord builds a MoveRecord table and returns its offset. func buildMoveRecord(b *flatbuffers.Builder, m backendclient.MoveRecordResp) flatbuffers.UOffsetT { - tileOffs := make([]flatbuffers.UOffsetT, len(m.Tiles)) + tiles := make([]wire.TileRecord, len(m.Tiles)) for i, t := range m.Tiles { - letter := b.CreateString(t.Letter) - fb.TileRecordStart(b) - fb.TileRecordAddRow(b, int32(t.Row)) - fb.TileRecordAddCol(b, int32(t.Col)) - fb.TileRecordAddLetter(b, letter) - fb.TileRecordAddBlank(b, t.Blank) - tileOffs[i] = fb.TileRecordEnd(b) + tiles[i] = wire.TileRecord{Row: t.Row, Col: t.Col, Letter: t.Letter, Blank: t.Blank} } - fb.MoveRecordStartTilesVector(b, len(tileOffs)) - for i := len(tileOffs) - 1; i >= 0; i-- { - b.PrependUOffsetT(tileOffs[i]) - } - tiles := b.EndVector(len(tileOffs)) - - words := buildStringVector(b, m.Words, fb.MoveRecordStartWordsVector) - - action := b.CreateString(m.Action) - dir := b.CreateString(m.Dir) - fb.MoveRecordStart(b) - fb.MoveRecordAddPlayer(b, int32(m.Player)) - fb.MoveRecordAddAction(b, action) - fb.MoveRecordAddDir(b, dir) - fb.MoveRecordAddMainRow(b, int32(m.MainRow)) - fb.MoveRecordAddMainCol(b, int32(m.MainCol)) - fb.MoveRecordAddTiles(b, tiles) - fb.MoveRecordAddWords(b, words) - fb.MoveRecordAddCount(b, int32(m.Count)) - fb.MoveRecordAddScore(b, int32(m.Score)) - fb.MoveRecordAddTotal(b, int32(m.Total)) - return fb.MoveRecordEnd(b) + return wire.BuildMoveRecord(b, wire.MoveRecord{ + Player: m.Player, + Action: m.Action, + Dir: m.Dir, + MainRow: m.MainRow, + MainCol: m.MainCol, + Tiles: tiles, + Words: m.Words, + Count: m.Count, + Score: m.Score, + Total: m.Total, + }) } // buildStringVector builds a vector of strings using the table-specific diff --git a/gateway/internal/transcode/encode_social.go b/gateway/internal/transcode/encode_social.go index 618e378..eabcbe8 100644 --- a/gateway/internal/transcode/encode_social.go +++ b/gateway/internal/transcode/encode_social.go @@ -5,19 +5,15 @@ import ( "scrabble/gateway/internal/backendclient" fb "scrabble/pkg/fbs/scrabblefb" + "scrabble/pkg/wire" ) -// Stage 8 encoders: friends, blocks, invitations, statistics and GCG. They follow +// Social encoders: friends, blocks, invitations, statistics and GCG. They follow // encode.go's bottom-up rule (build every string/child vector before the table). // buildAccountRef builds an AccountRef table and returns its offset. func buildAccountRef(b *flatbuffers.Builder, r backendclient.AccountRefResp) flatbuffers.UOffsetT { - id := b.CreateString(r.AccountID) - name := b.CreateString(r.DisplayName) - fb.AccountRefStart(b) - fb.AccountRefAddAccountId(b, id) - fb.AccountRefAddDisplayName(b, name) - return fb.AccountRefEnd(b) + return wire.BuildAccountRef(b, wire.AccountRef{AccountID: r.AccountID, DisplayName: r.DisplayName}) } // buildAccountRefVector builds a [AccountRef] vector using the table-specific @@ -110,43 +106,28 @@ func encodeStats(r backendclient.StatsResp) []byte { // buildInvitation builds an Invitation table and returns its offset. func buildInvitation(b *flatbuffers.Builder, inv backendclient.InvitationResp) flatbuffers.UOffsetT { - inviteeOffs := make([]flatbuffers.UOffsetT, len(inv.Invitees)) + invitees := make([]wire.InvitationInvitee, len(inv.Invitees)) for i, iv := range inv.Invitees { - aid := b.CreateString(iv.AccountID) - name := b.CreateString(iv.DisplayName) - resp := b.CreateString(iv.Response) - fb.InvitationInviteeStart(b) - fb.InvitationInviteeAddAccountId(b, aid) - fb.InvitationInviteeAddDisplayName(b, name) - fb.InvitationInviteeAddSeat(b, int32(iv.Seat)) - fb.InvitationInviteeAddResponse(b, resp) - inviteeOffs[i] = fb.InvitationInviteeEnd(b) + invitees[i] = wire.InvitationInvitee{ + AccountID: iv.AccountID, + DisplayName: iv.DisplayName, + Seat: iv.Seat, + Response: iv.Response, + } } - fb.InvitationStartInviteesVector(b, len(inviteeOffs)) - for i := len(inviteeOffs) - 1; i >= 0; i-- { - b.PrependUOffsetT(inviteeOffs[i]) - } - invitees := b.EndVector(len(inviteeOffs)) - - inviter := buildAccountRef(b, inv.Inviter) - id := b.CreateString(inv.ID) - variant := b.CreateString(inv.Variant) - dropout := b.CreateString(inv.DropoutTiles) - status := b.CreateString(inv.Status) - gameID := b.CreateString(inv.GameID) - fb.InvitationStart(b) - fb.InvitationAddId(b, id) - fb.InvitationAddInviter(b, inviter) - fb.InvitationAddInvitees(b, invitees) - fb.InvitationAddVariant(b, variant) - fb.InvitationAddTurnTimeoutSecs(b, int32(inv.TurnTimeoutSecs)) - fb.InvitationAddHintsAllowed(b, inv.HintsAllowed) - fb.InvitationAddHintsPerPlayer(b, int32(inv.HintsPerPlayer)) - fb.InvitationAddDropoutTiles(b, dropout) - fb.InvitationAddStatus(b, status) - fb.InvitationAddGameId(b, gameID) - fb.InvitationAddExpiresAtUnix(b, inv.ExpiresAtUnix) - return fb.InvitationEnd(b) + return wire.BuildInvitation(b, wire.Invitation{ + ID: inv.ID, + Inviter: wire.AccountRef{AccountID: inv.Inviter.AccountID, DisplayName: inv.Inviter.DisplayName}, + Invitees: invitees, + Variant: inv.Variant, + TurnTimeoutSecs: inv.TurnTimeoutSecs, + HintsAllowed: inv.HintsAllowed, + HintsPerPlayer: inv.HintsPerPlayer, + DropoutTiles: inv.DropoutTiles, + Status: inv.Status, + GameID: inv.GameID, + ExpiresAtUnix: inv.ExpiresAtUnix, + }) } // encodeInvitation builds an Invitation payload. diff --git a/gateway/internal/transcode/transcode.go b/gateway/internal/transcode/transcode.go index 386709c..7fd55f1 100644 --- a/gateway/internal/transcode/transcode.go +++ b/gateway/internal/transcode/transcode.go @@ -2,7 +2,7 @@ // maps to a handler that decodes the FlatBuffers request payload, calls the // backend over REST, and encodes the FlatBuffers response. The registry is the // authoritative message_type catalog; new operations are added here following the -// same pattern (PLAN.md Stage 6 vertical slice). +// same vertical-slice pattern. package transcode import ( @@ -69,7 +69,7 @@ type Registry struct { } // TelegramValidator validates Telegram credentials via the connector side-service: -// Mini App launch data (auth) and Login Widget data (linking, Stage 11). +// Mini App launch data (auth) and Login Widget data (linking). // *connector.Client implements it; a nil value disables the telegram auth and // telegram-link paths. type TelegramValidator interface { @@ -115,8 +115,8 @@ func NewRegistry(backend *backendclient.Client, tg TelegramValidator, defaultLan r.ops[MsgDraftGet] = Op{Handler: getDraftHandler(backend), Auth: true} r.ops[MsgDraftSave] = Op{Handler: saveDraftHandler(backend), Auth: true} r.ops[MsgGameHide] = Op{Handler: hideGameHandler(backend), Auth: true} - registerStage8(r, backend) - registerStage11(r, backend, tg, defaultLanguages) + registerSocialOps(r, backend) + registerLinkOps(r, backend, tg, defaultLanguages) return r } @@ -264,7 +264,7 @@ func chatPostHandler(backend *backendclient.Client) Handler { } } -// decodeTiles reads the index-addressed tiles to place from a SubmitPlayRequest (Stage 13). +// decodeTiles reads the index-addressed tiles to place from a SubmitPlayRequest. func decodeTiles(in *fb.SubmitPlayRequest) []backendclient.PlayTileJSON { n := in.TilesLength() tiles := make([]backendclient.PlayTileJSON, 0, n) @@ -282,7 +282,7 @@ func decodeTiles(in *fb.SubmitPlayRequest) []backendclient.PlayTileJSON { return tiles } -// decodeEvalTiles reads the index-addressed tentative tiles from an EvalRequest (Stage 13). +// decodeEvalTiles reads the index-addressed tentative tiles from an EvalRequest. func decodeEvalTiles(in *fb.EvalRequest) []backendclient.PlayTileJSON { n := in.TilesLength() tiles := make([]backendclient.PlayTileJSON, 0, n) @@ -301,7 +301,7 @@ func decodeEvalTiles(in *fb.EvalRequest) []backendclient.PlayTileJSON { } // bytesToInts widens a FlatBuffers ubyte vector (an alphabet-index list) to []int for the -// backend JSON edge (Stage 13: rack-exchange tiles and the word-check query). +// backend JSON edge (rack-exchange tiles and the word-check query). func bytesToInts(bs []byte) []int { out := make([]int, len(bs)) for i, b := range bs { @@ -429,7 +429,7 @@ func nudgeHandler(backend *backendclient.Client) Handler { } } -// getDraftHandler returns the player's saved composition (Stage 17). It reuses +// getDraftHandler returns the player's saved composition. It reuses // GameActionRequest for the game id and wraps the backend's raw JSON in a DraftView. func getDraftHandler(backend *backendclient.Client) Handler { return func(ctx context.Context, req Request) ([]byte, error) { @@ -442,7 +442,7 @@ func getDraftHandler(backend *backendclient.Client) Handler { } } -// saveDraftHandler upserts the player's composition (Stage 17), forwarding the opaque JSON +// saveDraftHandler upserts the player's composition, forwarding the opaque JSON // string verbatim. It echoes an empty DraftView as a well-formed acknowledgement. func saveDraftHandler(backend *backendclient.Client) Handler { return func(ctx context.Context, req Request) ([]byte, error) { @@ -454,7 +454,7 @@ func saveDraftHandler(backend *backendclient.Client) Handler { } } -// hideGameHandler hides a finished game from the caller's own list (Stage 17). It reuses +// hideGameHandler hides a finished game from the caller's own list. It reuses // GameActionRequest for the game id and echoes an Ack. func hideGameHandler(backend *backendclient.Client) Handler { return func(ctx context.Context, req Request) ([]byte, error) { diff --git a/gateway/internal/transcode/transcode_alphabet_test.go b/gateway/internal/transcode/transcode_alphabet_test.go index 3eca9a8..873aac6 100644 --- a/gateway/internal/transcode/transcode_alphabet_test.go +++ b/gateway/internal/transcode/transcode_alphabet_test.go @@ -15,7 +15,7 @@ import ( // TestGameStateIncludesAlphabet checks the include_alphabet flag reaches the backend and // the returned alphabet table plus the index rack (a blank is 255) are encoded into the -// StateView (Stage 13). +// StateView. func TestGameStateIncludesAlphabet(t *testing.T) { backend, cleanup := fakeBackend(t, func(w http.ResponseWriter, r *http.Request) { if got := r.URL.Query().Get("include_alphabet"); got != "true" { @@ -85,7 +85,7 @@ func TestGameStateOmitsAlphabetByDefault(t *testing.T) { } // TestSubmitPlayForwardsIndexTiles checks PlayTile indices reach the backend as integer -// letter fields in the JSON body, blank flag preserved (Stage 13). +// letter fields in the JSON body, blank flag preserved. func TestSubmitPlayForwardsIndexTiles(t *testing.T) { var body struct { Dir string `json:"dir"` @@ -135,7 +135,7 @@ func TestSubmitPlayForwardsIndexTiles(t *testing.T) { } // TestCheckWordForwardsIndices checks the word-check query rides as repeated ?idx= params -// and the decoded concrete word echoes back (Stage 13). +// and the decoded concrete word echoes back. func TestCheckWordForwardsIndices(t *testing.T) { backend, cleanup := fakeBackend(t, func(w http.ResponseWriter, r *http.Request) { if got := r.URL.Query()["idx"]; len(got) != 3 || got[0] != "2" || got[2] != "19" { @@ -167,7 +167,7 @@ func TestCheckWordForwardsIndices(t *testing.T) { } // TestExchangeForwardsIndices checks rack-exchange indices (blank 255) reach the backend -// body (Stage 13). +// body. func TestExchangeForwardsIndices(t *testing.T) { var body struct { Tiles []int `json:"tiles"` diff --git a/gateway/internal/transcode/transcode_draft_test.go b/gateway/internal/transcode/transcode_draft_test.go index 7a9a70b..5fe0d49 100644 --- a/gateway/internal/transcode/transcode_draft_test.go +++ b/gateway/internal/transcode/transcode_draft_test.go @@ -13,7 +13,7 @@ import ( ) // TestDraftSaveForwardsRawJSON checks the save handler forwards the client's composition JSON -// to the backend verbatim (the "no double-encode" contract, Stage 17) with the user header. +// to the backend verbatim (the "no double-encode" contract) with the user header. func TestDraftSaveForwardsRawJSON(t *testing.T) { const body = `{"rack_order":"1,0","board_tiles":[{"row":7,"col":7,"letter":"Q","blank":false}]}` backend, cleanup := fakeBackend(t, func(w http.ResponseWriter, r *http.Request) { diff --git a/gateway/internal/transcode/transcode_link.go b/gateway/internal/transcode/transcode_link.go index 1bf0fbf..007ae02 100644 --- a/gateway/internal/transcode/transcode_link.go +++ b/gateway/internal/transcode/transcode_link.go @@ -7,7 +7,7 @@ import ( fb "scrabble/pkg/fbs/scrabblefb" ) -// Stage 11 account linking & merge message types. The email ops carry the costly- +// Account linking & merge message types. The email ops carry the costly- // email rate flag; the telegram ops validate Login Widget data through the // connector (registered only when the connector is configured). All are // authenticated. The merge ops are the explicit irreversible step, gated in the UI @@ -20,11 +20,11 @@ const ( MsgLinkTelegramMerge = "link.telegram.merge" ) -// registerStage11 adds the linking & merge operations. The telegram ops need the +// registerLinkOps adds the linking & merge operations. The telegram ops need the // connector's Login Widget validator, so they are registered only when tg is set. // supportedLangs is the variant gating set for a switched link session (the link // flows run on the web, so the gateway default set). -func registerStage11(r *Registry, backend *backendclient.Client, tg TelegramValidator, supportedLangs []string) { +func registerLinkOps(r *Registry, backend *backendclient.Client, tg TelegramValidator, supportedLangs []string) { r.ops[MsgLinkEmailRequest] = Op{Handler: linkEmailRequestHandler(backend), Auth: true, Email: true} r.ops[MsgLinkEmailConfirm] = Op{Handler: linkEmailConfirmHandler(backend, supportedLangs), Auth: true, Email: true} r.ops[MsgLinkEmailMerge] = Op{Handler: linkEmailMergeHandler(backend, supportedLangs), Auth: true, Email: true} diff --git a/gateway/internal/transcode/transcode_social.go b/gateway/internal/transcode/transcode_social.go index b89c65c..eef958d 100644 --- a/gateway/internal/transcode/transcode_social.go +++ b/gateway/internal/transcode/transcode_social.go @@ -7,9 +7,9 @@ import ( fb "scrabble/pkg/fbs/scrabblefb" ) -// Stage 8 message types: friends (incl. the one-time code path), per-user blocks, +// Message types: friends (incl. the one-time code path), per-user blocks, // friend-game invitations, profile editing + email binding, statistics and GCG -// export. All are authenticated. Registered by registerStage8 from NewRegistry. +// export. All are authenticated. Registered by registerSocialOps from NewRegistry. const ( MsgFriendsList = "friends.list" MsgFriendsIncoming = "friends.incoming" @@ -33,9 +33,9 @@ const ( MsgGameGCG = "game.gcg" ) -// registerStage8 adds the Stage 8 social, account and history operations to the +// registerSocialOps adds the social, account and history operations to the // registry (all authenticated; the email-bind ops carry the costly-email flag). -func registerStage8(r *Registry, backend *backendclient.Client) { +func registerSocialOps(r *Registry, backend *backendclient.Client) { r.ops[MsgFriendsList] = Op{Handler: friendsListHandler(backend), Auth: true} r.ops[MsgFriendsIncoming] = Op{Handler: friendsIncomingHandler(backend), Auth: true} r.ops[MsgFriendsOutgoing] = Op{Handler: friendsOutgoingHandler(backend), Auth: true} diff --git a/gateway/internal/transcode/transcode_test.go b/gateway/internal/transcode/transcode_test.go index aeb0d9b..f66cb48 100644 --- a/gateway/internal/transcode/transcode_test.go +++ b/gateway/internal/transcode/transcode_test.go @@ -151,7 +151,7 @@ func gameActionPayload(gameID string) []byte { } // TestHideGameForwardsToBackend checks game.hide reuses GameActionRequest, POSTs to the -// game's /hide endpoint with the caller's id, and echoes an Ack (Stage 17). +// game's /hide endpoint with the caller's id, and echoes an Ack. func TestHideGameForwardsToBackend(t *testing.T) { var hit bool backend, cleanup := fakeBackend(t, func(w http.ResponseWriter, r *http.Request) { diff --git a/gateway/internal/webui/webui.go b/gateway/internal/webui/webui.go index 2336265..0192096 100644 --- a/gateway/internal/webui/webui.go +++ b/gateway/internal/webui/webui.go @@ -3,13 +3,13 @@ // The committed dist/ holds only a placeholder index.html so the gateway module // compiles with a plain `go build` (and in CI) without a UI build. The production // gateway image replaces dist/ with the real Vite build — minus landing.html, which -// ships in the separate landing container since R3 — before compiling (see +// ships in the separate landing container — before compiling (see // gateway/Dockerfile), so the binary ships the UI inside it. Because Vite is built // with a relative asset base, one build serves under any path: the game SPA is // mounted at /app/ (web) and /telegram/ (the Telegram Mini App) — the single-origin // model in docs/ARCHITECTURE.md §13. // -// Caching (Stage 17): Vite emits hash-named files under assets/, so those are immutable and +// Caching: Vite emits hash-named files under assets/, so those are immutable and // cached hard (a reload/relaunch is a cache hit, not a re-download); the HTML shells carry // no-cache so a new deploy is picked up immediately. package webui diff --git a/loadtest/README.md b/loadtest/README.md index 8b071fa..d12845f 100644 --- a/loadtest/README.md +++ b/loadtest/README.md @@ -1,6 +1,6 @@ -# loadtest — R2 stress harness +# loadtest — stress harness -Reusable load harness for the pre-release stress pass (`PRERELEASE.md` R2/R7). It +Reusable load harness for the pre-release stress pass. 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. @@ -91,4 +91,4 @@ runs unconditionally. The harness shares the host CPU with the contour, so the early-pass resource baseline is read with the harness's own container series in mind; a cleaner number on separate -hardware is an R7 goal. The moderate ramp keeps the generator from being the bottleneck. +hardware is future work. The moderate ramp keeps the generator from being the bottleneck. diff --git a/loadtest/cmd/loadtest/main.go b/loadtest/cmd/loadtest/main.go index 43b0fa0..9d58798 100644 --- a/loadtest/cmd/loadtest/main.go +++ b/loadtest/cmd/loadtest/main.go @@ -1,4 +1,4 @@ -// Command loadtest is the R2 reusable load harness. It seeds a large account +// Command loadtest is the reusable load harness. It seeds a large account // population with pre-created sessions directly in the backend Postgres, then drives // virtual players through the gateway edge protocol (real games assembled via // invitations, legal moves generated locally by the embedded solver), and a diff --git a/loadtest/internal/moves/moves.go b/loadtest/internal/moves/moves.go index 33b959d..a64e864 100644 --- a/loadtest/internal/moves/moves.go +++ b/loadtest/internal/moves/moves.go @@ -18,11 +18,11 @@ import ( "scrabble/loadtest/internal/edge" ) -// blankIndex is the rack/exchange sentinel for a blank tile on the wire (Stage 13). +// blankIndex is the rack/exchange sentinel for a blank tile on the wire. const blankIndex = 255 // variantSpec maps an edge variant label to its ruleset constructor and committed -// DAWG filename (the descriptive names kept by R1). +// DAWG filename (using descriptive names). type variantSpec struct { ruleset func() *rules.Ruleset dawg string diff --git a/loadtest/internal/report/report.go b/loadtest/internal/report/report.go index 1133c62..22ac78e 100644 --- a/loadtest/internal/report/report.go +++ b/loadtest/internal/report/report.go @@ -1,5 +1,5 @@ // Package report collects per-operation latency, result-code and live-event counts -// across all virtual players and renders a text summary for the R2 trip report. It +// across all virtual players and renders a text summary for the trip report. It // is safe for concurrent use. Latencies go into fixed buckets (a Prometheus-style // histogram) so percentiles cost no per-sample memory at load-test scale. package report diff --git a/loadtest/internal/scenario/scenario.go b/loadtest/internal/scenario/scenario.go index e822722..ccd964a 100644 --- a/loadtest/internal/scenario/scenario.go +++ b/loadtest/internal/scenario/scenario.go @@ -2,7 +2,7 @@ // assembles real games through the invitation flow, then runs each player's turn // loop (poll state, replay history, generate a legal move with the embedded solver, // submit it) plus a fraction of secondary operations. It exposes the moderate -// realistic ramp agreed for the R2 early pass and a separate gateway-hammer. +// realistic ramp and a separate gateway-hammer. package scenario import ( @@ -41,7 +41,7 @@ type RealisticConfig struct { SecondaryProb float64 // chance per tick of a non-move operation } -// DefaultRealistic returns the moderate ramp agreed for the R2 early pass: 50 -> 200 +// DefaultRealistic returns the moderate ramp: 50 -> 200 // -> 500 concurrent players, ~12 minutes per step, ~1 op/s per player. func DefaultRealistic() RealisticConfig { return RealisticConfig{ diff --git a/pkg/README.md b/pkg/README.md index c3f4431..62d9574 100644 --- a/pkg/README.md +++ b/pkg/README.md @@ -16,12 +16,12 @@ fbs/scrabblefb/ # committed generated Go for the schema - **`proto/push/v1`** is the single gRPC server-stream the backend exposes and the gateway subscribes to (`Event{user_id, kind, payload, event_id}`); the `payload` is an opaque FlatBuffers body the gateway forwards verbatim. -- **`proto/telegram/v1`** is the Telegram connector's RPC contract (Stage 9; Stage 11 - added `ValidateLoginWidget` for the web Login Widget sign-in). +- **`proto/telegram/v1`** is the Telegram connector's RPC contract (including + `ValidateLoginWidget` for the web Login Widget sign-in). - **`fbs`** holds the client↔gateway request/response and event payloads as FlatBuffers tables. The backend encodes the push payloads from these types; the gateway transcodes the rest to and from the backend's JSON; the UI generates - TypeScript from the same `.fbs` (Stage 7). + TypeScript from the same `.fbs`. ## Generated code diff --git a/pkg/fbs/scrabble.fbs b/pkg/fbs/scrabble.fbs index 462bcd0..bfc4bc6 100644 --- a/pkg/fbs/scrabble.fbs +++ b/pkg/fbs/scrabble.fbs @@ -1,9 +1,9 @@ -// FlatBuffers payloads for the client <-> gateway edge transport (Stage 6). +// FlatBuffers payloads for the client <-> gateway edge transport. // // Every request and response that rides inside the Connect envelope // (gateway/proto/edge) `payload` field, and every push Event payload, is one of // these tables. They are the binary wire contract shared with the UI, which -// generates TypeScript from this same schema (Stage 7). A single namespace keeps +// generates TypeScript from this same schema. A single namespace keeps // nested tables (GameView inside MoveResult / MatchResult) free of // cross-namespace imports. Keep this schema and the backend JSON DTOs in lockstep // — the gateway transcodes one to the other. @@ -25,7 +25,7 @@ table TileRecord { } // PlayTile is one inbound tile to place, addressed by its alphabet index rather than a -// concrete letter (Stage 13). For a blank, letter carries the designated letter's index +// concrete letter. For a blank, letter carries the designated letter's index // and blank is true. The board coordinate is its target square. table PlayTile { row:int; @@ -34,7 +34,7 @@ table PlayTile { blank:bool; } -// AlphabetEntry is one letter of a variant's alphabet, sent for display only (Stage 13): +// AlphabetEntry is one letter of a variant's alphabet, sent for display only: // index is the engine alphabet-index byte the wire uses for this letter, letter is the // concrete character and value is its tile score. The client caches the table per variant. table AlphabetEntry { @@ -67,7 +67,7 @@ table GameView { end_reason:string; seats:[SeatView]; // last_activity_unix is the lobby sort key: the current turn's start for an active - // game, the finish time for a finished one (Stage 17). + // game, the finish time for a finished one. last_activity_unix:long; } @@ -152,7 +152,7 @@ table Profile { // --- game (authenticated) --- // SubmitPlayRequest places tiles in a direction on the player's turn. tiles are addressed -// by alphabet index (Stage 13). +// by alphabet index. table SubmitPlayRequest { game_id:string; dir:string; @@ -161,8 +161,8 @@ table SubmitPlayRequest { // MoveResult is the outcome of a committed move: the move and the post-move game. rack and // bag_len carry the actor's own post-move private state — their refilled rack (alphabet indices, -// Stage 13; a blank is the sentinel index 255) and the bag size after drawing — so the mover -// renders the next state straight from this response without a follow-up game.state (R4; added +// a blank is the sentinel index 255) and the bag size after drawing — so the mover +// renders the next state straight from this response without a follow-up game.state (added // trailing — backward-compatible). table MoveResult { move:MoveRecord; @@ -172,7 +172,7 @@ table MoveResult { } // StateRequest asks for the requesting player's view of a game. include_alphabet asks the -// backend to embed the variant's AlphabetEntry table in the reply (Stage 13); the client +// backend to embed the variant's AlphabetEntry table in the reply; the client // sets it only on a per-variant cache miss so the table is not resent on every poll. table StateRequest { game_id:string; @@ -180,7 +180,7 @@ table StateRequest { } // StateView is a player's view of a game: the shared summary plus their private rack, the -// bag size and their remaining hint budget. rack carries alphabet indices (Stage 13); a +// bag size and their remaining hint budget. rack carries alphabet indices; a // blank tile is the sentinel index 255. alphabet is present only when the request set // include_alphabet (a display table the client caches per variant). table StateView { @@ -198,14 +198,14 @@ table GameActionRequest { } // ExchangeRequest swaps the listed rack tiles back into the bag. tiles are alphabet -// indices (Stage 13); a blank is the sentinel index 255. +// indices; a blank is the sentinel index 255. table ExchangeRequest { game_id:string; tiles:[ubyte]; } // EvalRequest previews a tentative play without committing it. tiles are addressed by -// alphabet index (Stage 13). +// alphabet index. table EvalRequest { game_id:string; dir:string; @@ -220,7 +220,7 @@ table EvalResult { } // CheckWordRequest looks a word up in the game's pinned dictionary. word is a sequence of -// alphabet indices (Stage 13); the client constrains input to the variant's alphabet. +// alphabet indices; the client constrains input to the variant's alphabet. table CheckWordRequest { game_id:string; word:[ubyte]; @@ -245,7 +245,7 @@ table HintResult { hints_remaining:int; } -// DraftRequest saves the player's client-side composition for a game (Stage 17): a single +// DraftRequest saves the player's client-side composition for a game: a single // JSON string of {rack_order, board_tiles} the client serializes itself, so the wire carries // no tile array. The gateway forwards json verbatim to the backend, which owns its shape. table DraftRequest { @@ -306,7 +306,7 @@ table ChatList { messages:[ChatMessage]; } -// --- Stage 8: account, statistics, friends, blocks, invitations, history --- +// --- account, statistics, friends, blocks, invitations, history --- // AccountRef is a referenced account with its display name resolved — the shared // shape for friends, blocked users and invitation participants. @@ -330,7 +330,7 @@ table UpdateProfileRequest { notifications_in_app_only:bool = true; } -// --- account linking & merge (Stage 11, authenticated) --- +// --- account linking & merge (authenticated) --- // LinkEmailRequest mails a confirm-code to email for a later link or merge. The // code is always sent (no pre-send "taken" signal), so a probe cannot enumerate @@ -400,7 +400,7 @@ table IncomingRequestList { // OutgoingRequestList is the accounts the caller has already requested and cannot // (re-)request: a live pending request or one the addressee declined. The game's -// "add to friends" item reads it to stay disabled across reloads (Stage 17). +// "add to friends" item reads it to stay disabled across reloads. table OutgoingRequestList { requests:[AccountRef]; } @@ -479,12 +479,12 @@ table GcgExport { // --- push event payloads --- // YourTurnEvent signals that it is now the recipient's turn. The trailing fields enrich the -// out-of-app push (Stage 17): opponent_name is the player who just moved, last_action is their +// out-of-app push: opponent_name is the player who just moved, last_action is their // move kind ("play"/"pass"/"exchange"/...), last_word is the main word of a scoring play (empty // otherwise), and score_line is the recipient-first running score (e.g. "120:95:80"). They are // appended (FlatBuffers-optional), so an older reader that only needs game_id/deadline is unaffected. // move_count is the post-move count (matching the opponent_moved GameView): the client uses it to -// tell whether its cached game already reflects the move, falling back to a refetch on a gap (R4). +// tell whether its cached game already reflects the move, falling back to a refetch on a gap. table YourTurnEvent { game_id:string; deadline_unix:long; @@ -496,10 +496,10 @@ table YourTurnEvent { } // GameOverEvent signals that a game the recipient is seated in has finished, driving the -// out-of-app "game over" push (Stage 17). result is the outcome from the recipient's own +// out-of-app "game over" push. result is the outcome from the recipient's own // perspective ("won"/"lost"/"draw"); score_line is the recipient-first final score. game is the // final post-game summary (adjusted scores after rack penalties + the winner flag), so the client -// settles the finished game from the event without a refetch (R4; added trailing). +// settles the finished game from the event without a refetch (added trailing). table GameOverEvent { game_id:string; result:string; @@ -508,16 +508,11 @@ table GameOverEvent { } // OpponentMovedEvent carries a move another seat just committed as a delta the client applies to -// its cached game without a refetch (R4): move is the decoded play/pass/exchange (the same record +// its cached game without a refetch: move is the decoded play/pass/exchange (the same record // game.history returns), game is the post-move summary (per-seat scores, to_move, move_count, -// status) and bag_len is the bag size after the draw. The leading seat/action/score/total scalars -// are the pre-R4 summary, now redundant with move/game and kept only for wire back-compat. +// status) and bag_len is the bag size after the draw. table OpponentMovedEvent { game_id:string; - seat:int; - action:string; - score:int; - total:int; move:MoveRecord; game:GameView; bag_len:int; @@ -531,8 +526,8 @@ table NudgeEvent { // MatchFoundEvent signals that an auto-match pairing (or robot substitution) started a game the // recipient is seated in. state is the recipient's full initial view of the new game (empty board, -// dealt rack), so the client navigates straight in from the event with no follow-up fetch (R4; -// added trailing — an older reader still reads just game_id). +// dealt rack), so the client navigates straight in from the event with no follow-up fetch (added +// trailing — an older reader still reads just game_id). table MatchFoundEvent { game_id:string; state:StateView; @@ -543,7 +538,7 @@ table MatchFoundEvent { // discriminator ("friend_request", "friend_added", "friend_declined", "invitation", // "game_started"); the client re-fetches its lobby counters (and, for a requester // watching a game, its friend state) on any of them. To let the client update its lobby without a -// follow-up fetch (R4), each event also carries the payload its kind changed: account for the +// follow-up fetch, each event also carries the payload its kind changed: account for the // friend_* kinds (the requester/friend), invitation for "invitation" (the new invitation) and // state for "game_started" (the started game's initial view, like match_found). Only the field // matching kind is set (all added trailing — backward-compatible). diff --git a/pkg/fbs/scrabblefb/OpponentMovedEvent.go b/pkg/fbs/scrabblefb/OpponentMovedEvent.go index 8b0e5c0..37a5561 100644 --- a/pkg/fbs/scrabblefb/OpponentMovedEvent.go +++ b/pkg/fbs/scrabblefb/OpponentMovedEvent.go @@ -49,52 +49,8 @@ func (rcv *OpponentMovedEvent) GameId() []byte { return nil } -func (rcv *OpponentMovedEvent) Seat() int32 { - o := flatbuffers.UOffsetT(rcv._tab.Offset(6)) - if o != 0 { - return rcv._tab.GetInt32(o + rcv._tab.Pos) - } - return 0 -} - -func (rcv *OpponentMovedEvent) MutateSeat(n int32) bool { - return rcv._tab.MutateInt32Slot(6, n) -} - -func (rcv *OpponentMovedEvent) Action() []byte { - o := flatbuffers.UOffsetT(rcv._tab.Offset(8)) - if o != 0 { - return rcv._tab.ByteVector(o + rcv._tab.Pos) - } - return nil -} - -func (rcv *OpponentMovedEvent) Score() int32 { - o := flatbuffers.UOffsetT(rcv._tab.Offset(10)) - if o != 0 { - return rcv._tab.GetInt32(o + rcv._tab.Pos) - } - return 0 -} - -func (rcv *OpponentMovedEvent) MutateScore(n int32) bool { - return rcv._tab.MutateInt32Slot(10, n) -} - -func (rcv *OpponentMovedEvent) Total() int32 { - o := flatbuffers.UOffsetT(rcv._tab.Offset(12)) - if o != 0 { - return rcv._tab.GetInt32(o + rcv._tab.Pos) - } - return 0 -} - -func (rcv *OpponentMovedEvent) MutateTotal(n int32) bool { - return rcv._tab.MutateInt32Slot(12, n) -} - func (rcv *OpponentMovedEvent) Move(obj *MoveRecord) *MoveRecord { - o := flatbuffers.UOffsetT(rcv._tab.Offset(14)) + o := flatbuffers.UOffsetT(rcv._tab.Offset(6)) if o != 0 { x := rcv._tab.Indirect(o + rcv._tab.Pos) if obj == nil { @@ -107,7 +63,7 @@ func (rcv *OpponentMovedEvent) Move(obj *MoveRecord) *MoveRecord { } func (rcv *OpponentMovedEvent) Game(obj *GameView) *GameView { - o := flatbuffers.UOffsetT(rcv._tab.Offset(16)) + o := flatbuffers.UOffsetT(rcv._tab.Offset(8)) if o != 0 { x := rcv._tab.Indirect(o + rcv._tab.Pos) if obj == nil { @@ -120,7 +76,7 @@ func (rcv *OpponentMovedEvent) Game(obj *GameView) *GameView { } func (rcv *OpponentMovedEvent) BagLen() int32 { - o := flatbuffers.UOffsetT(rcv._tab.Offset(18)) + o := flatbuffers.UOffsetT(rcv._tab.Offset(10)) if o != 0 { return rcv._tab.GetInt32(o + rcv._tab.Pos) } @@ -128,35 +84,23 @@ func (rcv *OpponentMovedEvent) BagLen() int32 { } func (rcv *OpponentMovedEvent) MutateBagLen(n int32) bool { - return rcv._tab.MutateInt32Slot(18, n) + return rcv._tab.MutateInt32Slot(10, n) } func OpponentMovedEventStart(builder *flatbuffers.Builder) { - builder.StartObject(8) + builder.StartObject(4) } func OpponentMovedEventAddGameId(builder *flatbuffers.Builder, gameId flatbuffers.UOffsetT) { builder.PrependUOffsetTSlot(0, flatbuffers.UOffsetT(gameId), 0) } -func OpponentMovedEventAddSeat(builder *flatbuffers.Builder, seat int32) { - builder.PrependInt32Slot(1, seat, 0) -} -func OpponentMovedEventAddAction(builder *flatbuffers.Builder, action flatbuffers.UOffsetT) { - builder.PrependUOffsetTSlot(2, flatbuffers.UOffsetT(action), 0) -} -func OpponentMovedEventAddScore(builder *flatbuffers.Builder, score int32) { - builder.PrependInt32Slot(3, score, 0) -} -func OpponentMovedEventAddTotal(builder *flatbuffers.Builder, total int32) { - builder.PrependInt32Slot(4, total, 0) -} func OpponentMovedEventAddMove(builder *flatbuffers.Builder, move flatbuffers.UOffsetT) { - builder.PrependUOffsetTSlot(5, flatbuffers.UOffsetT(move), 0) + builder.PrependUOffsetTSlot(1, flatbuffers.UOffsetT(move), 0) } func OpponentMovedEventAddGame(builder *flatbuffers.Builder, game flatbuffers.UOffsetT) { - builder.PrependUOffsetTSlot(6, flatbuffers.UOffsetT(game), 0) + builder.PrependUOffsetTSlot(2, flatbuffers.UOffsetT(game), 0) } func OpponentMovedEventAddBagLen(builder *flatbuffers.Builder, bagLen int32) { - builder.PrependInt32Slot(7, bagLen, 0) + builder.PrependInt32Slot(3, bagLen, 0) } func OpponentMovedEventEnd(builder *flatbuffers.Builder) flatbuffers.UOffsetT { return builder.EndObject() diff --git a/pkg/proto/push/v1/push.pb.go b/pkg/proto/push/v1/push.pb.go index 50f9581..97b1996 100644 --- a/pkg/proto/push/v1/push.pb.go +++ b/pkg/proto/push/v1/push.pb.go @@ -84,7 +84,7 @@ type Event struct { Kind string `protobuf:"bytes,2,opt,name=kind,proto3" json:"kind,omitempty"` Payload []byte `protobuf:"bytes,3,opt,name=payload,proto3" json:"payload,omitempty"` EventId string `protobuf:"bytes,4,opt,name=event_id,json=eventId,proto3" json:"event_id,omitempty"` - // language routes an out-of-app push to a specific per-language bot (Stage 17): for a game + // language routes an out-of-app push to a specific per-language bot: for a game // event it carries the game's language ("en"/"ru") so the notification comes from the game's // bot rather than the recipient's last-login bot. Empty falls back to the recipient's service // language at the gateway. diff --git a/pkg/proto/push/v1/push.proto b/pkg/proto/push/v1/push.proto index 4cd67c7..ae0bf18 100644 --- a/pkg/proto/push/v1/push.proto +++ b/pkg/proto/push/v1/push.proto @@ -33,7 +33,7 @@ message Event { string kind = 2; bytes payload = 3; string event_id = 4; - // language routes an out-of-app push to a specific per-language bot (Stage 17): for a game + // language routes an out-of-app push to a specific per-language bot: for a game // event it carries the game's language ("en"/"ru") so the notification comes from the game's // bot rather than the recipient's last-login bot. Empty falls back to the recipient's service // language at the gateway. diff --git a/pkg/proto/telegram/v1/telegram.proto b/pkg/proto/telegram/v1/telegram.proto index 87a567c..f70e50f 100644 --- a/pkg/proto/telegram/v1/telegram.proto +++ b/pkg/proto/telegram/v1/telegram.proto @@ -23,7 +23,7 @@ service Telegram { // ValidateLoginWidget verifies Telegram Login Widget authorization data (the web // sign-in flow, HMAC under SHA-256(bot_token)) and returns the authenticated // user. The gateway calls it during the link.telegram edge operation to attach a - // Telegram identity to an existing account (Stage 11). + // Telegram identity to an existing account. rpc ValidateLoginWidget(ValidateLoginWidgetRequest) returns (ValidateLoginWidgetResponse); // Notify delivers an out-of-app notification for a backend push event. The // gateway calls it only for a recipient with no live in-app stream (so the @@ -32,11 +32,11 @@ service Telegram { // payload; unrenderable kinds are skipped (delivered=false). rpc Notify(NotifyRequest) returns (NotifyResponse); // SendToUser sends an arbitrary text message to one user through the bot the - // request selects by language (admin use, wired in Stage 10). delivered is false + // request selects by language (admin use). delivered is false // when the user has not started that bot. rpc SendToUser(SendToUserRequest) returns (SendResponse); // SendToGameChannel posts an arbitrary text message to the game channel of the - // bot the request selects by language (admin use, wired in Stage 10); the channel + // bot the request selects by language (admin use); the channel // ids live only in the connector configuration. rpc SendToGameChannel(SendToGameChannelRequest) returns (SendResponse); } diff --git a/pkg/proto/telegram/v1/telegram_grpc.pb.go b/pkg/proto/telegram/v1/telegram_grpc.pb.go index f0bb97c..8099e7d 100644 --- a/pkg/proto/telegram/v1/telegram_grpc.pb.go +++ b/pkg/proto/telegram/v1/telegram_grpc.pb.go @@ -50,7 +50,7 @@ type TelegramClient interface { // ValidateLoginWidget verifies Telegram Login Widget authorization data (the web // sign-in flow, HMAC under SHA-256(bot_token)) and returns the authenticated // user. The gateway calls it during the link.telegram edge operation to attach a - // Telegram identity to an existing account (Stage 11). + // Telegram identity to an existing account. ValidateLoginWidget(ctx context.Context, in *ValidateLoginWidgetRequest, opts ...grpc.CallOption) (*ValidateLoginWidgetResponse, error) // Notify delivers an out-of-app notification for a backend push event. The // gateway calls it only for a recipient with no live in-app stream (so the @@ -59,11 +59,11 @@ type TelegramClient interface { // payload; unrenderable kinds are skipped (delivered=false). Notify(ctx context.Context, in *NotifyRequest, opts ...grpc.CallOption) (*NotifyResponse, error) // SendToUser sends an arbitrary text message to one user through the bot the - // request selects by language (admin use, wired in Stage 10). delivered is false + // request selects by language (admin use). delivered is false // when the user has not started that bot. SendToUser(ctx context.Context, in *SendToUserRequest, opts ...grpc.CallOption) (*SendResponse, error) // SendToGameChannel posts an arbitrary text message to the game channel of the - // bot the request selects by language (admin use, wired in Stage 10); the channel + // bot the request selects by language (admin use); the channel // ids live only in the connector configuration. SendToGameChannel(ctx context.Context, in *SendToGameChannelRequest, opts ...grpc.CallOption) (*SendResponse, error) } @@ -139,7 +139,7 @@ type TelegramServer interface { // ValidateLoginWidget verifies Telegram Login Widget authorization data (the web // sign-in flow, HMAC under SHA-256(bot_token)) and returns the authenticated // user. The gateway calls it during the link.telegram edge operation to attach a - // Telegram identity to an existing account (Stage 11). + // Telegram identity to an existing account. ValidateLoginWidget(context.Context, *ValidateLoginWidgetRequest) (*ValidateLoginWidgetResponse, error) // Notify delivers an out-of-app notification for a backend push event. The // gateway calls it only for a recipient with no live in-app stream (so the @@ -148,11 +148,11 @@ type TelegramServer interface { // payload; unrenderable kinds are skipped (delivered=false). Notify(context.Context, *NotifyRequest) (*NotifyResponse, error) // SendToUser sends an arbitrary text message to one user through the bot the - // request selects by language (admin use, wired in Stage 10). delivered is false + // request selects by language (admin use). delivered is false // when the user has not started that bot. SendToUser(context.Context, *SendToUserRequest) (*SendResponse, error) // SendToGameChannel posts an arbitrary text message to the game channel of the - // bot the request selects by language (admin use, wired in Stage 10); the channel + // bot the request selects by language (admin use); the channel // ids live only in the connector configuration. SendToGameChannel(context.Context, *SendToGameChannelRequest) (*SendResponse, error) mustEmbedUnimplementedTelegramServer() diff --git a/pkg/telemetry/telemetry.go b/pkg/telemetry/telemetry.go index 6e76606..c7e9cd3 100644 --- a/pkg/telemetry/telemetry.go +++ b/pkg/telemetry/telemetry.go @@ -8,8 +8,7 @@ // required locally or in CI), "stdout" (debugging) and "otlp" (gRPC export to a // collector). The OTLP endpoint and security are taken from the standard // OTEL_EXPORTER_OTLP_* environment variables read by the SDK, so no bespoke -// configuration is introduced; the collector itself is stood up with the deploy -// (PLAN.md Stage 14). +// configuration is introduced; the collector itself is stood up with the deploy. package telemetry import ( diff --git a/pkg/wire/build.go b/pkg/wire/build.go new file mode 100644 index 0000000..8837a49 --- /dev/null +++ b/pkg/wire/build.go @@ -0,0 +1,299 @@ +// Package wire holds the FlatBuffers builders for the nested wire tables that both +// the backend's push-event encoder (backend/internal/notify) and the gateway's edge +// transcoder (gateway/internal/transcode) emit. The two callers resolve their own +// source types (the backend's domain payloads, the gateway's REST DTOs) into the +// neutral structs below and delegate the actual FlatBuffers construction here, so the +// shared tables (GameView, MoveRecord, StateView, AccountRef, Invitation) have a +// single encoding definition that cannot drift between the two paths. +// +// FlatBuffers is built bottom-up: every string and child vector is created before the +// table that references it, and no two tables/vectors are under construction at once. +// Each builder returns the offset of the table (or vector) it built; the caller embeds +// that offset in a parent table or finishes the buffer with it. +package wire + +import ( + flatbuffers "github.com/google/flatbuffers/go" + + fb "scrabble/pkg/fbs/scrabblefb" +) + +// SeatView is one seat's public standing in a GameView. +type SeatView struct { + Seat int + AccountID string + Score int + HintsUsed int + IsWinner bool + DisplayName string +} + +// GameView is the shared, non-private game summary. +type GameView struct { + ID string + Variant string + DictVersion string + Status string + Players int + ToMove int + TurnTimeoutSecs int + MoveCount int + EndReason string + Seats []SeatView + LastActivityUnix int64 +} + +// TileRecord is one tile in a decoded MoveRecord (the concrete letter, "?" for a blank +// read from a hand). +type TileRecord struct { + Row int + Col int + Letter string + Blank bool +} + +// MoveRecord is one decoded move (a committed play, a hint preview). Action and Dir are +// the already-stringified move kind and direction. +type MoveRecord struct { + Player int + Action string + Dir string + MainRow int + MainCol int + Tiles []TileRecord + Words []string + Count int + Score int + Total int +} + +// AlphabetEntry is one letter of a variant's alphabet (Index is the wire alphabet-index +// byte; a value of 255 is the blank sentinel elsewhere). +type AlphabetEntry struct { + Index int + Letter string + Value int +} + +// StateView is a player's view of a game: the shared summary plus their private rack +// (wire alphabet indices), bag size and hint budget. Alphabet is set only when the +// recipient may not have cached the variant's display table yet. +type StateView struct { + Game GameView + Seat int + Rack []int + BagLen int + HintsRemaining int + Alphabet []AlphabetEntry +} + +// AccountRef is a referenced account with its display name resolved. +type AccountRef struct { + AccountID string + DisplayName string +} + +// InvitationInvitee is one invited player's seat and response inside an Invitation. +type InvitationInvitee struct { + AccountID string + DisplayName string + Seat int + Response string +} + +// Invitation is a friend-game invitation with its settings and invitees. +type Invitation struct { + ID string + Inviter AccountRef + Invitees []InvitationInvitee + Variant string + TurnTimeoutSecs int + HintsAllowed bool + HintsPerPlayer int + DropoutTiles string + Status string + GameID string + ExpiresAtUnix int64 +} + +// BuildGameView builds a GameView table from g and returns its offset. +func BuildGameView(b *flatbuffers.Builder, g GameView) flatbuffers.UOffsetT { + seatOffs := make([]flatbuffers.UOffsetT, len(g.Seats)) + for i, s := range g.Seats { + aid := b.CreateString(s.AccountID) + dname := b.CreateString(s.DisplayName) + fb.SeatViewStart(b) + fb.SeatViewAddSeat(b, int32(s.Seat)) + fb.SeatViewAddAccountId(b, aid) + fb.SeatViewAddScore(b, int32(s.Score)) + fb.SeatViewAddHintsUsed(b, int32(s.HintsUsed)) + fb.SeatViewAddIsWinner(b, s.IsWinner) + fb.SeatViewAddDisplayName(b, dname) + seatOffs[i] = fb.SeatViewEnd(b) + } + fb.GameViewStartSeatsVector(b, len(seatOffs)) + for i := len(seatOffs) - 1; i >= 0; i-- { + b.PrependUOffsetT(seatOffs[i]) + } + seats := b.EndVector(len(seatOffs)) + + id := b.CreateString(g.ID) + variant := b.CreateString(g.Variant) + dictVer := b.CreateString(g.DictVersion) + status := b.CreateString(g.Status) + endReason := b.CreateString(g.EndReason) + + fb.GameViewStart(b) + fb.GameViewAddId(b, id) + fb.GameViewAddVariant(b, variant) + fb.GameViewAddDictVersion(b, dictVer) + fb.GameViewAddStatus(b, status) + fb.GameViewAddPlayers(b, int32(g.Players)) + fb.GameViewAddToMove(b, int32(g.ToMove)) + fb.GameViewAddTurnTimeoutSecs(b, int32(g.TurnTimeoutSecs)) + fb.GameViewAddMoveCount(b, int32(g.MoveCount)) + fb.GameViewAddEndReason(b, endReason) + fb.GameViewAddSeats(b, seats) + fb.GameViewAddLastActivityUnix(b, g.LastActivityUnix) + return fb.GameViewEnd(b) +} + +// BuildMoveRecord builds a MoveRecord table from m and returns its offset. +func BuildMoveRecord(b *flatbuffers.Builder, m MoveRecord) flatbuffers.UOffsetT { + tileOffs := make([]flatbuffers.UOffsetT, len(m.Tiles)) + for i, t := range m.Tiles { + letter := b.CreateString(t.Letter) + fb.TileRecordStart(b) + fb.TileRecordAddRow(b, int32(t.Row)) + fb.TileRecordAddCol(b, int32(t.Col)) + fb.TileRecordAddLetter(b, letter) + fb.TileRecordAddBlank(b, t.Blank) + tileOffs[i] = fb.TileRecordEnd(b) + } + fb.MoveRecordStartTilesVector(b, len(tileOffs)) + for i := len(tileOffs) - 1; i >= 0; i-- { + b.PrependUOffsetT(tileOffs[i]) + } + tiles := b.EndVector(len(tileOffs)) + + wordOffs := make([]flatbuffers.UOffsetT, len(m.Words)) + for i, w := range m.Words { + wordOffs[i] = b.CreateString(w) + } + fb.MoveRecordStartWordsVector(b, len(wordOffs)) + for i := len(wordOffs) - 1; i >= 0; i-- { + b.PrependUOffsetT(wordOffs[i]) + } + words := b.EndVector(len(wordOffs)) + + action := b.CreateString(m.Action) + dir := b.CreateString(m.Dir) + fb.MoveRecordStart(b) + fb.MoveRecordAddPlayer(b, int32(m.Player)) + fb.MoveRecordAddAction(b, action) + fb.MoveRecordAddDir(b, dir) + fb.MoveRecordAddMainRow(b, int32(m.MainRow)) + fb.MoveRecordAddMainCol(b, int32(m.MainCol)) + fb.MoveRecordAddTiles(b, tiles) + fb.MoveRecordAddWords(b, words) + fb.MoveRecordAddCount(b, int32(m.Count)) + fb.MoveRecordAddScore(b, int32(m.Score)) + fb.MoveRecordAddTotal(b, int32(m.Total)) + return fb.MoveRecordEnd(b) +} + +// BuildStateViewAlphabet builds the AlphabetEntry vector embedded in a StateView and +// returns its offset. +func BuildStateViewAlphabet(b *flatbuffers.Builder, entries []AlphabetEntry) flatbuffers.UOffsetT { + offs := make([]flatbuffers.UOffsetT, len(entries)) + for i, e := range entries { + letter := b.CreateString(e.Letter) + fb.AlphabetEntryStart(b) + fb.AlphabetEntryAddIndex(b, byte(e.Index)) + fb.AlphabetEntryAddLetter(b, letter) + fb.AlphabetEntryAddValue(b, int32(e.Value)) + offs[i] = fb.AlphabetEntryEnd(b) + } + fb.StateViewStartAlphabetVector(b, len(offs)) + for i := len(offs) - 1; i >= 0; i-- { + b.PrependUOffsetT(offs[i]) + } + return b.EndVector(len(offs)) +} + +// BuildStateView builds a StateView table from s and returns its offset. The alphabet +// table is embedded only when s.Alphabet is non-empty. +func BuildStateView(b *flatbuffers.Builder, s StateView) flatbuffers.UOffsetT { + game := BuildGameView(b, s.Game) + rackBytes := make([]byte, len(s.Rack)) + for i, v := range s.Rack { + rackBytes[i] = byte(v) + } + rack := b.CreateByteVector(rackBytes) + hasAlphabet := len(s.Alphabet) > 0 + var alphabet flatbuffers.UOffsetT + if hasAlphabet { + alphabet = BuildStateViewAlphabet(b, s.Alphabet) + } + fb.StateViewStart(b) + fb.StateViewAddGame(b, game) + fb.StateViewAddSeat(b, int32(s.Seat)) + fb.StateViewAddRack(b, rack) + fb.StateViewAddBagLen(b, int32(s.BagLen)) + fb.StateViewAddHintsRemaining(b, int32(s.HintsRemaining)) + if hasAlphabet { + fb.StateViewAddAlphabet(b, alphabet) + } + return fb.StateViewEnd(b) +} + +// BuildAccountRef builds an AccountRef table from a and returns its offset. +func BuildAccountRef(b *flatbuffers.Builder, a AccountRef) flatbuffers.UOffsetT { + aid := b.CreateString(a.AccountID) + name := b.CreateString(a.DisplayName) + fb.AccountRefStart(b) + fb.AccountRefAddAccountId(b, aid) + fb.AccountRefAddDisplayName(b, name) + return fb.AccountRefEnd(b) +} + +// BuildInvitation builds an Invitation table from inv and returns its offset. +func BuildInvitation(b *flatbuffers.Builder, inv Invitation) flatbuffers.UOffsetT { + inviteeOffs := make([]flatbuffers.UOffsetT, len(inv.Invitees)) + for i, iv := range inv.Invitees { + aid := b.CreateString(iv.AccountID) + name := b.CreateString(iv.DisplayName) + resp := b.CreateString(iv.Response) + fb.InvitationInviteeStart(b) + fb.InvitationInviteeAddAccountId(b, aid) + fb.InvitationInviteeAddDisplayName(b, name) + fb.InvitationInviteeAddSeat(b, int32(iv.Seat)) + fb.InvitationInviteeAddResponse(b, resp) + inviteeOffs[i] = fb.InvitationInviteeEnd(b) + } + fb.InvitationStartInviteesVector(b, len(inviteeOffs)) + for i := len(inviteeOffs) - 1; i >= 0; i-- { + b.PrependUOffsetT(inviteeOffs[i]) + } + invitees := b.EndVector(len(inviteeOffs)) + + inviter := BuildAccountRef(b, inv.Inviter) + id := b.CreateString(inv.ID) + variant := b.CreateString(inv.Variant) + dropout := b.CreateString(inv.DropoutTiles) + status := b.CreateString(inv.Status) + gameID := b.CreateString(inv.GameID) + fb.InvitationStart(b) + fb.InvitationAddId(b, id) + fb.InvitationAddInviter(b, inviter) + fb.InvitationAddInvitees(b, invitees) + fb.InvitationAddVariant(b, variant) + fb.InvitationAddTurnTimeoutSecs(b, int32(inv.TurnTimeoutSecs)) + fb.InvitationAddHintsAllowed(b, inv.HintsAllowed) + fb.InvitationAddHintsPerPlayer(b, int32(inv.HintsPerPlayer)) + fb.InvitationAddDropoutTiles(b, dropout) + fb.InvitationAddStatus(b, status) + fb.InvitationAddGameId(b, gameID) + fb.InvitationAddExpiresAtUnix(b, inv.ExpiresAtUnix) + return fb.InvitationEnd(b) +} diff --git a/platform/telegram/README.md b/platform/telegram/README.md index f8dd987..07f83d5 100644 --- a/platform/telegram/README.md +++ b/platform/telegram/README.md @@ -34,7 +34,7 @@ route by an **operator-chosen** `language` (unrelated to login). - **Bot chat.** `/start ` (and the chat menu button) reply with a Mini App launch button; a deep-link payload routes the launch to a game / invitation / friend code. -- **Admin messaging** (wired in Stage 10). `SendToUser` and `SendToGameChannel` send +- **Admin messaging.** `SendToUser` and `SendToGameChannel` send arbitrary text to one user or a game channel through the bot the request selects by `language` (an operator choice in the admin console). @@ -47,7 +47,7 @@ Telegram-specific. `pkg/proto/telegram/v1`, service `Telegram`: `ValidateInitData`, `ValidateLoginWidget`, `Notify`, `SendToUser`, `SendToGameChannel`. Generated Go is -committed under `pkg`. `ValidateLoginWidget` (Stage 11) verifies Telegram **Login +committed under `pkg`. `ValidateLoginWidget` verifies Telegram **Login Widget** web sign-in data — HMAC under `SHA-256(bot_token)`, distinct from initData (`internal/loginwidget`) — for attaching a Telegram identity to an account from a browser. @@ -103,7 +103,7 @@ all egress through a VPN sidecar (`deploy/docker-compose.yml`, mirroring `../../15-puzzle`). It needs no public ingress — it long-polls Telegram and answers internal gRPC at `telegram:9091` on the shared `edge` network. The host reverse proxy routes public traffic to the **gateway** port only, which serves the Mini App under -`/telegram/`. The full multi-service deploy lands with Stage 12. +`/telegram/`. The full multi-service deploy is `deploy/docker-compose.yml`. A real end-to-end Telegram smoke needs a BotFather bot, its token, a public HTTPS Mini App origin, and the connector container; the unit tests cover the wire format, diff --git a/platform/telegram/internal/connector/server.go b/platform/telegram/internal/connector/server.go index 0b00d5b..3755392 100644 --- a/platform/telegram/internal/connector/server.go +++ b/platform/telegram/internal/connector/server.go @@ -1,6 +1,6 @@ // Package connector implements the Telegram gRPC service (pkg/proto/telegram/v1): // the gateway calls ValidateInitData (Mini App auth) and Notify (out-of-app push); -// the admin surface (Stage 10) calls SendToUser and SendToGameChannel. The generic +// the admin surface calls SendToUser and SendToGameChannel. The generic // methods address a recipient by the identity external_id, so a future platform // connector can implement the same service. // @@ -99,7 +99,7 @@ func (s *Server) ValidateInitData(ctx context.Context, req *telegramv1.ValidateI // ValidateLoginWidget verifies Login Widget authorization data against each bot's // token in turn and returns the user identity, for attaching a Telegram identity to -// an existing account (Stage 11). +// an existing account. func (s *Server) ValidateLoginWidget(ctx context.Context, req *telegramv1.ValidateLoginWidgetRequest) (*telegramv1.ValidateLoginWidgetResponse, error) { var lastErr error for _, lang := range s.order { diff --git a/platform/telegram/internal/initdata/validator.go b/platform/telegram/internal/initdata/validator.go index 6e96da8..d09c8e3 100644 --- a/platform/telegram/internal/initdata/validator.go +++ b/platform/telegram/internal/initdata/validator.go @@ -26,7 +26,7 @@ const defaultMaxAge = 24 * time.Hour // User is the identity extracted from a validated initData payload. ExternalID is // the Telegram user id used as the identities external_id; LanguageCode seeds a -// new account's preferred language (Stage 9). +// new account's preferred language. type User struct { ExternalID string Username string diff --git a/platform/telegram/internal/loginwidget/validator.go b/platform/telegram/internal/loginwidget/validator.go index 1b5ee90..61ba888 100644 --- a/platform/telegram/internal/loginwidget/validator.go +++ b/platform/telegram/internal/loginwidget/validator.go @@ -1,6 +1,6 @@ // Package loginwidget validates Telegram Login Widget authorization data, the // web (non-Mini-App) sign-in flow used to attach a Telegram identity to an existing -// account during linking (Stage 11). Like initdata it lives in the connector +// account during linking. Like initdata it lives in the connector // because the secret is derived from the bot token, held only here // (ARCHITECTURE.md §12); the gateway calls the connector's ValidateLoginWidget RPC. // diff --git a/platform/telegram/internal/render/render.go b/platform/telegram/internal/render/render.go index 396eafa..01dcef0 100644 --- a/platform/telegram/internal/render/render.go +++ b/platform/telegram/internal/render/render.go @@ -53,7 +53,7 @@ func Render(kind string, payload []byte, lang string) (Message, bool) { return Message{}, false } -// yourTurnText renders the enriched "your turn" body (Stage 17), voiced as the opponent who +// yourTurnText renders the enriched "your turn" body, voiced as the opponent who // just moved ("{name}: my move — «WORD». Score 120:95"). It falls back to the plain phrase when // the opponent name is missing (an older backend, or an unresolved name). func yourTurnText(ev *scrabblefb.YourTurnEvent, p phrases) string { @@ -76,7 +76,7 @@ func yourTurnText(ev *scrabblefb.YourTurnEvent, p phrases) string { } } -// gameOverText renders the "game over" body (Stage 17) from the recipient's own perspective. +// gameOverText renders the "game over" body from the recipient's own perspective. func gameOverText(ev *scrabblefb.GameOverEvent, p phrases) string { score := string(ev.ScoreLine()) switch string(ev.Result()) { diff --git a/ui/README.md b/ui/README.md index d24c980..b29e40f 100644 --- a/ui/README.md +++ b/ui/README.md @@ -4,10 +4,10 @@ Pure-HTML5 game client — **plain Svelte 5 (runes) + TypeScript + Vite**, no SvelteKit. Talks to the `gateway` over **Connect-RPC + FlatBuffers**; embeddable in platform webviews and packageable to native via Capacitor. -Stage 7 ships the **playable slice**: sign in (guest / email), the "my games" lobby, +The **playable slice**: sign in (guest / email), the "my games" lobby, auto-match, the board (place tiles by drag or tap, pass, exchange, resign), hint, word-check + complaint, per-game chat and nudge, the live in-app stream, i18n (en/ru), -theme, and the profile. **Stage 8** adds friends/blocks (with one-time friend codes), +theme, and the profile. **Social** surfaces add friends/blocks (with one-time friend codes), friend-game invitations, profile editing + email binding, the statistics screen, the lobby notification badge, and the in-game history + GCG export (share or download, finished games only). @@ -26,11 +26,11 @@ pnpm codegen # regenerate src/gen from edge.proto + scrabble.fbs (dev-time) ``` `GATEWAY_URL` overrides the dev proxy target; `VITE_GATEWAY_URL` sets the runtime -gateway origin for a packaged (non-proxied) build. `VITE_TELEGRAM_BOT_ID` (Stage 11) +gateway origin for a packaged (non-proxied) build. `VITE_TELEGRAM_BOT_ID` enables the "Link Telegram" web sign-in (the Login Widget) — inert until the site domain is registered with BotFather (`/setdomain`); `VITE_TELEGRAM_LINK` is the -share-to-Telegram deep-link base (Stage 9). `VITE_TELEGRAM_GAME_CHANNEL_NAME_EN` / `VITE_TELEGRAM_GAME_CHANNEL_NAME_RU` -are the per-language "Play in Telegram" links shown on the landing page (Stage 17). +share-to-Telegram deep-link base. `VITE_TELEGRAM_GAME_CHANNEL_NAME_EN` / `VITE_TELEGRAM_GAME_CHANNEL_NAME_RU` +are the per-language "Play in Telegram" links shown on the landing page. The build has **two entries**: the game SPA (`index.html`, served at `/app/` and `/telegram/`) and a lightweight landing page (`landing.html`, served at `/`). @@ -40,7 +40,7 @@ The build has **two entries**: the game SPA (`index.html`, served at `/app/` and A single Connect `Execute(message_type, payload)` carries every unary op; the request and response bodies are **FlatBuffers** tables (`pkg/fbs/scrabble.fbs`) in `payload`. The session token rides in `Authorization: Bearer`; a domain failure comes back in -`result_code`. `Subscribe` is the live event stream; R4 made its game events carry a state **delta** +`result_code`. `Subscribe` is the live event stream; its game events carry a state **delta** that `lib/gamedelta.ts` applies to the per-game cache (`lib/gamecache.ts`), so a move renders without a follow-up `game.state` (a gap falls back to a refetch). `lib/transport.ts` is the real client; `lib/mock/` is an in-memory fake selected by `MODE === 'mock'` (and tree-shaken @@ -48,7 +48,7 @@ out of production). Both speak the plain `lib/model.ts` types via `lib/codec.ts` **No board on the wire:** `StateView` is a summary + rack only, so the client reconstructs the 15×15 board by replaying the decoded move journal (`game.history`). -**The play loop is alphabet-agnostic (Stage 13):** the rack and the play / exchange / +**The play loop is alphabet-agnostic:** the rack and the play / exchange / word-check requests carry **alphabet indices**, and the client caches each variant's `(index, letter, value)` table — sent once behind `StateRequest.include_alphabet` — in `lib/alphabet.ts`, rendering the rack and blank chooser from it. **Premium squares** diff --git a/ui/e2e/game.spec.ts b/ui/e2e/game.spec.ts index d2c6383..b426192 100644 --- a/ui/e2e/game.spec.ts +++ b/ui/e2e/game.spec.ts @@ -16,7 +16,7 @@ async function openGame(page: Page): Promise { await expect(page.locator('.pane')).toHaveCount(1); } -test('offline shows the Connecting indicator and softly disables server actions (Stage 17)', async ({ page }) => { +test('offline shows the Connecting indicator and softly disables server actions', async ({ page }) => { await openGame(page); // The exchange/draw tab is a server action; on my turn with tiles in the bag it is live. const draw = page.locator('.tab').first(); @@ -56,7 +56,7 @@ test('a placed tile is saved as a draft and restored on reopening the game', asy await page.waitForTimeout(600); // let the debounced draft save flush to the mock store // Leave the game and reopen it. The mock keeps the saved composition, so the pending tile is - // restored without re-placing it (Stage 17 #4/#6). + // restored without re-placing it. await page.evaluate(() => (location.hash = '/')); await page.getByRole('button', { name: /Ann/ }).click(); await expect(page.locator('[data-cell]').first()).toBeVisible(); @@ -77,7 +77,7 @@ test('a pending tile recalls on double-tap, not on a single tap', async ({ page await page.locator('[data-cell]:not(.filled)').nth(30).click(); await expect(page.locator('[data-cell].pending')).toHaveCount(1); - // A single tap must NOT recall it (changed in Stage 17 — recall was too easy to trigger). + // A single tap must NOT recall it (recall was too easy to trigger). await page.waitForTimeout(350); // clear the double-tap window from the placing tap await page.locator('[data-cell].pending').first().click(); await expect(page.locator('[data-cell].pending')).toHaveCount(1); @@ -158,7 +158,7 @@ test('dropping the game ends it and shows the result', async ({ page }) => { await expect(page.locator('.status .over')).toBeVisible(); }); -test('a placed tile drags from one board cell to another (Stage 17 relocation)', async ({ page }) => { +test('a placed tile drags from one board cell to another (relocation)', async ({ page }) => { await openGame(page); await page.locator('.rack .tile').first().click(); await page.locator('[data-cell]:not(.filled)').nth(30).click(); @@ -181,7 +181,7 @@ test('a placed tile drags from one board cell to another (Stage 17 relocation)', expect(to).not.toBe(from); }); -test('chat and word-check open as their own screens and back to the game (Stage 17)', async ({ page }) => { +test('chat and word-check open as their own screens and back to the game', async ({ page }) => { await openGame(page); await page.locator('.burger').click(); diff --git a/ui/e2e/landing.spec.ts b/ui/e2e/landing.spec.ts index 7e6df0a..e0b6ff4 100644 --- a/ui/e2e/landing.spec.ts +++ b/ui/e2e/landing.spec.ts @@ -1,7 +1,7 @@ import { expect, test } from './fixtures'; // The landing page is a separate Vite entry (landing.html), served at "/" in production while -// the game SPA lives at /app/ and /telegram/ (Stage 17). In dev it is reachable at /landing.html. +// the game SPA lives at /app/ and /telegram/. In dev it is reachable at /landing.html. test('landing shows the pitch, switches language via the dropdown, and toggles theme', async ({ page }) => { await page.goto('/landing.html'); diff --git a/ui/e2e/social.spec.ts b/ui/e2e/social.spec.ts index 072202c..2fd42c3 100644 --- a/ui/e2e/social.spec.ts +++ b/ui/e2e/social.spec.ts @@ -1,6 +1,6 @@ import { expect, test, type Page } from './fixtures'; -// Stage 8 social / account / history surfaces against the mock transport (no backend). +// Social / account / history surfaces against the mock transport (no backend). // The mock profile is a durable account, so friends, invitations, stats and the GCG // export are reachable from the seeded fixture. @@ -154,7 +154,7 @@ test('game: an opponent who is already a friend shows a disabled "in friends"', await loginLobby(page); await page.getByRole('button', { name: /Kaya/ }).click(); // the finished game vs Kaya, a seeded friend await page.locator('.burger').first().click(); - // The in-game friend item is derived from the server's friend list (Stage 17): a friend reads + // The in-game friend item is derived from the server's friend list: a friend reads // a disabled "✓ in friends", not the addable "Add to friends". const inFriends = page.getByRole('button', { name: /in friends/i }); await expect(inFriends).toBeVisible(); @@ -210,7 +210,7 @@ test('chat: the message field shows on your turn, the nudge replaces it otherwis await page.getByRole('button', { name: /Ann/ }).click(); // g1: your turn await page.locator('.burger').first().click(); await page.getByRole('button', { name: 'Chat' }).click(); - // On your turn the message field + Send are shown and the nudge is hidden (Stage 17); + // On your turn the message field + Send are shown and the nudge is hidden; // chat and nudge are mutually exclusive by turn. Icon-only controls expose their action // through the aria-label. await expect(page.getByRole('button', { name: 'Send' })).toBeVisible(); diff --git a/ui/scripts/bundle-size.mjs b/ui/scripts/bundle-size.mjs index 3ccb9ef..76046ef 100644 --- a/ui/scripts/bundle-size.mjs +++ b/ui/scripts/bundle-size.mjs @@ -10,10 +10,9 @@ // - shared (svelte+i18n): near-static framework runtime; only drifts on a dep/Svelte bump. // - landing own: the landing's own code; kept minimal. // Today ~74 KB (app entry) + ~23 KB (shared) = ~97 KB for the app; the landing's own chunk is -// ~2 KB. Lazy-loading was analysed and rejected for R5 (no total-size win — every chunk still +// ~2 KB. Lazy-loading was analysed and rejected (no total-size win — every chunk still // ships and is summed — plus added request latency); the bulk is the Connect/FlatBuffers -// transport runtime + generated bindings + the Svelte runtime, irreducible within scope. See -// PRERELEASE.md R5 for the full rationale. +// transport runtime + generated bindings + the Svelte runtime, irreducible within scope. import { readFileSync, existsSync } from 'node:fs'; import { gzipSync } from 'node:zlib'; import { join } from 'node:path'; diff --git a/ui/src/Landing.svelte b/ui/src/Landing.svelte index 64fd2bd..142874e 100644 --- a/ui/src/Landing.svelte +++ b/ui/src/Landing.svelte @@ -6,7 +6,7 @@ import { aboutContent } from './lib/aboutContent'; import { telegramChannelLink } from './lib/landing'; - // Standalone landing page (Stage 17), the public entry at "/" (the game SPA lives at /app/ and + // Standalone landing page, the public entry at "/" (the game SPA lives at /app/ and // /telegram/). It reuses the app's theme/i18n/prefs leaf modules — not the app store — so it // stays light. Theme is EPHEMERAL here (no auto, no persistence): it starts from the system // scheme and the icon toggles light<->dark in memory. Language IS persisted (synced with the app). @@ -192,7 +192,7 @@ font-size: 1.05rem; } /* The Telegram entry is just the bigger logo (no button chrome, no caption); the link - keeps an aria-label for assistive tech (Stage 17). */ + keeps an aria-label for assistive tech. */ .tg { align-self: center; display: inline-flex; diff --git a/ui/src/app.css b/ui/src/app.css index bc94eda..9498f8e 100644 --- a/ui/src/app.css +++ b/ui/src/app.css @@ -42,10 +42,10 @@ --gap: 8px; --pad: 12px; /* Height Telegram's native nav overlays at the top in fullscreen; set from the SDK's - content-safe-area inset (Stage 17), 0 elsewhere. */ + content-safe-area inset, 0 elsewhere. */ --tg-content-top: 0px; /* Telegram device safe-area top (the notch); TG's own nav controls sit between it and - --tg-content-top, so the in-app header aligns to that band (Stage 17), 0 elsewhere. */ + --tg-content-top, so the in-app header aligns to that band, 0 elsewhere. */ --tg-safe-top: 0px; --font: system-ui, -apple-system, "Segoe UI", Roboto, "Helvetica Neue", Arial, "Noto Sans", "Liberation Sans", sans-serif; diff --git a/ui/src/components/Header.svelte b/ui/src/components/Header.svelte index 844d263..9cb64bc 100644 --- a/ui/src/components/Header.svelte +++ b/ui/src/components/Header.svelte @@ -110,7 +110,7 @@ (--tg-safe-top) and --tg-content-top. Our header spans that full band (so the layout below is unchanged) and centres the title + menu as a pair (hamburger right of the title) within it, BELOW the notch — lining them up vertically with Telegram's own back/menu controls, - which sit in the band's corners (Stage 17). */ + which sit in the band's corners. */ :global(html.tg-fullscreen) .bar { min-height: var(--tg-content-top); box-sizing: border-box; @@ -119,7 +119,7 @@ /* +12px of vertical breathing room (6 above / 6 below the centred content, on top of the notch) so Telegram's native controls aren't flush against our header. Applied as padding because the bar is sized by its content here, not by min-height (owner review - tweaks, Stage 17). */ + tweaks). */ padding-top: calc(var(--tg-safe-top) + 6px); padding-bottom: 6px; } diff --git a/ui/src/components/Modal.svelte b/ui/src/components/Modal.svelte index 5317616..c0adfea 100644 --- a/ui/src/components/Modal.svelte +++ b/ui/src/components/Modal.svelte @@ -24,7 +24,7 @@ // bottomSheet anchors a tall sheet (the chat) to the bottom and lifts it above the // keyboard with a transform (kb), driven by the visual viewport — a compositor-only // move, so neither the page behind nor the sheet relayouts as the keyboard animates - // (Stage 17). The backdrop is not resized in this mode (no per-event reflow). + // The backdrop is not resized in this mode (no per-event reflow). let vh = $state(0); let top = $state(0); let kb = $state(0); @@ -95,7 +95,7 @@ } /* Bottom-sheet mode (the chat): a wide sheet pinned to the bottom that lifts above the soft keyboard via a transform (--kb) — compositor-only, so the page behind and the - sheet itself do not relayout as the keyboard animates (Stage 17). */ + sheet itself do not relayout as the keyboard animates. */ .backdrop.bottom { align-items: flex-end; padding: 0; diff --git a/ui/src/components/Screen.svelte b/ui/src/components/Screen.svelte index 6c13a57..d63388b 100644 --- a/ui/src/components/Screen.svelte +++ b/ui/src/components/Screen.svelte @@ -31,11 +31,11 @@ // The promotional banner is feature-gated OFF until it is polished after release. The flag is // a compile-time `false`, so the {#if} branch — and with it the AdBanner import and its - // banner.ts logic — is dead-code-eliminated from the production bundle (Stage 17). Flip to + // banner.ts logic — is dead-code-eliminated from the production bundle. Flip to // true to bring it back. const SHOW_AD_BANNER = false; - // Edge-swipe back (Stage 17): a left-edge rightward drag returns to `back`, the standard + // Edge-swipe back: a left-edge rightward drag returns to `back`, the standard // mobile gesture. Listened at the window in the CAPTURE phase so the board's own pointer // handlers (which capture/stop the event) can never swallow it; armed only from the very // left edge (<=24px), touch/pen only, so it never competes with the board's gestures. @@ -72,7 +72,7 @@ flex-direction: column; /* Fit the visible viewport (set from visualViewport, app.svelte.ts) so a screen with a bottom input — chat, word-check — stays above an open soft keyboard without the page - scrolling; falls back to the full height where the var is unset (Stage 17). */ + scrolling; falls back to the full height where the var is unset. */ height: var(--vvh, 100%); } .content { diff --git a/ui/src/game/Board.svelte b/ui/src/game/Board.svelte index fd68828..d40275a 100644 --- a/ui/src/game/Board.svelte +++ b/ui/src/game/Board.svelte @@ -94,7 +94,7 @@ return () => cancelAnimationFrame(raf); }); - // Pinch zoom (Stage 17): a two-finger spread zooms in toward the pinch midpoint, a pinch + // Pinch zoom: a two-finger spread zooms in toward the pinch midpoint, a pinch // close zooms out. preventDefault fires only for two touches, so the one-finger native // scroll of the zoomed board is left untouched. It maps to the same two-state zoom as // double-tap, toggling toward the midpoint cell. @@ -278,7 +278,7 @@ .cell.pending { background: var(--tile-pending); /* The placed tile owns the pointer so it can be dragged to relocate it (even on the zoomed - board) instead of the touch starting a board pan (Stage 17). */ + board) instead of the touch starting a board pan. */ touch-action: none; } /* Lines-off variant: a gapless checkerboard. The 1px grid gaps (and the cell-line they diff --git a/ui/src/game/Chat.svelte b/ui/src/game/Chat.svelte index 10ec271..985288b 100644 --- a/ui/src/game/Chat.svelte +++ b/ui/src/game/Chat.svelte @@ -15,7 +15,7 @@ messages: ChatMessage[]; myId: string; busy: boolean; - // Chat and nudge are mutually exclusive by turn (Stage 17): on the player's own turn the + // Chat and nudge are mutually exclusive by turn: on the player's own turn the // message field + send are shown (and nudging makes no sense — there is no one to // hurry); on the opponent's turn only the nudge button shows. While the hourly nudge // cooldown is active the nudge is disabled with an "awaiting reply" caption. @@ -72,7 +72,7 @@ gap: 10px; /* Fill the chat screen; the list scrolls and the input pins to the bottom. The screen fits the visual viewport (--vvh), so an open keyboard simply shrinks it and the input - stays visible — no modal relayout, no page jump (Stage 17). */ + stays visible — no modal relayout, no page jump. */ flex: 1; min-height: 0; padding: 10px var(--pad); diff --git a/ui/src/game/ChatScreen.svelte b/ui/src/game/ChatScreen.svelte index abcadf7..82cb2be 100644 --- a/ui/src/game/ChatScreen.svelte +++ b/ui/src/game/ChatScreen.svelte @@ -7,7 +7,7 @@ import { t } from '../lib/i18n/index.svelte'; import type { ChatMessage, StateView } from '../lib/model'; - // The chat is its own screen (Stage 17), so the soft keyboard simply resizes the viewport with + // The chat is its own screen, so the soft keyboard simply resizes the viewport with // the input pinned to the bottom — no modal relayout jank. It loads the game state (for the // turn-based chat/nudge toggle) and the message list, and clears the unread badge while open. let { id }: { id: string } = $props(); diff --git a/ui/src/game/CheckScreen.svelte b/ui/src/game/CheckScreen.svelte index e59e0d2..410093a 100644 --- a/ui/src/game/CheckScreen.svelte +++ b/ui/src/game/CheckScreen.svelte @@ -8,7 +8,7 @@ import { canCheckWord, sanitizeCheckWord } from '../lib/checkword'; import type { Variant } from '../lib/model'; - // Word-check on its own screen (Stage 17): unlimited dictionary lookups, each with a + // Word-check on its own screen: unlimited dictionary lookups, each with a // complaint, off the board so the soft keyboard never relayouts the play area. let { id }: { id: string } = $props(); diff --git a/ui/src/game/Game.svelte b/ui/src/game/Game.svelte index 1c45789..65cee59 100644 --- a/ui/src/game/Game.svelte +++ b/ui/src/game/Game.svelte @@ -112,7 +112,7 @@ } let draftSaveTimer: ReturnType | null = null; // scheduleDraftSave persists the composition (rack order + pending tiles) after a short - // debounce; best-effort, so a failed save never interrupts play (Stage 17). + // debounce; best-effort, so a failed save never interrupts play. function scheduleDraftSave() { if (draftSaveTimer) clearTimeout(draftSaveTimer); draftSaveTimer = setTimeout(() => { @@ -178,7 +178,7 @@ if (!e) return; if (e.kind === 'opponent_moved' && e.gameId === id) { // While composing, reload so a draft overlapping the new move is reconciled; otherwise apply - // the move as a delta with no fetch (R4). + // the move as a delta with no fetch. if (placement.pending.length > 0) void load(); else applyDelta(applyMoveDelta(cacheSnapshot(), { move: e.move, game: e.game, bagLen: e.bagLen })); } else if (e.kind === 'your_turn' && e.gameId === id) { @@ -208,15 +208,15 @@ let hoverKey = ''; let hoverTimer: ReturnType | null = null; // The empty board cell the dragged tile is currently aimed at, highlighted as a drop - // target while carrying a tile over the board (Stage 17). Null over an occupied cell. + // target while carrying a tile over the board. Null over an occupied cell. let dropTarget = $state<{ row: number; col: number } | null>(null); - // Rack reordering (Stage 17): while a rack tile is dragged, reorderDragId is its stable id + // Rack reordering: while a rack tile is dragged, reorderDragId is its stable id // (so the rack hides it — the ghost stands in) and reorderTo is the drop slot over the rack // (a gap opens there). Only when no tiles are pending, so the order is a clean permutation. let reorderDragId = $state(null); let reorderTo = $state(null); // While a placed (pending) board tile is dragged to relocate it, draggingPend is its cell — - // hidden from the board (the ghost stands in) like a lifted rack tile (Stage 17). + // hidden from the board (the ghost stands in) like a lifted rack tile. let draggingPend = $state<{ row: number; col: number } | null>(null); let dragPointerId = -1; @@ -245,7 +245,7 @@ drag = null; } function onRackDown(e: PointerEvent, index: number) { - // Tiles may be arranged on the opponent's turn too (Stage 17 #5): only placement is + // Tiles may be arranged on the opponent's turn too: only placement is // relaxed — the preview and Make-move stay your-turn-only, so an off-turn draft is // position-only (never scored or submitted). if (busy || gameOver) return; @@ -390,7 +390,7 @@ window.removeEventListener('pointerdown', onExtraPointer); clearHover(); clearReorder(); - // Flush a pending draft save so leaving mid-composition still persists it (Stage 17). + // Flush a pending draft save so leaving mid-composition still persists it. if (draftSaveTimer) { clearTimeout(draftSaveTimer); void gateway.draftSave(id, serializeDraft(rackIds, placement.pending)).catch(() => {}); @@ -401,7 +401,7 @@ function onCell(row: number, col: number) { if (swallowClick) return; // A pending tile is recalled by a double-tap or by dragging it back to the rack, not - // by a single tap (which recalled too easily — Stage 17). + // by a single tap (which recalled too easily). if (pendingMap.has(`${row},${col}`)) return; if (selected != null) { // A committed tile already sits here: keep the rack selection so a stray tap @@ -418,7 +418,7 @@ scheduleDraftSave(); } // relocatePending moves a placed-but-unsubmitted tile from one board cell to another free one - // (a board→board drag), keeping its rack slot and any blank letter (Stage 17). + // (a board→board drag), keeping its rack slot and any blank letter. function relocatePending(fromRow: number, fromCol: number, toRow: number, toCol: number) { const pt = placement.pending.find((p) => p.row === fromRow && p.col === fromCol); if (!pt) return; @@ -459,7 +459,7 @@ function recompute() { preview = null; if (previewTimer) clearTimeout(previewTimer); - // Off-turn the composition is position-only: no score preview or evaluate (Stage 17 #5). + // Off-turn the composition is position-only: no score preview or evaluate. if (!isMyTurn) return; const sub = toSubmit(placement, dirOverride); if (!sub) return; @@ -473,7 +473,7 @@ } // applyMoveResult renders the actor's own just-committed move from the response — the move, the - // post-move game and the refilled rack — without a follow-up game.state + game.history (R4). + // post-move game and the refilled rack — without a follow-up game.state + game.history. function applyMoveResult(r: MoveResult) { view = { game: r.game, seat: r.move.player, rack: r.rack, bagLen: r.bagLen, hintsRemaining: view?.hintsRemaining ?? 0 }; moves = [...moves, r.move]; @@ -613,7 +613,7 @@ } // Friend state for the in-game "add to friends" item, derived from the server so it is - // correct across reloads and live-updates when a request is answered (Stage 17): + // correct across reloads and live-updates when a request is answered: // `friends` are the caller's accepted friends; `requested` are the addressees already // requested (pending or declined — both block a re-send and read as "request sent"). let friends = $state(new Set()); @@ -985,7 +985,7 @@ min-width: 0; } /* A borderless icon button (like the tab bar), not a filled accent button — and disabled - while the pending word is known to be illegal (Stage 17). */ + while the pending word is known to be illegal. */ .make { min-width: 56px; background: none; @@ -1045,7 +1045,7 @@ pointer-events: none; z-index: 60; } - /* On touch the finger covers the tile, so enlarge the drag ghost ~1.5x (Stage 17). */ + /* On touch the finger covers the tile, so enlarge the drag ghost ~1.5x. */ .ghost.touch { transform: translate(-50%, -50%) scale(1.5); } diff --git a/ui/src/game/Rack.svelte b/ui/src/game/Rack.svelte index e85295b..6ca7f80 100644 --- a/ui/src/game/Rack.svelte +++ b/ui/src/game/Rack.svelte @@ -20,7 +20,7 @@ selected: number | null; shuffling?: boolean; // While a rack tile is being dragged to reorder it, draggingId is its id (hidden here — - // the drag ghost stands in) and dropIndex is the slot where a gap opens (Stage 17). + // the drag ghost stands in) and dropIndex is the slot where a gap opens. draggingId?: number | null; dropIndex?: number | null; ondown: (e: PointerEvent, index: number) => void; @@ -93,7 +93,7 @@ user-select: none; -webkit-user-select: none; /* iOS shows a tap/active highlight that can linger on the neighbour sliding into a - dragged tile's slot (Stage 17); suppress it so only our own styles mark a tile. */ + dragged tile's slot; suppress it so only our own styles mark a tile. */ -webkit-tap-highlight-color: transparent; } .tile.selected { diff --git a/ui/src/gen/fbs/scrabblefb/opponent-moved-event.ts b/ui/src/gen/fbs/scrabblefb/opponent-moved-event.ts index 810700d..dd37bc2 100644 --- a/ui/src/gen/fbs/scrabblefb/opponent-moved-event.ts +++ b/ui/src/gen/fbs/scrabblefb/opponent-moved-event.ts @@ -31,77 +31,39 @@ gameId(optionalEncoding?:any):string|Uint8Array|null { return offset ? this.bb!.__string(this.bb_pos + offset, optionalEncoding) : null; } -seat():number { - const offset = this.bb!.__offset(this.bb_pos, 6); - return offset ? this.bb!.readInt32(this.bb_pos + offset) : 0; -} - -action():string|null -action(optionalEncoding:flatbuffers.Encoding):string|Uint8Array|null -action(optionalEncoding?:any):string|Uint8Array|null { - const offset = this.bb!.__offset(this.bb_pos, 8); - return offset ? this.bb!.__string(this.bb_pos + offset, optionalEncoding) : null; -} - -score():number { - const offset = this.bb!.__offset(this.bb_pos, 10); - return offset ? this.bb!.readInt32(this.bb_pos + offset) : 0; -} - -total():number { - const offset = this.bb!.__offset(this.bb_pos, 12); - return offset ? this.bb!.readInt32(this.bb_pos + offset) : 0; -} - move(obj?:MoveRecord):MoveRecord|null { - const offset = this.bb!.__offset(this.bb_pos, 14); + const offset = this.bb!.__offset(this.bb_pos, 6); return offset ? (obj || new MoveRecord()).__init(this.bb!.__indirect(this.bb_pos + offset), this.bb!) : null; } game(obj?:GameView):GameView|null { - const offset = this.bb!.__offset(this.bb_pos, 16); + const offset = this.bb!.__offset(this.bb_pos, 8); return offset ? (obj || new GameView()).__init(this.bb!.__indirect(this.bb_pos + offset), this.bb!) : null; } bagLen():number { - const offset = this.bb!.__offset(this.bb_pos, 18); + const offset = this.bb!.__offset(this.bb_pos, 10); return offset ? this.bb!.readInt32(this.bb_pos + offset) : 0; } static startOpponentMovedEvent(builder:flatbuffers.Builder) { - builder.startObject(8); + builder.startObject(4); } static addGameId(builder:flatbuffers.Builder, gameIdOffset:flatbuffers.Offset) { builder.addFieldOffset(0, gameIdOffset, 0); } -static addSeat(builder:flatbuffers.Builder, seat:number) { - builder.addFieldInt32(1, seat, 0); -} - -static addAction(builder:flatbuffers.Builder, actionOffset:flatbuffers.Offset) { - builder.addFieldOffset(2, actionOffset, 0); -} - -static addScore(builder:flatbuffers.Builder, score:number) { - builder.addFieldInt32(3, score, 0); -} - -static addTotal(builder:flatbuffers.Builder, total:number) { - builder.addFieldInt32(4, total, 0); -} - static addMove(builder:flatbuffers.Builder, moveOffset:flatbuffers.Offset) { - builder.addFieldOffset(5, moveOffset, 0); + builder.addFieldOffset(1, moveOffset, 0); } static addGame(builder:flatbuffers.Builder, gameOffset:flatbuffers.Offset) { - builder.addFieldOffset(6, gameOffset, 0); + builder.addFieldOffset(2, gameOffset, 0); } static addBagLen(builder:flatbuffers.Builder, bagLen:number) { - builder.addFieldInt32(7, bagLen, 0); + builder.addFieldInt32(3, bagLen, 0); } static endOpponentMovedEvent(builder:flatbuffers.Builder):flatbuffers.Offset { diff --git a/ui/src/lib/aboutContent.ts b/ui/src/lib/aboutContent.ts index dd43297..a4d4c33 100644 --- a/ui/src/lib/aboutContent.ts +++ b/ui/src/lib/aboutContent.ts @@ -1,5 +1,5 @@ // Localised "About" / landing copy, shared by the About screen and the public landing -// page (Stage 17). Kept out of the flat i18n catalog because it is structured (a heading, +// page. Kept out of the flat i18n catalog because it is structured (a heading, // a rules link, two bulleted sections) and only used in these two long-form places. import type { Locale } from './i18n/index.svelte'; diff --git a/ui/src/lib/alphabet.test.ts b/ui/src/lib/alphabet.test.ts index 61c7991..8c8843e 100644 --- a/ui/src/lib/alphabet.test.ts +++ b/ui/src/lib/alphabet.test.ts @@ -10,7 +10,7 @@ import { } from './alphabet'; // The cache module is per-file-isolated by vitest, so only what these tests seed exists. -describe('alphabet cache (Stage 13)', () => { +describe('alphabet cache', () => { it('upper-cases letters for display and maps indices and values case-insensitively', () => { setAlphabet('scrabble_en', [ { index: 0, letter: 'a', value: 1 }, diff --git a/ui/src/lib/alphabet.ts b/ui/src/lib/alphabet.ts index b729eae..3f0269f 100644 --- a/ui/src/lib/alphabet.ts +++ b/ui/src/lib/alphabet.ts @@ -1,4 +1,4 @@ -// Per-variant alphabet table cache (Stage 13). The client is alphabet-agnostic: it caches +// Per-variant alphabet table cache. The client is alphabet-agnostic: it caches // each variant's (index, letter, value) table — sent by the server on a per-variant cache // miss, behind game.state's include_alphabet flag — and renders the rack and the blank // chooser with it while live play exchanges bare alphabet indices on the wire. Letters are diff --git a/ui/src/lib/app.svelte.ts b/ui/src/lib/app.svelte.ts index 6fd32f7..28637e7 100644 --- a/ui/src/lib/app.svelte.ts +++ b/ui/src/lib/app.svelte.ts @@ -36,7 +36,7 @@ export interface Toast { export const app = $state<{ ready: boolean; - /** Whether the live-event stream is connected; drives the matchmaking poll fallback (R4). */ + /** Whether the live-event stream is connected; drives the matchmaking poll fallback. */ streamAlive: boolean; session: Session | null; profile: Profile | null; @@ -154,12 +154,12 @@ function openStream(): void { showToast(t('game.yourTurn'), 'info'); } else if (e.kind === 'match_found') { // Seed the cache from the event's initial state so the game renders instantly on arrival, - // then navigate (R4). + // then navigate. if (e.state) setCachedGame(e.state.game.id, e.state, []); navigate(`/game/${e.gameId}`); } else if (e.kind === 'notify') { // A started invited game seeds its cache so opening it is instant; the lobby badge stays - // on the authoritative refresh (R4). + // on the authoritative refresh. if (e.sub === 'game_started' && e.state) setCachedGame(e.state.game.id, e.state, []); void refreshNotifications(); } @@ -231,7 +231,7 @@ async function adoptSession(s: Session): Promise { } /** - * applyLinkResult applies a completed account link or merge (Stage 11): it adopts a + * applyLinkResult applies a completed account link or merge: it adopts a * switched session (a guest initiator whose durable counterpart won, so the active * account changed) or, otherwise, refreshes the current profile in place. */ @@ -261,7 +261,7 @@ function syncTelegramChrome(): void { * syncTelegramSafeArea mirrors Telegram's content-safe-area top inset (the height its native * nav overlays the viewport in fullscreen) into the --tg-content-top CSS var and toggles a * `tg-fullscreen` class, so the header can drop below the nav and lift the menu into its - * band (Stage 17). Called on launch and on Telegram's safe-area / fullscreen change events. + * band. Called on launch and on Telegram's safe-area / fullscreen change events. */ function syncTelegramSafeArea(): void { if (typeof document === 'undefined') return; @@ -275,7 +275,7 @@ function syncTelegramSafeArea(): void { * syncViewportHeight mirrors the visual-viewport height into the --vvh CSS var so a screen can * fit the visible area above an open soft keyboard (iOS does not shrink dvh for the keyboard). * On a screen whose input sits at the bottom (chat, word-check) this keeps the input visible - * without the page scrolling, so the layout no longer jumps when the keyboard appears (Stage 17). + * without the page scrolling, so the layout no longer jumps when the keyboard appears. */ function syncViewportHeight(): void { if (typeof document === 'undefined') return; diff --git a/ui/src/lib/client.ts b/ui/src/lib/client.ts index 2291477..8f2f0af 100644 --- a/ui/src/lib/client.ts +++ b/ui/src/lib/client.ts @@ -69,7 +69,7 @@ export interface GatewayClient { lobbyCancel(): Promise; // --- game --- - // Stage 13: the play loop exchanges alphabet indices, so submit/evaluate/exchange/ + // The play loop exchanges alphabet indices, so submit/evaluate/exchange/ // check-word take the game's variant (to map letters<->indices via the cached alphabet // table), and gameState's includeAlphabet asks the server to embed that table. gameState(gameId: string, includeAlphabet: boolean): Promise; @@ -82,10 +82,10 @@ export interface GatewayClient { evaluate(gameId: string, dir: 'H' | 'V', tiles: PlacedTile[], variant: Variant): Promise; checkWord(gameId: string, word: string, variant: Variant): Promise; complaint(gameId: string, word: string, note: string): Promise; - /** Hide a finished game from the caller's own lobby list (Stage 17); per-account, irreversible. */ + /** Hide a finished game from the caller's own lobby list; per-account, irreversible. */ hideGame(gameId: string): Promise; - // --- draft (Stage 17) --- + // --- draft --- /** The player's server-persisted client-side composition (rack order + board tiles), so a * reload or a second device resumes the same arrangement. The JSON is opaque to the * gateway; the client owns the {rack_order, board_tiles} shape. */ @@ -97,7 +97,7 @@ export interface GatewayClient { chatList(gameId: string): Promise; nudge(gameId: string): Promise; - // --- friends (Stage 8) --- + // --- friends --- friendsList(): Promise; friendsIncoming(): Promise; /** Addressees the caller has already requested (pending or declined); cannot re-request. */ @@ -109,24 +109,24 @@ export interface GatewayClient { friendCodeIssue(): Promise; friendCodeRedeem(code: string): Promise; - // --- blocks (Stage 8) --- + // --- blocks --- blocksList(): Promise; block(accountId: string): Promise; unblock(accountId: string): Promise; - // --- invitations (Stage 8) --- + // --- invitations --- invitationsList(): Promise; invitationCreate(inviteeIds: string[], settings: InvitationSettings): Promise; invitationAccept(invitationId: string): Promise; invitationDecline(invitationId: string): Promise; invitationCancel(invitationId: string): Promise; - // --- profile / stats / history (Stage 8) --- + // --- profile / stats / history --- profileUpdate(p: ProfileUpdate): Promise; statsGet(): Promise; exportGcg(gameId: string): Promise; - // --- account linking & merge (Stage 11) --- + // --- account linking & merge --- linkEmailRequest(email: string): Promise; linkEmailConfirm(email: string, code: string): Promise; linkEmailMerge(email: string, code: string): Promise; diff --git a/ui/src/lib/codec.test.ts b/ui/src/lib/codec.test.ts index a52e93c..547de5c 100644 --- a/ui/src/lib/codec.test.ts +++ b/ui/src/lib/codec.test.ts @@ -21,7 +21,7 @@ import { } from './codec'; describe('codec', () => { - it('round-trips a draft save request and view (Stage 17)', () => { + it('round-trips a draft save request and view', () => { const json = '{"rack_order":"1,0","board_tiles":[]}'; const req = fb.DraftRequest.getRootAsDraftRequest(new ByteBuffer(encodeDraftSave('g1', json))); expect(req.gameId()).toBe('g1'); @@ -35,7 +35,7 @@ describe('codec', () => { expect(decodeDraftView(b.asUint8Array())).toBe('{"x":1}'); }); - it('encodes a SubmitPlayRequest with alphabet indices (Stage 13)', () => { + it('encodes a SubmitPlayRequest with alphabet indices', () => { setAlphabet('scrabble_en', [ { index: 0, letter: 'a', value: 1 }, { index: 1, letter: 'b', value: 3 }, @@ -268,10 +268,10 @@ describe('codec', () => { }); }); -// Stage 13: the live play loop exchanges alphabet indices, mapped through the per-variant +// The live play loop exchanges alphabet indices, mapped through the per-variant // table cached in lib/alphabet. Each test seeds the cache it needs (setAlphabet replaces // the whole table), so they are independent of order. -describe('codec — alphabet on the wire (Stage 13)', () => { +describe('codec — alphabet on the wire', () => { it('encodes an ExchangeRequest as alphabet indices, blank as the sentinel', () => { setAlphabet('scrabble_en', [ { index: 0, letter: 'a', value: 1 }, diff --git a/ui/src/lib/codec.ts b/ui/src/lib/codec.ts index 41ce3b4..0d14875 100644 --- a/ui/src/lib/codec.ts +++ b/ui/src/lib/codec.ts @@ -38,7 +38,7 @@ import type { // --- request encoders --- -// buildPlayTile encodes one to-place tile by its alphabet index (Stage 13); a placed blank +// buildPlayTile encodes one to-place tile by its alphabet index; a placed blank // carries its designated letter's index with blank set. function buildPlayTile(b: Builder, t: PlacedTile, variant: Variant): Offset { fb.PlayTile.startPlayTile(b); @@ -73,7 +73,7 @@ export function encodeStateRequest(gameId: string, includeAlphabet: boolean): Ui return finish(b, fb.StateRequest.endStateRequest(b)); } -// encodeDraftSave wraps the player's composition JSON (Stage 17). The string is opaque on the +// encodeDraftSave wraps the player's composition JSON. The string is opaque on the // wire — the gateway forwards it verbatim and only the client reads {rack_order, board_tiles}. export function encodeDraftSave(gameId: string, json: string): Uint8Array { const b = new Builder(256); @@ -324,7 +324,7 @@ export function decodeProfile(buf: Uint8Array): Profile { // decodeStateViewTable projects a StateView table (a root or one nested in an event) to the // model. It caches the alphabet when present (a per-variant cache miss) and decodes the index -// rack to display letters with it (Stage 13). +// rack to display letters with it. function decodeStateViewTable(v: fb.StateView): StateView { const g = v.game(); const variant = (g ? s(g.variant()) : 'scrabble_en') as Variant; @@ -355,7 +355,7 @@ export function decodeMoveResult(buf: Uint8Array): MoveResult { const r = fb.MoveResult.getRootAsMoveResult(new ByteBuffer(buf)); const m = r.move(); const g = r.game(); - // The actor's refilled rack rides back as alphabet indices (R4); decode it with the game's variant. + // The actor's refilled rack rides back as alphabet indices; decode it with the game's variant. const variant = (g ? s(g.variant()) : 'scrabble_en') as Variant; const rack: string[] = []; for (let i = 0; i < r.rackLength(); i++) rack.push(letterForIndex(variant, r.rack(i) ?? 0)); @@ -493,7 +493,7 @@ export function decodeEvent(kind: string, payload: Uint8Array): PushEvent | null } } -// --- Stage 8 encoders --- +// --- social encoders --- export function encodeTarget(accountId: string): Uint8Array { const b = new Builder(64); @@ -563,7 +563,7 @@ export function encodeUpdateProfile(p: ProfileUpdate): Uint8Array { return finish(b, fb.UpdateProfileRequest.endUpdateProfileRequest(b)); } -// --- account linking & merge (Stage 11) --- +// --- account linking & merge --- export function encodeLinkEmailRequest(email: string): Uint8Array { const b = new Builder(128); @@ -604,7 +604,7 @@ export function decodeLinkResult(buf: Uint8Array): LinkResult { }; } -// --- Stage 8 decoders --- +// --- social decoders --- function decodeAccountRef(r: fb.AccountRef): AccountRef { return { accountId: s(r.accountId()), displayName: s(r.displayName()) }; diff --git a/ui/src/lib/connection.svelte.ts b/ui/src/lib/connection.svelte.ts index 97a14aa..0612ea1 100644 --- a/ui/src/lib/connection.svelte.ts +++ b/ui/src/lib/connection.svelte.ts @@ -1,4 +1,4 @@ -// Global connectivity signal (Stage 17). `online` is false while the app is actively failing to +// Global connectivity signal. `online` is false while the app is actively failing to // reach the gateway — a unary call retrying after a transport/rate-limit failure, or the live // stream dropped. The transport and the live-stream owner report transitions; the UI reads // `connection.online` to show the "Connecting…" indicator and to softly disable proactive diff --git a/ui/src/lib/draft.ts b/ui/src/lib/draft.ts index b2063ad..dd7038e 100644 --- a/ui/src/lib/draft.ts +++ b/ui/src/lib/draft.ts @@ -1,5 +1,5 @@ // Draft (client-side composition) serialization, kept pure for unit tests. The server stores -// the JSON opaquely (Stage 17); only the client interprets {rack_order, board_tiles}. The rack +// the JSON opaquely; only the client interprets {rack_order, board_tiles}. The rack // order is a comma-joined permutation of the server rack's indices, in the player's visual // order; the board tiles are the tiles laid but not yet submitted. diff --git a/ui/src/lib/gamedelta.test.ts b/ui/src/lib/gamedelta.test.ts index 67de394..1d3f74a 100644 --- a/ui/src/lib/gamedelta.test.ts +++ b/ui/src/lib/gamedelta.test.ts @@ -44,7 +44,7 @@ describe('applyMoveDelta', () => { expect(applyMoveDelta(undefined, delta(1, 1))).toEqual({ refetch: false }); }); - it('refetches when the payload carries no delta (pre-R4 peer / dropped payload)', () => { + it('refetches when the payload carries no delta (older peer / dropped payload)', () => { expect(applyMoveDelta(cache(3), { bagLen: 0 })).toEqual({ refetch: true }); }); diff --git a/ui/src/lib/gamedelta.ts b/ui/src/lib/gamedelta.ts index 208b4a8..9e7b46c 100644 --- a/ui/src/lib/gamedelta.ts +++ b/ui/src/lib/gamedelta.ts @@ -1,4 +1,4 @@ -// Pure reducers that advance the per-game cache from live events (R4), so the UI renders a move +// Pure reducers that advance the per-game cache from live events, so the UI renders a move // from the event without a follow-up game.state + game.history fetch. They never touch the network // or the cache store — the stream handler applies the returned cache and the game screen acts on // `refetch` — which keeps the gap / own-move / idempotency logic unit-testable in isolation. @@ -42,7 +42,7 @@ export function applyMoveDelta(cached: CachedGame | undefined, d: MoveDelta): De // Nothing cached to advance (the game was never opened on this device): ignore it; the next open // cold-loads the game. if (!cached) return { refetch: false }; - // A pre-R4 peer, or a dropped payload, carried no delta: the open game must refetch. + // An older peer, or a dropped payload, carried no delta: the open game must refetch. if (!d.move || !d.game) return { refetch: true }; const have = cached.view.game.moveCount; const next = d.game.moveCount; diff --git a/ui/src/lib/landing.ts b/ui/src/lib/landing.ts index 4368b2f..3fdf236 100644 --- a/ui/src/lib/landing.ts +++ b/ui/src/lib/landing.ts @@ -1,4 +1,4 @@ -// Pure helpers for the public landing page (Stage 17), kept out of the Svelte component so +// Pure helpers for the public landing page, kept out of the Svelte component so // the per-language Telegram-channel link selection is unit-testable. import type { Locale } from './i18n/index.svelte'; diff --git a/ui/src/lib/lobbysort.ts b/ui/src/lib/lobbysort.ts index 1735d95..8cad94e 100644 --- a/ui/src/lib/lobbysort.ts +++ b/ui/src/lib/lobbysort.ts @@ -1,4 +1,4 @@ -// Pure grouping + ordering of the lobby's game list (Stage 17). The lobby shows three +// Pure grouping + ordering of the lobby's game list. The lobby shows three // sections — games awaiting the caller's move, games awaiting the opponent, and finished // games — each ordered by last activity: your-turn oldest-first (the longest-neglected on // top), the other two newest-first. diff --git a/ui/src/lib/mock/alphabet.ts b/ui/src/lib/mock/alphabet.ts index e104848..d88e090 100644 --- a/ui/src/lib/mock/alphabet.ts +++ b/ui/src/lib/mock/alphabet.ts @@ -1,4 +1,4 @@ -// Mock alphabet fixtures (Stage 13). In production the per-variant (index, letter, value) +// Mock alphabet fixtures. In production the per-variant (index, letter, value) // table comes from the server; the mock seeds the same client cache from a local copy so // the rack, the blank chooser and the mock's scoring work with no backend. The data is the // solver's value tables (scrabble-solver/rules/rules.go), in alphabet-index order, so a diff --git a/ui/src/lib/mock/client.ts b/ui/src/lib/mock/client.ts index 7e8a7b7..76366d9 100644 --- a/ui/src/lib/mock/client.ts +++ b/ui/src/lib/mock/client.ts @@ -98,7 +98,7 @@ export class MockGateway implements GatewayClient { constructor() { // Seed the per-variant alphabet cache the rack, blank chooser and scoring read, so the - // mock-driven UI is alphabet-agnostic without a backend (Stage 13). + // mock-driven UI is alphabet-agnostic without a backend. seedMockAlphabets(); } @@ -324,7 +324,7 @@ export class MockGateway implements GatewayClient { } async complaint(): Promise {} - // Hide a finished game from the caller's list (Stage 17): drop it from the in-memory store so a + // Hide a finished game from the caller's list: drop it from the in-memory store so a // subsequent gamesList omits it, mirroring the backend's per-account, finished-only rule. async hideGame(gameId: string): Promise { const g = this.game(gameId); @@ -333,7 +333,7 @@ export class MockGateway implements GatewayClient { this.drafts.delete(gameId); } - // --- draft (Stage 17): an in-memory composition store, so the reload/off-turn flow is + // --- draft: an in-memory composition store, so the reload/off-turn flow is // exercised without a backend. A committed move clears the actor's own draft, as on the server. async draftGet(gameId: string): Promise { return this.drafts.get(gameId) ?? ''; @@ -470,7 +470,7 @@ export class MockGateway implements GatewayClient { Object.assign(this.profile, p); return { ...this.profile }; } - // --- account linking & merge (Stage 11) --- + // --- account linking & merge --- async linkEmailRequest(_email: string): Promise {} async linkEmailConfirm(email: string, _code: string): Promise { // An address containing "merge" stands in for one already owned by another diff --git a/ui/src/lib/mock/data.ts b/ui/src/lib/mock/data.ts index a50d107..1cbbd99 100644 --- a/ui/src/lib/mock/data.ts +++ b/ui/src/lib/mock/data.ts @@ -42,7 +42,7 @@ export const PROFILE: Profile = { }; // Seed social/account data for the mock (pnpm start + Playwright). The mock profile -// is a durable account so the Stage 8 surfaces (friends, stats, history) are reachable. +// is a durable account so the social surfaces (friends, stats, history) are reachable. // Ann is the active game's opponent but deliberately not a friend, so the in-game // "add to friends" flow is demonstrable; Kaya (a finished-game opponent) is the friend. export const MOCK_FRIENDS: AccountRef[] = [{ accountId: 'kaya', displayName: 'Kaya' }]; diff --git a/ui/src/lib/model.ts b/ui/src/lib/model.ts index fd4378a..394d8bf 100644 --- a/ui/src/lib/model.ts +++ b/ui/src/lib/model.ts @@ -70,7 +70,7 @@ export interface StateView { export interface MoveResult { move: MoveRecord; game: GameView; - /** The actor's refilled rack after the move (R4), so the mover renders the next state without a refetch. */ + /** The actor's refilled rack after the move, so the mover renders the next state without a refetch. */ rack: string[]; bagLen: number; } @@ -198,7 +198,7 @@ export interface Session { supportedLanguages: string[]; } -// LinkResult is the outcome of an account link/merge step (Stage 11). status is +// LinkResult is the outcome of an account link/merge step. status is // 'linked' (bound to the current account), 'merge_required' (the identity belongs to // another account — the secondary* fields summarise it for the irreversible // confirmation) or 'merged'. session is set only when the active account switched @@ -230,8 +230,8 @@ export interface GameList { * A live event delivered over the Subscribe stream. The game events carry the move as a * delta — move plus the post-move summary (and the bag size) — the client applies to its * cached game without a refetch; match_found / game_started carry the recipient's initial - * StateView; notify carries the changed lobby payload (R4). The enriched fields are optional - * so a client falls back to a refetch when a payload is absent (a gap, or a pre-R4 peer). + * StateView; notify carries the changed lobby payload. The enriched fields are optional + * so a client falls back to a refetch when a payload is absent (a gap, or an older peer). */ export type PushEvent = | { kind: 'your_turn'; gameId: string; deadlineUnix: number; moveCount: number } diff --git a/ui/src/lib/premiums.test.ts b/ui/src/lib/premiums.test.ts index ee63b9d..07db99e 100644 --- a/ui/src/lib/premiums.test.ts +++ b/ui/src/lib/premiums.test.ts @@ -4,7 +4,7 @@ import { BOARD_SIZE, centre, premiumGrid } from './premiums'; // Premium-square geometry parity with scrabble-solver/rules/rules.go: scrabble_en/scrabble_ru // share standardBoard (centre is a double word); erudit_ru shares the geometry but a // non-doubling centre. Tile-value and alphabet parity moved to the Go engine test -// (backend/internal/engine AlphabetTable) in Stage 13 — the server now owns that table. +// (backend/internal/engine AlphabetTable) — the server now owns that table. describe('premium layout', () => { it('is a 15x15 grid with TW corners', () => { const g = premiumGrid('scrabble_en'); diff --git a/ui/src/lib/premiums.ts b/ui/src/lib/premiums.ts index a2cf939..d97fb1c 100644 --- a/ui/src/lib/premiums.ts +++ b/ui/src/lib/premiums.ts @@ -2,7 +2,7 @@ // of truth, scrabble-solver/rules/rules.go (standardBoard / eruditBoard). The board is not // transmitted on the wire (StateView has no board), so the client renders the premiums // locally; only the centre differs by variant. A Vitest parity test pins the geometry. -// Tile values and the alphabet moved to the server-sent per-variant table in Stage 13 (see +// Tile values and the alphabet come from the server-sent per-variant table (see // lib/alphabet.ts), so this file is geometry only. import type { Variant } from './model'; @@ -85,5 +85,5 @@ export function centre(variant: Variant): { row: number; col: number } { return { row: 7, col: 7 }; } -// Tile values and the per-variant alphabet now arrive from the server (lib/alphabet.ts, -// Stage 13); the board geometry above is all this module owns. +// Tile values and the per-variant alphabet now arrive from the server (lib/alphabet.ts); +// the board geometry above is all this module owns. diff --git a/ui/src/lib/profileValidation.ts b/ui/src/lib/profileValidation.ts index c97377f..c51ce3a 100644 --- a/ui/src/lib/profileValidation.ts +++ b/ui/src/lib/profileValidation.ts @@ -14,7 +14,7 @@ export const maxAwayMinutes = 12 * 60; // Unicode letters joined by single space / "." / "_" separators, where a "." or "_" // may be followed by a single space. No leading separator and no adjacent separators -// except " "; a single trailing "." is allowed (Stage 17). Same +// except " "; a single trailing "." is allowed. Same // rule as the Go displayNameRe. const displayNameRe = /^\p{L}+(?:(?:[._] ?| )\p{L}+)*\.?$/u; diff --git a/ui/src/lib/result.test.ts b/ui/src/lib/result.test.ts index 801a6cd..e9b4a43 100644 --- a/ui/src/lib/result.test.ts +++ b/ui/src/lib/result.test.ts @@ -51,7 +51,7 @@ describe('resultBadge', () => { it('finished two-player: a 0-0 resignation is a defeat, not a score-tied win', () => { // The opponent won by resignation (isWinner) although neither side scored — the lobby - // must read this as a loss, matching the game-detail screen (Stage 17 regression). + // must read this as a loss, matching the game-detail screen (regression). expect(resultBadge(game([seat(0, 'me', 0), seat(1, 'a', 0, true)]), 'me')).toEqual({ key: 'result.defeat', emoji: '🥈', diff --git a/ui/src/lib/retry.ts b/ui/src/lib/retry.ts index ad724f5..b5b7775 100644 --- a/ui/src/lib/retry.ts +++ b/ui/src/lib/retry.ts @@ -1,4 +1,4 @@ -// Retry policy + error classification for the gateway transport (Stage 17). When a unary call +// Retry policy + error classification for the gateway transport. When a unary call // fails at the transport level the app retries it with capped exponential backoff while showing // the "Connecting…" indicator, instead of flashing a red toast each time. // diff --git a/ui/src/lib/telegram.ts b/ui/src/lib/telegram.ts index 0645102..f89b41b 100644 --- a/ui/src/lib/telegram.ts +++ b/ui/src/lib/telegram.ts @@ -192,7 +192,7 @@ export function onTelegramPath(): boolean { return location.pathname.startsWith('/telegram/'); } -// --- Login Widget (web sign-in for account linking, Stage 11) --- +// --- Login Widget (web sign-in for account linking) --- // The Login Widget is the web (non-Mini-App) Telegram sign-in. It is used only to // attach a Telegram identity to an existing account from a browser; inside the Mini diff --git a/ui/src/lib/variants.ts b/ui/src/lib/variants.ts index ba69e9e..747337b 100644 --- a/ui/src/lib/variants.ts +++ b/ui/src/lib/variants.ts @@ -1,4 +1,4 @@ -// Game variants offered on New Game, and the Stage 15 gating of that choice by the +// Game variants offered on New Game, and the gating of that choice by the // languages the sign-in service supports. Kept out of the .svelte screen so the // gating is unit-testable (the project's node-env Vitest layer). @@ -14,8 +14,7 @@ export interface VariantOption { // ALL_VARIANTS lists every variant in display order. The labels are display names keyed by // the game's alphabet, not the interface language: the English-alphabet game is always // "Scrabble" and the Russian-alphabet Scrabble always "Скрэббл" (both unlocalized, so the -// two never collide whatever the UI language); Erudit is localized "Erudite"/"Эрудит" -// (Stage 17). +// two never collide whatever the UI language); Erudit is localized "Erudite"/"Эрудит". export const ALL_VARIANTS: VariantOption[] = [ { id: 'scrabble_en', label: 'new.english' }, { id: 'scrabble_ru', label: 'new.russian' }, diff --git a/ui/src/screens/Lobby.svelte b/ui/src/screens/Lobby.svelte index fe3cf9a..338e3f5 100644 --- a/ui/src/screens/Lobby.svelte +++ b/ui/src/screens/Lobby.svelte @@ -62,7 +62,7 @@ return `${me?.score ?? 0} : ${opp.join(', ')}`; } - // Hiding a finished game (Stage 17). The delete action sits behind each finished row and is + // Hiding a finished game. The delete action sits behind each finished row and is // revealed by swiping the row left (touch) or tapping its kebab (any pointer); the action is // per-account and irreversible. Only one row is revealed at a time. let revealedId = $state(null); @@ -277,13 +277,13 @@ user-select: none; } /* Game rows are a compact, flat list: no per-card frame, a hairline divider between - consecutive rows (Stage 17). */ + consecutive rows. */ .list { display: flex; flex-direction: column; } /* Each finished row can slide left to reveal a delete action sitting behind it; the row's - own opaque background hides that action until revealed (Stage 17). */ + own opaque background hides that action until revealed. */ .rowwrap { position: relative; overflow: hidden; diff --git a/ui/src/screens/NewGame.svelte b/ui/src/screens/NewGame.svelte index 4fbc0ff..e830d50 100644 --- a/ui/src/screens/NewGame.svelte +++ b/ui/src/screens/NewGame.svelte @@ -12,8 +12,8 @@ // The auto-match move clock (mirrors backend game.DefaultTurnTimeout = 24h). const AUTO_MATCH_HOURS = 24; - // The offered variants are gated by the languages the sign-in service supports - // (Stage 15); the auto-match list and the friend-invite picker both use this. + // The offered variants are gated by the languages the sign-in service supports; + // the auto-match list and the friend-invite picker both use this. const variants = $derived(availableVariants(app.session?.supportedLanguages)); const timeouts = [ { secs: 300, key: 'time.minutes' as MessageKey, n: 5 }, @@ -39,7 +39,7 @@ } } // startPoll is the matchmaking fallback used only while the live stream is down: with the stream - // up the match_found push drives navigation (R4). It polls lobby.poll every 2.5s. + // up the match_found push drives navigation. It polls lobby.poll every 2.5s. function startPoll() { if (poll) return; poll = setInterval(async () => { @@ -58,7 +58,7 @@ } // cancelSearch leaves the auto-match pool on the backend too, so a cancelled quick-match // is actually dequeued — otherwise the account stays queued (blocking a re-queue) and the - // reaper later substitutes a robot for a game the player abandoned (Stage 17 fix). + // reaper later substitutes a robot for a game the player abandoned. function cancelSearch() { stop(); searching = false; @@ -104,7 +104,7 @@ let selected = $state([]); let friendFilter = $state(''); // No default game type yet — the player must pick one (a smarter default from play - // history / language is TODO-6). '' renders the disabled placeholder option. + // history / language would be a future refinement). '' renders the disabled placeholder option. let inviteVariant = $state(''); let timeoutSecs = $state(86400); let hints = $state(1); diff --git a/ui/src/screens/Profile.svelte b/ui/src/screens/Profile.svelte index fc83435..d40f821 100644 --- a/ui/src/screens/Profile.svelte +++ b/ui/src/screens/Profile.svelte @@ -49,7 +49,7 @@ } // populate loads the editable form from the current profile. The profile screen is - // edited inline (no edit/cancel toggle, Stage 17), so this runs on mount and after a + // edited inline (no edit/cancel toggle), so this runs on mount and after a // link/merge swaps the active account. function populate() { const p = app.profile; @@ -215,7 +215,7 @@ {/if} -

{t('profile.linkAccount')}

@@ -246,7 +246,7 @@ {/if}
- {/if} diff --git a/ui/vite.config.ts b/ui/vite.config.ts index 0b927ed..7a26d40 100644 --- a/ui/vite.config.ts +++ b/ui/vite.config.ts @@ -15,7 +15,7 @@ export default defineConfig(({ mode }) => ({ base: './', define: { // App version shown on the About screen, injected at build time from `git describe` - // via a Docker build-arg (Stage 17). Falls back to "dev" for a plain local/mock build, + // via a Docker build-arg. Falls back to "dev" for a plain local/mock build, // so a missing build-arg never breaks the build. __APP_VERSION__: JSON.stringify(process.env.VITE_APP_VERSION || 'dev'), }, @@ -35,7 +35,7 @@ export default defineConfig(({ mode }) => ({ build: { target: 'es2022', sourcemap: true, - // Two entries (Stage 17): the game SPA (index.html, served at /app/ + /telegram/) and the + // Two entries: the game SPA (index.html, served at /app/ + /telegram/) and the // public landing page (landing.html, served at /). Assets are shared in dist/assets/, and // the relative base lets one build serve under any path. rollupOptions: {