R6: refactor + docs reconciliation + de-staging #37

Merged
developer merged 6 commits from feature/r6-refactor-destage into development 2026-06-10 16:03:18 +00:00
161 changed files with 1415 additions and 1346 deletions
+5 -5
View File
@@ -1,6 +1,6 @@
name: CI 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 # cross-workflow `needs`, so the full test suite and the auto test-deploy live in
# one workflow. # one workflow.
# #
@@ -9,9 +9,9 @@ name: CI
# `development` or `master` (the full test suite — the merge gate) and on a push # `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` # 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 # (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 # code changed (the `changes` job decides). Because a skipped required check would
# block a merge under branch protection, the always-running `gate` job aggregates # 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 # 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_EN: ${{ vars.TEST_TELEGRAM_GAME_CHANNEL_ID_EN }}
TELEGRAM_GAME_CHANNEL_ID_RU: ${{ vars.TEST_TELEGRAM_GAME_CHANNEL_ID_RU }} TELEGRAM_GAME_CHANNEL_ID_RU: ${{ vars.TEST_TELEGRAM_GAME_CHANNEL_ID_RU }}
# The test contour always uses Telegram's test environment — pinned here, # 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" TELEGRAM_TEST_ENV: "true"
VITE_TELEGRAM_BOT_ID: ${{ vars.TEST_VITE_TELEGRAM_BOT_ID }} VITE_TELEGRAM_BOT_ID: ${{ vars.TEST_VITE_TELEGRAM_BOT_ID }}
VITE_TELEGRAM_LINK: ${{ vars.TEST_VITE_TELEGRAM_LINK }} VITE_TELEGRAM_LINK: ${{ vars.TEST_VITE_TELEGRAM_LINK }}
@@ -301,7 +301,7 @@ jobs:
- name: Probe the landing and the gateway through caddy - name: Probe the landing and the gateway through caddy
run: | run: |
set -u 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. # landing container, "/app/" is the gateway-served SPA shell.
for i in $(seq 1 20); do 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/ && if docker run --rm --network edge alpine:3.20 wget -q -T 5 -O /dev/null http://scrabble/ &&
+34 -2
View File
@@ -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** | | R3 | Edge hardening | 2 + 8 + 3 | **done** |
| R4 | Push enrichment + kill the last poll | 4 + 5 | **done** | | R4 | Push enrichment + kill the last poll | 4 + 5 | **done** |
| R5 | Bundle slimming | 6 | **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 | | R7 | Final stress run + tuning | 9b | todo |
| → | Stage 18 — prod contour deploy | — | see [`PLAN.md`](PLAN.md) | | → | 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. step is unchanged.
- Critical files: `ui/scripts/bundle-size.mjs`; no app code changed. - 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 Behaviour-preserving only. Three separable, separately-committed passes: (a) mechanical
**de-staging** — remove `Stage N`/`TODO-N` references from code, comments and service **de-staging** — remove `Stage N`/`TODO-N` references from code, comments and service
READMEs (rename `stage6_test.go`); (b) **docs↔code reconciliation** — reconcile 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. 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 - **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`. 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.
+2 -3
View File
@@ -8,14 +8,13 @@ supports English Scrabble, Russian Scrabble and Эрудит.
- **`gateway`** — the only public ingress: anti-abuse, platform authentication - **`gateway`** — the only public ingress: anti-abuse, platform authentication
(resolves the player and injects `X-User-ID`), routing to `backend`, and an (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 - **`backend`** — internal-only service that owns every domain concern and
embeds the [`scrabble-solver`](../scrabble-solver) engine library in-process. embeds the [`scrabble-solver`](../scrabble-solver) engine library in-process.
- **`ui`** — pure-HTML5 client (plain Svelte 5 + TypeScript + Vite) over Connect-RPC - **`ui`** — pure-HTML5 client (plain Svelte 5 + TypeScript + Vite) over Connect-RPC
+ FlatBuffers, embeddable in platform webviews and packageable to native via + FlatBuffers, embeddable in platform webviews and packageable to native via
Capacitor. See [`ui/README.md`](ui/README.md). Capacitor. See [`ui/README.md`](ui/README.md).
- **`platform/*`** — per-platform side-services (e.g. the Telegram bot). - **`platform/*`** — per-platform side-services (e.g. the Telegram bot).
*(added in a later stage)*
## Documentation (sources of truth) ## 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` 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 (`.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 the topology and the two-contour model are in
[`docs/ARCHITECTURE.md`](docs/ARCHITECTURE.md) §13. [`docs/ARCHITECTURE.md`](docs/ARCHITECTURE.md) §13.
+1 -1
View File
@@ -2,7 +2,7 @@
# a golang-alpine builder yields a static binary shipped on distroless nonroot. # a golang-alpine builder yields a static binary shipped on distroless nonroot.
# #
# The dictionary DAWGs are baked in from the scrabble-dictionary release artifact # 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 # binary at them. The published solver module is fetched directly from Gitea
# (GOPRIVATE), so the build stage needs git and network. # (GOPRIVATE), so the build stage needs git and network.
# #
+39 -41
View File
@@ -1,24 +1,24 @@
# backend # backend
Internal-only domain service for the Scrabble platform (module `scrabble/backend`). Internal-only domain service for the Scrabble platform (module `scrabble/backend`).
It owns identity/sessions, accounts, and — in later stages — the lobby, game It owns identity/sessions, accounts, the lobby, game runtime, robot, chat, history
runtime, robot, chat, history and administration. Its only network consumers are and administration. Its only network consumers are the `gateway` and the platform
the `gateway` and the platform side-services; it is never exposed publicly. side-services; it is never exposed publicly.
As of Stage 1 the backend provides the foundation: configuration, the HTTP The backend provides the foundation: configuration, the HTTP listener with the
listener with the `/api/v1` route-group skeleton and probes, the Postgres pool `/api/v1` route-group skeleton and probes, the Postgres pool with embedded goose
with embedded goose migrations, OpenTelemetry wiring, an in-memory session cache, migrations, OpenTelemetry wiring, an in-memory session cache, and the durable
and the durable accounts / identities / sessions data model. The session and accounts / identities / sessions data model. The session and account REST
account REST endpoints are added with the `gateway` (Stage 6); Stage 1 ships the endpoints live in the `gateway`; the backend ships the store/service layer they
store/service layer they will call. 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 library: a versioned dictionary registry, a deterministic tile bag, and a pure
rules `Game` (legal plays, passes, exchanges, resignations and end-condition rules `Game` (legal plays, passes, exchanges, resignations and end-condition
detection) that emits dictionary-independent move records. It is a library only; 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 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 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 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 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 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 that auto-resigns overdue turns (honouring each player's daily away window). Like
Stages 12 it is a service/store layer; the HTTP surface lands with the the engine it is a service/store layer; the HTTP surface lives in the `gateway`.
`gateway` (Stage 6).
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 matchmaking pool (FIFO per variant, pairs two humans into an auto-match) and
friend-game invitations (invite → accept, starting a 24 player game once every friend-game invitations (invite → accept, starting a 24 player game once every
invitee accepts). `internal/social` owns the friend graph (request/accept), 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 34 player game a resignation or timeout drops that seat and the rest play on a 34 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 (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 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` but their live delivery, and all REST endpoints, live in the `gateway`; the
(Stage 6); the services are exposed via `Server` accessors for those handlers. 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 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 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 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 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 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 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 fallback**: while the client is streaming, the live match-found push (enriched with the recipient's
initial game state) drives it instead. 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 handlers (`internal/server/handlers_*.go`): gateway-only session endpoints under
`/api/v1/internal` (Telegram/guest/email login → mint, resolve, revoke) and a `/api/v1/internal` (Telegram/guest/email login → mint, resolve, revoke) and a
slice of authenticated `/api/v1/user` operations (profile, submit play, game slice of authenticated `/api/v1/user` operations (profile, submit play, game
state, lobby enqueue/poll, chat). Stage 8 fills in the social/account/history state, lobby enqueue/poll, chat). The social/account/history operations under
operations under `/api/v1/user`: `friends/*` (request/respond/cancel/unfriend, `/api/v1/user`: `friends/*` (request/respond/cancel/unfriend,
list/incoming, the one-time `code` issue/redeem), `blocks/*`, `invitations/*` list/incoming, the one-time `code` issue/redeem), `blocks/*`, `invitations/*`
(create/accept/decline/cancel/list), `PUT profile`, `email/{request,confirm}`, (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 second listener — `internal/pushgrpc`, a gRPC server (`BACKEND_GRPC_ADDR`) streaming
live events (your-turn, opponent-moved, chat, nudge, match-found, notify) to the 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 gateway. 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 Telegram `external_id`, language and `notifications_in_app_only` flag) lets the gateway
uses to route out-of-app push to the Telegram connector, extends the Telegram login to route out-of-app push to the Telegram connector; the Telegram login
seed a new account's language and display name from the launch fields, and adds seeds a new account's language and display name from the launch fields, and the
the `accounts.notifications_in_app_only` flag (default true). `accounts.notifications_in_app_only` flag (default true).
`accounts.is_guest` marks an ephemeral guest — a durable row `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`; **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 the gateway fronts it with Basic-Auth and a same-origin guard protects its POSTs), the
**complaint resolution** lifecycle (the `complaints` `disposition`/`resolution_note`/ **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/<version>/` pipeline, dictionary **hot-reload** from `BACKEND_DICT_DIR/<version>/`
(`engine.OpenWithVersions` / `Registry.LoadAvailable`), and operator **broadcasts** via a (`engine.OpenWithVersions` / `Registry.LoadAvailable`), and operator **broadcasts** via a
backend Telegram-connector client (`internal/connector`, `BACKEND_CONNECTOR_ADDR`) — each backend Telegram-connector client (`internal/connector`, `BACKEND_CONNECTOR_ADDR`) — each
broadcast picks the delivering bot by an operator-chosen language. **Stage 15** adds broadcast picks the delivering bot by an operator-chosen language. `accounts.service_language`
`accounts.service_language`: the language tag of the bot a Telegram holds the language tag of the bot a Telegram
user last signed in through, written on every login and returned by 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 `/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. 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 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 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 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 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. 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 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 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 bounded in-memory episode window for the console's **Throttled** page and applies the
conservative auto-flag — an account sustaining `BACKEND_HIGHRATE_FLAG_THRESHOLD` 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` migrations/ # embedded *.sql (goose), schema `backend`
jet/ # generated go-jet models + table builders (committed) jet/ # generated go-jet models + table builders (committed)
internal/account/ # durable accounts + platform/email identities (store) + email/identity link primitives 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/accountmerge/ # single-transaction merge of a secondary account into a primary
internal/link/ # link/merge orchestrator over account + accountmerge + session (Stage 11) internal/link/ # link/merge orchestrator over account + accountmerge + session
internal/session/ # opaque tokens, sessions store, write-through cache, service (incl. RevokeAllForAccount) internal/session/ # opaque tokens, sessions store, write-through cache, service (incl. RevokeAllForAccount)
internal/server/ # gin engine, route groups, X-User-ID middleware, probes internal/server/ # gin engine, route groups, X-User-ID middleware, probes
internal/engine/ # in-process scrabble-solver bridge: registry, bag, Game, replay 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/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/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/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) ## 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_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_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_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. | | `BACKEND_HIGHRATE_FLAG_WINDOW` | `10m` | The rolling window those rejections accumulate over. |
## Run ## 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** (`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) 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 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 `BACKEND_DICT_DIR`. The backend loads them at startup as a hard dependency
(a missing dictionary aborts the boot). See [`../PLAN.md`](../PLAN.md) Stage 14 (a missing dictionary aborts the boot).
(TODO-1/TODO-2).
## Tests ## Tests
+7 -8
View File
@@ -108,7 +108,7 @@ func run(ctx context.Context, cfg config.Config, logger *zap.Logger) error {
zap.String("dir", cfg.Game.DictDir), zap.String("dir", cfg.Game.DictDir),
zap.String("version", cfg.Game.DictVersion)) 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) // side-service for operator broadcasts. Unset (BACKEND_CONNECTOR_ADDR empty)
// leaves broadcasts disabled — the console shows a "not configured" notice. // leaves broadcasts disabled — the console shows a "not configured" notice.
var conn *connector.Client 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", logger.Info("game turn-timeout sweeper started",
zap.Duration("interval", cfg.Game.TimeoutSweepInterval)) 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. // the retention window). Dependent rows fall away via ON DELETE CASCADE.
guestReaper := account.NewGuestReaper(accounts, cfg.GuestRetention, logger) guestReaper := account.NewGuestReaper(accounts, cfg.GuestRetention, logger)
go guestReaper.Run(ctx, cfg.GuestReapInterval) 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("interval", cfg.GuestReapInterval),
zap.Duration("retention", cfg.GuestRetention)) zap.Duration("retention", cfg.GuestRetention))
// Stage 4 lobby & social domains. Their REST and stream surface is added with // Lobby & social domains. Their REST and stream surface lives in the gateway,
// the gateway in Stage 6, so they are handed to the server (like the route // so they are handed to the server (like the route groups) for the handlers.
// groups) for the handlers to come.
mailer := newMailer(cfg.SMTP, logger) mailer := newMailer(cfg.SMTP, logger)
emails := account.NewEmailService(accounts, mailer) 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. // session layers. Wired to the /api/v1/user/link REST surface below.
links := link.NewService(emails, accounts, accountmerge.NewMerger(db), sessions) links := link.NewService(emails, accounts, accountmerge.NewMerger(db), sessions)
socialSvc := social.NewService(social.NewStore(db), accounts, games) socialSvc := social.NewService(social.NewStore(db), accounts, games)
socialSvc.SetNotifier(hub) socialSvc.SetNotifier(hub)
socialSvc.SetMetrics(tel.MeterProvider().Meter("scrabble/backend/social")) 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 // dependency, like the dictionaries) and start its move driver. The matchmaker
// substitutes a pooled robot for a missing human after the wait window. // 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) 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) invitations.SetNotifier(hub)
logger.Info("lobby and social domains ready", zap.Duration("robot_wait", cfg.Lobby.RobotWait)) 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. // admin throttled view and the conservative high-rate auto-flag.
rateWatch := ratewatch.New(cfg.RateWatch, accounts, logger) rateWatch := ratewatch.New(cfg.RateWatch, accounts, logger)
logger.Info("rate watch ready", logger.Info("rate watch ready",
+7 -7
View File
@@ -24,8 +24,8 @@ import (
// Identity kinds recognised by the backend. Email is modelled as an identity // Identity kinds recognised by the backend. Email is modelled as an identity
// alongside platform identities; its confirmed flag is driven by the email // 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 // confirm-code flow. Robot is a synthetic kind: each pooled
// robot opponent is a durable account bound to one robot identity (Stage 5). // robot opponent is a durable account bound to one robot identity.
const ( const (
KindTelegram = "telegram" KindTelegram = "telegram"
KindEmail = "email" KindEmail = "email"
@@ -66,19 +66,19 @@ type Account struct {
IsGuest bool IsGuest bool
// NotificationsInAppOnly confines notifications to the in-app live stream when // NotificationsInAppOnly confines notifications to the in-app live stream when
// true (the default): the platform side-service skips out-of-app push for the // true (the default): the platform side-service skips out-of-app push for the
// account (Stage 9). // account.
NotificationsInAppOnly bool NotificationsInAppOnly bool
// PaidAccount marks a lifetime one-time-payment account. It is a service field // 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 // (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 PaidAccount bool
// MergedInto is the primary account a retired (merged) secondary points at, or // 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 // 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 MergedInto uuid.UUID
// FlaggedHighRateAt is the soft, reversible "suspected high-rate" marker: the // FlaggedHighRateAt is the soft, reversible "suspected high-rate" marker: the
// zero time for an unflagged account, otherwise when the gateway-reported // 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. // operator clears it in the admin console; it never gates any request.
FlaggedHighRateAt time.Time FlaggedHighRateAt time.Time
CreatedAt 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 // 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 // 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 // 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. // It reports whether the flag was newly set.
func (s *Store) FlagHighRate(ctx context.Context, id uuid.UUID, at time.Time) (bool, error) { func (s *Store) FlagHighRate(ctx context.Context, id uuid.UUID, at time.Time) (bool, error) {
stmt := table.Accounts. stmt := table.Accounts.
+4 -4
View File
@@ -33,7 +33,7 @@ var (
// ErrInvalidEmail is returned for an unparseable email address. // ErrInvalidEmail is returned for an unparseable email address.
ErrInvalidEmail = errors.New("account: invalid email address") ErrInvalidEmail = errors.New("account: invalid email address")
// ErrEmailTaken is returned when the email is already confirmed by another // 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") ErrEmailTaken = errors.New("account: email already confirmed by another account")
// ErrAlreadyConfirmed is returned when the email is already confirmed by the // ErrAlreadyConfirmed is returned when the email is already confirmed by the
// requesting account. // requesting account.
@@ -52,8 +52,8 @@ var (
// Mailer and verifies it, binding a confirmed email identity to the requesting // 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), // 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 // matching the session model. Binding an email already confirmed by a different
// account is refused (ErrEmailTaken) — merging two accounts is Stage 11 — and // account is refused (ErrEmailTaken) — merging two accounts is the link/merge flow —
// using an email as a login is Stage 6, which reuses this mechanism. // and using an email as a login reuses this mechanism.
type EmailService struct { type EmailService struct {
store *Store store *Store
mailer Mailer 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, // 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 // 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 // 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 // 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. // the login. It returns the target account id for the subsequent LoginWithCode.
+5 -5
View File
@@ -13,14 +13,14 @@ import (
) )
// ErrIdentityTaken is returned when a platform identity being linked already // 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") var ErrIdentityTaken = errors.New("account: identity already linked to another account")
// RequestLinkCode issues and mails a confirm-code for email to accountID, // RequestLinkCode issues and mails a confirm-code for email to accountID,
// replacing any prior pending code. Unlike RequestCode it never refuses up front // replacing any prior pending code. Unlike RequestCode it never refuses up front
// (taken or already-confirmed): possession of the address is the authorization for // (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, // 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 { func (s *EmailService) RequestLinkCode(ctx context.Context, accountID uuid.UUID, email string) error {
addr, err := normalizeEmail(email) addr, err := normalizeEmail(email)
if err != nil { 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 // AccountIDByIdentity returns the account owning (kind, externalID) and true, or
// (uuid.Nil, false) when the identity is free. It backs the platform-identity link // (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) { func (s *Store) AccountIDByIdentity(ctx context.Context, kind, externalID string) (uuid.UUID, bool, error) {
acc, err := s.findByIdentity(ctx, kind, externalID) acc, err := s.findByIdentity(ctx, kind, externalID)
if errors.Is(err, ErrNotFound) { 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. // AttachIdentity links a new (kind, externalID) identity to an existing account.
// A unique-constraint violation means the identity was taken meanwhile, surfaced // A unique-constraint violation means the identity was taken meanwhile, surfaced
// as ErrIdentityTaken. It is used to attach a platform identity (e.g. Telegram) // 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 { func (s *Store) AttachIdentity(ctx context.Context, accountID uuid.UUID, kind, externalID string, confirmed bool) error {
id, err := uuid.NewV7() id, err := uuid.NewV7()
if err != nil { 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 // 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. // for an already-durable account.
func (s *Store) ClearGuest(ctx context.Context, accountID uuid.UUID) error { func (s *Store) ClearGuest(ctx context.Context, accountID uuid.UUID) error {
upd := table.Accounts.UPDATE(table.Accounts.IsGuest, table.Accounts.UpdatedAt). upd := table.Accounts.UPDATE(table.Accounts.IsGuest, table.Accounts.UpdatedAt).
+3 -3
View File
@@ -25,16 +25,16 @@ const maxDisplayName = 32
// maxDisplayNameSpecials caps the total special characters (the "." / "_" separators — // maxDisplayNameSpecials caps the total special characters (the "." / "_" separators —
// every name rune that is neither a letter nor a space) an editable display name may // 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 const maxDisplayNameSpecials = 5
// maxAwayWindow bounds the daily away window's duration (midnight-wrap aware). // maxAwayWindow bounds the daily away window's duration (midnight-wrap aware).
const maxAwayWindow = 12 * time.Hour 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 // joined by single space / "." / "_" separators, where a "." or "_" may be followed
// by a single space. No leading separator and no two adjacent separators (except // by a single space. No leading separator and no two adjacent separators (except
// "<dot|underscore> <space>"); a single trailing "." is allowed (Stage 17), so // "<dot|underscore> <space>"); a single trailing "." is allowed, so
// "Name_P. Last" and "Anna B." are valid, "Name P._Last" is not. // "Name_P. Last" and "Anna B." are valid, "Name P._Last" is not.
var displayNameRe = regexp.MustCompile(`^\p{L}+(?:(?:[._] ?| )\p{L}+)*\.?$`) var displayNameRe = regexp.MustCompile(`^\p{L}+(?:(?:[._] ?| )\p{L}+)*\.?$`)
+1 -1
View File
@@ -12,7 +12,7 @@ import (
// TestUpdateProfileValidation checks that bad fields are rejected before any // TestUpdateProfileValidation checks that bad fields are rejected before any
// database access, so a nil-backed Store is enough to exercise the guards. It also // 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. // offset/IANA timezone), not just their unit tests in validate_test.go.
func TestUpdateProfileValidation(t *testing.T) { func TestUpdateProfileValidation(t *testing.T) {
s := &Store{} s := &Store{}
+1 -1
View File
@@ -7,7 +7,7 @@ import (
) )
// offsetZoneRe matches a fixed UTC offset like "+03:00" or "-05:30" — the form the // 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})$`) var offsetZoneRe = regexp.MustCompile(`^([+-])(\d{2}):(\d{2})$`)
// parseOffsetZone parses a "±HH:MM" offset into a fixed-offset location, reporting // parseOffsetZone parses a "±HH:MM" offset into a fixed-offset location, reporting
+2 -2
View File
@@ -20,7 +20,7 @@ type UserListItem struct {
IsGuest bool IsGuest bool
IsRobot bool IsRobot bool
// FlaggedHighRateAt is the soft high-rate marker (zero when unflagged), shown // 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 FlaggedHighRateAt time.Time
CreatedAt time.Time CreatedAt time.Time
} }
@@ -105,7 +105,7 @@ type FlaggedAccount struct {
const flaggedListCap = 200 const flaggedListCap = 200
// ListFlaggedHighRate returns the accounts carrying the high-rate flag, most // 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) { func (s *Store) ListFlaggedHighRate(ctx context.Context) ([]FlaggedAccount, error) {
rows, err := s.db.QueryContext(ctx, rows, err := s.db.QueryContext(ctx,
`SELECT account_id, display_name, flagged_high_rate_at `SELECT account_id, display_name, flagged_high_rate_at
+1 -1
View File
@@ -2,7 +2,7 @@
// transaction: it sums statistics and the hint wallet, ORs the paid flag, repoints // transaction: it sums statistics and the hint wallet, ORs the paid flag, repoints
// the secondary's identities, transfers its games/chat/complaints/invitations, // the secondary's identities, transfers its games/chat/complaints/invitations,
// de-duplicates friends and blocks, and leaves the secondary as an audit tombstone // 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 // (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. // one layer up (the link service), since the in-memory session cache lives there.
package accountmerge package accountmerge
+3 -3
View File
@@ -60,7 +60,7 @@ type UsersView struct {
// UserRow is one account row in the list. MoveMin/Avg/Max are the account's // 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); // 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 { type UserRow struct {
ID string ID string
DisplayName string DisplayName string
@@ -111,10 +111,10 @@ type UserDetailView struct {
NotificationsInAppOnly bool NotificationsInAppOnly bool
PaidAccount bool PaidAccount bool
// MergedInto is the primary account id when this account has been retired by a // 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 MergedInto string
// FlaggedHighRateAt is the pre-formatted soft high-rate marker timestamp, // 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 FlaggedHighRateAt string
HintBalance int HintBalance int
CreatedAt string CreatedAt string
+1 -1
View File
@@ -37,7 +37,7 @@ type Config struct {
// Robot configures the robot opponent driver (scan cadence). // Robot configures the robot opponent driver (scan cadence).
Robot robot.Config Robot robot.Config
// RateWatch tunes the conservative high-rate auto-flag applied to the // 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 RateWatch ratewatch.Config
// SMTP configures the email relay used for confirm-codes. An empty Host // SMTP configures the email relay used for confirm-codes. An empty Host
// selects the development log mailer (the code is logged, not sent). // selects the development log mailer (the code is logged, not sent).
+1 -1
View File
@@ -7,7 +7,7 @@ import (
// AlphabetEntry is one letter of a variant's alphabet: its alphabet-index byte, the // 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 // 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 // ruleset (its alphabet and value table) and so pinned by the solver version, not by any
// dictionary. // dictionary.
type AlphabetEntry struct { type AlphabetEntry struct {
+1 -1
View File
@@ -8,7 +8,7 @@ import (
// TestAlphabetTableEnglish pins the English table against the solver ruleset: 26 letters, // TestAlphabetTableEnglish pins the English table against the solver ruleset: 26 letters,
// contiguous indices, the concrete lower-case characters the solver emits and the standard // 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) { func TestAlphabetTableEnglish(t *testing.T) {
tab, err := AlphabetTable(VariantEnglish) tab, err := AlphabetTable(VariantEnglish)
if err != nil { if err != nil {
+1 -1
View File
@@ -49,7 +49,7 @@ func (v Variant) String() string {
// Language returns the variant's interface/bot language tag: "en" for English Scrabble, "ru" for // 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 // 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 { func (v Variant) Language() string {
if v == VariantEnglish { if v == VariantEnglish {
return "en" return "en"
+1 -1
View File
@@ -4,7 +4,7 @@ import "testing"
// TestVariantLanguage checks the variant -> bot-language mapping that routes a game's out-of-app // 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 // 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) { func TestVariantLanguage(t *testing.T) {
cases := map[Variant]string{ cases := map[Variant]string{
VariantEnglish: "en", VariantEnglish: "en",
+2 -2
View File
@@ -21,7 +21,7 @@ type DraftTile struct {
Blank bool `json:"blank"` 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 // 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. // keeps it so a reload or a second device resumes the same arrangement.
type Draft struct { 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 // 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 // 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 { func (s *Store) resetConflictingBoardDrafts(ctx context.Context, gameID, actorID uuid.UUID, cells []DraftTile) error {
if len(cells) == 0 { if len(cells) == 0 {
return nil return nil
+1 -1
View File
@@ -6,7 +6,7 @@ import (
) )
// The mappers below project the game domain into the wire-agnostic notify.* input // 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 // game package: notify owns the FlatBuffers encoding, this file only resolves the
// values (seat display names, last-activity sort key) into its input shapes. // values (seat display names, last-activity sort key) into its input shapes.
+7 -8
View File
@@ -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 // 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 // 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) { func (svc *Service) GameVariant(ctx context.Context, gameID uuid.UUID) (engine.Variant, error) {
return svc.store.GetGameVariant(ctx, gameID) return svc.store.GetGameVariant(ctx, gameID)
} }
// GameLanguage returns the game's language tag ("en"/"ru"), derived from its variant, so a // 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 // game push routes out-of-app to the game's own bot rather than the recipient's last-login bot.
// (Stage 17).
func (svc *Service) GameLanguage(ctx context.Context, gameID uuid.UUID) (string, error) { func (svc *Service) GameLanguage(ctx context.Context, gameID uuid.UUID) (string, error) {
v, err := svc.GameVariant(ctx, gameID) v, err := svc.GameVariant(ctx, gameID)
if err != nil { 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 // 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 // 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) { func (svc *Service) LastMoveAt(ctx context.Context, gameID, accountID uuid.UUID) (time.Time, bool, error) {
return svc.store.LastMoveAt(ctx, gameID, accountID) 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 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 // 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 // draft, which is then reset. Best-effort — the move is already committed, so a draft
// cleanup failure is logged rather than failing the move. // 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)) 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 // 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() lang := post.Variant.Language()
switch post.Status { switch post.Status {
case StatusActive: 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 // 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 // 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 // alphabet table is always embedded (the recipient may be seeing the variant for the
// first time). It satisfies lobby.GameCreator. // 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 // 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 // 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 { func (svc *Service) HideGame(ctx context.Context, accountID, gameID uuid.UUID) error {
g, err := svc.store.GetGame(ctx, gameID) g, err := svc.store.GetGame(ctx, gameID)
if err != nil { if err != nil {
+4 -4
View File
@@ -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 // 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. // game and its seats.
func (s *Store) GetGameVariant(ctx context.Context, id uuid.UUID) (engine.Variant, error) { func (s *Store) GetGameVariant(ctx context.Context, id uuid.UUID) (engine.Variant, error) {
stmt := postgres.SELECT(table.Games.Variant). stmt := postgres.SELECT(table.Games.Variant).
@@ -186,7 +186,7 @@ func (s *Store) ListGamesForAccount(ctx context.Context, accountID uuid.UUID) ([
if len(grows) == 0 { if len(grows) == 0 {
return nil, nil 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) hidden, err := s.hiddenGameIDs(ctx, accountID)
if err != nil { if err != nil {
return nil, err 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 // 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 { func (s *Store) HideGame(ctx context.Context, accountID, gameID uuid.UUID) error {
_, err := s.db.ExecContext(ctx, _, err := s.db.ExecContext(ctx,
`INSERT INTO backend.game_hidden (account_id, game_id) VALUES ($1, $2) ON CONFLICT DO NOTHING`, `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 // 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 // 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) { func (s *Store) LastMoveAt(ctx context.Context, gameID, accountID uuid.UUID) (time.Time, bool, error) {
var at sql.NullTime var at sql.NullTime
err := s.db.QueryRowContext(ctx, err := s.db.QueryRowContext(ctx,
+3 -3
View File
@@ -15,8 +15,8 @@ const (
StatusFinished = "finished" StatusFinished = "finished"
) )
// Complaint lifecycle values. A complaint is filed StatusComplaintOpen (Stage 3) // Complaint lifecycle values. A complaint is filed StatusComplaintOpen
// and closed StatusComplaintResolved by the admin review queue (Stage 10) with a // and closed StatusComplaintResolved by the admin review queue with a
// Disposition. The CHECK constraints live in migration 00008. // Disposition. The CHECK constraints live in migration 00008.
const ( const (
StatusComplaintOpen = "open" 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 // 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 // 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. // follow-up game.state.
type MoveResult struct { type MoveResult struct {
Move engine.MoveRecord Move engine.MoveRecord
+62 -4
View File
@@ -11,6 +11,8 @@ import (
"github.com/google/uuid" "github.com/google/uuid"
"scrabble/backend/internal/account" "scrabble/backend/internal/account"
"scrabble/backend/internal/engine"
"scrabble/backend/internal/game"
) )
// TestAccountProvisionByIdentity covers find-or-create semantics, distinct // 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 // 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) { func TestGetStatsZeroForFreshAccount(t *testing.T) {
ctx := context.Background() ctx := context.Background()
store := account.NewStore(testDB) store := account.NewStore(testDB)
@@ -109,7 +111,7 @@ func identityConfirmed(t *testing.T, kind, externalID string) bool {
// TestProvisionTelegramSeedsNewAccountOnly checks that Telegram first contact // TestProvisionTelegramSeedsNewAccountOnly checks that Telegram first contact
// seeds the new account's language and display name from the launch fields, // 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 // 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) { func TestProvisionTelegramSeedsNewAccountOnly(t *testing.T) {
ctx := context.Background() ctx := context.Background()
store := account.NewStore(testDB) 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 // is unflagged, FlagHighRate stamps it exactly once (a second sustained episode
// never moves the timestamp), ClearHighRateFlag reverses it, and a re-flag after // never moves the timestamp), ClearHighRateFlag reverses it, and a re-flag after
// the operator clear takes a fresh timestamp. // 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. // through UpdateProfile and reads back through GetByID.
func TestNotificationsInAppOnlyRoundTrip(t *testing.T) { func TestNotificationsInAppOnlyRoundTrip(t *testing.T) {
ctx := context.Background() ctx := context.Background()
@@ -311,3 +313,59 @@ func TestNotificationsInAppOnlyRoundTrip(t *testing.T) {
t.Error("GetByID still reports in-app-only after clearing") 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")
}
}
+2 -2
View File
@@ -169,7 +169,7 @@ func TestConsoleServesAndGuardsCSRF(t *testing.T) {
} }
// TestConsoleGameDetailRobotSchedule checks the admin game card surfaces a robot seat's // 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) { func TestConsoleGameDetailRobotSchedule(t *testing.T) {
ctx := context.Background() ctx := context.Background()
svc := newGameService() 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 // 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 // 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) // user card carries the marker, and the operator clear (a same-origin POST)
+1 -26
View File
@@ -5,36 +5,11 @@ package inttest
import ( import (
"context" "context"
"testing" "testing"
"time"
"github.com/google/uuid"
"scrabble/backend/internal/engine"
"scrabble/backend/internal/game" "scrabble/backend/internal/game"
) )
// newDraftGame creates a started two-player English game on an opening seed and returns the // TestDraftPersistAndConflictReset covers draft persistence: a round-trip of 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
// rack order + board tiles, the actor's own draft cleared on their move, and an opponent's // 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). // board draft reset when a committed play overlaps one of its cells (the rack order kept).
func TestDraftPersistAndConflictReset(t *testing.T) { func TestDraftPersistAndConflictReset(t *testing.T) {
+47 -1
View File
@@ -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. // accepted, persisted verbatim, and resolved to the right fixed offset.
func TestUpdateProfileOffsetTimezone(t *testing.T) { func TestUpdateProfileOffsetTimezone(t *testing.T) {
ctx := context.Background() ctx := context.Background()
@@ -181,3 +181,49 @@ func TestUpdateProfileOffsetTimezone(t *testing.T) {
t.Fatalf("ResolveZone offset = %d, want 10800", off) 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)
}
}
+3 -74
View File
@@ -4,88 +4,17 @@ package inttest
import ( import (
"context" "context"
"database/sql"
"errors" "errors"
"sync" "sync"
"testing" "testing"
"time" "time"
"github.com/google/uuid" "github.com/google/uuid"
"go.uber.org/zap"
"scrabble/backend/internal/account"
"scrabble/backend/internal/engine" "scrabble/backend/internal/engine"
"scrabble/backend/internal/game" "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 // 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. // games the account is seated in (each with its seats), and nothing for an outsider.
func TestListForAccount(t *testing.T) { 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 // 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 // seat while it is not its turn — no ErrNotYourTurn, the game ends, and seat 0 loses
// despite leading on score. // 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. // created game's variant and ErrNotFound for an unknown id.
func TestGameVariant(t *testing.T) { func TestGameVariant(t *testing.T) {
ctx := context.Background() ctx := context.Background()
@@ -619,7 +548,7 @@ func equalStrings(a, b []string) bool {
return true 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. // is allowed only once the game is over, so an active game leaks nothing mid-play.
func TestExportGCGRefusesActiveGame(t *testing.T) { func TestExportGCGRefusesActiveGame(t *testing.T) {
ctx := context.Background() ctx := context.Background()
+167
View File
@@ -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
}
+1 -1
View File
@@ -12,7 +12,7 @@ import (
"scrabble/backend/internal/game" "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 // 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. // other player, an outsider cannot hide it, and the action is idempotent.
func TestHideFinishedGame(t *testing.T) { func TestHideFinishedGame(t *testing.T) {
+1 -20
View File
@@ -8,31 +8,12 @@ import (
"time" "time"
"github.com/google/uuid" "github.com/google/uuid"
"go.opentelemetry.io/otel/metric/noop"
"go.uber.org/zap"
"scrabble/backend/internal/account" "scrabble/backend/internal/account"
"scrabble/backend/internal/engine" "scrabble/backend/internal/engine"
"scrabble/backend/internal/game" "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 // 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. // idle) at a chosen instant, independent of wall time.
func setTurnStarted(t *testing.T, id uuid.UUID, at time.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) t.Fatalf("get robot account: %v", err)
} }
// A robot blocks chat but NOT friend requests: a request to a robot stays pending and // 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 { 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)", t.Errorf("robot profile wrong: name=%q chat-blocked=%v friends-blocked=%v (want chat blocked, friends open)",
acc.DisplayName, acc.BlockChat, acc.BlockFriendRequests) acc.DisplayName, acc.BlockChat, acc.BlockFriendRequests)
+7 -30
View File
@@ -45,32 +45,9 @@ func (c *capturePublisher) notified(user uuid.UUID, sub string) bool {
return false 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 // TestFriendRequestToRobotStaysPending checks a friend request to a robot is accepted as
// pending rather than blocked: robots no longer block friend requests, so the request // 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) { func TestFriendRequestToRobotStaysPending(t *testing.T) {
ctx := context.Background() ctx := context.Background()
svc := newSocialService() 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. // the player to move can post, the waiting player gets ErrChatNotYourTurn.
func TestChatOnlyOnYourTurn(t *testing.T) { func TestChatOnlyOnYourTurn(t *testing.T) {
ctx := context.Background() ctx := context.Background()
@@ -383,7 +360,7 @@ func TestNudgeRulesAndRateLimit(t *testing.T) {
} }
// TestNudgeCooldownResetsOnAction checks the nudge cooldown clears once the player has // 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) { func TestNudgeCooldownResetsOnAction(t *testing.T) {
ctx := context.Background() ctx := context.Background()
svc := newSocialService() svc := newSocialService()
@@ -413,7 +390,7 @@ func TestNudgeCooldownResetsOnAction(t *testing.T) {
} }
// TestListOutgoingRequests checks the requester-side list that backs the in-game "add to // 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 // 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). // still "sent"); a lazily expired pending one drops (it may be re-sent).
func TestListOutgoingRequests(t *testing.T) { func TestListOutgoingRequests(t *testing.T) {
@@ -469,7 +446,7 @@ func TestListOutgoingRequests(t *testing.T) {
} }
// TestRespondPublishesToRequester checks that answering a request notifies the original // 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. // friend_declined, so a game screen watching that opponent re-derives its friend state.
func TestRespondPublishesToRequester(t *testing.T) { func TestRespondPublishesToRequester(t *testing.T) {
ctx := context.Background() 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 // 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) { func TestNudgeRoutedByGameLanguage(t *testing.T) {
ctx := context.Background() ctx := context.Background()
svc := newSocialService() 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. // (nudges excluded), the game / sender pins, the sender glob masks, and the source label.
func TestAdminListMessages(t *testing.T) { func TestAdminListMessages(t *testing.T) {
ctx := context.Background() ctx := context.Background()
-130
View File
@@ -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)
}
}
+1 -1
View File
@@ -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 // 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 // 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 // platform identity), binds a free identity to the current account, and — when the
+2 -2
View File
@@ -106,7 +106,7 @@ func (svc *InvitationService) SetNotifier(p notify.Publisher) {
} }
// emitInvitation publishes the invitation notification to each invitee, carrying the invitation // 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) { func (svc *InvitationService) emitInvitation(ctx context.Context, inv Invitation, inviteeIDs []uuid.UUID) {
if len(inviteeIDs) == 0 { if len(inviteeIDs) == 0 {
return 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 // 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). // 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) { func (svc *InvitationService) emitGameStarted(ctx context.Context, g game.Game, seats []uuid.UUID) {
intents := make([]notify.Intent, 0, len(seats)) intents := make([]notify.Intent, 0, len(seats))
+1 -1
View File
@@ -23,7 +23,7 @@ type GameCreator interface {
Create(ctx context.Context, params game.CreateParams) (game.Game, error) Create(ctx context.Context, params game.CreateParams) (game.Game, error)
// InitialState returns a seated player's full initial view of a started game, used // 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 // 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) InitialState(ctx context.Context, gameID, accountID uuid.UUID) (notify.PlayerState, error)
} }
+1 -1
View File
@@ -76,7 +76,7 @@ func (m *Matchmaker) SetNotifier(p notify.Publisher) {
// emitMatchFound pushes match_found to every seat of a freshly started game. // 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). // Emitting to a robot seat is harmless (no client subscription exists for it).
func (m *Matchmaker) emitMatchFound(ctx context.Context, g game.Game) { 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)) intents := make([]notify.Intent, 0, len(g.Seats))
for _, s := range g.Seats { for _, s := range g.Seats {
state, err := m.games.InitialState(ctx, g.ID, s.AccountID) state, err := m.games.InitialState(ctx, g.ID, s.AccountID)
+1 -1
View File
@@ -249,7 +249,7 @@ func TestMatchmakerReaperSkipsCancelled(t *testing.T) {
// TestMatchmakerCancelClearsPendingResult covers the race where the reaper substitutes a // TestMatchmakerCancelClearsPendingResult covers the race where the reaper substitutes a
// robot just before the player cancels: Cancel must drop the pending result so the // 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) { func TestMatchmakerCancelClearsPendingResult(t *testing.T) {
creator := &fakeCreator{} creator := &fakeCreator{}
mm := newTestMatchmaker(creator, uuid.New()) mm := newTestMatchmaker(creator, uuid.New())
+84 -164
View File
@@ -4,194 +4,114 @@ import (
flatbuffers "github.com/google/flatbuffers/go" flatbuffers "github.com/google/flatbuffers/go"
"scrabble/backend/internal/engine" "scrabble/backend/internal/engine"
fb "scrabble/pkg/fbs/scrabblefb" "scrabble/pkg/wire"
) )
// The builders below encode the nested wire tables embedded in enriched event // 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 // payloads. They map the domain's already-resolved values (notify.* payload structs
// already-resolved values (notify.* input structs and the decoded engine.MoveRecord) // and the decoded engine.MoveRecord) to the neutral scrabble/pkg/wire structs and
// rather than the gateway's REST DTOs. Each returns the offset of the table it built; // delegate the FlatBuffers construction to package wire — the single definition of the
// callers must build every nested table before opening the parent event table. // 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. // buildGameView builds a GameView table from a GameSummary and returns its offset.
func buildGameView(b *flatbuffers.Builder, g GameSummary) flatbuffers.UOffsetT { func buildGameView(b *flatbuffers.Builder, g GameSummary) flatbuffers.UOffsetT {
seatOffs := make([]flatbuffers.UOffsetT, len(g.Seats)) return wire.BuildGameView(b, toWireGame(g))
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 a decoded engine move and returns // buildMoveRecord builds a MoveRecord table from a decoded engine move and returns its
// its offset. The values match the move-result DTO (Count is the engine count: the // offset (Count is the engine count: the number of tiles swapped on an exchange, zero
// number of tiles swapped on an exchange, zero otherwise). // otherwise).
func buildMoveRecord(b *flatbuffers.Builder, m engine.MoveRecord) flatbuffers.UOffsetT { 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 { for i, t := range m.Tiles {
letter := b.CreateString(t.Letter) tiles[i] = wire.TileRecord{Row: t.Row, Col: t.Col, Letter: t.Letter, Blank: t.Blank}
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)) return wire.BuildMoveRecord(b, wire.MoveRecord{
for i := len(tileOffs) - 1; i >= 0; i-- { Player: m.Player,
b.PrependUOffsetT(tileOffs[i]) Action: m.Action.String(),
} Dir: m.Dir.String(),
tiles := b.EndVector(len(tileOffs)) MainRow: m.MainRow,
MainCol: m.MainCol,
wordOffs := make([]flatbuffers.UOffsetT, len(m.Words)) Tiles: tiles,
for i, w := range m.Words { Words: m.Words,
wordOffs[i] = b.CreateString(w) Count: m.Count,
} Score: m.Score,
fb.MoveRecordStartWordsVector(b, len(wordOffs)) Total: m.Total,
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))
} }
// buildStateView builds a StateView table from a PlayerState and returns its offset. // buildStateView builds a StateView table from a PlayerState and returns its offset.
func buildStateView(b *flatbuffers.Builder, s PlayerState) flatbuffers.UOffsetT { func buildStateView(b *flatbuffers.Builder, s PlayerState) flatbuffers.UOffsetT {
game := buildGameView(b, s.Game) alphabet := make([]wire.AlphabetEntry, len(s.Alphabet))
rackBytes := make([]byte, len(s.Rack)) for i, e := range s.Alphabet {
for i, v := range s.Rack { alphabet[i] = wire.AlphabetEntry{Index: e.Index, Letter: e.Letter, Value: e.Value}
rackBytes[i] = byte(v)
} }
rack := b.CreateByteVector(rackBytes) return wire.BuildStateView(b, wire.StateView{
hasAlphabet := len(s.Alphabet) > 0 Game: toWireGame(s.Game),
var alphabet flatbuffers.UOffsetT Seat: s.Seat,
if hasAlphabet { Rack: s.Rack,
alphabet = buildAlphabet(b, s.Alphabet) BagLen: s.BagLen,
} HintsRemaining: s.HintsRemaining,
fb.StateViewStart(b) Alphabet: alphabet,
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 and returns its offset. // buildAccountRef builds an AccountRef table and returns its offset.
func buildAccountRef(b *flatbuffers.Builder, a AccountRef) flatbuffers.UOffsetT { func buildAccountRef(b *flatbuffers.Builder, a AccountRef) flatbuffers.UOffsetT {
aid := b.CreateString(a.AccountID) return wire.BuildAccountRef(b, wire.AccountRef{AccountID: a.AccountID, DisplayName: a.DisplayName})
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 an InvitationSummary and returns its offset. // buildInvitation builds an Invitation table from an InvitationSummary and returns its offset.
func buildInvitation(b *flatbuffers.Builder, inv InvitationSummary) flatbuffers.UOffsetT { func buildInvitation(b *flatbuffers.Builder, inv InvitationSummary) flatbuffers.UOffsetT {
inviter := buildAccountRef(b, inv.Inviter) invitees := make([]wire.InvitationInvitee, len(inv.Invitees))
inviteeOffs := make([]flatbuffers.UOffsetT, len(inv.Invitees))
for i, iv := range inv.Invitees { for i, iv := range inv.Invitees {
aid := b.CreateString(iv.AccountID) invitees[i] = wire.InvitationInvitee{
name := b.CreateString(iv.DisplayName) AccountID: iv.AccountID,
resp := b.CreateString(iv.Response) DisplayName: iv.DisplayName,
fb.InvitationInviteeStart(b) Seat: iv.Seat,
fb.InvitationInviteeAddAccountId(b, aid) Response: iv.Response,
fb.InvitationInviteeAddDisplayName(b, name) }
fb.InvitationInviteeAddSeat(b, int32(iv.Seat))
fb.InvitationInviteeAddResponse(b, resp)
inviteeOffs[i] = fb.InvitationInviteeEnd(b)
} }
fb.InvitationStartInviteesVector(b, len(inviteeOffs)) return wire.BuildInvitation(b, wire.Invitation{
for i := len(inviteeOffs) - 1; i >= 0; i-- { ID: inv.ID,
b.PrependUOffsetT(inviteeOffs[i]) Inviter: wire.AccountRef{AccountID: inv.Inviter.AccountID, DisplayName: inv.Inviter.DisplayName},
} Invitees: invitees,
invitees := b.EndVector(len(inviteeOffs)) Variant: inv.Variant,
TurnTimeoutSecs: inv.TurnTimeoutSecs,
id := b.CreateString(inv.ID) HintsAllowed: inv.HintsAllowed,
variant := b.CreateString(inv.Variant) HintsPerPlayer: inv.HintsPerPlayer,
dropout := b.CreateString(inv.DropoutTiles) DropoutTiles: inv.DropoutTiles,
status := b.CreateString(inv.Status) Status: inv.Status,
gameID := b.CreateString(inv.GameID) GameID: inv.GameID,
fb.InvitationStart(b) ExpiresAtUnix: inv.ExpiresAtUnix,
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)
} }
+12 -18
View File
@@ -15,11 +15,11 @@ import (
// the game/social/lobby services emit events without importing the wire schema. // 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 // 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 // deadline. opponentName, lastAction, lastWord and scoreLine enrich the out-of-app push:
// 17): the player who just moved, their move kind, the main word of a scoring play (empty // 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 // 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 // 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 { func YourTurn(userID, gameID uuid.UUID, deadline time.Time, opponentName, lastAction, lastWord, scoreLine string, moveCount int) Intent {
b := flatbuffers.NewBuilder(128) b := flatbuffers.NewBuilder(128)
gid := b.CreateString(gameID.String()) 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 // 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 // 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 // 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 { func GameOver(userID, gameID uuid.UUID, result, scoreLine string, game GameSummary) Intent {
b := flatbuffers.NewBuilder(512) b := flatbuffers.NewBuilder(512)
gid := b.CreateString(gameID.String()) 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 // 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 // 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 // bagLen is the bag size after the draw.
// summary for pre-R4 wire back-compat.
func OpponentMoved(userID, gameID uuid.UUID, move engine.MoveRecord, game GameSummary, bagLen int) Intent { func OpponentMoved(userID, gameID uuid.UUID, move engine.MoveRecord, game GameSummary, bagLen int) Intent {
b := flatbuffers.NewBuilder(512) b := flatbuffers.NewBuilder(512)
gid := b.CreateString(gameID.String()) gid := b.CreateString(gameID.String())
act := b.CreateString(move.Action.String())
moveOff := buildMoveRecord(b, move) moveOff := buildMoveRecord(b, move)
gameOff := buildGameView(b, game) gameOff := buildGameView(b, game)
fb.OpponentMovedEventStart(b) fb.OpponentMovedEventStart(b)
fb.OpponentMovedEventAddGameId(b, gid) 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.OpponentMovedEventAddMove(b, moveOff)
fb.OpponentMovedEventAddGame(b, gameOff) fb.OpponentMovedEventAddGame(b, gameOff)
fb.OpponentMovedEventAddBagLen(b, int32(bagLen)) 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 // 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, // 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 { func MatchFound(userID, gameID uuid.UUID, state PlayerState) Intent {
b := flatbuffers.NewBuilder(512) b := flatbuffers.NewBuilder(512)
gid := b.CreateString(gameID.String()) 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 // Notification is a lightweight "re-poll" signal to userID that something in their lobby
// changed. kind is a sub-discriminator (NotifyFriendRequest, NotifyFriendAdded, // changed. kind is a sub-discriminator (NotifyFriendRequest, NotifyFriendAdded,
// NotifyFriendDeclined, NotifyInvitation, NotifyGameStarted). It carries no payload; prefer the // 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 { func Notification(userID uuid.UUID, kind string) Intent {
b := flatbuffers.NewBuilder(32) b := flatbuffers.NewBuilder(32)
k := b.CreateString(kind) 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 // 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 // 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 { func NotificationAccount(userID uuid.UUID, kind string, acc AccountRef) Intent {
b := flatbuffers.NewBuilder(128) b := flatbuffers.NewBuilder(128)
k := b.CreateString(kind) 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 // 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 // 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 { func NotificationGameStarted(userID uuid.UUID, state PlayerState) Intent {
b := flatbuffers.NewBuilder(512) b := flatbuffers.NewBuilder(512)
k := b.CreateString(NotifyGameStarted) 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, // 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 { func NotificationInvitation(userID uuid.UUID, inv InvitationSummary) Intent {
b := flatbuffers.NewBuilder(512) b := flatbuffers.NewBuilder(512)
k := b.CreateString(NotifyInvitation) k := b.CreateString(NotifyInvitation)
+2 -2
View File
@@ -28,7 +28,7 @@ const (
// (incoming friend requests, invitations) that drives the lobby badge. // (incoming friend requests, invitations) that drives the lobby badge.
KindNotification = "notify" KindNotification = "notify"
// KindGameOver announces a finished game to each seated player, driving the // 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" KindGameOver = "game_over"
) )
@@ -52,7 +52,7 @@ type Intent struct {
Kind string Kind string
Payload []byte Payload []byte
EventID string 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 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 // game's bot rather than the recipient's last-login bot. Empty falls back to the
// recipient's service language at the gateway. // recipient's service language at the gateway.
+3 -5
View File
@@ -109,12 +109,10 @@ func TestOpponentMovedPayloadRoundTrips(t *testing.T) {
t.Fatalf("kind = %q", in.Kind) t.Fatalf("kind = %q", in.Kind)
} }
ev := fb.GetRootAsOpponentMovedEvent(in.Payload, 0) ev := fb.GetRootAsOpponentMovedEvent(in.Payload, 0)
// The pre-R4 summary scalars repeat the move. if string(ev.GameId()) != gid.String() {
if string(ev.GameId()) != gid.String() || ev.Seat() != 1 || string(ev.Action()) != "play" || ev.Score() != 24 || ev.Total() != 130 { t.Fatalf("game id = %q", ev.GameId())
t.Fatalf("scalars wrong: game=%q seat=%d action=%q score=%d total=%d",
ev.GameId(), ev.Seat(), ev.Action(), ev.Score(), ev.Total())
} }
// 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 { if ev.BagLen() != 42 {
t.Fatalf("bag_len = %d, want 42", ev.BagLen()) t.Fatalf("bag_len = %d, want 42", ev.BagLen())
} }
@@ -35,7 +35,7 @@ CREATE TABLE accounts (
merged_into uuid REFERENCES accounts (account_id) ON DELETE SET NULL, merged_into uuid REFERENCES accounts (account_id) ON DELETE SET NULL,
merged_at timestamptz, merged_at timestamptz,
service_language text CHECK (service_language IN ('en', 'ru')), 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 -- reports sustained rate-limiter rejections past the threshold; an operator
-- clears it in the admin console. Never an automatic ban. -- clears it in the admin console. Never an automatic ban.
flagged_high_rate_at timestamptz, flagged_high_rate_at timestamptz,
+1 -1
View File
@@ -1,5 +1,5 @@
// Package ratewatch ingests the gateway's periodic rate-limiter rejection // 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: // 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, // when one account's rejections within the rolling window cross the threshold,
// the account store stamps the soft, reversible flagged_high_rate_at marker // the account store stamps the soft, reversible flagged_high_rate_at marker
+6 -7
View File
@@ -93,13 +93,13 @@ type gameDTO struct {
MoveCount int `json:"move_count"` MoveCount int `json:"move_count"`
EndReason string `json:"end_reason"` EndReason string `json:"end_reason"`
// LastActivityUnix is the lobby sort key: the current turn's start for an active // 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"` LastActivityUnix int64 `json:"last_activity_unix"`
Seats []seatDTO `json:"seats"` Seats []seatDTO `json:"seats"`
} }
// moveResultDTO is the outcome of a committed move. Rack carries the actor's refilled rack as // 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. // next state from the response without a follow-up state fetch.
type moveResultDTO struct { type moveResultDTO struct {
Move moveRecordDTO `json:"move"` 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 // 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 // tile value), embedded in the state view for display only when the client requests it.
// (Stage 13).
type alphabetEntryDTO struct { type alphabetEntryDTO struct {
Index int `json:"index"` Index int `json:"index"`
Letter string `json:"letter"` Letter string `json:"letter"`
Value int `json:"value"` 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. // blank is engine.BlankIndex). Alphabet is present only when the request asked for it.
type stateDTO struct { type stateDTO struct {
Game gameDTO `json:"game"` 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 // 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) { func moveResultDTOFrom(r game.MoveResult) (moveResultDTO, error) {
rack, err := engine.EncodeRack(r.Game.Variant, r.Rack) rack, err := engine.EncodeRack(r.Game.Variant, r.Rack)
if err != nil { 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 // 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. // display table, which the client caches per variant and renders the rack with.
func stateDTOFrom(v game.StateView, includeAlphabet bool) (stateDTO, error) { func stateDTOFrom(v game.StateView, includeAlphabet bool) (stateDTO, error) {
rack, err := engine.EncodeRack(v.Game.Variant, v.Rack) rack, err := engine.EncodeRack(v.Game.Variant, v.Rack)
+5 -6
View File
@@ -18,11 +18,10 @@ import (
"scrabble/backend/internal/social" "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 // 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 // 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 // Basic-Auth proxy.
// operations follow the same pattern (PLAN.md Stage 6).
func (s *Server) registerRoutes() { func (s *Server) registerRoutes() {
if s.sessions != nil && s.accounts != nil { if s.sessions != nil && s.accounts != nil {
in := s.internal in := s.internal
@@ -32,13 +31,13 @@ func (s *Server) registerRoutes() {
in.POST("/sessions/email/login", s.handleEmailLogin) in.POST("/sessions/email/login", s.handleEmailLogin)
in.POST("/sessions/resolve", s.handleResolveSession) in.POST("/sessions/resolve", s.handleResolveSession)
in.POST("/sessions/revoke", s.handleRevokeSession) 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 // gateway resolves a recipient's Telegram chat + language + in-app-only flag
// before delivering an out-of-app notification. // before delivering an out-of-app notification.
in.POST("/push-target", s.handlePushTarget) in.POST("/push-target", s.handlePushTarget)
} }
if s.ratewatch != nil { 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. // admin console's throttled view and the high-rate auto-flag.
s.internal.POST("/ratelimit/report", s.handleRateLimitReport) s.internal.POST("/ratelimit/report", s.handleRateLimitReport)
} }
@@ -49,7 +48,7 @@ func (s *Server) registerRoutes() {
u.GET("/stats", s.handleStats) u.GET("/stats", s.handleStats)
} }
if s.links != nil { 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 // a required merge is revealed only after the code is verified, and the
// irreversible merge is an explicit second step. // irreversible merge is an explicit second step.
u.POST("/link/email/request", s.handleLinkEmailRequest) u.POST("/link/email/request", s.handleLinkEmailRequest)
+1 -1
View File
@@ -11,7 +11,7 @@ import (
) )
// The /api/v1/user account handlers wire profile editing, email binding and the // 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 // domain call, a JSON DTO. Profile editing overwrites the full editable set, so the
// client sends the complete desired profile. // client sends the complete desired profile.
@@ -560,7 +560,7 @@ func (s *Server) consolePostBroadcast(c *gin.Context) {
// consoleThrottled renders the rate-limit observability page: the recent // consoleThrottled renders the rate-limit observability page: the recent
// gateway-reported throttle episodes (in-memory, reset on a backend restart) // 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) { func (s *Server) consoleThrottled(c *gin.Context) {
ctx := c.Request.Context() ctx := c.Request.Context()
var view adminconsole.ThrottledView 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 // consoleClearHighRateFlag clears the soft high-rate marker — the operator's
// reversible review action (R3). // reversible review action.
func (s *Server) consoleClearHighRateFlag(c *gin.Context) { func (s *Server) consoleClearHighRateFlag(c *gin.Context) {
id, ok := s.consoleUUID(c, "/_gm/users") id, ok := s.consoleUUID(c, "/_gm/users")
if !ok { if !ok {
+1 -1
View File
@@ -7,7 +7,7 @@ import (
"github.com/google/uuid" "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 // 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 // friendship between the pair. They reuse the friend handlers' targetIDRequest and
// account-ref resolution. // account-ref resolution.
+1 -1
View File
@@ -9,7 +9,7 @@ import (
"github.com/google/uuid" "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 // 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 // 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 // call, a JSON DTO. Account ids are projected to {id, display_name} refs resolved
+9 -10
View File
@@ -12,10 +12,9 @@ import (
"scrabble/backend/internal/game" "scrabble/backend/internal/game"
) )
// The handlers below extend the Stage 6 vertical slice with the remaining game and // The handlers below cover the game and chat operations the UI needs. They follow
// chat operations the UI needs (PLAN.md Stage 7). They follow the same pattern as // the same pattern as handlers_user.go: X-User-ID identity, the domain service
// handlers_user.go: X-User-ID identity, the domain service call, a JSON DTO mapped // call, a JSON DTO mapped from the result.
// from the result.
// hintResultDTO is the top-ranked move plus the remaining hint budget. // hintResultDTO is the top-ranked move plus the remaining hint budget.
type hintResultDTO struct { type hintResultDTO struct {
@@ -53,7 +52,7 @@ type chatListDTO struct {
} }
// exchangeRequest swaps the given rack tiles back into the bag. Tiles are wire alphabet // 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 { type exchangeRequest struct {
Tiles []int `json:"tiles"` 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 // 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. // word for the lookup and echoes that concrete word back for the client's result cache.
func (s *Server) handleCheckWord(c *gin.Context) { func (s *Server) handleCheckWord(c *gin.Context) {
_, gameID, ok := s.userGame(c) _, 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. // 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) { func queryIndexes(c *gin.Context, key string) ([]int, error) {
raw := c.QueryArray(key) raw := c.QueryArray(key)
out := make([]int, 0, len(raw)) out := make([]int, 0, len(raw))
@@ -326,7 +325,7 @@ type draftTileDTO struct {
Blank bool `json:"blank"` 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 // 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. // submitted. The gateway forwards the JSON verbatim; this layer owns its shape.
type draftDTO struct { type draftDTO struct {
@@ -352,7 +351,7 @@ func (d draftDTO) toDomain() game.Draft {
return game.Draft{RackOrder: d.RackOrder, BoardTiles: tiles} 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. // draft when none is stored.
func (s *Server) handleGetDraft(c *gin.Context) { func (s *Server) handleGetDraft(c *gin.Context) {
uid, gameID, ok := s.userGame(c) uid, gameID, ok := s.userGame(c)
@@ -367,7 +366,7 @@ func (s *Server) handleGetDraft(c *gin.Context) {
c.JSON(http.StatusOK, draftDTOFrom(d)) 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. // rejects a non-player with ErrNotAPlayer.
func (s *Server) handleSaveDraft(c *gin.Context) { func (s *Server) handleSaveDraft(c *gin.Context) {
uid, gameID, ok := s.userGame(c) uid, gameID, ok := s.userGame(c)
@@ -12,7 +12,7 @@ import (
"scrabble/backend/internal/lobby" "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 // 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 list the open invitations touching the caller. Display names for the
// inviter and invitees are resolved from the account store. // inviter and invitees are resolved from the account store.
+1 -1
View File
@@ -10,7 +10,7 @@ import (
"scrabble/backend/internal/link" "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 // 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 // enumerate registered emails); confirm reveals a required merge only after the
// code is verified; merge performs the irreversible consolidation behind an // code is verified; merge performs the irreversible consolidation behind an
@@ -23,7 +23,7 @@ type rateLimitReportEntry struct {
} }
// handleRateLimitReport ingests one gateway rejection report into the rate // 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. // Internal, gateway-only: like sessions/resolve it trusts the network segment.
// Malformed individual entries are skipped by the watch itself. // Malformed individual entries are skipped by the watch itself.
func (s *Server) handleRateLimitReport(c *gin.Context) { func (s *Server) handleRateLimitReport(c *gin.Context) {
+1 -1
View File
@@ -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. // body is a 400, a valid report lands in the rate watch with 204.
func TestRateLimitReportEndpoint(t *testing.T) { func TestRateLimitReportEndpoint(t *testing.T) {
watch := ratewatch.New(ratewatch.DefaultConfig(), nil, nil) watch := ratewatch.New(ratewatch.DefaultConfig(), nil, nil)
+3 -3
View File
@@ -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 // 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 { type submitPlayRequest struct {
Dir string `json:"dir"` Dir string `json:"dir"`
Tiles []struct { Tiles []struct {
@@ -39,7 +39,7 @@ type submitPlayRequest struct {
} }
// tilesFromRequest maps a play/evaluate request's index-addressed tiles to engine tile // 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). // 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) { func tilesFromRequest(variant engine.Variant, req submitPlayRequest) ([]engine.TileRecord, error) {
tiles := make([]engine.TileRecord, 0, len(req.Tiles)) 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. // 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) { func (s *Server) handleHideGame(c *gin.Context) {
uid, ok := userID(c) uid, ok := userID(c)
if !ok { if !ok {
+10 -10
View File
@@ -50,20 +50,20 @@ type Deps struct {
// func skips the session-readiness check. // func skips the session-readiness check.
SessionsReady func() bool SessionsReady func() bool
// Sessions, Accounts and Games are the identity, account and game-domain // 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 Sessions *session.Service
Accounts *account.Store Accounts *account.Store
Games *game.Service Games *game.Service
// Social, Matchmaker, Invitations and Emails are the Stage 4 domain services // Social, Matchmaker, Invitations and Emails are the domain services
// the Stage 6 REST handlers route to. // the REST handlers route to.
Social *social.Service Social *social.Service
Matchmaker *lobby.Matchmaker Matchmaker *lobby.Matchmaker
Invitations *lobby.InvitationService Invitations *lobby.InvitationService
Emails *account.EmailService 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. // endpoints. A nil Links disables them.
Links *link.Service 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 // 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. // reload reads a version subdirectory from. A nil Registry disables the console.
Registry *engine.Registry Registry *engine.Registry
@@ -72,7 +72,7 @@ type Deps struct {
// nil when BACKEND_CONNECTOR_ADDR is unset (broadcasts show a "not configured" // nil when BACKEND_CONNECTOR_ADDR is unset (broadcasts show a "not configured"
// notice). // notice).
Connector *connector.Client 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 // admin console's throttled view + the high-rate auto-flag. A nil RateWatch
// disables the internal report endpoint and the console view. // disables the internal report endpoint and the console view.
RateWatch *ratewatch.Watch RateWatch *ratewatch.Watch
@@ -196,16 +196,16 @@ func (s *Server) UserGroup() *gin.RouterGroup { return s.user }
// InternalGroup returns the gateway-facing internal route group. // InternalGroup returns the gateway-facing internal route group.
func (s *Server) InternalGroup() *gin.RouterGroup { return s.internal } 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 } 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 } 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 } 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 } func (s *Server) Emails() *account.EmailService { return s.emails }
// Handler returns the underlying HTTP handler. It lets tests drive the server // Handler returns the underlying HTTP handler. It lets tests drive the server
+2 -2
View File
@@ -97,8 +97,8 @@ func (c *Cache) Remove(tokenHash string) {
} }
// RemoveByAccount evicts every cached session belonging to accountID. The // RemoveByAccount evicts every cached session belonging to accountID. The
// account-merge flow uses it to drop a retired secondary account's sessions // 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. // a linear scan is adequate at the cache's size.
func (c *Cache) RemoveByAccount(accountID uuid.UUID) { func (c *Cache) RemoveByAccount(accountID uuid.UUID) {
if c == nil { if c == nil {
return return
+2 -2
View File
@@ -73,8 +73,8 @@ func (svc *Service) Revoke(ctx context.Context, token string) error {
} }
// RevokeAllForAccount revokes every active session of accountID and evicts them // RevokeAllForAccount revokes every active session of accountID and evicts them
// from the cache. The account-merge flow calls it to retire a secondary account // from the cache. The account-merge flow calls it to retire a secondary account.
// (Stage 11). It is idempotent. // It is idempotent.
func (svc *Service) RevokeAllForAccount(ctx context.Context, accountID uuid.UUID) error { func (svc *Service) RevokeAllForAccount(ctx context.Context, accountID uuid.UUID) error {
if _, err := svc.store.RevokeAllForAccount(ctx, accountID, time.Now().UTC()); err != nil { if _, err := svc.store.RevokeAllForAccount(ctx, accountID, time.Now().UTC()); err != nil {
return err return err
+2 -2
View File
@@ -112,8 +112,8 @@ func (s *Store) RevokeByTokenHash(ctx context.Context, tokenHash string, at time
// RevokeAllForAccount transitions every active session of accountID to revoked // RevokeAllForAccount transitions every active session of accountID to revoked
// and returns the post-update rows (so the caller can evict them from the cache). // 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 // It backs the account-merge flow, which retires a secondary account's sessions.
// (Stage 11). No matching rows is not an error. // No matching rows is not an error.
func (s *Store) RevokeAllForAccount(ctx context.Context, accountID uuid.UUID, at time.Time) ([]Session, error) { func (s *Store) RevokeAllForAccount(ctx context.Context, accountID uuid.UUID, at time.Time) ([]Session, error) {
stmt := table.Sessions. stmt := table.Sessions.
UPDATE(table.Sessions.Status, table.Sessions.RevokedAt). UPDATE(table.Sessions.Status, table.Sessions.RevokedAt).
+1 -1
View File
@@ -10,7 +10,7 @@ import (
"scrabble/backend/internal/account" "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. // plus its sender's resolved display name and source, for the operator console.
type AdminMessage struct { type AdminMessage struct {
ID uuid.UUID ID uuid.UUID
+5 -5
View File
@@ -58,7 +58,7 @@ func (svc *Service) PostMessage(ctx context.Context, gameID, senderID uuid.UUID,
return Message{}, ErrNotParticipant return Message{}, ErrNotParticipant
} }
// Chat is allowed only on the sender's own turn in an active game; the opponent's-turn // 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 { if status != statusActive {
return Message{}, ErrGameNotActive 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 { if ok && svc.now().Sub(last) < nudgeInterval {
// The cooldown resets once the sender has acted (moved or chatted) since the last // 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) acted, err := svc.actedSince(ctx, gameID, senderID, last)
if err != nil { if err != nil {
return Message{}, err 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) { if toMove >= 0 && toMove < len(seats) {
nudge := notify.Nudge(seats[toMove], gameID, senderID) nudge := notify.Nudge(seats[toMove], gameID, senderID)
if lang, err := svc.games.GameLanguage(ctx, gameID); err == nil { 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) 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 // 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) { 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 { if mv, ok, err := svc.games.LastMoveAt(ctx, gameID, senderID); err != nil {
return false, err 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 // 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 // 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) { func (s *Store) lastMessageAt(ctx context.Context, gameID, senderID uuid.UUID) (time.Time, bool, error) {
stmt := postgres.SELECT(table.ChatMessages.CreatedAt). stmt := postgres.SELECT(table.ChatMessages.CreatedAt).
FROM(table.ChatMessages). FROM(table.ChatMessages).
+1 -1
View File
@@ -31,7 +31,7 @@ const friendRequestTTL = 30 * 24 * time.Hour
// accountRef resolves accountID into a notify.AccountRef (the display name from the account // 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 // 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 { func (svc *Service) accountRef(ctx context.Context, accountID uuid.UUID) notify.AccountRef {
ref := notify.AccountRef{AccountID: accountID.String()} ref := notify.AccountRef{AccountID: accountID.String()}
if acc, err := svc.accounts.GetByID(ctx, accountID); err == nil { if acc, err := svc.accounts.GetByID(ctx, accountID); err == nil {
+2 -2
View File
@@ -32,7 +32,7 @@ type GameReader interface {
// has moved); the nudge cooldown resets once the player has taken a turn. // 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) 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 // 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) GameLanguage(ctx context.Context, gameID uuid.UUID) (string, error)
} }
@@ -75,7 +75,7 @@ var (
ErrGameNotActive = errors.New("social: game is not active") ErrGameNotActive = errors.New("social: game is not active")
// ErrChatNotYourTurn is returned when a chat message is sent while it is not the // 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 // 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") ErrChatNotYourTurn = errors.New("social: cannot chat while it is not your turn")
) )
+3 -3
View File
@@ -1,6 +1,6 @@
# Environment for deploy/docker-compose.yml. The CI deploy job (ci.yaml) maps the # 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 # Gitea TEST_-prefixed secrets/variables onto these unprefixed names; the prod
# maps the PROD_-prefixed set the same way. Copy to deploy/.env for a local run. # 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. # Full reference (required vs optional, defaults, secret-vs-variable): deploy/README.md.
@@ -17,7 +17,7 @@ LOG_LEVEL=info
# --- Edge / caddy ----------------------------------------------------------- # --- Edge / caddy -----------------------------------------------------------
# Test: ":80" (the host caddy terminates TLS and forwards to scrabble:80 on the # 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 CADDY_SITE_ADDRESS=:80
GM_BASICAUTH_USER=gm GM_BASICAUTH_USER=gm
GM_BASICAUTH_HASH= # required; `caddy hash-password` bcrypt hash GM_BASICAUTH_HASH= # required; `caddy hash-password` bcrypt hash
+4 -4
View File
@@ -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`. | | `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/`. | | `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. | | `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). | | `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`. | | `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 **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 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` **`PROD_`** set the same way. So a Gitea secret named `TEST_POSTGRES_PASSWORD`
feeds the compose's `POSTGRES_PASSWORD`, etc. 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. | | `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_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_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<token>/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<token>/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`. | | `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). | | `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. | | `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 `<domain> → scrabble:80` (the in-compose caddy serves HTTP - **Host caddy** route `<domain> → scrabble:80` (the in-compose caddy serves HTTP
in the test contour; the host caddy terminates TLS). Not needed on prod, where the 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). 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 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 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 when each succeeded or was skipped), so a skipped job never blocks a merge. See
+3 -3
View File
@@ -2,11 +2,11 @@
# every operator surface under /_gm (the backend-rendered admin console and the # 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 # 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 # 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. # Mirrors ../galaxy-game's /_gm model.
# #
# CADDY_SITE_ADDRESS is ":80" in the test contour (the host caddy terminates TLS # 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. # ACME and the contour is self-contained.
{ {
admin off admin off
@@ -14,7 +14,7 @@
# (chat moderation + per-IP rate limiting in the gateway). Test contour: the host caddy # (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): # (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 — # 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 { servers {
trusted_proxies static private_ranges trusted_proxies static private_ranges
} }
+2 -2
View File
@@ -17,7 +17,7 @@
# - `edge` (external): the host caddy reaches this contour at `scrabble:80` # - `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 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 # 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. # does its own ACME — the contour is then self-contained.
# - The connector egresses to api.telegram.org through the `vpn` sidecar # - The connector egresses to api.telegram.org through the `vpn` sidecar
# (network_mode: service:vpn); it answers internal gRPC at `telegram:9091`. # (network_mode: service:vpn); it answers internal gRPC at `telegram:9091`.
@@ -102,7 +102,7 @@ services:
networks: [internal] networks: [internal]
# --- Landing (static) ------------------------------------------------------- # --- 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/, # routes the catch-all (notably /) here, the gateway keeps only /app/,
# /telegram/ and the Connect edge. Shares the gateway Dockerfile's UI build # /telegram/ and the Connect edge. Shares the gateway Dockerfile's UI build
# stage — identical build args keep that stage a single cached build. # stage — identical build args keep that stage a single cached build.
+2 -2
View File
@@ -37,8 +37,8 @@
}, },
{ {
"type": "timeseries", "type": "timeseries",
"title": "Rate limiting — request rate vs rejections (R3)", "title": "Rate limiting — request rate vs rejections",
"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.", "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 }, "gridPos": { "h": 8, "w": 24, "x": 0, "y": 16 },
"fieldConfig": { "defaults": { "unit": "reqps" }, "overrides": [] }, "fieldConfig": { "defaults": { "unit": "reqps" }, "overrides": [] },
"datasource": { "type": "prometheus", "uid": "prometheus" }, "datasource": { "type": "prometheus", "uid": "prometheus" },
+1 -1
View File
@@ -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 # 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 # 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 # stray public paths are absorbed by static file serving and never reach the Go
+78 -78
View File
@@ -28,10 +28,10 @@ Three executables plus per-platform side-services:
- **`ui`** — pure-HTML5 client (plain Svelte 5 + TypeScript + Vite, static build; - **`ui`** — pure-HTML5 client (plain Svelte 5 + TypeScript + Vite, static build;
no SvelteKit). Talks to `backend` only through `gateway` over Connect-RPC + no SvelteKit). Talks to `backend` only through `gateway` over Connect-RPC +
FlatBuffers, with the edge TS bindings generated from the **same** `edge.proto` FlatBuffers, with the edge TS bindings generated from the **same** `edge.proto`
and `scrabble.fbs` and committed under `ui/src/gen/`. The **playable slice** and `scrabble.fbs` and committed under `ui/src/gen/`. The client covers auth,
(Stage 7) covers auth, "my games", auto-match, the board (play/pass/exchange/ "my games", auto-match, the board (play/pass/exchange/
resign), hint, word-check, chat/nudge, the live stream, i18n (en/ru) and a profile 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 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 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 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. 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), 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 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 and a client **board-style** setting (bonus-label
mode). The visual/interaction design system is documented in mode). The visual/interaction design system is documented in
[`UI_DESIGN.md`](UI_DESIGN.md). [`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 auth operations are unauthenticated and return the minted token. A unary
operation's domain outcome rides back in `ExecuteResponse.result_code` (HTTP operation's domain outcome rides back in `ExecuteResponse.result_code` (HTTP
200); only edge failures (rate limit, missing session, unknown type, internal) 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 **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 `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 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 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 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. 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 `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 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 `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 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 `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. `http.Server` sets only `ReadHeaderTimeout` (10 s) — Read/WriteTimeout would kill the stream.
R7 revisits the exact values under load. - **Alphabet on the wire**: live play exchanges **alphabet indices**, not
- **Alphabet on the wire (Stage 13)**: live play exchanges **alphabet indices**, not
concrete letters. The rack (`StateView.rack`), the `SubmitPlay`/`Evaluate` tiles, the 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 `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 (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 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`) Telegram contact seeds the new account's language (from the launch `language_code`)
and display name (§4). 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 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 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 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 **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 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 → 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 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 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). 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 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 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 friends, statistics or history are kept for it, and it is restricted to
auto-match. Platform and email users are auto-provisioned **durable** accounts auto-match. A background **guest reaper** deletes an abandoned guest — flagged
with an identity. (Reaping abandoned guest rows is deferred — PLAN.md TODO-3.) `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 ## 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. a platform auto-provisions a durable account bound to that platform identity.
Concretely, platform and email identities share one `identities` table keyed by 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 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 `confirmed` flag. A synthetic `kind='robot'` identity backs each pooled
robot opponent (§7). The **email confirm-code flow** (Stage 4) binds an email to the 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 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 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 development log mailer when none is configured) and, once verified, attaches a
confirmed email identity. Accounts and identities use application-generated confirmed email identity. Accounts and identities use application-generated
**UUIDv7** primary keys. A service flag `paid_account` (lifetime one-time **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. 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 control of the identity before attaching it: **email** through the confirm-code
flow, **Telegram** through the web **Login Widget** (validated by the connector, flow, **Telegram** through the web **Login Widget** (validated by the connector,
HMAC under `SHA-256(bot_token)` — distinct from Mini App initData; the gateway 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 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 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 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`) ## 5. Game engine integration (`scrabble-solver`)
@@ -241,8 +243,7 @@ Key points:
boot version plus each subdirectory). In-flight games keep their pinned version; 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 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 dictionaries ship as a separate versioned **release artifact** from the
`scrabble-dictionary` repo — TODO-1/TODO-2, Stage 14; the runtime contract above is `scrabble-dictionary` repo; the runtime contract above is unchanged.)
unchanged.)
- Move generation/validation/scoring use `Solver.GenerateMoves` (ranked), - Move generation/validation/scoring use `Solver.GenerateMoves` (ranked),
`Solver.ValidatePlay` and `Solver.ScorePlay`; board mutation uses `Solver.ValidatePlay` and `Solver.ScorePlay`; board mutation uses
`scrabble.Apply`. The engine adds its own deterministic, seeded tile **bag** `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 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)` 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 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`/ decoded, solver-free API (`SubmitPlay`/`SubmitExchange`/`EvaluatePlay`/
`HintView`/`Hand`) so `internal/game` drives it without importing the solver. `HintView`/`Hand`) so `internal/game` drives it without importing the solver.
- The **game domain** (`internal/game`) owns everything the engine does not — - 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 replay. A pool of durable accounts — each a `kind='robot'` identity (§4), keyed
`robot-<lang>-<index>` and provisioned at startup with **chat blocked but friend `robot-<lang>-<index>` and provisioned at startup with **chat blocked but friend
requests open** — a request to a robot is accepted as pending and expires unanswered 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 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 **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 / 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 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 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 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 - **Observability**: robot accounts accrue ordinary statistics (§9) — the
authoritative balance metric (target ≈ 40% robot wins) — and a authoritative balance metric (target ≈ 40% robot wins) — and a
`robot_games_finished_total` OTel counter plus a per-finish log give a live view. `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 **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 ## 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. `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 **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 pending matched result, so a cancelled quick-match is dequeued rather than left for
the reaper to robot-substitute (Stage 17). the reaper to robot-substitute.
- **Friends** (Stage 8): two add paths over one `friendships` table. A **one-time - **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, 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 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. 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, 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 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 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**: two independent **global** account toggles (`block_chat`,
`block_friend_requests`) **plus** a **per-user block list**. A per-user block is `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 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 and **validated on input** — links, email addresses and phone numbers (including
lightly obfuscated forms) are rejected, since the chat is for quick reactions, 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 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 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 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 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). 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 - **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 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 turn. The platform-native delivery runs through the gateway and the platform
side-service (Stage 6 / 8). side-service.
- **Profile**: `preferred_language` (en/ru, edited in Settings), display name, email - **Profile**: `preferred_language` (en/ru, edited in Settings), display name, email
(confirm-code binding, see §4), **timezone**, the daily **away window** and the (confirm-code binding, see §4), **timezone**, the daily **away window** and the
block toggles — all editable through `account.UpdateProfile`, which validates them block toggles — all editable through `account.UpdateProfile`, which validates them:
(Stage 8): a display name is Unicode letters joined by single ` `/`.`/`_` a display name is Unicode letters joined by single ` `/`.`/`_`
separators (no leading/trailing/adjacent separators, ≤ 32 runes); the timezone is a 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` 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 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 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 ## 9. Persistence
@@ -423,23 +424,22 @@ English game the Latin pool.
into `internal/postgres/jet` and committed, regenerated by `cmd/jetgen`). into `internal/postgres/jet` and committed, regenerated by `cmd/jetgen`).
Migrations are embedded SQL applied with `pressly/goose/v3` at startup. Primary Migrations are embedded SQL applied with `pressly/goose/v3` at startup. Primary
keys are application-generated **UUIDv7**. keys are application-generated **UUIDv7**.
- Tables: `accounts` (durable internal accounts; Stage 3 added the away-window - Tables: `accounts` (durable internal accounts, carrying the away-window
columns `away_start`/`away_end` and the hint wallet `hint_balance`; Stage 6's columns `away_start`/`away_end`, the hint wallet `hint_balance`, the `is_guest`
migration `00005` added the `is_guest` flag for ephemeral guest rows; Stage 9's flag for ephemeral guest rows, the `notifications_in_app_only` out-of-app push
migration `00007` added the `notifications_in_app_only` out-of-app push toggle; toggle, the `paid_account` service flag and the merge-tombstone columns
Stage 11's migration `00009` added the `paid_account` service flag and the `merged_into`/`merged_at`),
merge-tombstone columns `merged_into`/`merged_at`), `identities` (platform/email/robot identities, unique `(kind, external_id)`,
`identities` (platform/email/robot identities, unique `(kind, external_id)`; the `kind` admitting `robot`),
Stage 5's migration `00004` admits the `robot` kind), `sessions` (revoke-only opaque-token hashes), the game tables
`sessions` (revoke-only opaque-token hashes), the Stage 3 game tables `games` (carrying the `dropout_tiles` disposition column), `game_players`,
`games` (Stage 4 added the `dropout_tiles` disposition column), `game_players`,
`game_moves` (the move journal), `complaints` and `account_stats`, and the `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` (per-user blocks), `chat_messages` (per-game chat and nudges), `email_confirmations`
(pending confirm-codes) and `game_invitations` / `game_invitation_invitees` (pending confirm-codes), `game_invitations` / `game_invitation_invitees`
(friend-game invitations). Stage 8's migration `00006` widened the `friendships` (friend-game invitations), `friend_codes` (one-time add-a-friend codes),
status to admit `declined` and added `friend_codes` (one-time add-a-friend codes). `game_drafts` (a player's in-progress rack order + board composition per
Stage 17 added `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 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 account's own lobby list, leaving it visible to the other players — finished-only and
irreversible by design, so there is no un-hide). 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` pragmas, `8G`/`H8` coordinates, lower-case blanks, `.` pass-throughs, `-TILES`
exchanges), plus `#note` lines for resignations and timeouts, which the standard 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` 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. 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 — 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 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. game still replays with the variant's `rules.Alphabet` alone, independent of any dictionary.
## 10. Notifications ## 10. Notifications
Two channels: the **in-app live stream** (delivered from Stage 6) and Two channels: the **in-app live stream** and
**platform-native push** (out-of-app, via the platform side-service — Stage 9). **platform-native push** (out-of-app, via the platform side-service).
The backend emits notification intents through an in-process hub The backend emits notification intents through an in-process hub
(`internal/notify`, a `Publisher` seam installed on the game, social and lobby (`internal/notify`, a `Publisher` seam installed on the game, social and lobby
services); a single backend→gateway **gRPC server-stream** (`Push.Subscribe`, 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 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** 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** (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, 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 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, 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 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, 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, 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). 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 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 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 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 — 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 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 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 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 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 `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, 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, 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 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 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 and the gateway↔connector calls. The OTLP **Collector** (OTLP/gRPC → Prometheus
metrics + Tempo traces), **Prometheus** (15d), **Tempo** (72h) and **Grafana** metrics + Tempo traces), **Prometheus** (15d), **Tempo** (72h) and **Grafana**
(provisioned datasources + dashboards, behind the caddy `/_gm/grafana` Basic-Auth) (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 `none`, so CI needs no collector. The contour also runs **cAdvisor** (per-container
CPU/memory/network) and **postgres_exporter** (connections, cache-hit ratio, CPU/memory/network) and **postgres_exporter** (connections, cache-hit ratio,
transactions, db size), scraped by Prometheus and surfaced on the **Scrabble — 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). 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 - 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 carries method, route, status, latency and the active trace id). A
client-measured RTT piggybacked on the next request is a later enhancement. 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 until an exporter is configured: histograms `game_replay_duration` (journal
rebuild on a cache miss), `game_move_validate_duration` and `game_move_duration` 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, `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 whose synthetic timing dominates the tail, so per-human analysis lives in the admin
console, below); counters `games_started_total`, `games_abandoned_total` (a 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`); `edge_request_duration` (the UI-perceived roundtrip, by `message_type`/`result`);
and Go runtime/heap metrics. Game-scoped metrics carry a `variant` attribute and Go runtime/heap metrics. Game-scoped metrics carry a `variant` attribute
(scrabble_en/scrabble_ru/erudit_ru). (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 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 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 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. 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) 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) — and a gateway **in-memory** observable gauge `active_users` (`window` = 24h/7d) —
distinct accounts that performed an authenticated edge action in the window. The 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 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. 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 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; 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** 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 | | 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 | | 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 minting; email-code / guest validation | gateway (with backend) |
| Session → `user_id` resolution, `X-User-ID` injection | gateway | | Session → `user_id` resolution, `X-User-ID` injection | gateway |
| Authorisation, ownership, state transitions | backend (`X-User-ID` is the sole identity input) | | 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) | | 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 This is an explicit, accepted MVP risk: compromise of the gateway↔backend
network segment defeats backend authentication. Mitigated by network isolation; network segment defeats backend authentication. Mitigated by network isolation;
mutual auth is a future hardening step. 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 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** 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 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 (`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 `/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 `/` 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, `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 `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 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`). (alias `scrabble`).
Two contours, two secret/variable prefixes (`TEST_` / `PROD_`): 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 (`.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 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 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 (`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 — 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 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 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). real peer, so the single config is correct and spoof-safe in both contours.
- **Prod** (Stage 18): a manual SSH deploy after `development → master`. There is no - **Prod**: a manual SSH deploy after `development → master`. There is no
host caddy, so the contour ships its own caddy terminating TLS — set 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. `CADDY_SITE_ADDRESS` to the domain and the caddy does its own ACME.
## 14. CI & branches ## 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 trunk and **`master`** the production trunk; `feature/*` branches are cut from
`development` and PR back into it (the genesis commit necessarily landed on `development` and PR back into it (the genesis commit necessarily landed on
`master`). A commit to a `feature/*` branch triggers nothing. `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 suite on a PR into `development`/`master` and on a push to `development`. Its
`unit` (gofmt/vet/build/unit-test), `integration` (Postgres-backed `integration` `unit` (gofmt/vet/build/unit-test), `integration` (Postgres-backed `integration`
tag, testcontainers `postgres:17-alpine`, Ryuk off, serial) and `ui` 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 `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 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 branch-protection required check (`CI / gate`), so a path-skipped job never blocks
a merge. a merge.
- A gated **`deploy`** job auto-rolls the **test contour** on a PR into — or a push - 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 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 `docker inspect`: running, not restarting, stable restart count, with a
VPN-handshake grace period, since the connector has no public ingress and 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 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. `TEST_`/`PROD_` per contour.
- The engine consumes `scrabble-solver` as a **published, versioned module** - The engine consumes `scrabble-solver` as a **published, versioned module**
(`gitea.iliadenisov.ru/developer/scrabble-solver`, pinned in `backend/go.mod`); both Go (`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 (no public proxy/checksum DB, no sibling clone). The dictionaries ship as a **release
artifact** from the `scrabble-dictionary` repo; the workflows download artifact** from the `scrabble-dictionary` repo; the workflows download
`scrabble-dawg-<DICT_VERSION>.tar.gz` and point the engine tests at it via `scrabble-dawg-<DICT_VERSION>.tar.gz` and point the engine tests at it via
`BACKEND_DICT_DIR` (TODO-1/TODO-2 discharged in Stage 14). `BACKEND_DICT_DIR`.
- After any push, the run is watched to green before a stage is declared done - After any push, the run is watched to green before a stage is declared done
(`python3 ~/.claude/bin/gitea-ci-watch.py`). (`python3 ~/.claude/bin/gitea-ci-watch.py`).
+19 -20
View File
@@ -4,18 +4,17 @@ Per-domain user stories: what each user-visible operation does. This is the
starting point for any change request that touches behaviour. The English starting point for any change request that touches behaviour. The English
version is authoritative; [`FUNCTIONAL_ru.md`](FUNCTIONAL_ru.md) is a mirror for 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 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 changed paragraphs).
the detail is authored.
## Domains ## Domains
### Client app *(Stage 7 / 8)* ### Client app
The web/app client (Svelte + Vite) realizes these stories. The **playable slice** The web/app client (Svelte + Vite) realizes these stories. It
(Stage 7) covers signing in (guest or email), the "my games" lobby, starting an 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), 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, 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 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 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. 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 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 `/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. (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 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 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 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 reconnect), and pending reads resume on their own — the interface stays usable instead of
flashing a red banner each time. 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 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 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 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 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. 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 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 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 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, 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. 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 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 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 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 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; (always 2 players) joins a per-variant pool and is paired with the next waiting human;
after 10 s with no human the robot substitutes (the robot arrives in Stage 5). Friend games (24) are after 10 s with no human the robot substitutes. Friend games (24) are
formed by inviting players from the friend list (an invitation, like a friend code, 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 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 settings and the game starts once every invitee has accepted — any decline cancels it, and an unanswered invitation
expires after seven days. 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 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 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 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 the opponent's turn**, but that draft is position-only — the score preview and submission
stay available only on the player's own turn. 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 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 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 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 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. 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 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 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 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 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 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 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 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 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 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 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. 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 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 "." / "_" optional trailing ".", up to 32 characters and at most 5 special characters — the "." / "_"
punctuation, spaces aside), the timezone (chosen as a UTC offset), 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 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 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 & 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 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 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 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 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). 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 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 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, 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 State-changing actions are protected by a same-origin check; the console tracks no
operator identity. 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 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 a backend restart) and the accounts currently carrying the soft **high-rate flag**. An
account sustaining rejections past a tunable threshold is flagged automatically — account sustaining rejections past a tunable threshold is flagged automatically —
+19 -20
View File
@@ -3,18 +3,17 @@
Пользовательские сценарии по доменам: что делает каждая видимая пользователю Пользовательские сценарии по доменам: что делает каждая видимая пользователю
операция. Это зеркало [`FUNCTIONAL.md`](FUNCTIONAL.md) для владельца проекта; операция. Это зеркало [`FUNCTIONAL.md`](FUNCTIONAL.md) для владельца проекта;
**авторитетна английская версия**. Любую точечную правку переносим в том же **авторитетна английская версия**. Любую точечную правку переносим в том же
патче (переводим только изменённые абзацы). Разделы наполняются по мере этапов; патче (переводим только изменённые абзацы).
*(Stage N)* помечает, где пишется детализация.
## Домены ## Домены
### Клиентское приложение *(Stage 7 / 8)* ### Клиентское приложение
Веб/приложение-клиент (Svelte + Vite) воплощает эти истории. **Играбельный срез** Веб/приложение-клиент (Svelte + Vite) воплощает эти истории. Он
(Stage 7) покрывает вход (гость или email), лобби «мои игры», старт авто-подбора, покрывает вход (гость или email), лобби «мои игры», старт авто-подбора,
игру на доске (постановка фишек перетаскиванием или тапом, пас, обмен, сдача), игру на доске (постановка фишек перетаскиванием или тапом, пас, обмен, сдача),
top-1 подсказку, безлимитную проверку слова с жалобой, чат и nudge в партии, top-1 подсказку, безлимитную проверку слова с жалобой, чат и nudge в партии,
обновления в реальном времени, переключение языка интерфейса (en/ru) и темы и обновления в реальном времени, переключение языка интерфейса (en/ru) и темы и
профиль только для чтения. **Stage 8** добавляет управление друзьями (в т.ч. профиль только для чтения. Он также включает управление друзьями (в т.ч.
одноразовые коды-приглашения) и блоками, дружеские приглашения в игру, одноразовые коды-приглашения) и блоками, дружеские приглашения в игру,
редактирование профиля и привязку email, экран статистики и просмотр истории редактирование профиля и привязку email, экран статистики и просмотр истории
партии с экспортом GCG. В настройках также выбирается стиль подписей бонус-клеток партии с экспортом GCG. В настройках также выбирается стиль подписей бонус-клеток
@@ -27,7 +26,7 @@ top-1 подсказку, безлимитную проверку слова с
`/app/` (веб) и `/telegram/` (Telegram Mini App). Тема на странице эфемерна (берётся из `/app/` (веб) и `/telegram/` (Telegram Mini App). Тема на странице эфемерна (берётся из
системной настройки, а не из сохранённой), выбор языка сохраняется. системной настройки, а не из сохранённой), выбор языка сохраняется.
### Личность и сессии *(Stage 1 / 6 / 9 / 15)* ### Личность и сессии
Игрок приходит с платформы (сначала Telegram), через email-вход или как Игрок приходит с платформы (сначала Telegram), через email-вход или как
эфемерный гость. Gateway один раз валидирует доступ и выдаёт тонкий эфемерный гость. Gateway один раз валидирует доступ и выдаёт тонкий
session-токен; backend сопоставляет его с внутренним `user_id`. Запуск **Telegram session-токен; backend сопоставляет его с внутренним `user_id`. Запуск **Telegram
@@ -58,7 +57,7 @@ nudge) приходят от бота **этой партии** — по язы
подгружается при реконнекте), а незавершённые чтения возобновляются сами — интерфейс остаётся подгружается при реконнекте), а незавершённые чтения возобновляются сами — интерфейс остаётся
рабочим вместо красного баннера каждый раз. рабочим вместо красного баннера каждый раз.
### Аккаунты, привязка и слияние *(Stage 1 / 11)* ### Аккаунты, привязка и слияние
Первый контакт с платформы заводит постоянный аккаунт. Из профиля игрок Первый контакт с платформы заводит постоянный аккаунт. Из профиля игрок
привязывает email (по confirm-коду) или свой Telegram (через веб-вход); гость, привязывает email (по confirm-коду) или свой Telegram (через веб-вход); гость,
привязавший первую личность, становится постоянным аккаунтом. Факт «личность уже привязавший первую личность, становится постоянным аккаунтом. Факт «личность уже
@@ -70,12 +69,12 @@ nudge) приходят от бота **этой партии** — по язы
тогда сохраняется постоянный аккаунт, а игры гостя переходят в него. Слияние тогда сохраняется постоянный аккаунт, а игры гостя переходят в него. Слияние
запрещено, только пока у аккаунтов есть общая незавершённая игра. запрещено, только пока у аккаунтов есть общая незавершённая игра.
### Лобби и подбор *(Stage 4 / 15)* ### Лобби и подбор
Нижнее tab-меню: **мои игры**, **профиль**. Список **мои игры** разбит на три секции — Нижнее tab-меню: **мои игры**, **профиль**. Список **мои игры** разбит на три секции —
*твой ход*, *ход соперника* и *завершённые* (пустые секции скрыты) — и упорядочен так, *твой ход*, *ход соперника* и *завершённые* (пустые секции скрыты) — и упорядочен так,
что игры, ждущие твоего хода, идут первыми, дольше всего ждущие сверху, а игры на ходу что игры, ждущие твоего хода, идут первыми, дольше всего ждущие сверху, а игры на ходу
соперника и завершённые — самые свежие сверху; отображается компактным списком с соперника и завершённые — самые свежие сверху; отображается компактным списком с
линиями-разделителями (Stage 17). Завершённую партию можно **убрать из своего списка**: линиями-разделителями. Завершённую партию можно **убрать из своего списка**:
проведи по строке завершённой партии влево (или, на десктопе, нажми её **⋮**), чтобы открыть проведи по строке завершённой партии влево (или, на десктопе, нажми её **⋮**), чтобы открыть
**❌**, и нажми её. Удаление действует только для твоего аккаунта и необратимо — партия **❌**, и нажми её. Удаление действует только для твоего аккаунта и необратимо — партия
исчезает лишь из твоего списка и остаётся в списках других игроков, отмены нет. Типы партий исчезает лишь из твоего списка и остаётся в списках других игроков, отмены нет. Типы партий
@@ -88,13 +87,13 @@ nudge) приходят от бота **этой партии** — по язы
приглашение друга, — поэтому игрок по-прежнему видит и играет существующие игры на приглашение друга, — поэтому игрок по-прежнему видит и играет существующие игры на
любом языке. Авто-подбор (всегда 2 игрока) любом языке. Авто-подбор (всегда 2 игрока)
встаёт в пул по варианту и сводится со следующим ожидающим человеком; через 10 с встаёт в пул по варианту и сводится со следующим ожидающим человеком; через 10 с
без человека подставляется робот (робот — в Stage 5). Игры с друзьями (2–4) без человека подставляется робот. Игры с друзьями (2–4)
формируются приглашением игроков из списка друзей (приглашение, как и код друга, формируются приглашением игроков из списка друзей (приглашение, как и код друга,
можно отправить deep-link'ом в Telegram, который откроет его сразу): инициатор можно отправить deep-link'ом в Telegram, который откроет его сразу): инициатор
выбирает настройки, и партия стартует, когда приняли все приглашённые — любой отказ отменяет приглашение, а без выбирает настройки, и партия стартует, когда приняли все приглашённые — любой отказ отменяет приглашение, а без
ответа приглашение протухает через семь дней. ответа приглашение протухает через семь дней.
### Игровой процесс *(Stage 3)* ### Игровой процесс
Выкладывание фишек, пас, обмен или сдача. Ход проверяется по словарю партии при Выкладывание фишек, пас, обмен или сдача. Ход проверяется по словарю партии при
сдаче и считается; безлимитный предпросмотр сообщает, сколько принёс бы сдаче и считается; безлимитный предпросмотр сообщает, сколько принёс бы
предполагаемый ход и легален ли он. Инструмент проверки слова безлимитный и предполагаемый ход и легален ли он. Инструмент проверки слова безлимитный и
@@ -115,7 +114,7 @@ nudge) приходят от бота **этой партии** — по язы
**раскладывать фишки и в ход соперника**, но такой черновик только позиционный — **раскладывать фишки и в ход соперника**, но такой черновик только позиционный —
предпросмотр счёта и отправка доступны лишь в собственный ход. предпросмотр счёта и отправка доступны лишь в собственный ход.
### Робот-соперник *(Stage 5)* ### Робот-соперник
Если авто-подбор не находит человека за десять секунд, свободное место занимает Если авто-подбор не находит человека за десять секунд, свободное место занимает
робот-соперник, и партия стартует без ожидания. Он задуман неотличимым от человека: робот-соперник, и партия стартует без ожидания. Он задуман неотличимым от человека:
один раз за партию решает, играть ли на победу (примерно в 40% случаев, так что один раз за партию решает, играть ли на победу (примерно в 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 спецсимволов — «_», с необязательной завершающей «.», до 32 символов и не более 5 спецсимволов —
пунктуации «.» / «_», пробелы не в счёт), таймзоны (выбор смещения от пунктуации «.» / «_», пробелы не в счёт), таймзоны (выбор смещения от
UTC), суточного окна отсутствия (away; сетка по 10 минут, не более 12 часов, с UTC), суточного окна отсутствия (away; сетка по 10 минут, не более 12 часов, с
переходом через полночь) и переключателей блокировок. Форма профиля редактируется переходом через полночь) и переключателей блокировок. Форма профиля редактируется
сразу (без отдельного режима редактирования). Привязка email и Telegram, а также сразу (без отдельного режима редактирования). Привязка email и Telegram, а также
слияние аккаунтов вынесены в раздел «Аккаунты, привязка и слияние» (Stage 11). слияние аккаунтов вынесены в раздел «Аккаунты, привязка и слияние».
### История и статистика *(Stage 3 / 8)* ### История и статистика
Завершённые партии архивируются в независимом от словаря виде и экспортируются Завершённые партии архивируются в независимом от словаря виде и экспортируются
в GCG; экспорт доступен **только после завершения партии** (экспорт идущей партии в GCG; экспорт доступен **только после завершения партии** (экспорт идущей партии
раскрыл бы журнал ходов), и клиент делится файлом `.gcg` там, где платформа это раскрыл бы журнал ходов), и клиент делится файлом `.gcg` там, где платформа это
@@ -164,7 +163,7 @@ UTC), суточного окна отсутствия (away; сетка по 10
победы, поражения, ничьи, макс. очков за партию и макс. очков за один ход (лучший победы, поражения, ничьи, макс. очков за партию и макс. очков за один ход (лучший
ход, уже включающий все образованные им слова и бонус за все фишки). ход, уже включающий все образованные им слова и бонус за все фишки).
### Администрирование *(Stage 10)* ### Администрирование
Оператор открывает серверную админ-консоль по адресу `${DOMAIN}/_gm` — её рендерит Оператор открывает серверную админ-консоль по адресу `${DOMAIN}/_gm` — её рендерит
backend; gateway закрывает её HTTP Basic Auth на публичном порту и проксирует backend; gateway закрывает её HTTP Basic Auth на публичном порту и проксирует
один-в-один. В консоли можно смотреть **пользователей** (профиль, статистика, один-в-один. В консоли можно смотреть **пользователей** (профиль, статистика,
@@ -177,7 +176,7 @@ identity, их игры) и **игры** (сводка + места), разби
Telegram-identity) или **отправить пост в игровой канал**. Изменяющие действия Telegram-identity) или **отправить пост в игровой канал**. Изменяющие действия
защищены проверкой same-origin; личность оператора не отслеживается. защищены проверкой same-origin; личность оператора не отслеживается.
Консоль также показывает **злоупотребление лимитами** (R3): страница **Throttled** Консоль также показывает **злоупотребление лимитами**: страница **Throttled**
перечисляет недавно затроттленных пользователей/IP по отчётам gateway (окно в памяти — перечисляет недавно затроттленных пользователей/IP по отчётам gateway (окно в памяти —
сбрасывается при рестарте backend) и аккаунты с действующим мягким **high-rate сбрасывается при рестарте backend) и аккаунты с действующим мягким **high-rate
флагом**. Аккаунт, устойчиво превышающий настраиваемый порог отказов, помечается флагом**. Аккаунт, устойчиво превышающий настраиваемый порог отказов, помечается
+22 -22
View File
@@ -9,19 +9,19 @@ tests or touching CI.
Every functional change ships with regression coverage. Run: Every functional change ships with regression coverage. Run:
`go test -count=1 ./backend/... ./pkg/... ./gateway/...` (the module list grows `go test -count=1 ./backend/... ./pkg/... ./gateway/...` (the module list grows
with the workspace). 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 build tag spin a throwaway `postgres:17-alpine` via `testcontainers-go`. They
live in `backend/internal/inttest` and run with live in `backend/internal/inttest` and run with
`go test -tags=integration -count=1 -p=1 ./backend/...` (needs Docker), guarded `go test -tags=integration -count=1 -p=1 ./backend/...` (needs Docker), guarded
by a separate CI workflow (`integration.yaml`; Ryuk disabled, serial). Slow. by a separate CI workflow (`integration.yaml`; Ryuk disabled, serial). Slow.
- **UI** *(introduced with the UI in Stage 7)* — Vitest (unit) + Playwright - **UI** — Vitest (unit) + Playwright
(e2e), mirroring the chosen plain-Svelte + Vite toolchain. Stage 8 adds Vitest for (e2e), mirroring the chosen plain-Svelte + Vite toolchain. Vitest covers
the new FlatBuffers codecs (friend list, invitation, stats), the win-rate the FlatBuffers codecs (friend list, invitation, stats), the win-rate
derivation and the GCG share/download choice, plus Playwright specs against the 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 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 invitations section, the stats screen, profile editing, and the GCG export's
finished-only visibility. 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 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 top of the embedded solver: per-variant smoke tests (load all three committed
DAWGs and validate a known word, including Эрудит), bag draw/return determinism 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 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` read the DAWGs from `BACKEND_DICT_DIR` (or the sibling `scrabble-solver`
checkout) and fail loudly when it is absent. 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 (the GCG writer, the away-window / effective-deadline boundaries, the hint
budget, the live-game cache and per-game lock, payload round-trips) plus budget, the live-game cache and per-game lock, payload round-trips) plus
Postgres-backed integration tests in `inttest` (full lifecycle to a natural Postgres-backed integration tests in `inttest` (full lifecycle to a natural
end, **journal-replay equivalence**, the turn-timeout sweep with away-window end, **journal-replay equivalence**, the turn-timeout sweep with away-window
grace, resign win/loss and statistics, the hint allowance-then-wallet policy, 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, 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 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` for a 3-player **timeout that continues**, and the engine's `Candidates`
ranked/decoded test (Stage 5). ranked/decoded test.
- **Social & lobby** *(Stage 4+)*`backend/internal/social` unit-tests the chat - **Social & lobby** — `backend/internal/social` unit-tests the chat
**content filter** (links/emails/phones plus obfuscated forms) and **content filter** (links/emails/phones plus obfuscated forms) and
`backend/internal/lobby` unit-tests the in-memory **matchmaker** (FIFO pairing, `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 `Poll` delivery) with fake game-creator and robot-provider seams. Postgres-backed
`inttest` covers the friend request/accept lifecycle with the block/toggle guards, `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, 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 content and block-visibility rules, the nudge turn/rate-limit rules, the
invitation flow (all-accept starts the game, decline cancels, lazy expiry, invitation flow (all-accept starts the game, decline cancels, lazy expiry,
inviter-only cancel), and the email confirm-code flow (request/confirm, taken 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 **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, 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 self/single-use, decline-bypass), `ListInvitations`, the zero-value `GetStats`, and
the GCG **finished-only** gate. 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 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 (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 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 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 → statistics row), the matchmaker substitution end-to-end (enqueue → reap →
`[human, robot]`, discoverable via `Poll`), and a proactive 12-hour nudge. `[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 hub fan-out (delivery, overflow drop, unsubscribe) and the FlatBuffers event
constructors (payload round-trip). `gateway/...` unit-tests are hermetic (no constructors (payload round-trip). `gateway/...` unit-tests are hermetic (no
real network — an `httptest` fake backend and fixtures): the Telegram initData 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 unsubscribe), the transcode round-trips (FlatBuffers↔JSON, X-User-ID
forwarding, nested GameView, domain-code surfacing), the admin Basic-Auth forwarding, nested GameView, domain-code surfacing), the admin Basic-Auth
reverse proxy (401 / forward), and a full Connect `Execute` path end to end 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 edge-hardening cases: an oversized `Execute` payload is refused
(`resource_exhausted`, the `GATEWAY_MAX_BODY_BYTES` cap), a limiter rejection (`resource_exhausted`, the `GATEWAY_MAX_BODY_BYTES` cap), a limiter rejection
lands in `gateway_rate_limited_total{class}` and the rejection tracker lands in `gateway_rate_limited_total{class}` and the rejection tracker
(drain/aggregate unit tests), the report POST reaches (drain/aggregate unit tests), the report POST reaches
`/api/v1/internal/ratelimit/report` with the agreed JSON shape, the `/_gm` `/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 `/` 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 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) 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, operations (friends list, friend code issue/redeem, invitation create, stats, GCG,
the profile-update away round-trip) and a `notify`-event constructor round-trip. 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` the template renderer over every page plus the embedded asset; `backend/internal/engine`
adds the **dictionary hot-reload** cases (`LoadAvailable` loads only the present adds the **dictionary hot-reload** cases (`LoadAvailable` loads only the present
variants, `OpenWithVersions` scans version subdirectories, a reload registers a new 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 → 404 when not). Postgres-backed `inttest` drives the **complaint resolution →
dictionary-change pipeline** (file → resolve with a disposition → pending change → mark dictionary-change pipeline** (file → resolve with a disposition → pending change → mark
applied), the admin **list/count** read queries, and the **/_gm console over HTTP** 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 unit tests (window accumulation, the auto-flag threshold + expiry, the bounded
episode map), the account-store **high-rate flag round-trip** (set-once / clear / 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, 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 the **Throttled** page shows the episode and the flagged queue, the user card
carries the marker and the CSRF-guarded **Clear** reverses it. 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; selection (`none`/`stdout`/`otlp` build providers; OTLP constructs with no collector;
the nil-runtime fallback). The domain metrics are exercised through a manual the nil-runtime fallback). The domain metrics are exercised through a manual
`sdkmetric` reader: `backend/internal/game` and `…/social` assert the counters and `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. `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 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). 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 (`scrabble/loadtest`) is the pre-release stress harness. It **seeds** a large account
population with pre-created sessions directly in Postgres (token hashes matching population with pre-created sessions directly in Postgres (token hashes matching
`backend/internal/session`), **drives** virtual players through the edge protocol — `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 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 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 + 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 ## Principles
+11 -11
View File
@@ -5,7 +5,7 @@ Visual and interaction conventions for the `ui` client. Behaviour lives in
points this doc references) lives in [`ARCHITECTURE.md`](ARCHITECTURE.md). The client 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 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 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 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 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. 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 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 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`). 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 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 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 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 (`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" and refresh in the background, removing the blank-loading flash and the lobby's "draw-in"
on lobby ↔ game navigation. 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`, 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 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` / 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 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** 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 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 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 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); 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 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 **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 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). recalled tile returns to its original rack slot.
- **Players plaque & history** (`Game.svelte`, Stage 17): the seats above the board share - **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) 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 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 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 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 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). 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 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 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 `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 - **Bonus-square labels** — a Settings choice (`boardlabels.ts`): `beginner` shows a
split `3×` / `word` (localized слово/буква), `classic` a single `3W` / `3С`, `none` split `3×` / `word` (localized слово/буква), `classic` a single `3W` / `3С`, `none`
nothing. Default **beginner**. 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 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 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 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 - **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 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). 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 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 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. 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`) ## 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 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 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 minimal markdown (text + links, escaped + linkified). A parameterised **rotator** drives messages: a fitting message
@@ -138,7 +138,7 @@ Lobby rows show two lines (opponents, then result + score) with a large place-ba
on the right: Victory 🏆 / Defeat 🥈 / Draw 🏅, and for 34-player games II 🥈 / III 🥉 / on the right: Victory 🏆 / Defeat 🥈 / Draw 🏅, and for 34-player games II 🥈 / III 🥉 /
IV 🏅; active games show Your move 🟢 / Opponent's move ⏳; invitations use 💌. 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 - **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 pairing a code **input** with a **Show my code** action that reveals a large 6-digit
+2 -2
View File
@@ -42,7 +42,7 @@ COPY ui ./
RUN pnpm build RUN pnpm build
# --- landing ------------------------------------------------------------------- # --- 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 # served by caddy at /, so stray public traffic is absorbed by static file
# serving and never reaches the Go edge. # serving and never reaches the Go edge.
FROM caddy:2-alpine AS landing 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 # 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 # 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 RUN rm -rf gateway/internal/webui/dist
COPY --from=ui /ui/dist gateway/internal/webui/dist COPY --from=ui /ui/dist gateway/internal/webui/dist
RUN rm gateway/internal/webui/dist/landing.html RUN rm gateway/internal/webui/dist/landing.html
+13 -13
View File
@@ -23,7 +23,7 @@ proto/edge/v1/ # Connect envelope contract (committed generated Go)
internal/config/ # GATEWAY_* env config internal/config/ # GATEWAY_* env config
internal/backendclient/ # typed REST client (+ X-User-ID) and push gRPC client internal/backendclient/ # typed REST client (+ X-User-ID) and push gRPC client
internal/session/ # in-memory session cache (LRU/TTL, backend fallback) 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/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/push/ # live-event fan-out hub (per-user client streams)
internal/transcode/ # FlatBuffers<->REST bridge + message_type registry 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 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. (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`, `auth.email.request`, `auth.email.login`, `profile.get`, `game.submit_play`,
`game.state`, `lobby.enqueue`, `lobby.poll`, `chat.post`; live events `game.state`, `lobby.enqueue`, `lobby.poll`, `chat.post` and the play-loop ops;
`your_turn`, `opponent_moved`, `chat_message`, `nudge`, `match_found` (R4 enriched the game events live events
and `game_over`/`notify` — to carry the state delta the client applies without a `game.state` `your_turn`, `opponent_moved`, `chat_message`, `nudge`, `match_found` (the game events —
refetch). Stage 7 and `game_over`/`notify` — carry the state delta the client applies without a `game.state`
added the play-loop ops; **Stage 8** added the social/account/history ops — refetch). The social/account/history ops —
`friends.*` (list/incoming/request/respond/cancel/unfriend/code.issue/code.redeem), `friends.*` (list/incoming/request/respond/cancel/unfriend/code.issue/code.redeem),
`blocks.*`, `invitation.*` (list/create/accept/decline/cancel), `profile.update`, `blocks.*`, `invitation.*` (list/create/accept/decline/cancel), `profile.update`,
`stats.get`, `game.gcg`, and the `notify` live event — all via the identical `stats.get`, `game.gcg`, and the `notify` live event — go through the identical
transcode pattern (`transcode_social.go`). **Stage 11** added account linking & merge transcode pattern (`transcode_social.go`). Account linking & merge
`link.email.request/confirm/merge` and `link.telegram.confirm/merge` `link.email.request/confirm/merge` and `link.telegram.confirm/merge`
(`transcode_link.go`); the telegram ops validate the **Login Widget** payload via the (`transcode_link.go`); the telegram ops validate the **Login Widget** payload via the
connector (`ValidateLoginWidget`) and forward the trusted `external_id`. These 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 ## Configuration
@@ -81,13 +81,13 @@ connector (`ValidateLoginWidget`) and forward the trusted `external_id`. These
| `GATEWAY_SESSION_TTL` | `10m` | cached session lifetime | | `GATEWAY_SESSION_TTL` | `10m` | cached session lifetime |
| `GATEWAY_SESSION_CACHE_MAX` | `50000` | cached session cap | | `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_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_SERVICE_NAME` | `scrabble-gateway` | OpenTelemetry `service.name` |
| `GATEWAY_OTEL_TRACES_EXPORTER` | `none` | `none`, `stdout` or `otlp` (gRPC; endpoint from `OTEL_EXPORTER_OTLP_*`) | | `GATEWAY_OTEL_TRACES_EXPORTER` | `none` | `none`, `stdout` or `otlp` (gRPC; endpoint from `OTEL_EXPORTER_OTLP_*`) |
| `GATEWAY_OTEL_METRICS_EXPORTER` | `none` | `none`, `stdout` or `otlp` | | `GATEWAY_OTEL_METRICS_EXPORTER` | `none` | `none`, `stdout` or `otlp` |
Rate-limit defaults (built-in): public 30/min·IP (burst 10), authenticated 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), 60/min·IP (burst 20, guarding the `/_gm` mount ahead of its Basic-Auth),
email-code 5/10 min·IP (burst 2). 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 (`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 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 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 ## Run
+3 -3
View File
@@ -42,10 +42,10 @@ const (
// readHeaderTimeout bounds reading one request's headers on the public // readHeaderTimeout bounds reading one request's headers on the public
// listener (a slowloris guard). Bodies and long-lived streams are governed by // listener (a slowloris guard). Bodies and long-lived streams are governed by
// the h2c settings in connectsrv — Read/WriteTimeout stay unset on purpose, // 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 readHeaderTimeout = 10 * time.Second
// throttleReportInterval is the cadence of the rate-limiter rejection // 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 throttleReportInterval = 30 * time.Second
) )
@@ -281,7 +281,7 @@ func deliverOutOfApp(ctx context.Context, backend *backendclient.Client, conn *c
return return
} }
// A game event carries its own language, so the push comes from the game's bot rather than // 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 lang := target.Language
if gameLang != "" { if gameLang != "" {
lang = gameLang lang = gameLang
+14 -14
View File
@@ -35,7 +35,7 @@ type ProfileResp struct {
NotificationsInAppOnly bool `json:"notifications_in_app_only"` 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 // "linked", "merge_required" (the secondary_* fields summarise the other account) or
// "merged". Token is a switched-session token (a guest initiator's durable // "merged". Token is a switched-session token (a guest initiator's durable
// counterpart won); Profile is the surviving/active account's profile. // 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 // 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 { type TileJSON struct {
Row int `json:"row"` Row int `json:"row"`
Col int `json:"col"` Col int `json:"col"`
@@ -58,7 +58,7 @@ type TileJSON struct {
Blank bool `json:"blank"` 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. // blank, Letter is the designated letter's index and Blank is true.
type PlayTileJSON struct { type PlayTileJSON struct {
Row int `json:"row"` 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 // 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 { type MoveResultResp struct {
Move MoveRecordResp `json:"move"` Move MoveRecordResp `json:"move"`
Game GameResp `json:"game"` 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 // 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 { type AlphabetEntryJSON struct {
Index int `json:"index"` Index int `json:"index"`
Letter string `json:"letter"` Letter string `json:"letter"`
Value int `json:"value"` 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. // Alphabet is present only when the request asked for it.
type StateResp struct { type StateResp struct {
Game GameResp `json:"game"` 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 // 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) { func (c *Client) SubmitPlay(ctx context.Context, userID, gameID, dir string, tiles []PlayTileJSON) (MoveResultResp, error) {
var out MoveResultResp var out MoveResultResp
body := map[string]any{"dir": dir, "tiles": tiles} 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 // 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. // cache miss only.
func (c *Client) GameState(ctx context.Context, userID, gameID string, includeAlphabet bool) (StateResp, error) { func (c *Client) GameState(ctx context.Context, userID, gameID string, includeAlphabet bool) (StateResp, error) {
var out StateResp 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 // 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) { func (c *Client) Exchange(ctx context.Context, userID, gameID string, tiles []int) (MoveResultResp, error) {
var out MoveResultResp var out MoveResultResp
err := c.do(ctx, http.MethodPost, c.gamePath(gameID, "/exchange"), userID, "", 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 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. // 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) { func (c *Client) GetDraft(ctx context.Context, userID, gameID string) (json.RawMessage, error) {
var out json.RawMessage var out json.RawMessage
@@ -350,21 +350,21 @@ func (c *Client) GetDraft(ctx context.Context, userID, gameID string) (json.RawM
return out, err 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 // {rack_order, board_tiles} JSON, forwarded verbatim — a json.RawMessage marshals as-is, so
// there is no double-encode. // there is no double-encode.
func (c *Client) SaveDraft(ctx context.Context, userID, gameID string, body json.RawMessage) error { 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) 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. // per-account and irreversible; the game stays visible to the other players.
func (c *Client) HideGame(ctx context.Context, userID, gameID string) error { func (c *Client) HideGame(ctx context.Context, userID, gameID string) error {
return c.do(ctx, http.MethodPost, c.gamePath(gameID, "/hide"), userID, "", struct{}{}, nil) 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 // 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) { func (c *Client) Evaluate(ctx context.Context, userID, gameID, dir string, tiles []PlayTileJSON) (EvalResultResp, error) {
var out EvalResultResp var out EvalResultResp
err := c.do(ctx, http.MethodPost, c.gamePath(gameID, "/evaluate"), userID, "", 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 // 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) { func (c *Client) CheckWord(ctx context.Context, userID, gameID string, word []int) (WordCheckResp, error) {
var out WordCheckResp var out WordCheckResp
q := url.Values{} q := url.Values{}
+5 -5
View File
@@ -6,7 +6,7 @@ import (
"net/url" "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. // account and history JSON DTOs. The transcode layer maps them to FlatBuffers.
// AccountRefResp is a referenced account with its display name resolved. // 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 // 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) { func (c *Client) LinkEmailConfirm(ctx context.Context, userID, email, code string) (LinkResultResp, error) {
var out LinkResultResp var out LinkResultResp
err := c.do(ctx, http.MethodPost, "/api/v1/user/link/email/confirm", userID, "", 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 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) { func (c *Client) LinkEmailMerge(ctx context.Context, userID, email, code string) (LinkResultResp, error) {
var out LinkResultResp var out LinkResultResp
err := c.do(ctx, http.MethodPost, "/api/v1/user/link/email/merge", userID, "", 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 // 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) { func (c *Client) LinkTelegram(ctx context.Context, userID, externalID string) (LinkResultResp, error) {
var out LinkResultResp var out LinkResultResp
err := c.do(ctx, http.MethodPost, "/api/v1/user/link/telegram", userID, "", 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 // 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) { func (c *Client) LinkTelegramMerge(ctx context.Context, userID, externalID string) (LinkResultResp, error) {
var out LinkResultResp var out LinkResultResp
err := c.do(ctx, http.MethodPost, "/api/v1/user/link/telegram/merge", userID, "", err := c.do(ctx, http.MethodPost, "/api/v1/user/link/telegram/merge", userID, "",
+1 -1
View File
@@ -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 // ReportRateLimited posts the gateway's periodic rate-limiter rejection summary
// to the backend, which feeds the admin console's throttled view and the // to the backend, which feeds the admin console's throttled view and the
// high-rate auto-flag. The endpoint carries no user identity: like // 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 { func (c *Client) ReportRateLimited(ctx context.Context, windowSeconds int, entries []ratelimit.Rejection) error {
body := struct { body := struct {
WindowSeconds int `json:"window_seconds"` WindowSeconds int `json:"window_seconds"`
+4 -4
View File
@@ -76,13 +76,13 @@ const (
defaultBackendTimeout = 5 * time.Second defaultBackendTimeout = 5 * time.Second
defaultSessionTTL = 10 * time.Minute defaultSessionTTL = 10 * time.Minute
defaultSessionCacheMax = 50000 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" defaultServiceName = "scrabble-gateway"
) )
// DefaultMaxBodyBytes is the default request-body cap (GATEWAY_MAX_BODY_BYTES): // 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) // 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 const DefaultMaxBodyBytes = 1 << 20
// supportedLanguages is the set of game languages a service may declare for the // supportedLanguages is the set of game languages a service may declare for the
@@ -98,8 +98,8 @@ func DefaultRateLimit() RateLimitConfig {
PublicPerMinute: 30, PublicBurst: 10, PublicPerMinute: 30, PublicBurst: 10,
// Per-user (not per-IP): one user may run several devices, each holding a // 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 // 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 // budget is generous (a per-user cap cannot DoS the service). It is raised
// after multi-device play tripped the old 120/40. // because multi-device play tripped the old 120/40.
UserPerMinute: 300, UserBurst: 80, UserPerMinute: 300, UserBurst: 80,
AdminPerMinute: 60, AdminBurst: 20, AdminPerMinute: 60, AdminBurst: 20,
EmailPer10Min: 5, EmailBurst: 2, EmailPer10Min: 5, EmailBurst: 2,
+1 -1
View File
@@ -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 // ValidateLoginWidget verifies Telegram Login Widget data and returns the user
// identity, mapping a connector InvalidArgument to ErrInvalidLoginWidget. It backs // 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) { func (c *Client) ValidateLoginWidget(ctx context.Context, data string) (User, error) {
resp, err := c.c.ValidateLoginWidget(ctx, &telegramv1.ValidateLoginWidgetRequest{Data: data}) resp, err := c.c.ValidateLoginWidget(ctx, &telegramv1.ValidateLoginWidgetRequest{Data: data})
if err != nil { if err != nil {
+1 -1
View File
@@ -7,7 +7,7 @@ import (
// TestPeerIP covers the client-IP extraction the chat-moderation IP and the per-IP rate // 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 // 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) { func TestPeerIP(t *testing.T) {
tests := []struct { tests := []struct {
name string name string
+13 -13
View File
@@ -35,7 +35,7 @@ import (
const heartbeatKind = "heartbeat" const heartbeatKind = "heartbeat"
// Limiter classes, the `class` attribute of gateway_rate_limited_total and the // 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 ( const (
classUser = "user" classUser = "user"
classPublic = "public" classPublic = "public"
@@ -43,13 +43,13 @@ const (
classAdmin = "admin" classAdmin = "admin"
) )
// Explicit h2c server sizing (R3, after the R2 stress run questioned the // Explicit h2c server sizing, made explicit rather than relying on the
// implicit defaults). // implicit defaults.
const ( const (
// h2cMaxConcurrentStreams bounds the open streams per client connection — the // h2cMaxConcurrentStreams bounds the open streams per client connection — the
// x/net default made explicit. A real client holds one Subscribe stream plus a // 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 // few unary calls; only a synthetic load multiplexing many players over one
// transport approaches it. R7 revisits the sizing. // transport approaches it.
h2cMaxConcurrentStreams = 250 h2cMaxConcurrentStreams = 250
// h2cIdleTimeout closes a connection with no open streams. A live Subscribe // h2cIdleTimeout closes a connection with no open streams. A live Subscribe
// stream keeps its connection active, so long-lived clients are unaffected; // 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 // 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 // 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 — // 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)) mux.Handle("/_gm/", s.limitAdmin(s.adminProxy))
} else { } else {
// With the console disabled here, keep /_gm a 404 so the SPA catch-all below // 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 // 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 // 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 // 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 // public landing lives in its own static container behind the contour caddy,
// (R3), so the catch-all redirects a stray root hit to the app shell — which // so the catch-all redirects a stray root hit to the app shell — which
// keeps a local no-caddy run usable. // keeps a local no-caddy run usable.
mux.Handle("/telegram/", webui.Handler("/telegram/", "index.html")) mux.Handle("/telegram/", webui.Handler("/telegram/", "index.html"))
mux.Handle("/app/", webui.Handler("/app/", "index.html")) mux.Handle("/app/", webui.Handler("/app/", "index.html"))
mux.Handle("/", http.RedirectHandler("/app/", http.StatusPermanentRedirect)) mux.Handle("/", http.RedirectHandler("/app/", http.StatusPermanentRedirect))
// Every request body on the public listener is capped (the admin proxy POSTs // 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{ return h2c.NewHandler(maxBodyHandler(s.maxBodyBytes, mux), &http2.Server{
MaxConcurrentStreams: h2cMaxConcurrentStreams, MaxConcurrentStreams: h2cMaxConcurrentStreams,
IdleTimeout: h2cIdleTimeout, 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 // 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 // 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 // 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 { if err := stream.Send(&edgev1.Event{Kind: heartbeatKind}); err != nil {
return err 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 // noteRateLimited accounts one limiter rejection: the aggregate counter, the
// per-rejection Debug line and the periodic-report tracker. The operational // 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 // 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) { func (s *Server) noteRateLimited(ctx context.Context, class, key, msgType string) {
s.metrics.recordRateLimited(ctx, class) s.metrics.recordRateLimited(ctx, class)
s.tracker.Add(class, key) 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). // 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 // 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 // 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 { func (s *Server) limitAdmin(next http.Handler) http.Handler {
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
ip := peerIP(r.RemoteAddr, r.Header) 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 // 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 // stays silent; anything else — a resolve timeout, a refused connection, a
// backend 5xx — is an infra failure misread as "unauthenticated" by the // backend 5xx — is an infra failure misread as "unauthenticated" by the
// client, so surface the cause (the transient resolves seen under load in // client, so surface the cause (the transient resolves seen under load).
// the R2 stress run). The token itself is never logged. // The token itself is never logged.
var apiErr *backendclient.APIError var apiErr *backendclient.APIError
if !errors.As(err, &apiErr) || apiErr.Status >= http.StatusInternalServerError { if !errors.As(err, &apiErr) || apiErr.Status >= http.StatusInternalServerError {
s.log.Warn("session resolve failed", zap.Error(err)) s.log.Warn("session resolve failed", zap.Error(err))
+4 -4
View File
@@ -85,7 +85,7 @@ func TestExecuteAuthedRequiresSession(t *testing.T) {
// TestExecuteRateLimitedTracked verifies a limiter rejection returns // TestExecuteRateLimitedTracked verifies a limiter rejection returns
// ResourceExhausted and lands in the rejection tracker under the public class, // 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) { func TestExecuteRateLimitedTracked(t *testing.T) {
backendSrv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { 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"}`)) _, _ = 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 // 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) { func TestAdminMountRateLimited(t *testing.T) {
backendSrv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {})) backendSrv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {}))
defer backendSrv.Close() defer backendSrv.Close()
@@ -181,7 +181,7 @@ func TestAdminMountRateLimited(t *testing.T) {
// TestExecuteOversizedPayloadRejected verifies the request-body cap: an Execute // TestExecuteOversizedPayloadRejected verifies the request-body cap: an Execute
// message above GATEWAY_MAX_BODY_BYTES is refused at the edge without reaching // message above GATEWAY_MAX_BODY_BYTES is refused at the edge without reaching
// the backend (R3). // the backend.
func TestExecuteOversizedPayloadRejected(t *testing.T) { func TestExecuteOversizedPayloadRejected(t *testing.T) {
client, cleanup := newEdge(t, func(w http.ResponseWriter, r *http.Request) { client, cleanup := newEdge(t, func(w http.ResponseWriter, r *http.Request) {
t.Error("backend must not be called for an oversized payload") 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 "/" // 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. // to the app shell.
func TestRootRedirectsToApp(t *testing.T) { func TestRootRedirectsToApp(t *testing.T) {
front := httptest.NewServer(connectsrv.NewServer(connectsrv.Deps{}).HTTPHandler()) front := httptest.NewServer(connectsrv.NewServer(connectsrv.Deps{}).HTTPHandler())
+56 -102
View File
@@ -5,6 +5,7 @@ import (
"scrabble/gateway/internal/backendclient" "scrabble/gateway/internal/backendclient"
fb "scrabble/pkg/fbs/scrabblefb" fb "scrabble/pkg/fbs/scrabblefb"
"scrabble/pkg/wire"
) )
// The encoders build the FlatBuffers response payloads from the backend's typed // 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() 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 // (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 // 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 // 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 // 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). // client requests it on a per-variant cache miss).
func encodeState(s backendclient.StateResp) []byte { func encodeState(s backendclient.StateResp) []byte {
b := flatbuffers.NewBuilder(512) b := flatbuffers.NewBuilder(512)
game := buildGameView(b, s.Game) b.Finish(wire.BuildStateView(b, toWireState(s)))
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))
return b.FinishedBytes() return b.FinishedBytes()
} }
// buildAlphabet builds the AlphabetEntry vector for a StateView and returns its offset. // toWireState maps a StateResp to the shared wire.StateView.
func buildAlphabet(b *flatbuffers.Builder, entries []backendclient.AlphabetEntryJSON) flatbuffers.UOffsetT { func toWireState(s backendclient.StateResp) wire.StateView {
offs := make([]flatbuffers.UOffsetT, len(entries)) alphabet := make([]wire.AlphabetEntry, len(s.Alphabet))
for i, e := range entries { for i, e := range s.Alphabet {
letter := b.CreateString(e.Letter) alphabet[i] = wire.AlphabetEntry{Index: e.Index, Letter: e.Letter, Value: e.Value}
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)) return wire.StateView{
for i := len(offs) - 1; i >= 0; i-- { Game: toWireGame(s.Game),
b.PrependUOffsetT(offs[i]) Seat: s.Seat,
Rack: s.Rack,
BagLen: s.BagLen,
HintsRemaining: s.HintsRemaining,
Alphabet: alphabet,
} }
return b.EndVector(len(offs))
} }
// encodeMatch builds a MatchResult payload. // encodeMatch builds a MatchResult payload.
@@ -328,80 +307,55 @@ func encodeChatList(r backendclient.ChatListResp) []byte {
// buildGameView builds a GameView table and returns its offset. // buildGameView builds a GameView table and returns its offset.
func buildGameView(b *flatbuffers.Builder, g backendclient.GameResp) flatbuffers.UOffsetT { 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 { for i, s := range g.Seats {
aid := b.CreateString(s.AccountID) seats[i] = wire.SeatView{
dname := b.CreateString(s.DisplayName) Seat: s.Seat,
fb.SeatViewStart(b) AccountID: s.AccountID,
fb.SeatViewAddSeat(b, int32(s.Seat)) Score: s.Score,
fb.SeatViewAddAccountId(b, aid) HintsUsed: s.HintsUsed,
fb.SeatViewAddScore(b, int32(s.Score)) IsWinner: s.IsWinner,
fb.SeatViewAddHintsUsed(b, int32(s.HintsUsed)) DisplayName: s.DisplayName,
fb.SeatViewAddIsWinner(b, s.IsWinner) }
fb.SeatViewAddDisplayName(b, dname)
seatOffs[i] = fb.SeatViewEnd(b)
} }
fb.GameViewStartSeatsVector(b, len(seatOffs)) return wire.GameView{
for i := len(seatOffs) - 1; i >= 0; i-- { ID: g.ID,
b.PrependUOffsetT(seatOffs[i]) 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. // buildMoveRecord builds a MoveRecord table and returns its offset.
func buildMoveRecord(b *flatbuffers.Builder, m backendclient.MoveRecordResp) flatbuffers.UOffsetT { 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 { for i, t := range m.Tiles {
letter := b.CreateString(t.Letter) tiles[i] = wire.TileRecord{Row: t.Row, Col: t.Col, Letter: t.Letter, Blank: t.Blank}
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)) return wire.BuildMoveRecord(b, wire.MoveRecord{
for i := len(tileOffs) - 1; i >= 0; i-- { Player: m.Player,
b.PrependUOffsetT(tileOffs[i]) Action: m.Action,
} Dir: m.Dir,
tiles := b.EndVector(len(tileOffs)) MainRow: m.MainRow,
MainCol: m.MainCol,
words := buildStringVector(b, m.Words, fb.MoveRecordStartWordsVector) Tiles: tiles,
Words: m.Words,
action := b.CreateString(m.Action) Count: m.Count,
dir := b.CreateString(m.Dir) Score: m.Score,
fb.MoveRecordStart(b) Total: m.Total,
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)
} }
// buildStringVector builds a vector of strings using the table-specific // buildStringVector builds a vector of strings using the table-specific
+23 -42
View File
@@ -5,19 +5,15 @@ import (
"scrabble/gateway/internal/backendclient" "scrabble/gateway/internal/backendclient"
fb "scrabble/pkg/fbs/scrabblefb" 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). // encode.go's bottom-up rule (build every string/child vector before the table).
// buildAccountRef builds an AccountRef table and returns its offset. // buildAccountRef builds an AccountRef table and returns its offset.
func buildAccountRef(b *flatbuffers.Builder, r backendclient.AccountRefResp) flatbuffers.UOffsetT { func buildAccountRef(b *flatbuffers.Builder, r backendclient.AccountRefResp) flatbuffers.UOffsetT {
id := b.CreateString(r.AccountID) return wire.BuildAccountRef(b, wire.AccountRef{AccountID: r.AccountID, DisplayName: r.DisplayName})
name := b.CreateString(r.DisplayName)
fb.AccountRefStart(b)
fb.AccountRefAddAccountId(b, id)
fb.AccountRefAddDisplayName(b, name)
return fb.AccountRefEnd(b)
} }
// buildAccountRefVector builds a [AccountRef] vector using the table-specific // 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. // buildInvitation builds an Invitation table and returns its offset.
func buildInvitation(b *flatbuffers.Builder, inv backendclient.InvitationResp) flatbuffers.UOffsetT { 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 { for i, iv := range inv.Invitees {
aid := b.CreateString(iv.AccountID) invitees[i] = wire.InvitationInvitee{
name := b.CreateString(iv.DisplayName) AccountID: iv.AccountID,
resp := b.CreateString(iv.Response) DisplayName: iv.DisplayName,
fb.InvitationInviteeStart(b) Seat: iv.Seat,
fb.InvitationInviteeAddAccountId(b, aid) Response: iv.Response,
fb.InvitationInviteeAddDisplayName(b, name) }
fb.InvitationInviteeAddSeat(b, int32(iv.Seat))
fb.InvitationInviteeAddResponse(b, resp)
inviteeOffs[i] = fb.InvitationInviteeEnd(b)
} }
fb.InvitationStartInviteesVector(b, len(inviteeOffs)) return wire.BuildInvitation(b, wire.Invitation{
for i := len(inviteeOffs) - 1; i >= 0; i-- { ID: inv.ID,
b.PrependUOffsetT(inviteeOffs[i]) Inviter: wire.AccountRef{AccountID: inv.Inviter.AccountID, DisplayName: inv.Inviter.DisplayName},
} Invitees: invitees,
invitees := b.EndVector(len(inviteeOffs)) Variant: inv.Variant,
TurnTimeoutSecs: inv.TurnTimeoutSecs,
inviter := buildAccountRef(b, inv.Inviter) HintsAllowed: inv.HintsAllowed,
id := b.CreateString(inv.ID) HintsPerPlayer: inv.HintsPerPlayer,
variant := b.CreateString(inv.Variant) DropoutTiles: inv.DropoutTiles,
dropout := b.CreateString(inv.DropoutTiles) Status: inv.Status,
status := b.CreateString(inv.Status) GameID: inv.GameID,
gameID := b.CreateString(inv.GameID) ExpiresAtUnix: inv.ExpiresAtUnix,
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)
} }
// encodeInvitation builds an Invitation payload. // encodeInvitation builds an Invitation payload.
+10 -10
View File
@@ -2,7 +2,7 @@
// maps to a handler that decodes the FlatBuffers request payload, calls the // maps to a handler that decodes the FlatBuffers request payload, calls the
// backend over REST, and encodes the FlatBuffers response. The registry is the // backend over REST, and encodes the FlatBuffers response. The registry is the
// authoritative message_type catalog; new operations are added here following 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 package transcode
import ( import (
@@ -69,7 +69,7 @@ type Registry struct {
} }
// TelegramValidator validates Telegram credentials via the connector side-service: // 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 // *connector.Client implements it; a nil value disables the telegram auth and
// telegram-link paths. // telegram-link paths.
type TelegramValidator interface { 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[MsgDraftGet] = Op{Handler: getDraftHandler(backend), Auth: true}
r.ops[MsgDraftSave] = Op{Handler: saveDraftHandler(backend), Auth: true} r.ops[MsgDraftSave] = Op{Handler: saveDraftHandler(backend), Auth: true}
r.ops[MsgGameHide] = Op{Handler: hideGameHandler(backend), Auth: true} r.ops[MsgGameHide] = Op{Handler: hideGameHandler(backend), Auth: true}
registerStage8(r, backend) registerSocialOps(r, backend)
registerStage11(r, backend, tg, defaultLanguages) registerLinkOps(r, backend, tg, defaultLanguages)
return r 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 { func decodeTiles(in *fb.SubmitPlayRequest) []backendclient.PlayTileJSON {
n := in.TilesLength() n := in.TilesLength()
tiles := make([]backendclient.PlayTileJSON, 0, n) tiles := make([]backendclient.PlayTileJSON, 0, n)
@@ -282,7 +282,7 @@ func decodeTiles(in *fb.SubmitPlayRequest) []backendclient.PlayTileJSON {
return tiles 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 { func decodeEvalTiles(in *fb.EvalRequest) []backendclient.PlayTileJSON {
n := in.TilesLength() n := in.TilesLength()
tiles := make([]backendclient.PlayTileJSON, 0, n) 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 // 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 { func bytesToInts(bs []byte) []int {
out := make([]int, len(bs)) out := make([]int, len(bs))
for i, b := range 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. // GameActionRequest for the game id and wraps the backend's raw JSON in a DraftView.
func getDraftHandler(backend *backendclient.Client) Handler { func getDraftHandler(backend *backendclient.Client) Handler {
return func(ctx context.Context, req Request) ([]byte, error) { 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. // string verbatim. It echoes an empty DraftView as a well-formed acknowledgement.
func saveDraftHandler(backend *backendclient.Client) Handler { func saveDraftHandler(backend *backendclient.Client) Handler {
return func(ctx context.Context, req Request) ([]byte, error) { 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. // GameActionRequest for the game id and echoes an Ack.
func hideGameHandler(backend *backendclient.Client) Handler { func hideGameHandler(backend *backendclient.Client) Handler {
return func(ctx context.Context, req Request) ([]byte, error) { return func(ctx context.Context, req Request) ([]byte, error) {
@@ -15,7 +15,7 @@ import (
// TestGameStateIncludesAlphabet checks the include_alphabet flag reaches the backend and // 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 // 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) { func TestGameStateIncludesAlphabet(t *testing.T) {
backend, cleanup := fakeBackend(t, func(w http.ResponseWriter, r *http.Request) { backend, cleanup := fakeBackend(t, func(w http.ResponseWriter, r *http.Request) {
if got := r.URL.Query().Get("include_alphabet"); got != "true" { 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 // 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) { func TestSubmitPlayForwardsIndexTiles(t *testing.T) {
var body struct { var body struct {
Dir string `json:"dir"` Dir string `json:"dir"`
@@ -135,7 +135,7 @@ func TestSubmitPlayForwardsIndexTiles(t *testing.T) {
} }
// TestCheckWordForwardsIndices checks the word-check query rides as repeated ?idx= params // 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) { func TestCheckWordForwardsIndices(t *testing.T) {
backend, cleanup := fakeBackend(t, func(w http.ResponseWriter, r *http.Request) { 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" { 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 // TestExchangeForwardsIndices checks rack-exchange indices (blank 255) reach the backend
// body (Stage 13). // body.
func TestExchangeForwardsIndices(t *testing.T) { func TestExchangeForwardsIndices(t *testing.T) {
var body struct { var body struct {
Tiles []int `json:"tiles"` Tiles []int `json:"tiles"`
@@ -13,7 +13,7 @@ import (
) )
// TestDraftSaveForwardsRawJSON checks the save handler forwards the client's composition JSON // 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) { func TestDraftSaveForwardsRawJSON(t *testing.T) {
const body = `{"rack_order":"1,0","board_tiles":[{"row":7,"col":7,"letter":"Q","blank":false}]}` 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) { backend, cleanup := fakeBackend(t, func(w http.ResponseWriter, r *http.Request) {
+3 -3
View File
@@ -7,7 +7,7 @@ import (
fb "scrabble/pkg/fbs/scrabblefb" 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 // email rate flag; the telegram ops validate Login Widget data through the
// connector (registered only when the connector is configured). All are // connector (registered only when the connector is configured). All are
// authenticated. The merge ops are the explicit irreversible step, gated in the UI // authenticated. The merge ops are the explicit irreversible step, gated in the UI
@@ -20,11 +20,11 @@ const (
MsgLinkTelegramMerge = "link.telegram.merge" 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. // 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 // supportedLangs is the variant gating set for a switched link session (the link
// flows run on the web, so the gateway default set). // 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[MsgLinkEmailRequest] = Op{Handler: linkEmailRequestHandler(backend), Auth: true, Email: true}
r.ops[MsgLinkEmailConfirm] = Op{Handler: linkEmailConfirmHandler(backend, supportedLangs), 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} r.ops[MsgLinkEmailMerge] = Op{Handler: linkEmailMergeHandler(backend, supportedLangs), Auth: true, Email: true}
@@ -7,9 +7,9 @@ import (
fb "scrabble/pkg/fbs/scrabblefb" 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 // 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 ( const (
MsgFriendsList = "friends.list" MsgFriendsList = "friends.list"
MsgFriendsIncoming = "friends.incoming" MsgFriendsIncoming = "friends.incoming"
@@ -33,9 +33,9 @@ const (
MsgGameGCG = "game.gcg" 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). // 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[MsgFriendsList] = Op{Handler: friendsListHandler(backend), Auth: true}
r.ops[MsgFriendsIncoming] = Op{Handler: friendsIncomingHandler(backend), Auth: true} r.ops[MsgFriendsIncoming] = Op{Handler: friendsIncomingHandler(backend), Auth: true}
r.ops[MsgFriendsOutgoing] = Op{Handler: friendsOutgoingHandler(backend), Auth: true} r.ops[MsgFriendsOutgoing] = Op{Handler: friendsOutgoingHandler(backend), Auth: true}
+1 -1
View File
@@ -151,7 +151,7 @@ func gameActionPayload(gameID string) []byte {
} }
// TestHideGameForwardsToBackend checks game.hide reuses GameActionRequest, POSTs to the // 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) { func TestHideGameForwardsToBackend(t *testing.T) {
var hit bool var hit bool
backend, cleanup := fakeBackend(t, func(w http.ResponseWriter, r *http.Request) { backend, cleanup := fakeBackend(t, func(w http.ResponseWriter, r *http.Request) {
+2 -2
View File
@@ -3,13 +3,13 @@
// The committed dist/ holds only a placeholder index.html so the gateway module // 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 // 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 // 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 // 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 // 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 // mounted at /app/ (web) and /telegram/ (the Telegram Mini App) — the single-origin
// model in docs/ARCHITECTURE.md §13. // 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 // 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. // no-cache so a new deploy is picked up immediately.
package webui package webui
+3 -3
View File
@@ -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 seeds a large account population with pre-created sessions, drives virtual players
through the **gateway edge protocol** in realistic games, hammers the rate limiter, 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. 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 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 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.
+1 -1
View File
@@ -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 // population with pre-created sessions directly in the backend Postgres, then drives
// virtual players through the gateway edge protocol (real games assembled via // virtual players through the gateway edge protocol (real games assembled via
// invitations, legal moves generated locally by the embedded solver), and a // invitations, legal moves generated locally by the embedded solver), and a
+2 -2
View File
@@ -18,11 +18,11 @@ import (
"scrabble/loadtest/internal/edge" "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 const blankIndex = 255
// variantSpec maps an edge variant label to its ruleset constructor and committed // 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 { type variantSpec struct {
ruleset func() *rules.Ruleset ruleset func() *rules.Ruleset
dawg string dawg string

Some files were not shown because too many files have changed in this diff Show More