Merge pull request 'R6: refactor + docs reconciliation + de-staging' (#37) from feature/r6-refactor-destage into development
This commit was merged in pull request #37.
This commit is contained in:
@@ -1,6 +1,6 @@
|
||||
name: CI
|
||||
|
||||
# Single gated pipeline for the test contour (Stage 16/17). Gitea cannot express
|
||||
# Single gated pipeline for the test contour. Gitea cannot express
|
||||
# cross-workflow `needs`, so the full test suite and the auto test-deploy live in
|
||||
# one workflow.
|
||||
#
|
||||
@@ -9,9 +9,9 @@ name: CI
|
||||
# `development` or `master` (the full test suite — the merge gate) and on a push
|
||||
# to `development` (after a merge). The deploy job runs only for `development`
|
||||
# (PR or merge), so a PR into `master` is test-only; the prod deploy is a manual
|
||||
# workflow (Stage 18).
|
||||
# workflow.
|
||||
#
|
||||
# Path-conditional jobs (Stage 17): `unit`/`integration`/`ui` run only when their
|
||||
# Path-conditional jobs: `unit`/`integration`/`ui` run only when their
|
||||
# code changed (the `changes` job decides). Because a skipped required check would
|
||||
# block a merge under branch protection, the always-running `gate` job aggregates
|
||||
# their results and is the ONLY required status check; it passes when every
|
||||
@@ -263,7 +263,7 @@ jobs:
|
||||
TELEGRAM_GAME_CHANNEL_ID_EN: ${{ vars.TEST_TELEGRAM_GAME_CHANNEL_ID_EN }}
|
||||
TELEGRAM_GAME_CHANNEL_ID_RU: ${{ vars.TEST_TELEGRAM_GAME_CHANNEL_ID_RU }}
|
||||
# The test contour always uses Telegram's test environment — pinned here,
|
||||
# not an operator variable. Stage 18's prod workflow leaves it false.
|
||||
# not an operator variable. The prod workflow leaves it false.
|
||||
TELEGRAM_TEST_ENV: "true"
|
||||
VITE_TELEGRAM_BOT_ID: ${{ vars.TEST_VITE_TELEGRAM_BOT_ID }}
|
||||
VITE_TELEGRAM_LINK: ${{ vars.TEST_VITE_TELEGRAM_LINK }}
|
||||
@@ -301,7 +301,7 @@ jobs:
|
||||
- name: Probe the landing and the gateway through caddy
|
||||
run: |
|
||||
set -u
|
||||
# Two probes through the contour caddy (R3 split): "/" is the static
|
||||
# Two probes through the contour caddy: "/" is the static
|
||||
# landing container, "/app/" is the gateway-served SPA shell.
|
||||
for i in $(seq 1 20); do
|
||||
if docker run --rm --network edge alpine:3.20 wget -q -T 5 -O /dev/null http://scrabble/ &&
|
||||
|
||||
+34
-2
@@ -22,7 +22,7 @@ the edge before prod. Each phase maps back to the owner's raw pre-release TODO l
|
||||
| R3 | Edge hardening | 2 + 8 + 3 | **done** |
|
||||
| R4 | Push enrichment + kill the last poll | 4 + 5 | **done** |
|
||||
| R5 | Bundle slimming | 6 | **done** |
|
||||
| R6 | Refactor + docs reconciliation + de-staging | 7 | todo |
|
||||
| R6 | Refactor + docs reconciliation + de-staging | 7 | **done** |
|
||||
| R7 | Final stress run + tuning | 9b | todo |
|
||||
| → | Stage 18 — prod contour deploy | — | see [`PLAN.md`](PLAN.md) |
|
||||
|
||||
@@ -156,7 +156,7 @@ landing (≈24 KB) is reported separately and kept minimal. Same CLI + exit-code
|
||||
step is unchanged.
|
||||
- Critical files: `ui/scripts/bundle-size.mjs`; no app code changed.
|
||||
|
||||
### R6 — Refactor + docs reconciliation + de-staging *(TODO 7)* — near last
|
||||
### R6 — Refactor + docs reconciliation + de-staging *(TODO 7)* — done
|
||||
Behaviour-preserving only. Three separable, separately-committed passes: (a) mechanical
|
||||
**de-staging** — remove `Stage N`/`TODO-N` references from code, comments and service
|
||||
READMEs (rename `stage6_test.go`); (b) **docs↔code reconciliation** — reconcile
|
||||
@@ -348,3 +348,35 @@ Then Stage 18.
|
||||
app total (≈97) and landing total (≈24.5). Same CLI + exit-code contract, so the CI step is unchanged.
|
||||
- **No app/source/build change** (`App.svelte`, `lib/i18n/`, `vite.config.ts` untouched); no schema
|
||||
change, no contour wipe. The stale "~82 KB" figure was corrected in `bundle-size.mjs` and `ui/README.md`.
|
||||
|
||||
- **R6** (interview + implementation):
|
||||
- **Locked decisions:** apply **both** wire/code structural changes (**B** + **A**) and **only C1+C2** of
|
||||
the test consolidation (not C3/C5); strip the `*(Stage N)*` tags from **all current-state docs**
|
||||
(ARCHITECTURE / FUNCTIONAL+`_ru` / TESTING / UI_DESIGN), keeping PLAN.md / PRERELEASE.md / CLAUDE.md as
|
||||
history; **split `stage6_test.go`** by domain. The `h2cMaxConcurrentStreams` sizing stays an **R7**
|
||||
concern (tuning, not behaviour-preserving); the R2 early run forced no code fix, so nothing was carried in.
|
||||
- **(a) De-staging:** removed the `Stage N` / `TODO-N` / `(RN)` references across code, comments, service
|
||||
READMEs and the current-state docs, rewording narratives to present tense (no technical content lost).
|
||||
Renamed the only stage-named identifiers (`registerStage8`→`registerSocialOps`,
|
||||
`registerStage11`→`registerLinkOps`) and split `stage6_test.go` (`TestEmailLoginFlow`→`email_test.go`;
|
||||
`TestGuestAutoMatchLeavesNoStats`+`provisionGuest`→`account_test.go`). De-staged the `.fbs`/`.proto`
|
||||
comments and regenerated: only the `.proto`-derived Go docstrings (`*_grpc.pb.go`, `push.pb.go`) changed —
|
||||
flatc strips schema comments, so the FB Go/TS bindings were untouched.
|
||||
- **(b) Reconciliation:** the docs were accurate (each R-phase baked its own); the one drift was a stale
|
||||
"guest-reaping deferred (TODO-3)" note in `ARCHITECTURE.md` §3 — guest reaping is implemented, so the
|
||||
note was replaced with the current behaviour (FUNCTIONAL/TESTING already described it).
|
||||
- **(c) B — dead `opponent_moved` scalars:** removed `seat/action/score/total` from `OpponentMovedEvent`
|
||||
(`pkg/fbs/scrabble.fbs` + the `notify` emit + the round-trip test); regenerated FB Go + TS. No reader
|
||||
used them (the UI codec/mock take `move`/`game`/`bag_len`; the gateway forwards the payload verbatim).
|
||||
A pre-release wire-slot renumber — free with no prod data, no DB change.
|
||||
- **(c) A — shared FB builders:** new `scrabble/pkg/wire` holds the single definition of the nested wire
|
||||
tables (GameView / MoveRecord / StateView / AccountRef / Invitation) shared by the backend `notify`
|
||||
encoder and the gateway `transcode`; both map their own source types to neutral `wire.*` structs and
|
||||
delegate. **Honest tradeoff:** the verbose `Start/Add/End` + reverse-prepend boilerplate is now written
|
||||
once, but the field *set* is still mapped per side, and the new package makes the change net **+~145 LOC**
|
||||
— a single-source / anti-drift win for the fiddly mechanics rather than a line-count cut. Behaviour-
|
||||
preserving: the two sides' field sets were verified identical and the round-trip tests pass unchanged.
|
||||
- **(c) C1+C2 — inttest fixtures:** moved the cross-file service/game fixtures (`newGameService` was used by
|
||||
10 files) into `backend/internal/inttest/helpers.go`; single-file helpers stay local. Pure relocation.
|
||||
- **No schema change → no contour DB wipe.** Regression gate: the full unit + integration + UI suites plus
|
||||
the R7 stress run.
|
||||
|
||||
@@ -8,14 +8,13 @@ supports English Scrabble, Russian Scrabble and Эрудит.
|
||||
|
||||
- **`gateway`** — the only public ingress: anti-abuse, platform authentication
|
||||
(resolves the player and injects `X-User-ID`), routing to `backend`, and an
|
||||
admin surface behind Basic Auth. *(added in a later stage)*
|
||||
admin surface behind Basic Auth.
|
||||
- **`backend`** — internal-only service that owns every domain concern and
|
||||
embeds the [`scrabble-solver`](../scrabble-solver) engine library in-process.
|
||||
- **`ui`** — pure-HTML5 client (plain Svelte 5 + TypeScript + Vite) over Connect-RPC
|
||||
+ FlatBuffers, embeddable in platform webviews and packageable to native via
|
||||
Capacitor. See [`ui/README.md`](ui/README.md).
|
||||
- **`platform/*`** — per-platform side-services (e.g. the Telegram bot).
|
||||
*(added in a later stage)*
|
||||
|
||||
## Documentation (sources of truth)
|
||||
|
||||
@@ -98,6 +97,6 @@ docker compose -f deploy/docker-compose.yml config # validate (needs the
|
||||
|
||||
CI auto-deploys the **test contour** on a PR into — or push to — `development`
|
||||
(`.gitea/workflows/ci.yaml`); the **prod contour** is a manual deploy after
|
||||
`development → master` (Stage 18). Env reference: [`deploy/.env.example`](deploy/.env.example);
|
||||
`development → master`. Env reference: [`deploy/.env.example`](deploy/.env.example);
|
||||
the topology and the two-contour model are in
|
||||
[`docs/ARCHITECTURE.md`](docs/ARCHITECTURE.md) §13.
|
||||
|
||||
+1
-1
@@ -2,7 +2,7 @@
|
||||
# a golang-alpine builder yields a static binary shipped on distroless nonroot.
|
||||
#
|
||||
# The dictionary DAWGs are baked in from the scrabble-dictionary release artifact
|
||||
# (Stage 14) — the same set the Go CI downloads — and BACKEND_DICT_DIR points the
|
||||
# — the same set the Go CI downloads — and BACKEND_DICT_DIR points the
|
||||
# binary at them. The published solver module is fetched directly from Gitea
|
||||
# (GOPRIVATE), so the build stage needs git and network.
|
||||
#
|
||||
|
||||
+39
-41
@@ -1,24 +1,24 @@
|
||||
# backend
|
||||
|
||||
Internal-only domain service for the Scrabble platform (module `scrabble/backend`).
|
||||
It owns identity/sessions, accounts, and — in later stages — the lobby, game
|
||||
runtime, robot, chat, history and administration. Its only network consumers are
|
||||
the `gateway` and the platform side-services; it is never exposed publicly.
|
||||
It owns identity/sessions, accounts, the lobby, game runtime, robot, chat, history
|
||||
and administration. Its only network consumers are the `gateway` and the platform
|
||||
side-services; it is never exposed publicly.
|
||||
|
||||
As of Stage 1 the backend provides the foundation: configuration, the HTTP
|
||||
listener with the `/api/v1` route-group skeleton and probes, the Postgres pool
|
||||
with embedded goose migrations, OpenTelemetry wiring, an in-memory session cache,
|
||||
and the durable accounts / identities / sessions data model. The session and
|
||||
account REST endpoints are added with the `gateway` (Stage 6); Stage 1 ships the
|
||||
store/service layer they will call.
|
||||
The backend provides the foundation: configuration, the HTTP listener with the
|
||||
`/api/v1` route-group skeleton and probes, the Postgres pool with embedded goose
|
||||
migrations, OpenTelemetry wiring, an in-memory session cache, and the durable
|
||||
accounts / identities / sessions data model. The session and account REST
|
||||
endpoints live in the `gateway`; the backend ships the store/service layer they
|
||||
call.
|
||||
|
||||
Stage 2 adds `internal/engine`, the in-process bridge to the `scrabble-solver`
|
||||
`internal/engine` is the in-process bridge to the `scrabble-solver`
|
||||
library: a versioned dictionary registry, a deterministic tile bag, and a pure
|
||||
rules `Game` (legal plays, passes, exchanges, resignations and end-condition
|
||||
detection) that emits dictionary-independent move records. It is a library only;
|
||||
the game domain wires it into the process in Stage 3.
|
||||
the game domain wires it into the process.
|
||||
|
||||
Stage 3 adds `internal/game`, the game domain over the engine. Active games are
|
||||
`internal/game` is the game domain over the engine. Active games are
|
||||
event-sourced: a `games` row plus an append-only decoded move journal, with the
|
||||
live `engine.Game` kept warm in a cache and rebuilt by replay on a miss. It
|
||||
provides create, the play/pass/exchange/resign transitions, an unlimited
|
||||
@@ -26,10 +26,9 @@ score/legality preview, the hint (per-game allowance plus a profile wallet), the
|
||||
word-check tool with complaint capture, per-player game state, history and GCG
|
||||
export, per-account statistics on finish, and a background turn-timeout sweeper
|
||||
that auto-resigns overdue turns (honouring each player's daily away window). Like
|
||||
Stages 1–2 it is a service/store layer; the HTTP surface lands with the
|
||||
`gateway` (Stage 6).
|
||||
the engine it is a service/store layer; the HTTP surface lives in the `gateway`.
|
||||
|
||||
Stage 4 adds the lobby and social fabric. `internal/lobby` holds an in-memory
|
||||
The lobby and social fabric. `internal/lobby` holds an in-memory
|
||||
matchmaking pool (FIFO per variant, pairs two humans into an auto-match) and
|
||||
friend-game invitations (invite → accept, starting a 2–4 player game once every
|
||||
invitee accepts). `internal/social` owns the friend graph (request/accept),
|
||||
@@ -41,10 +40,10 @@ development log mailer). The engine now also handles **multi-player drop-out**:
|
||||
a 3–4 player game a resignation or timeout drops that seat and the rest play on
|
||||
(the tile disposition is a per-game setting), the game ending when one active seat
|
||||
remains. As before this is a service/store layer — chat and nudges are persisted
|
||||
but their live delivery, and all REST endpoints, arrive with the `gateway`
|
||||
(Stage 6); the services are exposed via `Server` accessors for those handlers.
|
||||
but their live delivery, and all REST endpoints, live in the `gateway`; the
|
||||
services are exposed via `Server` accessors for those handlers.
|
||||
|
||||
Stage 5 adds the robot opponent (`internal/robot`). A pool of durable accounts —
|
||||
The robot opponent (`internal/robot`). A pool of durable accounts —
|
||||
each a `kind='robot'` identity, provisioned at startup with chat and friend
|
||||
requests blocked — backs human-like, per-language composed names. A background driver plays the
|
||||
robot's moves through the public game API as an ordinary seated player (so only
|
||||
@@ -53,28 +52,28 @@ win (≈ 40%), targets a small score margin, and times its moves with a move-num
|
||||
right-skewed delay (quick openings, long endgames), a night-sleep window anchored to the opponent's timezone, and nudge
|
||||
behaviour — all derived deterministically from the game seed, so it keeps no extra
|
||||
state. The matchmaker now substitutes a pooled robot (matching the game's language) after a 10-second wait and
|
||||
exposes `Poll` so a waiting player can collect the started game — R4 made it the **stream-down
|
||||
exposes `Poll` so a waiting player can collect the started game — it is the **stream-down
|
||||
fallback**: while the client is streaming, the live match-found push (enriched with the recipient's
|
||||
initial game state) drives it instead.
|
||||
|
||||
Stage 6 opens the backend to the edge. The route groups gain their first
|
||||
The backend opens to the edge. The route groups gain their first
|
||||
handlers (`internal/server/handlers_*.go`): gateway-only session endpoints under
|
||||
`/api/v1/internal` (Telegram/guest/email login → mint, resolve, revoke) and a
|
||||
slice of authenticated `/api/v1/user` operations (profile, submit play, game
|
||||
state, lobby enqueue/poll, chat). Stage 8 fills in the social/account/history
|
||||
operations under `/api/v1/user`: `friends/*` (request/respond/cancel/unfriend,
|
||||
state, lobby enqueue/poll, chat). The social/account/history operations under
|
||||
`/api/v1/user`: `friends/*` (request/respond/cancel/unfriend,
|
||||
list/incoming, the one-time `code` issue/redeem), `blocks/*`, `invitations/*`
|
||||
(create/accept/decline/cancel/list), `PUT profile`, `email/{request,confirm}`,
|
||||
`stats`, and `games/:id/gcg` (finished-only). A new `internal/notify` hub feeds a
|
||||
`stats`, and `games/:id/gcg` (finished-only). The `internal/notify` hub feeds a
|
||||
second listener — `internal/pushgrpc`, a gRPC server (`BACKEND_GRPC_ADDR`) streaming
|
||||
live events (your-turn, opponent-moved, chat, nudge, match-found, notify) to the
|
||||
gateway. Stage 9 adds the gateway-only `POST /api/v1/internal/push-target` (a user's
|
||||
Telegram `external_id`, language and `notifications_in_app_only` flag) that the gateway
|
||||
uses to route out-of-app push to the Telegram connector, extends the Telegram login to
|
||||
seed a new account's language and display name from the launch fields, and adds
|
||||
the `accounts.notifications_in_app_only` flag (default true).
|
||||
gateway. The gateway-only `POST /api/v1/internal/push-target` (a user's
|
||||
Telegram `external_id`, language and `notifications_in_app_only` flag) lets the gateway
|
||||
route out-of-app push to the Telegram connector; the Telegram login
|
||||
seeds a new account's language and display name from the launch fields, and the
|
||||
`accounts.notifications_in_app_only` flag (default true).
|
||||
`accounts.is_guest` marks an ephemeral guest — a durable row
|
||||
with no identity, excluded from statistics. **Stage 10** adds the server-rendered
|
||||
with no identity, excluded from statistics. The server-rendered
|
||||
**admin console** at `/_gm` (`internal/adminconsole` + `internal/server/handlers_admin_console.go`;
|
||||
the gateway fronts it with Basic-Auth and a same-origin guard protects its POSTs), the
|
||||
**complaint resolution** lifecycle (the `complaints` `disposition`/`resolution_note`/
|
||||
@@ -82,13 +81,13 @@ the gateway fronts it with Basic-Auth and a same-origin guard protects its POSTs
|
||||
pipeline, dictionary **hot-reload** from `BACKEND_DICT_DIR/<version>/`
|
||||
(`engine.OpenWithVersions` / `Registry.LoadAvailable`), and operator **broadcasts** via a
|
||||
backend Telegram-connector client (`internal/connector`, `BACKEND_CONNECTOR_ADDR`) — each
|
||||
broadcast picks the delivering bot by an operator-chosen language. **Stage 15** adds
|
||||
`accounts.service_language`: the language tag of the bot a Telegram
|
||||
broadcast picks the delivering bot by an operator-chosen language. `accounts.service_language`
|
||||
holds the language tag of the bot a Telegram
|
||||
user last signed in through, written on every login and returned by
|
||||
`/internal/push-target` (falling back to `preferred_language`) so out-of-app push routes
|
||||
to the right bot. The shared wire contracts live in the sibling [`../pkg`](../pkg) module.
|
||||
|
||||
Stage 11 adds **account linking & merge** (`/api/v1/user/link/*`). `internal/link`
|
||||
**Account linking & merge** (`/api/v1/user/link/*`). `internal/link`
|
||||
orchestrates it: an email confirm-code or a gateway-validated Telegram identity is
|
||||
attached to the current account, and when the identity already has its own account
|
||||
the two are merged in one transaction (`internal/accountmerge`) — stats and the hint
|
||||
@@ -98,9 +97,9 @@ shared finished game's foreign keys hold); a shared **active** game blocks the m
|
||||
The current account is primary, except a guest initiator whose linked identity has a
|
||||
durable owner — then the durable account wins and a fresh session is minted for it.
|
||||
The `accounts.paid_account`/`merged_into`/`merged_at` columns back this. This supersedes the
|
||||
Stage 8 `email.bind.*` edge surface (the `RequestCode`/`ConfirmCode` primitives stay).
|
||||
former `email.bind.*` edge surface (the `RequestCode`/`ConfirmCode` primitives stay).
|
||||
|
||||
**R3** adds rate-limit observability: the gateway posts its periodic rejection
|
||||
Rate-limit observability: the gateway posts its periodic rejection
|
||||
summaries to `POST /api/v1/internal/ratelimit/report`; `internal/ratewatch` keeps a
|
||||
bounded in-memory episode window for the console's **Throttled** page and applies the
|
||||
conservative auto-flag — an account sustaining `BACKEND_HIGHRATE_FLAG_THRESHOLD`
|
||||
@@ -119,8 +118,8 @@ internal/postgres/ # pgx-over-database/sql pool (otelsql), goose migrations
|
||||
migrations/ # embedded *.sql (goose), schema `backend`
|
||||
jet/ # generated go-jet models + table builders (committed)
|
||||
internal/account/ # durable accounts + platform/email identities (store) + email/identity link primitives
|
||||
internal/accountmerge/ # single-transaction merge of a secondary account into a primary (Stage 11)
|
||||
internal/link/ # link/merge orchestrator over account + accountmerge + session (Stage 11)
|
||||
internal/accountmerge/ # single-transaction merge of a secondary account into a primary
|
||||
internal/link/ # link/merge orchestrator over account + accountmerge + session
|
||||
internal/session/ # opaque tokens, sessions store, write-through cache, service (incl. RevokeAllForAccount)
|
||||
internal/server/ # gin engine, route groups, X-User-ID middleware, probes
|
||||
internal/engine/ # in-process scrabble-solver bridge: registry, bag, Game, replay
|
||||
@@ -130,7 +129,7 @@ internal/lobby/ # in-memory matchmaking pool (+ robot substitution) + frien
|
||||
internal/robot/ # human-like robot opponent: account pool, seed-derived strategy, move driver
|
||||
internal/adminconsole/ # server-rendered admin console (Go templates + embedded CSS, view models), served at /_gm
|
||||
internal/connector/ # backend gRPC client to the Telegram connector (operator broadcasts)
|
||||
internal/ratewatch/ # gateway rate-limit reports: episode window for the console + the high-rate auto-flag (R3)
|
||||
internal/ratewatch/ # gateway rate-limit reports: episode window for the console + the high-rate auto-flag
|
||||
```
|
||||
|
||||
## Configuration (environment)
|
||||
@@ -163,7 +162,7 @@ internal/ratewatch/ # gateway rate-limit reports: episode window for the consol
|
||||
| `BACKEND_CONNECTOR_ADDR` | — | Telegram connector gRPC address for admin-console operator broadcasts. Empty disables broadcasts. |
|
||||
| `BACKEND_GUEST_REAP_INTERVAL` | `1h` | How often the abandoned-guest reaper sweeps. |
|
||||
| `BACKEND_GUEST_RETENTION` | `720h` | Account age past which a guest with no game seat is deleted. |
|
||||
| `BACKEND_HIGHRATE_FLAG_THRESHOLD` | `1000` | Gateway-reported rejected calls within the window past which an account is soft-flagged (R3). |
|
||||
| `BACKEND_HIGHRATE_FLAG_THRESHOLD` | `1000` | Gateway-reported rejected calls within the window past which an account is soft-flagged. |
|
||||
| `BACKEND_HIGHRATE_FLAG_WINDOW` | `10m` | The rolling window those rejections accumulate over. |
|
||||
|
||||
## Run
|
||||
@@ -209,9 +208,8 @@ local solver co-development you may add a temporary replace — see `go.work`).
|
||||
(`en_sowpods.dawg`, `ru_scrabble.dawg`, `ru_erudit.dawg`) ship as a **release artifact**
|
||||
from the [`scrabble-dictionary`](https://gitea.iliadenisov.ru/developer/scrabble-dictionary)
|
||||
repo (one semver per set); the engine loads them by `(variant, dict_version)` from
|
||||
`BACKEND_DICT_DIR`. Since Stage 3 the backend loads them at startup as a hard dependency
|
||||
(a missing dictionary aborts the boot). See [`../PLAN.md`](../PLAN.md) Stage 14
|
||||
(TODO-1/TODO-2).
|
||||
`BACKEND_DICT_DIR`. The backend loads them at startup as a hard dependency
|
||||
(a missing dictionary aborts the boot).
|
||||
|
||||
## Tests
|
||||
|
||||
|
||||
@@ -108,7 +108,7 @@ func run(ctx context.Context, cfg config.Config, logger *zap.Logger) error {
|
||||
zap.String("dir", cfg.Game.DictDir),
|
||||
zap.String("version", cfg.Game.DictVersion))
|
||||
|
||||
// Stage 10 admin console: an optional backend client to the Telegram connector
|
||||
// Admin console: an optional backend client to the Telegram connector
|
||||
// side-service for operator broadcasts. Unset (BACKEND_CONNECTOR_ADDR empty)
|
||||
// leaves broadcasts disabled — the console shows a "not configured" notice.
|
||||
var conn *connector.Client
|
||||
@@ -141,7 +141,7 @@ func run(ctx context.Context, cfg config.Config, logger *zap.Logger) error {
|
||||
logger.Info("game turn-timeout sweeper started",
|
||||
zap.Duration("interval", cfg.Game.TimeoutSweepInterval))
|
||||
|
||||
// Stage 12 TODO-3: reap abandoned guest accounts (no game seat, account age past
|
||||
// Reap abandoned guest accounts (no game seat, account age past
|
||||
// the retention window). Dependent rows fall away via ON DELETE CASCADE.
|
||||
guestReaper := account.NewGuestReaper(accounts, cfg.GuestRetention, logger)
|
||||
go guestReaper.Run(ctx, cfg.GuestReapInterval)
|
||||
@@ -149,19 +149,18 @@ func run(ctx context.Context, cfg config.Config, logger *zap.Logger) error {
|
||||
zap.Duration("interval", cfg.GuestReapInterval),
|
||||
zap.Duration("retention", cfg.GuestRetention))
|
||||
|
||||
// Stage 4 lobby & social domains. Their REST and stream surface is added with
|
||||
// the gateway in Stage 6, so they are handed to the server (like the route
|
||||
// groups) for the handlers to come.
|
||||
// Lobby & social domains. Their REST and stream surface lives in the gateway,
|
||||
// so they are handed to the server (like the route groups) for the handlers.
|
||||
mailer := newMailer(cfg.SMTP, logger)
|
||||
emails := account.NewEmailService(accounts, mailer)
|
||||
// Stage 11 account linking & merge: the orchestrator over the account, merge and
|
||||
// Account linking & merge: the orchestrator over the account, merge and
|
||||
// session layers. Wired to the /api/v1/user/link REST surface below.
|
||||
links := link.NewService(emails, accounts, accountmerge.NewMerger(db), sessions)
|
||||
socialSvc := social.NewService(social.NewStore(db), accounts, games)
|
||||
socialSvc.SetNotifier(hub)
|
||||
socialSvc.SetMetrics(tel.MeterProvider().Meter("scrabble/backend/social"))
|
||||
|
||||
// Stage 5 robot opponent: provision its durable account pool (a hard startup
|
||||
// Robot opponent: provision its durable account pool (a hard startup
|
||||
// dependency, like the dictionaries) and start its move driver. The matchmaker
|
||||
// substitutes a pooled robot for a missing human after the wait window.
|
||||
robots := robot.NewService(games, accounts, socialSvc, tel.MeterProvider().Meter("scrabble/backend/robot"), logger)
|
||||
@@ -178,7 +177,7 @@ func run(ctx context.Context, cfg config.Config, logger *zap.Logger) error {
|
||||
invitations.SetNotifier(hub)
|
||||
logger.Info("lobby and social domains ready", zap.Duration("robot_wait", cfg.Lobby.RobotWait))
|
||||
|
||||
// R3 rate-limit observability: ingest the gateway's rejection reports for the
|
||||
// Rate-limit observability: ingest the gateway's rejection reports for the
|
||||
// admin throttled view and the conservative high-rate auto-flag.
|
||||
rateWatch := ratewatch.New(cfg.RateWatch, accounts, logger)
|
||||
logger.Info("rate watch ready",
|
||||
|
||||
@@ -24,8 +24,8 @@ import (
|
||||
|
||||
// Identity kinds recognised by the backend. Email is modelled as an identity
|
||||
// alongside platform identities; its confirmed flag is driven by the email
|
||||
// confirm-code flow in a later stage. Robot is a synthetic kind: each pooled
|
||||
// robot opponent is a durable account bound to one robot identity (Stage 5).
|
||||
// confirm-code flow. Robot is a synthetic kind: each pooled
|
||||
// robot opponent is a durable account bound to one robot identity.
|
||||
const (
|
||||
KindTelegram = "telegram"
|
||||
KindEmail = "email"
|
||||
@@ -66,19 +66,19 @@ type Account struct {
|
||||
IsGuest bool
|
||||
// NotificationsInAppOnly confines notifications to the in-app live stream when
|
||||
// true (the default): the platform side-service skips out-of-app push for the
|
||||
// account (Stage 9).
|
||||
// account.
|
||||
NotificationsInAppOnly bool
|
||||
// PaidAccount marks a lifetime one-time-payment account. It is a service field
|
||||
// (no purchase flow yet); an account linking & merge ORs it so a paid status is
|
||||
// never lost when accounts are consolidated (Stage 11).
|
||||
// never lost when accounts are consolidated.
|
||||
PaidAccount bool
|
||||
// MergedInto is the primary account a retired (merged) secondary points at, or
|
||||
// uuid.Nil for a live account. A tombstone keeps the row so the no-cascade
|
||||
// foreign keys of a shared finished game stay valid (Stage 11).
|
||||
// foreign keys of a shared finished game stay valid.
|
||||
MergedInto uuid.UUID
|
||||
// FlaggedHighRateAt is the soft, reversible "suspected high-rate" marker: the
|
||||
// zero time for an unflagged account, otherwise when the gateway-reported
|
||||
// rate-limiter rejections first crossed the sustained threshold (R3). An
|
||||
// rate-limiter rejections first crossed the sustained threshold. An
|
||||
// operator clears it in the admin console; it never gates any request.
|
||||
FlaggedHighRateAt time.Time
|
||||
CreatedAt time.Time
|
||||
@@ -430,7 +430,7 @@ func (s *Store) SpendHint(ctx context.Context, id uuid.UUID) (bool, error) {
|
||||
// FlagHighRate stamps the soft "suspected high-rate" marker with at, only when
|
||||
// the account is not already flagged — the first sustained episode wins, and a
|
||||
// re-flag after an operator clear starts a fresh timestamp. An infra marker, not
|
||||
// a profile edit, so updated_at is untouched; it never gates any request (R3).
|
||||
// a profile edit, so updated_at is untouched; it never gates any request.
|
||||
// It reports whether the flag was newly set.
|
||||
func (s *Store) FlagHighRate(ctx context.Context, id uuid.UUID, at time.Time) (bool, error) {
|
||||
stmt := table.Accounts.
|
||||
|
||||
@@ -33,7 +33,7 @@ var (
|
||||
// ErrInvalidEmail is returned for an unparseable email address.
|
||||
ErrInvalidEmail = errors.New("account: invalid email address")
|
||||
// ErrEmailTaken is returned when the email is already confirmed by another
|
||||
// account; binding it would be a merge, which Stage 11 owns.
|
||||
// account; binding it would be a merge, which the link/merge flow owns.
|
||||
ErrEmailTaken = errors.New("account: email already confirmed by another account")
|
||||
// ErrAlreadyConfirmed is returned when the email is already confirmed by the
|
||||
// requesting account.
|
||||
@@ -52,8 +52,8 @@ var (
|
||||
// Mailer and verifies it, binding a confirmed email identity to the requesting
|
||||
// account. Only the SHA-256 hash of a code is stored (never the plaintext),
|
||||
// matching the session model. Binding an email already confirmed by a different
|
||||
// account is refused (ErrEmailTaken) — merging two accounts is Stage 11 — and
|
||||
// using an email as a login is Stage 6, which reuses this mechanism.
|
||||
// account is refused (ErrEmailTaken) — merging two accounts is the link/merge flow —
|
||||
// and using an email as a login reuses this mechanism.
|
||||
type EmailService struct {
|
||||
store *Store
|
||||
mailer Mailer
|
||||
@@ -128,7 +128,7 @@ func (s *EmailService) ConfirmCode(ctx context.Context, accountID uuid.UUID, ema
|
||||
|
||||
// RequestLoginCode issues a login confirm-code to the account that owns email,
|
||||
// provisioning a fresh (unconfirmed) durable account when the email is new. It is
|
||||
// the unauthenticated email-login entry point (Stage 6) and, unlike RequestCode,
|
||||
// the unauthenticated email-login entry point and, unlike RequestCode,
|
||||
// does not refuse an already-confirmed email — that is the ordinary returning-user
|
||||
// login. The code is mailed to the address, so only its real owner can complete
|
||||
// the login. It returns the target account id for the subsequent LoginWithCode.
|
||||
|
||||
@@ -13,14 +13,14 @@ import (
|
||||
)
|
||||
|
||||
// ErrIdentityTaken is returned when a platform identity being linked already
|
||||
// belongs to another account; the caller turns it into a merge (Stage 11).
|
||||
// belongs to another account; the caller turns it into a merge.
|
||||
var ErrIdentityTaken = errors.New("account: identity already linked to another account")
|
||||
|
||||
// RequestLinkCode issues and mails a confirm-code for email to accountID,
|
||||
// replacing any prior pending code. Unlike RequestCode it never refuses up front
|
||||
// (taken or already-confirmed): possession of the address is the authorization for
|
||||
// a later link or merge, and the merge is only revealed once the code is verified,
|
||||
// so a probe cannot learn whether an address is registered (Stage 11).
|
||||
// so a probe cannot learn whether an address is registered.
|
||||
func (s *EmailService) RequestLinkCode(ctx context.Context, accountID uuid.UUID, email string) error {
|
||||
addr, err := normalizeEmail(email)
|
||||
if err != nil {
|
||||
@@ -94,7 +94,7 @@ func (s *EmailService) verifyPendingCode(ctx context.Context, accountID uuid.UUI
|
||||
|
||||
// AccountIDByIdentity returns the account owning (kind, externalID) and true, or
|
||||
// (uuid.Nil, false) when the identity is free. It backs the platform-identity link
|
||||
// flow (Stage 11).
|
||||
// flow.
|
||||
func (s *Store) AccountIDByIdentity(ctx context.Context, kind, externalID string) (uuid.UUID, bool, error) {
|
||||
acc, err := s.findByIdentity(ctx, kind, externalID)
|
||||
if errors.Is(err, ErrNotFound) {
|
||||
@@ -109,7 +109,7 @@ func (s *Store) AccountIDByIdentity(ctx context.Context, kind, externalID string
|
||||
// AttachIdentity links a new (kind, externalID) identity to an existing account.
|
||||
// A unique-constraint violation means the identity was taken meanwhile, surfaced
|
||||
// as ErrIdentityTaken. It is used to attach a platform identity (e.g. Telegram)
|
||||
// to the current account during linking (Stage 11).
|
||||
// to the current account during linking.
|
||||
func (s *Store) AttachIdentity(ctx context.Context, accountID uuid.UUID, kind, externalID string, confirmed bool) error {
|
||||
id, err := uuid.NewV7()
|
||||
if err != nil {
|
||||
@@ -129,7 +129,7 @@ func (s *Store) AttachIdentity(ctx context.Context, accountID uuid.UUID, kind, e
|
||||
}
|
||||
|
||||
// ClearGuest removes the is_guest flag from accountID, promoting an ephemeral guest
|
||||
// to a durable account once it gains its first identity (Stage 11). It is a no-op
|
||||
// to a durable account once it gains its first identity. It is a no-op
|
||||
// for an already-durable account.
|
||||
func (s *Store) ClearGuest(ctx context.Context, accountID uuid.UUID) error {
|
||||
upd := table.Accounts.UPDATE(table.Accounts.IsGuest, table.Accounts.UpdatedAt).
|
||||
|
||||
@@ -25,16 +25,16 @@ const maxDisplayName = 32
|
||||
|
||||
// maxDisplayNameSpecials caps the total special characters (the "." / "_" separators —
|
||||
// every name rune that is neither a letter nor a space) an editable display name may
|
||||
// carry, so a still-well-formed name cannot be made of mostly punctuation (Stage 17).
|
||||
// carry, so a still-well-formed name cannot be made of mostly punctuation.
|
||||
const maxDisplayNameSpecials = 5
|
||||
|
||||
// maxAwayWindow bounds the daily away window's duration (midnight-wrap aware).
|
||||
const maxAwayWindow = 12 * time.Hour
|
||||
|
||||
// displayNameRe enforces the editable display-name format (Stage 8): Unicode letters
|
||||
// displayNameRe enforces the editable display-name format: Unicode letters
|
||||
// joined by single space / "." / "_" separators, where a "." or "_" may be followed
|
||||
// by a single space. No leading separator and no two adjacent separators (except
|
||||
// "<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.
|
||||
var displayNameRe = regexp.MustCompile(`^\p{L}+(?:(?:[._] ?| )\p{L}+)*\.?$`)
|
||||
|
||||
|
||||
@@ -12,7 +12,7 @@ import (
|
||||
|
||||
// TestUpdateProfileValidation checks that bad fields are rejected before any
|
||||
// database access, so a nil-backed Store is enough to exercise the guards. It also
|
||||
// confirms UpdateProfile wires the Stage 8 validators (name format, away window,
|
||||
// confirms UpdateProfile wires the validators (name format, away window,
|
||||
// offset/IANA timezone), not just their unit tests in validate_test.go.
|
||||
func TestUpdateProfileValidation(t *testing.T) {
|
||||
s := &Store{}
|
||||
|
||||
@@ -7,7 +7,7 @@ import (
|
||||
)
|
||||
|
||||
// offsetZoneRe matches a fixed UTC offset like "+03:00" or "-05:30" — the form the
|
||||
// Stage 8 profile editor stores (an offset dropdown rather than an IANA name).
|
||||
// profile editor stores (an offset dropdown rather than an IANA name).
|
||||
var offsetZoneRe = regexp.MustCompile(`^([+-])(\d{2}):(\d{2})$`)
|
||||
|
||||
// parseOffsetZone parses a "±HH:MM" offset into a fixed-offset location, reporting
|
||||
|
||||
@@ -20,7 +20,7 @@ type UserListItem struct {
|
||||
IsGuest bool
|
||||
IsRobot bool
|
||||
// FlaggedHighRateAt is the soft high-rate marker (zero when unflagged), shown
|
||||
// as a badge in the console list (R3).
|
||||
// as a badge in the console list.
|
||||
FlaggedHighRateAt time.Time
|
||||
CreatedAt time.Time
|
||||
}
|
||||
@@ -105,7 +105,7 @@ type FlaggedAccount struct {
|
||||
const flaggedListCap = 200
|
||||
|
||||
// ListFlaggedHighRate returns the accounts carrying the high-rate flag, most
|
||||
// recently flagged first (R3).
|
||||
// recently flagged first.
|
||||
func (s *Store) ListFlaggedHighRate(ctx context.Context) ([]FlaggedAccount, error) {
|
||||
rows, err := s.db.QueryContext(ctx,
|
||||
`SELECT account_id, display_name, flagged_high_rate_at
|
||||
|
||||
@@ -2,7 +2,7 @@
|
||||
// transaction: it sums statistics and the hint wallet, ORs the paid flag, repoints
|
||||
// the secondary's identities, transfers its games/chat/complaints/invitations,
|
||||
// de-duplicates friends and blocks, and leaves the secondary as an audit tombstone
|
||||
// (accounts.merged_into). It is the data core of Stage 11 account linking & merge
|
||||
// (accounts.merged_into). It is the data core of account linking & merge
|
||||
// (ARCHITECTURE.md §4); session revocation and any session switch are orchestrated
|
||||
// one layer up (the link service), since the in-memory session cache lives there.
|
||||
package accountmerge
|
||||
|
||||
@@ -60,7 +60,7 @@ type UsersView struct {
|
||||
|
||||
// UserRow is one account row in the list. MoveMin/Avg/Max are the account's
|
||||
// pre-formatted move-duration summary (empty when it has no timed move);
|
||||
// FlaggedHighRate marks the soft high-rate badge (R3).
|
||||
// FlaggedHighRate marks the soft high-rate badge.
|
||||
type UserRow struct {
|
||||
ID string
|
||||
DisplayName string
|
||||
@@ -111,10 +111,10 @@ type UserDetailView struct {
|
||||
NotificationsInAppOnly bool
|
||||
PaidAccount bool
|
||||
// MergedInto is the primary account id when this account has been retired by a
|
||||
// merge (Stage 11), or empty for a live account.
|
||||
// merge, or empty for a live account.
|
||||
MergedInto string
|
||||
// FlaggedHighRateAt is the pre-formatted soft high-rate marker timestamp,
|
||||
// empty for an unflagged account; the card shows it with the Clear action (R3).
|
||||
// empty for an unflagged account; the card shows it with the Clear action.
|
||||
FlaggedHighRateAt string
|
||||
HintBalance int
|
||||
CreatedAt string
|
||||
|
||||
@@ -37,7 +37,7 @@ type Config struct {
|
||||
// Robot configures the robot opponent driver (scan cadence).
|
||||
Robot robot.Config
|
||||
// RateWatch tunes the conservative high-rate auto-flag applied to the
|
||||
// gateway's rate-limiter rejection reports (R3).
|
||||
// gateway's rate-limiter rejection reports.
|
||||
RateWatch ratewatch.Config
|
||||
// SMTP configures the email relay used for confirm-codes. An empty Host
|
||||
// selects the development log mailer (the code is logged, not sent).
|
||||
|
||||
@@ -7,7 +7,7 @@ import (
|
||||
|
||||
// AlphabetEntry is one letter of a variant's alphabet: its alphabet-index byte, the
|
||||
// concrete character and its tile point value. It is the dictionary-independent display
|
||||
// table the edge sends to the client (Stage 13), produced from the variant's solver
|
||||
// table the edge sends to the client, produced from the variant's solver
|
||||
// ruleset (its alphabet and value table) and so pinned by the solver version, not by any
|
||||
// dictionary.
|
||||
type AlphabetEntry struct {
|
||||
|
||||
@@ -8,7 +8,7 @@ import (
|
||||
|
||||
// TestAlphabetTableEnglish pins the English table against the solver ruleset: 26 letters,
|
||||
// contiguous indices, the concrete lower-case characters the solver emits and the standard
|
||||
// tile values. This is the real parity check the UI no longer carries (Stage 13).
|
||||
// tile values. This is the real parity check the UI no longer carries.
|
||||
func TestAlphabetTableEnglish(t *testing.T) {
|
||||
tab, err := AlphabetTable(VariantEnglish)
|
||||
if err != nil {
|
||||
|
||||
@@ -49,7 +49,7 @@ func (v Variant) String() string {
|
||||
|
||||
// Language returns the variant's interface/bot language tag: "en" for English Scrabble, "ru" for
|
||||
// the Russian variants (Russian Scrabble and Erudite). It routes a game's out-of-app push to the
|
||||
// matching per-language Telegram bot — by the game, not the recipient's last-login bot (Stage 17).
|
||||
// matching per-language Telegram bot — by the game, not the recipient's last-login bot.
|
||||
func (v Variant) Language() string {
|
||||
if v == VariantEnglish {
|
||||
return "en"
|
||||
|
||||
@@ -4,7 +4,7 @@ import "testing"
|
||||
|
||||
// TestVariantLanguage checks the variant -> bot-language mapping that routes a game's out-of-app
|
||||
// push by the game itself (English -> en, the Russian variants -> ru), rather than the recipient's
|
||||
// last-login bot (Stage 17).
|
||||
// last-login bot.
|
||||
func TestVariantLanguage(t *testing.T) {
|
||||
cases := map[Variant]string{
|
||||
VariantEnglish: "en",
|
||||
|
||||
@@ -21,7 +21,7 @@ type DraftTile struct {
|
||||
Blank bool `json:"blank"`
|
||||
}
|
||||
|
||||
// Draft is a player's persisted client-side composition for a game (Stage 17): the
|
||||
// Draft is a player's persisted client-side composition for a game: the
|
||||
// preferred rack tile order and the board tiles laid but not yet submitted. The server
|
||||
// keeps it so a reload or a second device resumes the same arrangement.
|
||||
type Draft struct {
|
||||
@@ -101,7 +101,7 @@ func (s *Store) clearDraft(ctx context.Context, gameID, accountID uuid.UUID) err
|
||||
|
||||
// resetConflictingBoardDrafts clears the board_tiles of every OTHER player's draft that has
|
||||
// a tile on one of the just-committed cells, since that draft can no longer be placed; the
|
||||
// rack order is kept (Stage 17 #6).
|
||||
// rack order is kept.
|
||||
func (s *Store) resetConflictingBoardDrafts(ctx context.Context, gameID, actorID uuid.UUID, cells []DraftTile) error {
|
||||
if len(cells) == 0 {
|
||||
return nil
|
||||
|
||||
@@ -6,7 +6,7 @@ import (
|
||||
)
|
||||
|
||||
// The mappers below project the game domain into the wire-agnostic notify.* input
|
||||
// structs the enriched live events carry (R4). They keep the wire schema out of the
|
||||
// structs the enriched live events carry. They keep the wire schema out of the
|
||||
// game package: notify owns the FlatBuffers encoding, this file only resolves the
|
||||
// values (seat display names, last-activity sort key) into its input shapes.
|
||||
|
||||
|
||||
@@ -217,14 +217,13 @@ func (svc *Service) Resign(ctx context.Context, gameID, accountID uuid.UUID) (Mo
|
||||
|
||||
// GameVariant returns just a game's variant. The edge layer uses it to map wire alphabet
|
||||
// indices to concrete letters before delegating to the letter-based play, exchange and
|
||||
// word-check methods (Stage 13), keeping a single domain path shared with the robot.
|
||||
// word-check methods, keeping a single domain path shared with the robot.
|
||||
func (svc *Service) GameVariant(ctx context.Context, gameID uuid.UUID) (engine.Variant, error) {
|
||||
return svc.store.GetGameVariant(ctx, gameID)
|
||||
}
|
||||
|
||||
// GameLanguage returns the game's language tag ("en"/"ru"), derived from its variant, so a
|
||||
// game push routes out-of-app to the game's own bot rather than the recipient's last-login bot
|
||||
// (Stage 17).
|
||||
// game push routes out-of-app to the game's own bot rather than the recipient's last-login bot.
|
||||
func (svc *Service) GameLanguage(ctx context.Context, gameID uuid.UUID) (string, error) {
|
||||
v, err := svc.GameVariant(ctx, gameID)
|
||||
if err != nil {
|
||||
@@ -241,7 +240,7 @@ func (svc *Service) RobotSchedule(ctx context.Context, gameID uuid.UUID) (seed i
|
||||
|
||||
// LastMoveAt returns the time of an account's most recent move in a game (and whether it
|
||||
// has moved). The social service uses it to reset the nudge cooldown once a player has
|
||||
// taken a turn (Stage 17).
|
||||
// taken a turn.
|
||||
func (svc *Service) LastMoveAt(ctx context.Context, gameID, accountID uuid.UUID) (time.Time, bool, error) {
|
||||
return svc.store.LastMoveAt(ctx, gameID, accountID)
|
||||
}
|
||||
@@ -294,7 +293,7 @@ func (svc *Service) transition(ctx context.Context, gameID, accountID uuid.UUID,
|
||||
return MoveResult{Move: rec, Game: post, Rack: g.Hand(seat), BagLen: g.BagLen()}, nil
|
||||
}
|
||||
|
||||
// afterCommitDrafts maintains the Stage 17 drafts after a committed move: the actor's own
|
||||
// afterCommitDrafts maintains the drafts after a committed move: the actor's own
|
||||
// composition is consumed, so clear it; a play's tiles may overlap an opponent's board
|
||||
// draft, which is then reset. Best-effort — the move is already committed, so a draft
|
||||
// cleanup failure is logged rather than failing the move.
|
||||
@@ -382,7 +381,7 @@ func (svc *Service) emitMove(ctx context.Context, post Game, rec engine.MoveReco
|
||||
intents = append(intents, notify.OpponentMoved(s.AccountID, post.ID, rec, summary, bagLen))
|
||||
}
|
||||
// Game pushes are routed out-of-app by the game's own language, not the recipient's
|
||||
// last-login bot (Stage 17).
|
||||
// last-login bot.
|
||||
lang := post.Variant.Language()
|
||||
switch post.Status {
|
||||
case StatusActive:
|
||||
@@ -789,7 +788,7 @@ func (svc *Service) GameState(ctx context.Context, gameID, accountID uuid.UUID)
|
||||
}
|
||||
|
||||
// InitialState returns accountID's full initial view of game gameID as the notify
|
||||
// PlayerState carried by the match_found / game_started events (R4), so a client can
|
||||
// PlayerState carried by the match_found / game_started events, so a client can
|
||||
// render a freshly started game from the event without a follow-up fetch. The variant
|
||||
// alphabet table is always embedded (the recipient may be seeing the variant for the
|
||||
// first time). It satisfies lobby.GameCreator.
|
||||
@@ -837,7 +836,7 @@ func (svc *Service) ListForAccount(ctx context.Context, accountID uuid.UUID) ([]
|
||||
|
||||
// HideGame hides a finished game from accountID's own lobby (it stays visible to the other
|
||||
// players); it is irreversible by design. Only a player of a finished game may hide it
|
||||
// (ErrNotAPlayer / ErrGameActive otherwise); hiding an already-hidden game is a no-op (Stage 17).
|
||||
// (ErrNotAPlayer / ErrGameActive otherwise); hiding an already-hidden game is a no-op.
|
||||
func (svc *Service) HideGame(ctx context.Context, accountID, gameID uuid.UUID) error {
|
||||
g, err := svc.store.GetGame(ctx, gameID)
|
||||
if err != nil {
|
||||
|
||||
@@ -136,7 +136,7 @@ func (s *Store) GetGame(ctx context.Context, id uuid.UUID) (Game, error) {
|
||||
}
|
||||
|
||||
// GetGameVariant reads just a game's variant — a cheap single-column lookup the edge uses
|
||||
// to map wire alphabet indices to concrete letters (Stage 13) without loading the whole
|
||||
// to map wire alphabet indices to concrete letters without loading the whole
|
||||
// game and its seats.
|
||||
func (s *Store) GetGameVariant(ctx context.Context, id uuid.UUID) (engine.Variant, error) {
|
||||
stmt := postgres.SELECT(table.Games.Variant).
|
||||
@@ -186,7 +186,7 @@ func (s *Store) ListGamesForAccount(ctx context.Context, accountID uuid.UUID) ([
|
||||
if len(grows) == 0 {
|
||||
return nil, nil
|
||||
}
|
||||
// Drop games the account has hidden from its own lobby (Stage 17).
|
||||
// Drop games the account has hidden from its own lobby.
|
||||
hidden, err := s.hiddenGameIDs(ctx, accountID)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
@@ -233,7 +233,7 @@ func (s *Store) ListGamesForAccount(ctx context.Context, accountID uuid.UUID) ([
|
||||
}
|
||||
|
||||
// HideGame hides a game from the account's own lobby list (idempotent). The caller validates the
|
||||
// game is finished and the account is a player (Stage 17).
|
||||
// game is finished and the account is a player.
|
||||
func (s *Store) HideGame(ctx context.Context, accountID, gameID uuid.UUID) error {
|
||||
_, err := s.db.ExecContext(ctx,
|
||||
`INSERT INTO backend.game_hidden (account_id, game_id) VALUES ($1, $2) ON CONFLICT DO NOTHING`,
|
||||
@@ -700,7 +700,7 @@ func (s *Store) GameSeed(ctx context.Context, id uuid.UUID) (int64, error) {
|
||||
|
||||
// LastMoveAt returns the time of the account's most recent move in the game and true, or
|
||||
// the zero time and false when it has not moved. The social service uses it to reset the
|
||||
// nudge cooldown once the player has taken a turn (Stage 17).
|
||||
// nudge cooldown once the player has taken a turn.
|
||||
func (s *Store) LastMoveAt(ctx context.Context, gameID, accountID uuid.UUID) (time.Time, bool, error) {
|
||||
var at sql.NullTime
|
||||
err := s.db.QueryRowContext(ctx,
|
||||
|
||||
@@ -15,8 +15,8 @@ const (
|
||||
StatusFinished = "finished"
|
||||
)
|
||||
|
||||
// Complaint lifecycle values. A complaint is filed StatusComplaintOpen (Stage 3)
|
||||
// and closed StatusComplaintResolved by the admin review queue (Stage 10) with a
|
||||
// Complaint lifecycle values. A complaint is filed StatusComplaintOpen
|
||||
// and closed StatusComplaintResolved by the admin review queue with a
|
||||
// Disposition. The CHECK constraints live in migration 00008.
|
||||
const (
|
||||
StatusComplaintOpen = "open"
|
||||
@@ -125,7 +125,7 @@ func (g Game) seatOf(accountID uuid.UUID) (int, bool) {
|
||||
|
||||
// MoveResult is the outcome of a committed transition: the decoded move and the
|
||||
// post-move game, plus the actor's own refilled rack and the bag size after the draw
|
||||
// (Rack/BagLen, R4), so the mover renders the next state from the response without a
|
||||
// (Rack/BagLen), so the mover renders the next state from the response without a
|
||||
// follow-up game.state.
|
||||
type MoveResult struct {
|
||||
Move engine.MoveRecord
|
||||
|
||||
@@ -11,6 +11,8 @@ import (
|
||||
"github.com/google/uuid"
|
||||
|
||||
"scrabble/backend/internal/account"
|
||||
"scrabble/backend/internal/engine"
|
||||
"scrabble/backend/internal/game"
|
||||
)
|
||||
|
||||
// TestAccountProvisionByIdentity covers find-or-create semantics, distinct
|
||||
@@ -78,7 +80,7 @@ func TestAccountProvisionByIdentity(t *testing.T) {
|
||||
}
|
||||
|
||||
// TestGetStatsZeroForFreshAccount checks that an account with no finished games
|
||||
// reads back the zero statistics rather than an error (the Stage 8 stats screen).
|
||||
// reads back the zero statistics rather than an error (the stats screen).
|
||||
func TestGetStatsZeroForFreshAccount(t *testing.T) {
|
||||
ctx := context.Background()
|
||||
store := account.NewStore(testDB)
|
||||
@@ -109,7 +111,7 @@ func identityConfirmed(t *testing.T, kind, externalID string) bool {
|
||||
// TestProvisionTelegramSeedsNewAccountOnly checks that Telegram first contact
|
||||
// seeds the new account's language and display name from the launch fields,
|
||||
// defaults the in-app-only flag on, and never overwrites an existing account on a
|
||||
// later login (Stage 9 language seeding).
|
||||
// later login (language seeding).
|
||||
func TestProvisionTelegramSeedsNewAccountOnly(t *testing.T) {
|
||||
ctx := context.Background()
|
||||
store := account.NewStore(testDB)
|
||||
@@ -196,7 +198,7 @@ func TestServiceLanguageRoundTrip(t *testing.T) {
|
||||
}
|
||||
}
|
||||
|
||||
// TestHighRateFlagRoundTrip covers the R3 soft high-rate marker: a fresh account
|
||||
// TestHighRateFlagRoundTrip covers the soft high-rate marker: a fresh account
|
||||
// is unflagged, FlagHighRate stamps it exactly once (a second sustained episode
|
||||
// never moves the timestamp), ClearHighRateFlag reverses it, and a re-flag after
|
||||
// the operator clear takes a fresh timestamp.
|
||||
@@ -279,7 +281,7 @@ func TestIdentityExternalID(t *testing.T) {
|
||||
}
|
||||
}
|
||||
|
||||
// TestNotificationsInAppOnlyRoundTrip checks the Stage 9 profile flag persists
|
||||
// TestNotificationsInAppOnlyRoundTrip checks the profile flag persists
|
||||
// through UpdateProfile and reads back through GetByID.
|
||||
func TestNotificationsInAppOnlyRoundTrip(t *testing.T) {
|
||||
ctx := context.Background()
|
||||
@@ -311,3 +313,59 @@ func TestNotificationsInAppOnlyRoundTrip(t *testing.T) {
|
||||
t.Error("GetByID still reports in-app-only after clearing")
|
||||
}
|
||||
}
|
||||
|
||||
// TestGuestAutoMatchLeavesNoStats drives a guest through a full auto-match game
|
||||
// against a robot to a natural end and checks the guest holds a seat (the
|
||||
// game_players foreign key is satisfied) yet accrues no statistics, while the
|
||||
// durable robot opponent does.
|
||||
func TestGuestAutoMatchLeavesNoStats(t *testing.T) {
|
||||
ctx := context.Background()
|
||||
svc := newGameService()
|
||||
robots := newRobotService(t, svc)
|
||||
if err := robots.EnsurePool(ctx); err != nil {
|
||||
t.Fatalf("ensure pool: %v", err)
|
||||
}
|
||||
robotID, err := robots.Pick(engine.VariantEnglish)
|
||||
if err != nil {
|
||||
t.Fatalf("pick: %v", err)
|
||||
}
|
||||
guest := provisionGuest(t)
|
||||
seed := openingSeed(t)
|
||||
|
||||
g, err := svc.Create(ctx, game.CreateParams{
|
||||
Variant: engine.VariantEnglish, Seats: []uuid.UUID{guest, robotID},
|
||||
TurnTimeout: 24 * time.Hour, Seed: seed,
|
||||
})
|
||||
if err != nil {
|
||||
t.Fatalf("create: %v", err)
|
||||
}
|
||||
const robotSeat = 1 // seats = [guest, robot]
|
||||
|
||||
finished := false
|
||||
for i := 0; i < 400 && !finished; i++ {
|
||||
_, toMove, status, err := svc.Participants(ctx, g.ID)
|
||||
if err != nil {
|
||||
t.Fatalf("participants: %v", err)
|
||||
}
|
||||
if status != game.StatusActive {
|
||||
finished = true
|
||||
break
|
||||
}
|
||||
if toMove == robotSeat {
|
||||
setTurnStarted(t, g.ID, daytime.Add(-2*time.Hour))
|
||||
robots.Drive(ctx, daytime)
|
||||
continue
|
||||
}
|
||||
playHuman(t, ctx, svc, g.ID, guest)
|
||||
}
|
||||
if !finished {
|
||||
t.Fatal("guest game did not finish within the move budget")
|
||||
}
|
||||
|
||||
if _, _, _, _, _, ok := readStats(t, guest); ok {
|
||||
t.Error("a guest must not accrue a statistics row")
|
||||
}
|
||||
if _, _, _, _, _, ok := readStats(t, robotID); !ok {
|
||||
t.Error("the durable robot opponent should have a statistics row")
|
||||
}
|
||||
}
|
||||
|
||||
@@ -169,7 +169,7 @@ func TestConsoleServesAndGuardsCSRF(t *testing.T) {
|
||||
}
|
||||
|
||||
// TestConsoleGameDetailRobotSchedule checks the admin game card surfaces a robot seat's
|
||||
// play-to-win intent and, while it is the robot's turn, its next-move ETA (Stage 17).
|
||||
// play-to-win intent and, while it is the robot's turn, its next-move ETA.
|
||||
func TestConsoleGameDetailRobotSchedule(t *testing.T) {
|
||||
ctx := context.Background()
|
||||
svc := newGameService()
|
||||
@@ -207,7 +207,7 @@ func TestConsoleGameDetailRobotSchedule(t *testing.T) {
|
||||
}
|
||||
}
|
||||
|
||||
// TestConsoleThrottledViewAndFlagClear drives the R3 rate-limit surface end to
|
||||
// TestConsoleThrottledViewAndFlagClear drives the rate-limit surface end to
|
||||
// end against real stores: a gateway report past the threshold auto-flags the
|
||||
// account, the throttled view shows the episode and the flagged account, the
|
||||
// user card carries the marker, and the operator clear (a same-origin POST)
|
||||
|
||||
@@ -5,36 +5,11 @@ package inttest
|
||||
import (
|
||||
"context"
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
"github.com/google/uuid"
|
||||
|
||||
"scrabble/backend/internal/engine"
|
||||
"scrabble/backend/internal/game"
|
||||
)
|
||||
|
||||
// newDraftGame creates a started two-player English game on an opening seed and returns the
|
||||
// service, game id, seats, and the opening play (from a mirror) used to drive a real commit.
|
||||
func newDraftGame(t *testing.T) (*game.Service, uuid.UUID, []uuid.UUID, engine.MoveRecord) {
|
||||
t.Helper()
|
||||
ctx := context.Background()
|
||||
svc := newGameService()
|
||||
seats := []uuid.UUID{provisionAccount(t), provisionAccount(t)}
|
||||
seed := openingSeed(t)
|
||||
g, err := svc.Create(ctx, game.CreateParams{
|
||||
Variant: engine.VariantEnglish, Seats: seats, TurnTimeout: 24 * time.Hour, Seed: seed,
|
||||
})
|
||||
if err != nil {
|
||||
t.Fatalf("create: %v", err)
|
||||
}
|
||||
hint, ok := newMirror(t, seed, 2).HintView()
|
||||
if !ok || len(hint.Tiles) == 0 {
|
||||
t.Fatal("no opening move")
|
||||
}
|
||||
return svc, g.ID, seats, hint
|
||||
}
|
||||
|
||||
// TestDraftPersistAndConflictReset covers Stage 17 draft persistence: a round-trip of the
|
||||
// TestDraftPersistAndConflictReset covers draft persistence: a round-trip of the
|
||||
// rack order + board tiles, the actor's own draft cleared on their move, and an opponent's
|
||||
// board draft reset when a committed play overlaps one of its cells (the rack order kept).
|
||||
func TestDraftPersistAndConflictReset(t *testing.T) {
|
||||
|
||||
@@ -159,7 +159,7 @@ func TestUpdateProfilePersists(t *testing.T) {
|
||||
}
|
||||
}
|
||||
|
||||
// TestUpdateProfileOffsetTimezone checks the Stage 8 UTC-offset timezone: it is
|
||||
// TestUpdateProfileOffsetTimezone checks the UTC-offset timezone: it is
|
||||
// accepted, persisted verbatim, and resolved to the right fixed offset.
|
||||
func TestUpdateProfileOffsetTimezone(t *testing.T) {
|
||||
ctx := context.Background()
|
||||
@@ -181,3 +181,49 @@ func TestUpdateProfileOffsetTimezone(t *testing.T) {
|
||||
t.Fatalf("ResolveZone offset = %d, want 10800", off)
|
||||
}
|
||||
}
|
||||
|
||||
// TestEmailLoginFlow covers the email-as-login path: a code is mailed to
|
||||
// a new address, verifying it provisions and returns the owning account, and a
|
||||
// second login for the same address resolves to that same account (a returning
|
||||
// user), with the identity confirmed.
|
||||
func TestEmailLoginFlow(t *testing.T) {
|
||||
ctx := context.Background()
|
||||
mailer := &capturingMailer{}
|
||||
svc := account.NewEmailService(account.NewStore(testDB), mailer)
|
||||
email := "login-" + uuid.NewString() + "@example.com"
|
||||
|
||||
accountID, err := svc.RequestLoginCode(ctx, email)
|
||||
if err != nil {
|
||||
t.Fatalf("request login code: %v", err)
|
||||
}
|
||||
code := sixDigit.FindString(mailer.lastBody)
|
||||
if code == "" {
|
||||
t.Fatalf("no code in mail body %q", mailer.lastBody)
|
||||
}
|
||||
|
||||
acc, err := svc.LoginWithCode(ctx, email, code)
|
||||
if err != nil {
|
||||
t.Fatalf("login with code: %v", err)
|
||||
}
|
||||
if acc.ID != accountID {
|
||||
t.Errorf("login account = %s, want %s", acc.ID, accountID)
|
||||
}
|
||||
if acc.IsGuest {
|
||||
t.Error("an email account must be durable, not a guest")
|
||||
}
|
||||
if !identityConfirmed(t, account.KindEmail, email) {
|
||||
t.Error("the email identity must be confirmed after login")
|
||||
}
|
||||
|
||||
// A second login for the same email is the returning user: same account.
|
||||
if _, err := svc.RequestLoginCode(ctx, email); err != nil {
|
||||
t.Fatalf("second request: %v", err)
|
||||
}
|
||||
acc2, err := svc.LoginWithCode(ctx, email, sixDigit.FindString(mailer.lastBody))
|
||||
if err != nil {
|
||||
t.Fatalf("second login: %v", err)
|
||||
}
|
||||
if acc2.ID != accountID {
|
||||
t.Errorf("returning login account = %s, want %s", acc2.ID, accountID)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -4,88 +4,17 @@ package inttest
|
||||
|
||||
import (
|
||||
"context"
|
||||
"database/sql"
|
||||
"errors"
|
||||
"sync"
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
"github.com/google/uuid"
|
||||
"go.uber.org/zap"
|
||||
|
||||
"scrabble/backend/internal/account"
|
||||
"scrabble/backend/internal/engine"
|
||||
"scrabble/backend/internal/game"
|
||||
)
|
||||
|
||||
// newGameService builds a game service over the shared pool and registry.
|
||||
func newGameService() *game.Service {
|
||||
return game.NewService(
|
||||
game.NewStore(testDB),
|
||||
account.NewStore(testDB),
|
||||
testRegistry,
|
||||
game.Config{
|
||||
DictDir: dictDir(),
|
||||
DictVersion: testDictVersion,
|
||||
TimeoutSweepInterval: time.Minute,
|
||||
CacheTTL: time.Hour,
|
||||
},
|
||||
zap.NewNop(),
|
||||
)
|
||||
}
|
||||
|
||||
// provisionAccount creates a fresh durable account and returns its id.
|
||||
func provisionAccount(t *testing.T) uuid.UUID {
|
||||
t.Helper()
|
||||
acc, err := account.NewStore(testDB).ProvisionByIdentity(context.Background(), account.KindTelegram, "tg-"+uuid.NewString())
|
||||
if err != nil {
|
||||
t.Fatalf("provision account: %v", err)
|
||||
}
|
||||
return acc.ID
|
||||
}
|
||||
|
||||
// openingSeed returns a seed whose fresh two-player English opening rack has a
|
||||
// legal move, so a greedy mirror can drive a game.
|
||||
func openingSeed(t *testing.T) int64 {
|
||||
t.Helper()
|
||||
for seed := int64(1); seed <= 200; seed++ {
|
||||
g, err := engine.New(testRegistry, engine.Options{Variant: engine.VariantEnglish, Version: testDictVersion, Players: 2, Seed: seed})
|
||||
if err != nil {
|
||||
t.Fatalf("engine new: %v", err)
|
||||
}
|
||||
if _, ok := g.HintView(); ok {
|
||||
return seed
|
||||
}
|
||||
}
|
||||
t.Fatal("no opening seed found")
|
||||
return 0
|
||||
}
|
||||
|
||||
// newMirror builds a parallel engine game with the same seed, used to compute
|
||||
// legal moves to feed the service under test.
|
||||
func newMirror(t *testing.T, seed int64, players int) *engine.Game {
|
||||
t.Helper()
|
||||
g, err := engine.New(testRegistry, engine.Options{Variant: engine.VariantEnglish, Version: testDictVersion, Players: players, Seed: seed})
|
||||
if err != nil {
|
||||
t.Fatalf("mirror new: %v", err)
|
||||
}
|
||||
return g
|
||||
}
|
||||
|
||||
// readStats reads an account's statistics row.
|
||||
func readStats(t *testing.T, id uuid.UUID) (wins, losses, draws, maxGame, maxWord int, found bool) {
|
||||
t.Helper()
|
||||
row := testDB.QueryRowContext(context.Background(),
|
||||
`SELECT wins, losses, draws, max_game_points, max_word_points FROM backend.account_stats WHERE account_id = $1`, id)
|
||||
if err := row.Scan(&wins, &losses, &draws, &maxGame, &maxWord); err != nil {
|
||||
if errors.Is(err, sql.ErrNoRows) {
|
||||
return 0, 0, 0, 0, 0, false
|
||||
}
|
||||
t.Fatalf("read stats: %v", err)
|
||||
}
|
||||
return wins, losses, draws, maxGame, maxWord, true
|
||||
}
|
||||
|
||||
// TestListForAccount checks the lobby "my games" query: it returns exactly the
|
||||
// games the account is seated in (each with its seats), and nothing for an outsider.
|
||||
func TestListForAccount(t *testing.T) {
|
||||
@@ -299,7 +228,7 @@ func TestResignWinnerAndStats(t *testing.T) {
|
||||
}
|
||||
}
|
||||
|
||||
// TestResignOnOpponentTurn checks the Stage 17 fix: a player can forfeit on the
|
||||
// TestResignOnOpponentTurn checks a player can forfeit on the
|
||||
// opponent's turn. Seat 0 plays (so it is seat 1's turn), then seat 0 resigns its own
|
||||
// seat while it is not its turn — no ErrNotYourTurn, the game ends, and seat 0 loses
|
||||
// despite leading on score.
|
||||
@@ -464,7 +393,7 @@ func TestHintPolicy(t *testing.T) {
|
||||
}
|
||||
}
|
||||
|
||||
// TestGameVariant covers the edge's lightweight variant lookup (Stage 13): it returns the
|
||||
// TestGameVariant covers the edge's lightweight variant lookup: it returns the
|
||||
// created game's variant and ErrNotFound for an unknown id.
|
||||
func TestGameVariant(t *testing.T) {
|
||||
ctx := context.Background()
|
||||
@@ -619,7 +548,7 @@ func equalStrings(a, b []string) bool {
|
||||
return true
|
||||
}
|
||||
|
||||
// TestExportGCGRefusesActiveGame checks the Stage 8 finished-only gate: a GCG export
|
||||
// TestExportGCGRefusesActiveGame checks the finished-only gate: a GCG export
|
||||
// is allowed only once the game is over, so an active game leaks nothing mid-play.
|
||||
func TestExportGCGRefusesActiveGame(t *testing.T) {
|
||||
ctx := context.Background()
|
||||
|
||||
@@ -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
|
||||
}
|
||||
@@ -12,7 +12,7 @@ import (
|
||||
"scrabble/backend/internal/game"
|
||||
)
|
||||
|
||||
// TestHideFinishedGame covers Stage 17 per-account game hiding: an active game cannot be
|
||||
// TestHideFinishedGame covers per-account game hiding: an active game cannot be
|
||||
// hidden, a finished game is removed from the hider's own list while staying visible to the
|
||||
// other player, an outsider cannot hide it, and the action is idempotent.
|
||||
func TestHideFinishedGame(t *testing.T) {
|
||||
|
||||
@@ -8,31 +8,12 @@ import (
|
||||
"time"
|
||||
|
||||
"github.com/google/uuid"
|
||||
"go.opentelemetry.io/otel/metric/noop"
|
||||
"go.uber.org/zap"
|
||||
|
||||
"scrabble/backend/internal/account"
|
||||
"scrabble/backend/internal/engine"
|
||||
"scrabble/backend/internal/game"
|
||||
"scrabble/backend/internal/lobby"
|
||||
"scrabble/backend/internal/robot"
|
||||
)
|
||||
|
||||
// newRobotService builds a robot service over games (shared so its moves and the
|
||||
// test's human moves use the same live-game cache and per-game locks), a fresh
|
||||
// social service for nudges, and a no-op meter.
|
||||
func newRobotService(t *testing.T, games *game.Service) *robot.Service {
|
||||
t.Helper()
|
||||
return robot.NewService(games, account.NewStore(testDB), newSocialService(), noop.NewMeterProvider().Meter("robot-test"), zap.NewNop())
|
||||
}
|
||||
|
||||
// newMatchmaker builds a matchmaker starting real games and substituting from
|
||||
// robots after wait.
|
||||
func newMatchmaker(t *testing.T, robots lobby.RobotProvider, wait time.Duration) *lobby.Matchmaker {
|
||||
t.Helper()
|
||||
return lobby.NewMatchmaker(newGameService(), robots, wait, zap.NewNop())
|
||||
}
|
||||
|
||||
// setTurnStarted rewrites a game's turn clock so a robot turn can be made due (or
|
||||
// idle) at a chosen instant, independent of wall time.
|
||||
func setTurnStarted(t *testing.T, id uuid.UUID, at time.Time) {
|
||||
@@ -97,7 +78,7 @@ func TestRobotPoolProvisionsRobotAccounts(t *testing.T) {
|
||||
t.Fatalf("get robot account: %v", err)
|
||||
}
|
||||
// A robot blocks chat but NOT friend requests: a request to a robot stays pending and
|
||||
// expires, mirroring a human who ignores it (Stage 17).
|
||||
// expires, mirroring a human who ignores it.
|
||||
if acc.DisplayName == "" || !acc.BlockChat || acc.BlockFriendRequests {
|
||||
t.Errorf("robot profile wrong: name=%q chat-blocked=%v friends-blocked=%v (want chat blocked, friends open)",
|
||||
acc.DisplayName, acc.BlockChat, acc.BlockFriendRequests)
|
||||
|
||||
@@ -45,32 +45,9 @@ func (c *capturePublisher) notified(user uuid.UUID, sub string) bool {
|
||||
return false
|
||||
}
|
||||
|
||||
// newSocialService builds a social service over the shared pool, reading game
|
||||
// state through a real game service.
|
||||
func newSocialService() *social.Service {
|
||||
return social.NewService(social.NewStore(testDB), account.NewStore(testDB), newGameService())
|
||||
}
|
||||
|
||||
// newGameWithSeats creates a started game seating n fresh accounts and returns the
|
||||
// game id and the seated account ids in seat order.
|
||||
func newGameWithSeats(t *testing.T, n int) (uuid.UUID, []uuid.UUID) {
|
||||
t.Helper()
|
||||
seats := make([]uuid.UUID, n)
|
||||
for i := range seats {
|
||||
seats[i] = provisionAccount(t)
|
||||
}
|
||||
g, err := newGameService().Create(context.Background(), game.CreateParams{
|
||||
Variant: engine.VariantEnglish, Seats: seats, TurnTimeout: 24 * time.Hour, Seed: openingSeed(t),
|
||||
})
|
||||
if err != nil {
|
||||
t.Fatalf("create game: %v", err)
|
||||
}
|
||||
return g.ID, seats
|
||||
}
|
||||
|
||||
// TestFriendRequestToRobotStaysPending checks a friend request to a robot is accepted as
|
||||
// pending rather than blocked: robots no longer block friend requests, so the request
|
||||
// just sits unanswered and later expires — mirroring a human who ignores it (Stage 17).
|
||||
// just sits unanswered and later expires — mirroring a human who ignores it.
|
||||
func TestFriendRequestToRobotStaysPending(t *testing.T) {
|
||||
ctx := context.Background()
|
||||
svc := newSocialService()
|
||||
@@ -342,7 +319,7 @@ func TestChatRejectsBadContent(t *testing.T) {
|
||||
}
|
||||
}
|
||||
|
||||
// TestChatOnlyOnYourTurn checks chat is allowed only on the sender's own turn (Stage 17):
|
||||
// TestChatOnlyOnYourTurn checks chat is allowed only on the sender's own turn:
|
||||
// the player to move can post, the waiting player gets ErrChatNotYourTurn.
|
||||
func TestChatOnlyOnYourTurn(t *testing.T) {
|
||||
ctx := context.Background()
|
||||
@@ -383,7 +360,7 @@ func TestNudgeRulesAndRateLimit(t *testing.T) {
|
||||
}
|
||||
|
||||
// TestNudgeCooldownResetsOnAction checks the nudge cooldown clears once the player has
|
||||
// acted (moved or chatted) since their last nudge, even within the hour (Stage 17).
|
||||
// acted (moved or chatted) since their last nudge, even within the hour.
|
||||
func TestNudgeCooldownResetsOnAction(t *testing.T) {
|
||||
ctx := context.Background()
|
||||
svc := newSocialService()
|
||||
@@ -413,7 +390,7 @@ func TestNudgeCooldownResetsOnAction(t *testing.T) {
|
||||
}
|
||||
|
||||
// TestListOutgoingRequests checks the requester-side list that backs the in-game "add to
|
||||
// friends" item (Stage 17): a pending request shows for the requester only; an accepted one
|
||||
// friends" item: a pending request shows for the requester only; an accepted one
|
||||
// clears (it is a friendship now); a declined one stays (cannot be re-sent, so it reads as
|
||||
// still "sent"); a lazily expired pending one drops (it may be re-sent).
|
||||
func TestListOutgoingRequests(t *testing.T) {
|
||||
@@ -469,7 +446,7 @@ func TestListOutgoingRequests(t *testing.T) {
|
||||
}
|
||||
|
||||
// TestRespondPublishesToRequester checks that answering a request notifies the original
|
||||
// requester over the live channel (Stage 17): accept -> friend_added, decline ->
|
||||
// requester over the live channel: accept -> friend_added, decline ->
|
||||
// friend_declined, so a game screen watching that opponent re-derives its friend state.
|
||||
func TestRespondPublishesToRequester(t *testing.T) {
|
||||
ctx := context.Background()
|
||||
@@ -503,7 +480,7 @@ func TestRespondPublishesToRequester(t *testing.T) {
|
||||
}
|
||||
|
||||
// TestNudgeRoutedByGameLanguage checks a nudge's out-of-app push carries the game's language, so
|
||||
// it is delivered by the game's bot rather than the recipient's last-login bot (Stage 17).
|
||||
// it is delivered by the game's bot rather than the recipient's last-login bot.
|
||||
func TestNudgeRoutedByGameLanguage(t *testing.T) {
|
||||
ctx := context.Background()
|
||||
svc := newSocialService()
|
||||
@@ -528,7 +505,7 @@ func TestNudgeRoutedByGameLanguage(t *testing.T) {
|
||||
}
|
||||
}
|
||||
|
||||
// TestAdminListMessages checks the admin moderation list (Stage 17): real messages only
|
||||
// TestAdminListMessages checks the admin moderation list: real messages only
|
||||
// (nudges excluded), the game / sender pins, the sender glob masks, and the source label.
|
||||
func TestAdminListMessages(t *testing.T) {
|
||||
ctx := context.Background()
|
||||
|
||||
@@ -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,4 +1,4 @@
|
||||
// Package link orchestrates account linking & merge (Stage 11, ARCHITECTURE.md §4).
|
||||
// Package link orchestrates account linking & merge (ARCHITECTURE.md §4).
|
||||
// It sits above the account, accountmerge and session layers: it verifies the
|
||||
// caller's control of an identity (an email confirm-code or a gateway-validated
|
||||
// platform identity), binds a free identity to the current account, and — when the
|
||||
|
||||
@@ -106,7 +106,7 @@ func (svc *InvitationService) SetNotifier(p notify.Publisher) {
|
||||
}
|
||||
|
||||
// emitInvitation publishes the invitation notification to each invitee, carrying the invitation
|
||||
// itself so the client adds it to its lobby list without a refetch (R4).
|
||||
// itself so the client adds it to its lobby list without a refetch.
|
||||
func (svc *InvitationService) emitInvitation(ctx context.Context, inv Invitation, inviteeIDs []uuid.UUID) {
|
||||
if len(inviteeIDs) == 0 {
|
||||
return
|
||||
@@ -120,7 +120,7 @@ func (svc *InvitationService) emitInvitation(ctx context.Context, inv Invitation
|
||||
}
|
||||
|
||||
// emitGameStarted publishes the game_started notification to each seated player, carrying their
|
||||
// initial view of the started game so the client seeds its game cache without a refetch (R4). A
|
||||
// initial view of the started game so the client seeds its game cache without a refetch. A
|
||||
// seat whose state cannot be read is skipped (it still sees the game on the next lobby load).
|
||||
func (svc *InvitationService) emitGameStarted(ctx context.Context, g game.Game, seats []uuid.UUID) {
|
||||
intents := make([]notify.Intent, 0, len(seats))
|
||||
|
||||
@@ -23,7 +23,7 @@ type GameCreator interface {
|
||||
Create(ctx context.Context, params game.CreateParams) (game.Game, error)
|
||||
// InitialState returns a seated player's full initial view of a started game, used
|
||||
// to enrich the match_found / game_started events so the client renders the new game
|
||||
// without a follow-up fetch (R4).
|
||||
// without a follow-up fetch.
|
||||
InitialState(ctx context.Context, gameID, accountID uuid.UUID) (notify.PlayerState, error)
|
||||
}
|
||||
|
||||
|
||||
@@ -76,7 +76,7 @@ func (m *Matchmaker) SetNotifier(p notify.Publisher) {
|
||||
// emitMatchFound pushes match_found to every seat of a freshly started game.
|
||||
// Emitting to a robot seat is harmless (no client subscription exists for it).
|
||||
func (m *Matchmaker) emitMatchFound(ctx context.Context, g game.Game) {
|
||||
lang := g.Variant.Language() // route the push by the game's language, not the recipient's bot (Stage 17)
|
||||
lang := g.Variant.Language() // route the push by the game's language, not the recipient's bot
|
||||
intents := make([]notify.Intent, 0, len(g.Seats))
|
||||
for _, s := range g.Seats {
|
||||
state, err := m.games.InitialState(ctx, g.ID, s.AccountID)
|
||||
|
||||
@@ -249,7 +249,7 @@ func TestMatchmakerReaperSkipsCancelled(t *testing.T) {
|
||||
|
||||
// TestMatchmakerCancelClearsPendingResult covers the race where the reaper substitutes a
|
||||
// robot just before the player cancels: Cancel must drop the pending result so the
|
||||
// abandoned game never surfaces through Poll (Stage 17).
|
||||
// abandoned game never surfaces through Poll.
|
||||
func TestMatchmakerCancelClearsPendingResult(t *testing.T) {
|
||||
creator := &fakeCreator{}
|
||||
mm := newTestMatchmaker(creator, uuid.New())
|
||||
|
||||
@@ -4,194 +4,114 @@ import (
|
||||
flatbuffers "github.com/google/flatbuffers/go"
|
||||
|
||||
"scrabble/backend/internal/engine"
|
||||
fb "scrabble/pkg/fbs/scrabblefb"
|
||||
"scrabble/pkg/wire"
|
||||
)
|
||||
|
||||
// The builders below encode the nested wire tables embedded in enriched event
|
||||
// payloads (R4). They mirror the gateway's transcode encoders, but read the domain's
|
||||
// already-resolved values (notify.* input structs and the decoded engine.MoveRecord)
|
||||
// rather than the gateway's REST DTOs. Each returns the offset of the table it built;
|
||||
// callers must build every nested table before opening the parent event table.
|
||||
// payloads. They map the domain's already-resolved values (notify.* payload structs
|
||||
// and the decoded engine.MoveRecord) to the neutral scrabble/pkg/wire structs and
|
||||
// delegate the FlatBuffers construction to package wire — the single definition of the
|
||||
// nested-table layout shared with the gateway transcoder. Each returns the offset of
|
||||
// the table it built; callers must build every nested table before opening the parent.
|
||||
|
||||
// toWireGame maps a GameSummary to the shared wire.GameView.
|
||||
func toWireGame(g GameSummary) wire.GameView {
|
||||
seats := make([]wire.SeatView, len(g.Seats))
|
||||
for i, s := range g.Seats {
|
||||
seats[i] = wire.SeatView{
|
||||
Seat: s.Seat,
|
||||
AccountID: s.AccountID,
|
||||
Score: s.Score,
|
||||
HintsUsed: s.HintsUsed,
|
||||
IsWinner: s.IsWinner,
|
||||
DisplayName: s.DisplayName,
|
||||
}
|
||||
}
|
||||
return wire.GameView{
|
||||
ID: g.ID,
|
||||
Variant: g.Variant,
|
||||
DictVersion: g.DictVersion,
|
||||
Status: g.Status,
|
||||
Players: g.Players,
|
||||
ToMove: g.ToMove,
|
||||
TurnTimeoutSecs: g.TurnTimeoutSecs,
|
||||
MoveCount: g.MoveCount,
|
||||
EndReason: g.EndReason,
|
||||
Seats: seats,
|
||||
LastActivityUnix: g.LastActivityUnix,
|
||||
}
|
||||
}
|
||||
|
||||
// buildGameView builds a GameView table from a GameSummary and returns its offset.
|
||||
func buildGameView(b *flatbuffers.Builder, g GameSummary) flatbuffers.UOffsetT {
|
||||
seatOffs := make([]flatbuffers.UOffsetT, len(g.Seats))
|
||||
for i, s := range g.Seats {
|
||||
aid := b.CreateString(s.AccountID)
|
||||
dname := b.CreateString(s.DisplayName)
|
||||
fb.SeatViewStart(b)
|
||||
fb.SeatViewAddSeat(b, int32(s.Seat))
|
||||
fb.SeatViewAddAccountId(b, aid)
|
||||
fb.SeatViewAddScore(b, int32(s.Score))
|
||||
fb.SeatViewAddHintsUsed(b, int32(s.HintsUsed))
|
||||
fb.SeatViewAddIsWinner(b, s.IsWinner)
|
||||
fb.SeatViewAddDisplayName(b, dname)
|
||||
seatOffs[i] = fb.SeatViewEnd(b)
|
||||
}
|
||||
fb.GameViewStartSeatsVector(b, len(seatOffs))
|
||||
for i := len(seatOffs) - 1; i >= 0; i-- {
|
||||
b.PrependUOffsetT(seatOffs[i])
|
||||
}
|
||||
seats := b.EndVector(len(seatOffs))
|
||||
|
||||
id := b.CreateString(g.ID)
|
||||
variant := b.CreateString(g.Variant)
|
||||
dictVer := b.CreateString(g.DictVersion)
|
||||
status := b.CreateString(g.Status)
|
||||
endReason := b.CreateString(g.EndReason)
|
||||
|
||||
fb.GameViewStart(b)
|
||||
fb.GameViewAddId(b, id)
|
||||
fb.GameViewAddVariant(b, variant)
|
||||
fb.GameViewAddDictVersion(b, dictVer)
|
||||
fb.GameViewAddStatus(b, status)
|
||||
fb.GameViewAddPlayers(b, int32(g.Players))
|
||||
fb.GameViewAddToMove(b, int32(g.ToMove))
|
||||
fb.GameViewAddTurnTimeoutSecs(b, int32(g.TurnTimeoutSecs))
|
||||
fb.GameViewAddMoveCount(b, int32(g.MoveCount))
|
||||
fb.GameViewAddEndReason(b, endReason)
|
||||
fb.GameViewAddSeats(b, seats)
|
||||
fb.GameViewAddLastActivityUnix(b, g.LastActivityUnix)
|
||||
return fb.GameViewEnd(b)
|
||||
return wire.BuildGameView(b, toWireGame(g))
|
||||
}
|
||||
|
||||
// buildMoveRecord builds a MoveRecord table from a decoded engine move and returns
|
||||
// its offset. The values match the move-result DTO (Count is the engine count: the
|
||||
// number of tiles swapped on an exchange, zero otherwise).
|
||||
// buildMoveRecord builds a MoveRecord table from a decoded engine move and returns its
|
||||
// offset (Count is the engine count: the number of tiles swapped on an exchange, zero
|
||||
// otherwise).
|
||||
func buildMoveRecord(b *flatbuffers.Builder, m engine.MoveRecord) flatbuffers.UOffsetT {
|
||||
tileOffs := make([]flatbuffers.UOffsetT, len(m.Tiles))
|
||||
tiles := make([]wire.TileRecord, len(m.Tiles))
|
||||
for i, t := range m.Tiles {
|
||||
letter := b.CreateString(t.Letter)
|
||||
fb.TileRecordStart(b)
|
||||
fb.TileRecordAddRow(b, int32(t.Row))
|
||||
fb.TileRecordAddCol(b, int32(t.Col))
|
||||
fb.TileRecordAddLetter(b, letter)
|
||||
fb.TileRecordAddBlank(b, t.Blank)
|
||||
tileOffs[i] = fb.TileRecordEnd(b)
|
||||
tiles[i] = wire.TileRecord{Row: t.Row, Col: t.Col, Letter: t.Letter, Blank: t.Blank}
|
||||
}
|
||||
fb.MoveRecordStartTilesVector(b, len(tileOffs))
|
||||
for i := len(tileOffs) - 1; i >= 0; i-- {
|
||||
b.PrependUOffsetT(tileOffs[i])
|
||||
}
|
||||
tiles := b.EndVector(len(tileOffs))
|
||||
|
||||
wordOffs := make([]flatbuffers.UOffsetT, len(m.Words))
|
||||
for i, w := range m.Words {
|
||||
wordOffs[i] = b.CreateString(w)
|
||||
}
|
||||
fb.MoveRecordStartWordsVector(b, len(wordOffs))
|
||||
for i := len(wordOffs) - 1; i >= 0; i-- {
|
||||
b.PrependUOffsetT(wordOffs[i])
|
||||
}
|
||||
words := b.EndVector(len(wordOffs))
|
||||
|
||||
action := b.CreateString(m.Action.String())
|
||||
dir := b.CreateString(m.Dir.String())
|
||||
fb.MoveRecordStart(b)
|
||||
fb.MoveRecordAddPlayer(b, int32(m.Player))
|
||||
fb.MoveRecordAddAction(b, action)
|
||||
fb.MoveRecordAddDir(b, dir)
|
||||
fb.MoveRecordAddMainRow(b, int32(m.MainRow))
|
||||
fb.MoveRecordAddMainCol(b, int32(m.MainCol))
|
||||
fb.MoveRecordAddTiles(b, tiles)
|
||||
fb.MoveRecordAddWords(b, words)
|
||||
fb.MoveRecordAddCount(b, int32(m.Count))
|
||||
fb.MoveRecordAddScore(b, int32(m.Score))
|
||||
fb.MoveRecordAddTotal(b, int32(m.Total))
|
||||
return fb.MoveRecordEnd(b)
|
||||
}
|
||||
|
||||
// buildAlphabet builds the AlphabetEntry vector embedded in a StateView and returns
|
||||
// its offset.
|
||||
func buildAlphabet(b *flatbuffers.Builder, entries []AlphabetLetter) flatbuffers.UOffsetT {
|
||||
offs := make([]flatbuffers.UOffsetT, len(entries))
|
||||
for i, e := range entries {
|
||||
letter := b.CreateString(e.Letter)
|
||||
fb.AlphabetEntryStart(b)
|
||||
fb.AlphabetEntryAddIndex(b, byte(e.Index))
|
||||
fb.AlphabetEntryAddLetter(b, letter)
|
||||
fb.AlphabetEntryAddValue(b, int32(e.Value))
|
||||
offs[i] = fb.AlphabetEntryEnd(b)
|
||||
}
|
||||
fb.StateViewStartAlphabetVector(b, len(offs))
|
||||
for i := len(offs) - 1; i >= 0; i-- {
|
||||
b.PrependUOffsetT(offs[i])
|
||||
}
|
||||
return b.EndVector(len(offs))
|
||||
return wire.BuildMoveRecord(b, wire.MoveRecord{
|
||||
Player: m.Player,
|
||||
Action: m.Action.String(),
|
||||
Dir: m.Dir.String(),
|
||||
MainRow: m.MainRow,
|
||||
MainCol: m.MainCol,
|
||||
Tiles: tiles,
|
||||
Words: m.Words,
|
||||
Count: m.Count,
|
||||
Score: m.Score,
|
||||
Total: m.Total,
|
||||
})
|
||||
}
|
||||
|
||||
// buildStateView builds a StateView table from a PlayerState and returns its offset.
|
||||
func buildStateView(b *flatbuffers.Builder, s PlayerState) flatbuffers.UOffsetT {
|
||||
game := buildGameView(b, s.Game)
|
||||
rackBytes := make([]byte, len(s.Rack))
|
||||
for i, v := range s.Rack {
|
||||
rackBytes[i] = byte(v)
|
||||
alphabet := make([]wire.AlphabetEntry, len(s.Alphabet))
|
||||
for i, e := range s.Alphabet {
|
||||
alphabet[i] = wire.AlphabetEntry{Index: e.Index, Letter: e.Letter, Value: e.Value}
|
||||
}
|
||||
rack := b.CreateByteVector(rackBytes)
|
||||
hasAlphabet := len(s.Alphabet) > 0
|
||||
var alphabet flatbuffers.UOffsetT
|
||||
if hasAlphabet {
|
||||
alphabet = buildAlphabet(b, s.Alphabet)
|
||||
}
|
||||
fb.StateViewStart(b)
|
||||
fb.StateViewAddGame(b, game)
|
||||
fb.StateViewAddSeat(b, int32(s.Seat))
|
||||
fb.StateViewAddRack(b, rack)
|
||||
fb.StateViewAddBagLen(b, int32(s.BagLen))
|
||||
fb.StateViewAddHintsRemaining(b, int32(s.HintsRemaining))
|
||||
if hasAlphabet {
|
||||
fb.StateViewAddAlphabet(b, alphabet)
|
||||
}
|
||||
return fb.StateViewEnd(b)
|
||||
return wire.BuildStateView(b, wire.StateView{
|
||||
Game: toWireGame(s.Game),
|
||||
Seat: s.Seat,
|
||||
Rack: s.Rack,
|
||||
BagLen: s.BagLen,
|
||||
HintsRemaining: s.HintsRemaining,
|
||||
Alphabet: alphabet,
|
||||
})
|
||||
}
|
||||
|
||||
// buildAccountRef builds an AccountRef table and returns its offset.
|
||||
func buildAccountRef(b *flatbuffers.Builder, a AccountRef) flatbuffers.UOffsetT {
|
||||
aid := b.CreateString(a.AccountID)
|
||||
name := b.CreateString(a.DisplayName)
|
||||
fb.AccountRefStart(b)
|
||||
fb.AccountRefAddAccountId(b, aid)
|
||||
fb.AccountRefAddDisplayName(b, name)
|
||||
return fb.AccountRefEnd(b)
|
||||
return wire.BuildAccountRef(b, wire.AccountRef{AccountID: a.AccountID, DisplayName: a.DisplayName})
|
||||
}
|
||||
|
||||
// buildInvitation builds an Invitation table from an InvitationSummary and returns its offset.
|
||||
func buildInvitation(b *flatbuffers.Builder, inv InvitationSummary) flatbuffers.UOffsetT {
|
||||
inviter := buildAccountRef(b, inv.Inviter)
|
||||
inviteeOffs := make([]flatbuffers.UOffsetT, len(inv.Invitees))
|
||||
invitees := make([]wire.InvitationInvitee, len(inv.Invitees))
|
||||
for i, iv := range inv.Invitees {
|
||||
aid := b.CreateString(iv.AccountID)
|
||||
name := b.CreateString(iv.DisplayName)
|
||||
resp := b.CreateString(iv.Response)
|
||||
fb.InvitationInviteeStart(b)
|
||||
fb.InvitationInviteeAddAccountId(b, aid)
|
||||
fb.InvitationInviteeAddDisplayName(b, name)
|
||||
fb.InvitationInviteeAddSeat(b, int32(iv.Seat))
|
||||
fb.InvitationInviteeAddResponse(b, resp)
|
||||
inviteeOffs[i] = fb.InvitationInviteeEnd(b)
|
||||
invitees[i] = wire.InvitationInvitee{
|
||||
AccountID: iv.AccountID,
|
||||
DisplayName: iv.DisplayName,
|
||||
Seat: iv.Seat,
|
||||
Response: iv.Response,
|
||||
}
|
||||
}
|
||||
fb.InvitationStartInviteesVector(b, len(inviteeOffs))
|
||||
for i := len(inviteeOffs) - 1; i >= 0; i-- {
|
||||
b.PrependUOffsetT(inviteeOffs[i])
|
||||
}
|
||||
invitees := b.EndVector(len(inviteeOffs))
|
||||
|
||||
id := b.CreateString(inv.ID)
|
||||
variant := b.CreateString(inv.Variant)
|
||||
dropout := b.CreateString(inv.DropoutTiles)
|
||||
status := b.CreateString(inv.Status)
|
||||
gameID := b.CreateString(inv.GameID)
|
||||
fb.InvitationStart(b)
|
||||
fb.InvitationAddId(b, id)
|
||||
fb.InvitationAddInviter(b, inviter)
|
||||
fb.InvitationAddInvitees(b, invitees)
|
||||
fb.InvitationAddVariant(b, variant)
|
||||
fb.InvitationAddTurnTimeoutSecs(b, int32(inv.TurnTimeoutSecs))
|
||||
fb.InvitationAddHintsAllowed(b, inv.HintsAllowed)
|
||||
fb.InvitationAddHintsPerPlayer(b, int32(inv.HintsPerPlayer))
|
||||
fb.InvitationAddDropoutTiles(b, dropout)
|
||||
fb.InvitationAddStatus(b, status)
|
||||
fb.InvitationAddGameId(b, gameID)
|
||||
fb.InvitationAddExpiresAtUnix(b, inv.ExpiresAtUnix)
|
||||
return fb.InvitationEnd(b)
|
||||
return wire.BuildInvitation(b, wire.Invitation{
|
||||
ID: inv.ID,
|
||||
Inviter: wire.AccountRef{AccountID: inv.Inviter.AccountID, DisplayName: inv.Inviter.DisplayName},
|
||||
Invitees: invitees,
|
||||
Variant: inv.Variant,
|
||||
TurnTimeoutSecs: inv.TurnTimeoutSecs,
|
||||
HintsAllowed: inv.HintsAllowed,
|
||||
HintsPerPlayer: inv.HintsPerPlayer,
|
||||
DropoutTiles: inv.DropoutTiles,
|
||||
Status: inv.Status,
|
||||
GameID: inv.GameID,
|
||||
ExpiresAtUnix: inv.ExpiresAtUnix,
|
||||
})
|
||||
}
|
||||
|
||||
@@ -15,11 +15,11 @@ import (
|
||||
// the game/social/lobby services emit events without importing the wire schema.
|
||||
|
||||
// YourTurn announces to userID that it is their turn in game gameID, with the turn's nominal
|
||||
// deadline. opponentName, lastAction, lastWord and scoreLine enrich the out-of-app push (Stage
|
||||
// 17): the player who just moved, their move kind, the main word of a scoring play (empty
|
||||
// deadline. opponentName, lastAction, lastWord and scoreLine enrich the out-of-app push:
|
||||
// the player who just moved, their move kind, the main word of a scoring play (empty
|
||||
// otherwise) and the recipient-first running score line. Empty strings render the plain "your
|
||||
// turn" text. moveCount is the post-move count, which the client compares against its cached
|
||||
// game to detect a missed in-app move and fall back to a refetch (R4).
|
||||
// game to detect a missed in-app move and fall back to a refetch.
|
||||
func YourTurn(userID, gameID uuid.UUID, deadline time.Time, opponentName, lastAction, lastWord, scoreLine string, moveCount int) Intent {
|
||||
b := flatbuffers.NewBuilder(128)
|
||||
gid := b.CreateString(gameID.String())
|
||||
@@ -41,9 +41,9 @@ func YourTurn(userID, gameID uuid.UUID, deadline time.Time, opponentName, lastAc
|
||||
|
||||
// GameOver announces to userID that game gameID finished. result is the outcome from userID's
|
||||
// own perspective ("won"/"lost"/"draw") and scoreLine is the recipient-first final score; both
|
||||
// feed the out-of-app "game over" push (Stage 17). game is the final post-game summary (the
|
||||
// feed the out-of-app "game over" push. game is the final post-game summary (the
|
||||
// adjusted scores after rack penalties and the winner flag), so an in-app client settles the
|
||||
// finished game from the event without a refetch (R4).
|
||||
// finished game from the event without a refetch.
|
||||
func GameOver(userID, gameID uuid.UUID, result, scoreLine string, game GameSummary) Intent {
|
||||
b := flatbuffers.NewBuilder(512)
|
||||
gid := b.CreateString(gameID.String())
|
||||
@@ -60,22 +60,16 @@ func GameOver(userID, gameID uuid.UUID, result, scoreLine string, game GameSumma
|
||||
}
|
||||
|
||||
// OpponentMoved tells userID that move was just committed in game gameID, carrying it as a delta
|
||||
// the client applies to its cached game without a refetch (R4): move is the decoded play/pass/
|
||||
// the client applies to its cached game without a refetch: move is the decoded play/pass/
|
||||
// exchange, game is the post-move summary (per-seat scores, to_move, move_count, status) and
|
||||
// bagLen is the bag size after the draw. The seat/action/score/total scalars repeat the move's
|
||||
// summary for pre-R4 wire back-compat.
|
||||
// bagLen is the bag size after the draw.
|
||||
func OpponentMoved(userID, gameID uuid.UUID, move engine.MoveRecord, game GameSummary, bagLen int) Intent {
|
||||
b := flatbuffers.NewBuilder(512)
|
||||
gid := b.CreateString(gameID.String())
|
||||
act := b.CreateString(move.Action.String())
|
||||
moveOff := buildMoveRecord(b, move)
|
||||
gameOff := buildGameView(b, game)
|
||||
fb.OpponentMovedEventStart(b)
|
||||
fb.OpponentMovedEventAddGameId(b, gid)
|
||||
fb.OpponentMovedEventAddSeat(b, int32(move.Player))
|
||||
fb.OpponentMovedEventAddAction(b, act)
|
||||
fb.OpponentMovedEventAddScore(b, int32(move.Score))
|
||||
fb.OpponentMovedEventAddTotal(b, int32(move.Total))
|
||||
fb.OpponentMovedEventAddMove(b, moveOff)
|
||||
fb.OpponentMovedEventAddGame(b, gameOff)
|
||||
fb.OpponentMovedEventAddBagLen(b, int32(bagLen))
|
||||
@@ -116,7 +110,7 @@ func Nudge(userID, gameID, fromUserID uuid.UUID) Intent {
|
||||
|
||||
// MatchFound tells userID that game gameID, which they are seated in, has started (an auto-match
|
||||
// pairing or a robot substitution). state is the recipient's full initial view of the new game,
|
||||
// so the client navigates straight in from the event with no follow-up fetch (R4).
|
||||
// so the client navigates straight in from the event with no follow-up fetch.
|
||||
func MatchFound(userID, gameID uuid.UUID, state PlayerState) Intent {
|
||||
b := flatbuffers.NewBuilder(512)
|
||||
gid := b.CreateString(gameID.String())
|
||||
@@ -131,7 +125,7 @@ func MatchFound(userID, gameID uuid.UUID, state PlayerState) Intent {
|
||||
// Notification is a lightweight "re-poll" signal to userID that something in their lobby
|
||||
// changed. kind is a sub-discriminator (NotifyFriendRequest, NotifyFriendAdded,
|
||||
// NotifyFriendDeclined, NotifyInvitation, NotifyGameStarted). It carries no payload; prefer the
|
||||
// enriched constructors below, which let the client update its lobby without a refetch (R4).
|
||||
// enriched constructors below, which let the client update its lobby without a refetch.
|
||||
func Notification(userID uuid.UUID, kind string) Intent {
|
||||
b := flatbuffers.NewBuilder(32)
|
||||
k := b.CreateString(kind)
|
||||
@@ -143,7 +137,7 @@ func Notification(userID uuid.UUID, kind string) Intent {
|
||||
|
||||
// NotificationAccount builds a lobby notification of one of the friend_* kinds carrying the
|
||||
// account it concerns (the requester, the new friend or the decliner), so the client updates its
|
||||
// requests/friends lists and the in-game "add friend" state without a refetch (R4).
|
||||
// requests/friends lists and the in-game "add friend" state without a refetch.
|
||||
func NotificationAccount(userID uuid.UUID, kind string, acc AccountRef) Intent {
|
||||
b := flatbuffers.NewBuilder(128)
|
||||
k := b.CreateString(kind)
|
||||
@@ -157,7 +151,7 @@ func NotificationAccount(userID uuid.UUID, kind string, acc AccountRef) Intent {
|
||||
|
||||
// NotificationGameStarted builds the NotifyGameStarted notification carrying the recipient's
|
||||
// initial view of the just-started invited game, so the client seeds its game cache and the
|
||||
// lobby list without a refetch (R4).
|
||||
// lobby list without a refetch.
|
||||
func NotificationGameStarted(userID uuid.UUID, state PlayerState) Intent {
|
||||
b := flatbuffers.NewBuilder(512)
|
||||
k := b.CreateString(NotifyGameStarted)
|
||||
@@ -170,7 +164,7 @@ func NotificationGameStarted(userID uuid.UUID, state PlayerState) Intent {
|
||||
}
|
||||
|
||||
// NotificationInvitation builds the NotifyInvitation notification carrying the new invitation,
|
||||
// so the client adds it to its lobby invitations list without a refetch (R4).
|
||||
// so the client adds it to its lobby invitations list without a refetch.
|
||||
func NotificationInvitation(userID uuid.UUID, inv InvitationSummary) Intent {
|
||||
b := flatbuffers.NewBuilder(512)
|
||||
k := b.CreateString(NotifyInvitation)
|
||||
|
||||
@@ -28,7 +28,7 @@ const (
|
||||
// (incoming friend requests, invitations) that drives the lobby badge.
|
||||
KindNotification = "notify"
|
||||
// KindGameOver announces a finished game to each seated player, driving the
|
||||
// out-of-app "game over" push (Stage 17).
|
||||
// out-of-app "game over" push.
|
||||
KindGameOver = "game_over"
|
||||
)
|
||||
|
||||
@@ -52,7 +52,7 @@ type Intent struct {
|
||||
Kind string
|
||||
Payload []byte
|
||||
EventID string
|
||||
// Language routes an out-of-app push to a specific per-language bot (Stage 17): for a
|
||||
// Language routes an out-of-app push to a specific per-language bot: for a
|
||||
// game event it is the game's language ("en"/"ru"), so the notification comes from the
|
||||
// game's bot rather than the recipient's last-login bot. Empty falls back to the
|
||||
// recipient's service language at the gateway.
|
||||
|
||||
@@ -109,12 +109,10 @@ func TestOpponentMovedPayloadRoundTrips(t *testing.T) {
|
||||
t.Fatalf("kind = %q", in.Kind)
|
||||
}
|
||||
ev := fb.GetRootAsOpponentMovedEvent(in.Payload, 0)
|
||||
// The pre-R4 summary scalars repeat the move.
|
||||
if string(ev.GameId()) != gid.String() || ev.Seat() != 1 || string(ev.Action()) != "play" || ev.Score() != 24 || ev.Total() != 130 {
|
||||
t.Fatalf("scalars wrong: game=%q seat=%d action=%q score=%d total=%d",
|
||||
ev.GameId(), ev.Seat(), ev.Action(), ev.Score(), ev.Total())
|
||||
if string(ev.GameId()) != gid.String() {
|
||||
t.Fatalf("game id = %q", ev.GameId())
|
||||
}
|
||||
// The R4 delta: the move, the post-move summary and the bag size.
|
||||
// The delta: the move, the post-move summary and the bag size.
|
||||
if ev.BagLen() != 42 {
|
||||
t.Fatalf("bag_len = %d, want 42", ev.BagLen())
|
||||
}
|
||||
|
||||
@@ -35,7 +35,7 @@ CREATE TABLE accounts (
|
||||
merged_into uuid REFERENCES accounts (account_id) ON DELETE SET NULL,
|
||||
merged_at timestamptz,
|
||||
service_language text CHECK (service_language IN ('en', 'ru')),
|
||||
-- Soft, reversible "suspected high-rate" marker (R3): set once when the gateway
|
||||
-- Soft, reversible "suspected high-rate" marker: set once when the gateway
|
||||
-- reports sustained rate-limiter rejections past the threshold; an operator
|
||||
-- clears it in the admin console. Never an automatic ban.
|
||||
flagged_high_rate_at timestamptz,
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
// Package ratewatch ingests the gateway's periodic rate-limiter rejection
|
||||
// reports (R3). It keeps an in-memory window of recent throttle episodes for
|
||||
// reports. It keeps an in-memory window of recent throttle episodes for
|
||||
// the admin console's view and applies the conservative high-rate auto-flag:
|
||||
// when one account's rejections within the rolling window cross the threshold,
|
||||
// the account store stamps the soft, reversible flagged_high_rate_at marker
|
||||
|
||||
@@ -93,13 +93,13 @@ type gameDTO struct {
|
||||
MoveCount int `json:"move_count"`
|
||||
EndReason string `json:"end_reason"`
|
||||
// LastActivityUnix is the lobby sort key: the current turn's start for an active
|
||||
// game, the finish time once finished (Stage 17).
|
||||
// game, the finish time once finished.
|
||||
LastActivityUnix int64 `json:"last_activity_unix"`
|
||||
Seats []seatDTO `json:"seats"`
|
||||
}
|
||||
|
||||
// moveResultDTO is the outcome of a committed move. Rack carries the actor's refilled rack as
|
||||
// wire alphabet indices and BagLen the bag size after the draw (R4), so the mover renders the
|
||||
// wire alphabet indices and BagLen the bag size after the draw, so the mover renders the
|
||||
// next state from the response without a follow-up state fetch.
|
||||
type moveResultDTO struct {
|
||||
Move moveRecordDTO `json:"move"`
|
||||
@@ -109,15 +109,14 @@ type moveResultDTO struct {
|
||||
}
|
||||
|
||||
// alphabetEntryDTO is one letter of a variant's alphabet (its index, concrete letter and
|
||||
// tile value), embedded in the state view for display only when the client requests it
|
||||
// (Stage 13).
|
||||
// tile value), embedded in the state view for display only when the client requests it.
|
||||
type alphabetEntryDTO struct {
|
||||
Index int `json:"index"`
|
||||
Letter string `json:"letter"`
|
||||
Value int `json:"value"`
|
||||
}
|
||||
|
||||
// stateDTO is a player's view of a game. Rack carries wire alphabet indices (Stage 13; a
|
||||
// stateDTO is a player's view of a game. Rack carries wire alphabet indices (a
|
||||
// blank is engine.BlankIndex). Alphabet is present only when the request asked for it.
|
||||
type stateDTO struct {
|
||||
Game gameDTO `json:"game"`
|
||||
@@ -236,7 +235,7 @@ func moveRecordDTOFrom(m engine.MoveRecord) moveRecordDTO {
|
||||
}
|
||||
|
||||
// moveResultDTOFrom projects a committed move result into its DTO, encoding the actor's rack as
|
||||
// wire alphabet indices (Stage 13; R4).
|
||||
// wire alphabet indices.
|
||||
func moveResultDTOFrom(r game.MoveResult) (moveResultDTO, error) {
|
||||
rack, err := engine.EncodeRack(r.Game.Variant, r.Rack)
|
||||
if err != nil {
|
||||
@@ -251,7 +250,7 @@ func moveResultDTOFrom(r game.MoveResult) (moveResultDTO, error) {
|
||||
}
|
||||
|
||||
// stateDTOFrom projects a player's state view into its DTO, encoding the rack as wire
|
||||
// alphabet indices (Stage 13). When includeAlphabet is set it also embeds the variant's
|
||||
// alphabet indices. When includeAlphabet is set it also embeds the variant's
|
||||
// display table, which the client caches per variant and renders the rack with.
|
||||
func stateDTOFrom(v game.StateView, includeAlphabet bool) (stateDTO, error) {
|
||||
rack, err := engine.EncodeRack(v.Game.Variant, v.Rack)
|
||||
|
||||
@@ -18,11 +18,10 @@ import (
|
||||
"scrabble/backend/internal/social"
|
||||
)
|
||||
|
||||
// registerRoutes wires the Stage 6 REST handlers onto the /api/v1 groups. The
|
||||
// registerRoutes wires the REST handlers onto the /api/v1 groups. The
|
||||
// internal group is gateway-only (the gateway authenticates and forwards); the
|
||||
// user group requires X-User-ID; the admin group is reached through the gateway's
|
||||
// Basic-Auth proxy. This is the representative vertical slice — further domain
|
||||
// operations follow the same pattern (PLAN.md Stage 6).
|
||||
// Basic-Auth proxy.
|
||||
func (s *Server) registerRoutes() {
|
||||
if s.sessions != nil && s.accounts != nil {
|
||||
in := s.internal
|
||||
@@ -32,13 +31,13 @@ func (s *Server) registerRoutes() {
|
||||
in.POST("/sessions/email/login", s.handleEmailLogin)
|
||||
in.POST("/sessions/resolve", s.handleResolveSession)
|
||||
in.POST("/sessions/revoke", s.handleRevokeSession)
|
||||
// Out-of-app push routing for the platform side-service (Stage 9): the
|
||||
// Out-of-app push routing for the platform side-service: the
|
||||
// gateway resolves a recipient's Telegram chat + language + in-app-only flag
|
||||
// before delivering an out-of-app notification.
|
||||
in.POST("/push-target", s.handlePushTarget)
|
||||
}
|
||||
if s.ratewatch != nil {
|
||||
// The gateway's periodic rate-limiter rejection summary (R3): feeds the
|
||||
// The gateway's periodic rate-limiter rejection summary: feeds the
|
||||
// admin console's throttled view and the high-rate auto-flag.
|
||||
s.internal.POST("/ratelimit/report", s.handleRateLimitReport)
|
||||
}
|
||||
@@ -49,7 +48,7 @@ func (s *Server) registerRoutes() {
|
||||
u.GET("/stats", s.handleStats)
|
||||
}
|
||||
if s.links != nil {
|
||||
// Account linking & merge (Stage 11). The request step always mails a code;
|
||||
// Account linking & merge. The request step always mails a code;
|
||||
// a required merge is revealed only after the code is verified, and the
|
||||
// irreversible merge is an explicit second step.
|
||||
u.POST("/link/email/request", s.handleLinkEmailRequest)
|
||||
|
||||
@@ -11,7 +11,7 @@ import (
|
||||
)
|
||||
|
||||
// The /api/v1/user account handlers wire profile editing, email binding and the
|
||||
// statistics read (Stage 8). They follow handlers_user.go: X-User-ID identity, a
|
||||
// statistics read. They follow handlers_user.go: X-User-ID identity, a
|
||||
// domain call, a JSON DTO. Profile editing overwrites the full editable set, so the
|
||||
// client sends the complete desired profile.
|
||||
|
||||
|
||||
@@ -560,7 +560,7 @@ func (s *Server) consolePostBroadcast(c *gin.Context) {
|
||||
|
||||
// consoleThrottled renders the rate-limit observability page: the recent
|
||||
// gateway-reported throttle episodes (in-memory, reset on a backend restart)
|
||||
// and the accounts currently carrying the soft high-rate flag (R3).
|
||||
// and the accounts currently carrying the soft high-rate flag.
|
||||
func (s *Server) consoleThrottled(c *gin.Context) {
|
||||
ctx := c.Request.Context()
|
||||
var view adminconsole.ThrottledView
|
||||
@@ -595,7 +595,7 @@ func (s *Server) consoleThrottled(c *gin.Context) {
|
||||
}
|
||||
|
||||
// consoleClearHighRateFlag clears the soft high-rate marker — the operator's
|
||||
// reversible review action (R3).
|
||||
// reversible review action.
|
||||
func (s *Server) consoleClearHighRateFlag(c *gin.Context) {
|
||||
id, ok := s.consoleUUID(c, "/_gm/users")
|
||||
if !ok {
|
||||
|
||||
@@ -7,7 +7,7 @@ import (
|
||||
"github.com/google/uuid"
|
||||
)
|
||||
|
||||
// The /api/v1/user/blocks/* handlers wire the per-user block list (Stage 8). A block
|
||||
// The /api/v1/user/blocks/* handlers wire the per-user block list. A block
|
||||
// is mutual in effect (the social checks apply it both ways) and severs any
|
||||
// friendship between the pair. They reuse the friend handlers' targetIDRequest and
|
||||
// account-ref resolution.
|
||||
|
||||
@@ -9,7 +9,7 @@ import (
|
||||
"github.com/google/uuid"
|
||||
)
|
||||
|
||||
// The /api/v1/user/friends/* handlers wire the social friend graph (Stage 8): the
|
||||
// The /api/v1/user/friends/* handlers wire the social friend graph: the
|
||||
// befriend-an-opponent request flow, the one-time friend-code path, and the
|
||||
// friends/incoming lists. They follow handlers_user.go: X-User-ID identity, a domain
|
||||
// call, a JSON DTO. Account ids are projected to {id, display_name} refs resolved
|
||||
|
||||
@@ -12,10 +12,9 @@ import (
|
||||
"scrabble/backend/internal/game"
|
||||
)
|
||||
|
||||
// The handlers below extend the Stage 6 vertical slice with the remaining game and
|
||||
// chat operations the UI needs (PLAN.md Stage 7). They follow the same pattern as
|
||||
// handlers_user.go: X-User-ID identity, the domain service call, a JSON DTO mapped
|
||||
// from the result.
|
||||
// The handlers below cover the game and chat operations the UI needs. They follow
|
||||
// the same pattern as handlers_user.go: X-User-ID identity, the domain service
|
||||
// call, a JSON DTO mapped from the result.
|
||||
|
||||
// hintResultDTO is the top-ranked move plus the remaining hint budget.
|
||||
type hintResultDTO struct {
|
||||
@@ -53,7 +52,7 @@ type chatListDTO struct {
|
||||
}
|
||||
|
||||
// exchangeRequest swaps the given rack tiles back into the bag. Tiles are wire alphabet
|
||||
// indices (Stage 13); a blank is engine.BlankIndex.
|
||||
// indices; a blank is engine.BlankIndex.
|
||||
type exchangeRequest struct {
|
||||
Tiles []int `json:"tiles"`
|
||||
}
|
||||
@@ -211,7 +210,7 @@ func (s *Server) handleEvaluate(c *gin.Context) {
|
||||
}
|
||||
|
||||
// handleCheckWord looks a word up in the game's pinned dictionary. The word arrives as
|
||||
// repeated ?idx= alphabet indices (Stage 13); the backend decodes them to the concrete
|
||||
// repeated ?idx= alphabet indices; the backend decodes them to the concrete
|
||||
// word for the lookup and echoes that concrete word back for the client's result cache.
|
||||
func (s *Server) handleCheckWord(c *gin.Context) {
|
||||
_, gameID, ok := s.userGame(c)
|
||||
@@ -242,7 +241,7 @@ func (s *Server) handleCheckWord(c *gin.Context) {
|
||||
}
|
||||
|
||||
// queryIndexes parses repeated integer query parameters (e.g. ?idx=2&idx=0) into a slice.
|
||||
// It carries a word-check query as alphabet indices on a GET (Stage 13).
|
||||
// It carries a word-check query as alphabet indices on a GET.
|
||||
func queryIndexes(c *gin.Context, key string) ([]int, error) {
|
||||
raw := c.QueryArray(key)
|
||||
out := make([]int, 0, len(raw))
|
||||
@@ -326,7 +325,7 @@ type draftTileDTO struct {
|
||||
Blank bool `json:"blank"`
|
||||
}
|
||||
|
||||
// draftDTO is a player's persisted client-side composition for a game (Stage 17): the
|
||||
// draftDTO is a player's persisted client-side composition for a game: the
|
||||
// preferred rack tile order (an opaque client string) and the board tiles laid but not yet
|
||||
// submitted. The gateway forwards the JSON verbatim; this layer owns its shape.
|
||||
type draftDTO struct {
|
||||
@@ -352,7 +351,7 @@ func (d draftDTO) toDomain() game.Draft {
|
||||
return game.Draft{RackOrder: d.RackOrder, BoardTiles: tiles}
|
||||
}
|
||||
|
||||
// handleGetDraft returns the player's saved composition for a game (Stage 17), or an empty
|
||||
// handleGetDraft returns the player's saved composition for a game, or an empty
|
||||
// draft when none is stored.
|
||||
func (s *Server) handleGetDraft(c *gin.Context) {
|
||||
uid, gameID, ok := s.userGame(c)
|
||||
@@ -367,7 +366,7 @@ func (s *Server) handleGetDraft(c *gin.Context) {
|
||||
c.JSON(http.StatusOK, draftDTOFrom(d))
|
||||
}
|
||||
|
||||
// handleSaveDraft upserts the player's composition for a game (Stage 17). The service
|
||||
// handleSaveDraft upserts the player's composition for a game. The service
|
||||
// rejects a non-player with ErrNotAPlayer.
|
||||
func (s *Server) handleSaveDraft(c *gin.Context) {
|
||||
uid, gameID, ok := s.userGame(c)
|
||||
|
||||
@@ -12,7 +12,7 @@ import (
|
||||
"scrabble/backend/internal/lobby"
|
||||
)
|
||||
|
||||
// The /api/v1/user/invitations/* handlers wire friend-game invitations (Stage 8):
|
||||
// The /api/v1/user/invitations/* handlers wire friend-game invitations:
|
||||
// create a 2-4 player invitation, accept/decline as an invitee, cancel as the
|
||||
// inviter, and list the open invitations touching the caller. Display names for the
|
||||
// inviter and invitees are resolved from the account store.
|
||||
|
||||
@@ -10,7 +10,7 @@ import (
|
||||
"scrabble/backend/internal/link"
|
||||
)
|
||||
|
||||
// The /api/v1/user/link handlers drive account linking & merge (Stage 11). The
|
||||
// The /api/v1/user/link handlers drive account linking & merge. The
|
||||
// request step always mails a code (no pre-send "taken" signal, so a probe cannot
|
||||
// enumerate registered emails); confirm reveals a required merge only after the
|
||||
// code is verified; merge performs the irreversible consolidation behind an
|
||||
|
||||
@@ -23,7 +23,7 @@ type rateLimitReportEntry struct {
|
||||
}
|
||||
|
||||
// handleRateLimitReport ingests one gateway rejection report into the rate
|
||||
// watch — the admin console's throttled view and the high-rate auto-flag (R3).
|
||||
// watch — the admin console's throttled view and the high-rate auto-flag.
|
||||
// Internal, gateway-only: like sessions/resolve it trusts the network segment.
|
||||
// Malformed individual entries are skipped by the watch itself.
|
||||
func (s *Server) handleRateLimitReport(c *gin.Context) {
|
||||
|
||||
@@ -58,7 +58,7 @@ func TestResolveSessionRejectsEmptyToken(t *testing.T) {
|
||||
}
|
||||
}
|
||||
|
||||
// TestRateLimitReportEndpoint covers the internal R3 report route: a malformed
|
||||
// TestRateLimitReportEndpoint covers the internal report route: a malformed
|
||||
// body is a 400, a valid report lands in the rate watch with 204.
|
||||
func TestRateLimitReportEndpoint(t *testing.T) {
|
||||
watch := ratewatch.New(ratewatch.DefaultConfig(), nil, nil)
|
||||
|
||||
@@ -27,7 +27,7 @@ func (s *Server) handleProfile(c *gin.Context) {
|
||||
}
|
||||
|
||||
// submitPlayRequest places tiles in a direction on the player's turn. Each tile's Letter
|
||||
// is a wire alphabet index (Stage 13); for a blank it is the designated letter's index.
|
||||
// is a wire alphabet index; for a blank it is the designated letter's index.
|
||||
type submitPlayRequest struct {
|
||||
Dir string `json:"dir"`
|
||||
Tiles []struct {
|
||||
@@ -39,7 +39,7 @@ type submitPlayRequest struct {
|
||||
}
|
||||
|
||||
// tilesFromRequest maps a play/evaluate request's index-addressed tiles to engine tile
|
||||
// records for the game's variant (Stage 13: a placed blank carries its designated letter's
|
||||
// records for the game's variant (a placed blank carries its designated letter's
|
||||
// index with Blank set). An out-of-range index surfaces as engine.ErrIllegalPlay (HTTP 400).
|
||||
func tilesFromRequest(variant engine.Variant, req submitPlayRequest) ([]engine.TileRecord, error) {
|
||||
tiles := make([]engine.TileRecord, 0, len(req.Tiles))
|
||||
@@ -94,7 +94,7 @@ func (s *Server) handleSubmitPlay(c *gin.Context) {
|
||||
}
|
||||
|
||||
// handleGameState returns the player's view of a game.
|
||||
// handleHideGame hides a finished game from the caller's own lobby list (Stage 17).
|
||||
// handleHideGame hides a finished game from the caller's own lobby list.
|
||||
func (s *Server) handleHideGame(c *gin.Context) {
|
||||
uid, ok := userID(c)
|
||||
if !ok {
|
||||
|
||||
@@ -50,20 +50,20 @@ type Deps struct {
|
||||
// func skips the session-readiness check.
|
||||
SessionsReady func() bool
|
||||
// Sessions, Accounts and Games are the identity, account and game-domain
|
||||
// services the Stage 6 REST handlers route to.
|
||||
// services the REST handlers route to.
|
||||
Sessions *session.Service
|
||||
Accounts *account.Store
|
||||
Games *game.Service
|
||||
// Social, Matchmaker, Invitations and Emails are the Stage 4 domain services
|
||||
// the Stage 6 REST handlers route to.
|
||||
// Social, Matchmaker, Invitations and Emails are the domain services
|
||||
// the REST handlers route to.
|
||||
Social *social.Service
|
||||
Matchmaker *lobby.Matchmaker
|
||||
Invitations *lobby.InvitationService
|
||||
Emails *account.EmailService
|
||||
// Links drives account linking & merge (Stage 11): the /api/v1/user/link
|
||||
// Links drives account linking & merge: the /api/v1/user/link
|
||||
// endpoints. A nil Links disables them.
|
||||
Links *link.Service
|
||||
// Registry holds the resident dictionaries; the admin console (Stage 10) reads
|
||||
// Registry holds the resident dictionaries; the admin console reads
|
||||
// its versions and hot-reloads new ones. DictDir is the dictionary directory a
|
||||
// reload reads a version subdirectory from. A nil Registry disables the console.
|
||||
Registry *engine.Registry
|
||||
@@ -72,7 +72,7 @@ type Deps struct {
|
||||
// nil when BACKEND_CONNECTOR_ADDR is unset (broadcasts show a "not configured"
|
||||
// notice).
|
||||
Connector *connector.Client
|
||||
// RateWatch ingests the gateway's rate-limiter rejection reports (R3): the
|
||||
// RateWatch ingests the gateway's rate-limiter rejection reports: the
|
||||
// admin console's throttled view + the high-rate auto-flag. A nil RateWatch
|
||||
// disables the internal report endpoint and the console view.
|
||||
RateWatch *ratewatch.Watch
|
||||
@@ -196,16 +196,16 @@ func (s *Server) UserGroup() *gin.RouterGroup { return s.user }
|
||||
// InternalGroup returns the gateway-facing internal route group.
|
||||
func (s *Server) InternalGroup() *gin.RouterGroup { return s.internal }
|
||||
|
||||
// Social returns the social domain service for the handlers added in Stage 6.
|
||||
// Social returns the social domain service for the handlers.
|
||||
func (s *Server) Social() *social.Service { return s.social }
|
||||
|
||||
// Matchmaker returns the in-memory matchmaking pool for the Stage 6 handlers.
|
||||
// Matchmaker returns the in-memory matchmaking pool for the handlers.
|
||||
func (s *Server) Matchmaker() *lobby.Matchmaker { return s.matchmaker }
|
||||
|
||||
// Invitations returns the friend-game invitation service for the Stage 6 handlers.
|
||||
// Invitations returns the friend-game invitation service for the handlers.
|
||||
func (s *Server) Invitations() *lobby.InvitationService { return s.invitations }
|
||||
|
||||
// Emails returns the email confirm-code service for the Stage 6 handlers.
|
||||
// Emails returns the email confirm-code service for the handlers.
|
||||
func (s *Server) Emails() *account.EmailService { return s.emails }
|
||||
|
||||
// Handler returns the underlying HTTP handler. It lets tests drive the server
|
||||
|
||||
@@ -97,8 +97,8 @@ func (c *Cache) Remove(tokenHash string) {
|
||||
}
|
||||
|
||||
// RemoveByAccount evicts every cached session belonging to accountID. The
|
||||
// account-merge flow uses it to drop a retired secondary account's sessions
|
||||
// (Stage 11); a linear scan is adequate at the cache's size.
|
||||
// account-merge flow uses it to drop a retired secondary account's sessions;
|
||||
// a linear scan is adequate at the cache's size.
|
||||
func (c *Cache) RemoveByAccount(accountID uuid.UUID) {
|
||||
if c == nil {
|
||||
return
|
||||
|
||||
@@ -73,8 +73,8 @@ func (svc *Service) Revoke(ctx context.Context, token string) error {
|
||||
}
|
||||
|
||||
// RevokeAllForAccount revokes every active session of accountID and evicts them
|
||||
// from the cache. The account-merge flow calls it to retire a secondary account
|
||||
// (Stage 11). It is idempotent.
|
||||
// from the cache. The account-merge flow calls it to retire a secondary account.
|
||||
// It is idempotent.
|
||||
func (svc *Service) RevokeAllForAccount(ctx context.Context, accountID uuid.UUID) error {
|
||||
if _, err := svc.store.RevokeAllForAccount(ctx, accountID, time.Now().UTC()); err != nil {
|
||||
return err
|
||||
|
||||
@@ -112,8 +112,8 @@ func (s *Store) RevokeByTokenHash(ctx context.Context, tokenHash string, at time
|
||||
|
||||
// RevokeAllForAccount transitions every active session of accountID to revoked
|
||||
// and returns the post-update rows (so the caller can evict them from the cache).
|
||||
// It backs the account-merge flow, which retires a secondary account's sessions
|
||||
// (Stage 11). No matching rows is not an error.
|
||||
// It backs the account-merge flow, which retires a secondary account's sessions.
|
||||
// No matching rows is not an error.
|
||||
func (s *Store) RevokeAllForAccount(ctx context.Context, accountID uuid.UUID, at time.Time) ([]Session, error) {
|
||||
stmt := table.Sessions.
|
||||
UPDATE(table.Sessions.Status, table.Sessions.RevokedAt).
|
||||
|
||||
@@ -10,7 +10,7 @@ import (
|
||||
"scrabble/backend/internal/account"
|
||||
)
|
||||
|
||||
// AdminMessage is one chat message in the admin moderation list (Stage 17): the message
|
||||
// AdminMessage is one chat message in the admin moderation list: the message
|
||||
// plus its sender's resolved display name and source, for the operator console.
|
||||
type AdminMessage struct {
|
||||
ID uuid.UUID
|
||||
|
||||
@@ -58,7 +58,7 @@ func (svc *Service) PostMessage(ctx context.Context, gameID, senderID uuid.UUID,
|
||||
return Message{}, ErrNotParticipant
|
||||
}
|
||||
// Chat is allowed only on the sender's own turn in an active game; the opponent's-turn
|
||||
// control is the nudge (Stage 17).
|
||||
// control is the nudge.
|
||||
if status != statusActive {
|
||||
return Message{}, ErrGameNotActive
|
||||
}
|
||||
@@ -115,7 +115,7 @@ func (svc *Service) Nudge(ctx context.Context, gameID, senderID uuid.UUID) (Mess
|
||||
}
|
||||
if ok && svc.now().Sub(last) < nudgeInterval {
|
||||
// The cooldown resets once the sender has acted (moved or chatted) since the last
|
||||
// nudge — engagement clears the "don't spam" limit (Stage 17).
|
||||
// nudge — engagement clears the "don't spam" limit.
|
||||
acted, err := svc.actedSince(ctx, gameID, senderID, last)
|
||||
if err != nil {
|
||||
return Message{}, err
|
||||
@@ -132,7 +132,7 @@ func (svc *Service) Nudge(ctx context.Context, gameID, senderID uuid.UUID) (Mess
|
||||
if toMove >= 0 && toMove < len(seats) {
|
||||
nudge := notify.Nudge(seats[toMove], gameID, senderID)
|
||||
if lang, err := svc.games.GameLanguage(ctx, gameID); err == nil {
|
||||
nudge.Language = lang // route by the game's bot, not the recipient's last-login one (Stage 17)
|
||||
nudge.Language = lang // route by the game's bot, not the recipient's last-login one
|
||||
}
|
||||
svc.pub.Publish(nudge)
|
||||
}
|
||||
@@ -140,7 +140,7 @@ func (svc *Service) Nudge(ctx context.Context, gameID, senderID uuid.UUID) (Mess
|
||||
}
|
||||
|
||||
// actedSince reports whether senderID made a move or posted a chat message in the game
|
||||
// after t — the events that reset the nudge cooldown (Stage 17).
|
||||
// after t — the events that reset the nudge cooldown.
|
||||
func (svc *Service) actedSince(ctx context.Context, gameID, senderID uuid.UUID, t time.Time) (bool, error) {
|
||||
if mv, ok, err := svc.games.LastMoveAt(ctx, gameID, senderID); err != nil {
|
||||
return false, err
|
||||
@@ -291,7 +291,7 @@ func (s *Store) lastNudgeAt(ctx context.Context, gameID, senderID uuid.UUID) (ti
|
||||
|
||||
// lastMessageAt returns the time of senderID's most recent non-nudge chat message in
|
||||
// gameID, if any. The nudge cooldown resets when the player chats (or moves), so a stale
|
||||
// nudge no longer blocks a new one (Stage 17).
|
||||
// nudge no longer blocks a new one.
|
||||
func (s *Store) lastMessageAt(ctx context.Context, gameID, senderID uuid.UUID) (time.Time, bool, error) {
|
||||
stmt := postgres.SELECT(table.ChatMessages.CreatedAt).
|
||||
FROM(table.ChatMessages).
|
||||
|
||||
@@ -31,7 +31,7 @@ const friendRequestTTL = 30 * 24 * time.Hour
|
||||
|
||||
// accountRef resolves accountID into a notify.AccountRef (the display name from the account
|
||||
// store, empty on a lookup failure), for enriching the friend_* live events so the client
|
||||
// updates its requests/friends state without a refetch (R4).
|
||||
// updates its requests/friends state without a refetch.
|
||||
func (svc *Service) accountRef(ctx context.Context, accountID uuid.UUID) notify.AccountRef {
|
||||
ref := notify.AccountRef{AccountID: accountID.String()}
|
||||
if acc, err := svc.accounts.GetByID(ctx, accountID); err == nil {
|
||||
|
||||
@@ -32,7 +32,7 @@ type GameReader interface {
|
||||
// has moved); the nudge cooldown resets once the player has taken a turn.
|
||||
LastMoveAt(ctx context.Context, gameID, accountID uuid.UUID) (time.Time, bool, error)
|
||||
// GameLanguage is the game's language tag ("en"/"ru"), so a nudge's out-of-app push routes
|
||||
// to the game's bot rather than the recipient's last-login bot (Stage 17).
|
||||
// to the game's bot rather than the recipient's last-login bot.
|
||||
GameLanguage(ctx context.Context, gameID uuid.UUID) (string, error)
|
||||
}
|
||||
|
||||
@@ -75,7 +75,7 @@ var (
|
||||
ErrGameNotActive = errors.New("social: game is not active")
|
||||
// ErrChatNotYourTurn is returned when a chat message is sent while it is not the
|
||||
// sender's turn — chat is allowed only on your own turn (the opponent's-turn control
|
||||
// is the nudge, Stage 17).
|
||||
// is the nudge).
|
||||
ErrChatNotYourTurn = errors.New("social: cannot chat while it is not your turn")
|
||||
)
|
||||
|
||||
|
||||
+3
-3
@@ -1,6 +1,6 @@
|
||||
# Environment for deploy/docker-compose.yml. The CI deploy job (ci.yaml) maps the
|
||||
# Gitea TEST_-prefixed secrets/variables onto these unprefixed names; Stage 18
|
||||
# maps the PROD_-prefixed set the same way. Copy to deploy/.env for a local run.
|
||||
# Gitea TEST_-prefixed secrets/variables onto these unprefixed names; the prod
|
||||
# deploy maps the PROD_-prefixed set the same way. Copy to deploy/.env for a local run.
|
||||
#
|
||||
# Full reference (required vs optional, defaults, secret-vs-variable): deploy/README.md.
|
||||
|
||||
@@ -17,7 +17,7 @@ LOG_LEVEL=info
|
||||
|
||||
# --- Edge / caddy -----------------------------------------------------------
|
||||
# Test: ":80" (the host caddy terminates TLS and forwards to scrabble:80 on the
|
||||
# external `edge` network). Prod (Stage 18): a domain so caddy does its own ACME.
|
||||
# external `edge` network). Prod: a domain so caddy does its own ACME.
|
||||
CADDY_SITE_ADDRESS=:80
|
||||
GM_BASICAUTH_USER=gm
|
||||
GM_BASICAUTH_HASH= # required; `caddy hash-password` bcrypt hash
|
||||
|
||||
+4
-4
@@ -13,7 +13,7 @@ operational reference for **every environment variable**.
|
||||
| --- | --- | --- |
|
||||
| `caddy` | `caddy:2-alpine` | Edge proxy (alias `scrabble` on `edge`): single `/_gm` Basic-Auth → admin console + Grafana; `/app/`, `/telegram/` + the Connect path → gateway; the catch-all (incl. `/`) → landing. TLS per `CADDY_SITE_ADDRESS`. |
|
||||
| `gateway` | built (`gateway/Dockerfile`, target `gateway`) | Public edge; serves the embedded game SPA at `/app/` + `/telegram/`; Connect-RPC edge. `/` redirects to `/app/`. |
|
||||
| `landing` | built (`gateway/Dockerfile`, target `landing`) | Static landing page at `/` (caddy:2-alpine + the shared Vite build, `deploy/landing/Caddyfile`); absorbs stray public paths (R3). |
|
||||
| `landing` | built (`gateway/Dockerfile`, target `landing`) | Static landing page at `/` (caddy:2-alpine + the shared Vite build, `deploy/landing/Caddyfile`); absorbs stray public paths. |
|
||||
| `backend` | built (`backend/Dockerfile`) | Domain service; bakes in the DAWG dictionaries; runs migrations at boot. |
|
||||
| `postgres` | `postgres:17-alpine` | Database (named volume, `pg_isready` healthcheck). |
|
||||
| `vpn` + `telegram` | sidecar + built (`platform/telegram/Dockerfile`) | Telegram connector; egresses through the AmneziaWG sidecar; internal gRPC at `telegram:9091`. |
|
||||
@@ -39,7 +39,7 @@ cd deploy && docker compose up -d --build
|
||||
|
||||
**In CI** (the test contour) — `.gitea/workflows/ci.yaml`'s `deploy` job maps the
|
||||
Gitea **`TEST_`-prefixed** secrets/variables onto the unprefixed names below and
|
||||
runs `docker compose up -d --build` on the runner host. Stage 18 (prod) maps the
|
||||
runs `docker compose up -d --build` on the runner host. The prod deploy maps the
|
||||
**`PROD_`** set the same way. So a Gitea secret named `TEST_POSTGRES_PASSWORD`
|
||||
feeds the compose's `POSTGRES_PASSWORD`, etc.
|
||||
|
||||
@@ -80,7 +80,7 @@ connector **fails at boot** if both are empty.
|
||||
| `GRAFANA_ADMIN_PASSWORD` | secret | `admin` | Grafana admin password. Low impact (the login form is disabled, access is anonymous-admin behind caddy) but set it anyway. |
|
||||
| `TELEGRAM_GAME_CHANNEL_ID_EN` | variable | _(empty)_ | English game-channel id; empty/`0` disables channel posts. |
|
||||
| `TELEGRAM_GAME_CHANNEL_ID_RU` | variable | _(empty)_ | Russian game-channel id; empty/`0` disables channel posts. |
|
||||
| `TELEGRAM_TEST_ENV` | _pinned_ | `false` | `true` routes the bot through Telegram's test environment (`.../bot<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`. |
|
||||
| `GATEWAY_DEFAULT_SUPPORTED_LANGUAGES` | variable | `en,ru` | Variant-gating set for non-Telegram logins (web/email/guest). |
|
||||
| `VITE_TELEGRAM_BOT_ID` | variable | _(empty)_ | UI build-arg: numeric bot id for the web Login Widget. |
|
||||
@@ -114,7 +114,7 @@ resolves both `otelcol` and `api.telegram.org`. `GATEWAY_ADMIN_*` is intentional
|
||||
- **Host caddy** route `<domain> → scrabble:80` (the in-compose caddy serves HTTP
|
||||
in the test contour; the host caddy terminates TLS). Not needed on prod, where the
|
||||
contour caddy owns TLS (set `CADDY_SITE_ADDRESS` to the domain).
|
||||
- **Branch protection** requires the single status check `CI / gate` (Stage 17).
|
||||
- **Branch protection** requires the single status check `CI / gate`.
|
||||
The `unit` / `integration` / `ui` jobs are path-conditional (they skip when their
|
||||
code did not change), and the always-running `gate` job aggregates them (passing
|
||||
when each succeeded or was skipped), so a skipped job never blocks a merge. See
|
||||
|
||||
@@ -2,11 +2,11 @@
|
||||
# every operator surface under /_gm (the backend-rendered admin console and the
|
||||
# Grafana subpath); the game SPA (/app/, /telegram/) and the Connect edge go to
|
||||
# the gateway; the catch-all — notably the public landing at / — goes to the
|
||||
# static landing container (R3), so stray traffic never reaches the Go edge.
|
||||
# static landing container, so stray traffic never reaches the Go edge.
|
||||
# Mirrors ../galaxy-game's /_gm model.
|
||||
#
|
||||
# CADDY_SITE_ADDRESS is ":80" in the test contour (the host caddy terminates TLS
|
||||
# and forwards); set it to a domain in prod (Stage 18) so this caddy does its own
|
||||
# and forwards); set it to a domain in prod so this caddy does its own
|
||||
# ACME and the contour is self-contained.
|
||||
{
|
||||
admin off
|
||||
@@ -14,7 +14,7 @@
|
||||
# (chat moderation + per-IP rate limiting in the gateway). Test contour: the host caddy
|
||||
# (a private IP) is trusted, so its forwarded client IP is preserved. Prod (no host caddy):
|
||||
# clients connect from public IPs, which are NOT trusted, so Caddy uses the real peer —
|
||||
# the same config is correct (and spoof-safe) in both contours (Stage 17).
|
||||
# the same config is correct (and spoof-safe) in both contours.
|
||||
servers {
|
||||
trusted_proxies static private_ranges
|
||||
}
|
||||
|
||||
@@ -17,7 +17,7 @@
|
||||
# - `edge` (external): the host caddy reaches this contour at `scrabble:80`
|
||||
# (the in-compose caddy's alias). The in-compose caddy terminates only HTTP in
|
||||
# the test contour; the host caddy terminates TLS and forwards. For prod
|
||||
# (Stage 18, no host caddy) set CADDY_SITE_ADDRESS to the domain so the caddy
|
||||
# (no host caddy) set CADDY_SITE_ADDRESS to the domain so the caddy
|
||||
# does its own ACME — the contour is then self-contained.
|
||||
# - The connector egresses to api.telegram.org through the `vpn` sidecar
|
||||
# (network_mode: service:vpn); it answers internal gRPC at `telegram:9091`.
|
||||
@@ -102,7 +102,7 @@ services:
|
||||
networks: [internal]
|
||||
|
||||
# --- Landing (static) -------------------------------------------------------
|
||||
# The public landing page in its own caddy container (R3): the contour caddy
|
||||
# The public landing page in its own caddy container: the contour caddy
|
||||
# routes the catch-all (notably /) here, the gateway keeps only /app/,
|
||||
# /telegram/ and the Connect edge. Shares the gateway Dockerfile's UI build
|
||||
# stage — identical build args keep that stage a single cached build.
|
||||
|
||||
@@ -37,8 +37,8 @@
|
||||
},
|
||||
{
|
||||
"type": "timeseries",
|
||||
"title": "Rate limiting — request rate vs rejections (R3)",
|
||||
"description": "Aggregate only (no per-user labels, the Stage 12/17 discipline): total edge request rate against the limiter rejection rate by class. Per-key detail lives in the admin console's Throttled view.",
|
||||
"title": "Rate limiting — request rate vs rejections",
|
||||
"description": "Aggregate only (no per-user labels): total edge request rate against the limiter rejection rate by class. Per-key detail lives in the admin console's Throttled view.",
|
||||
"gridPos": { "h": 8, "w": 24, "x": 0, "y": 16 },
|
||||
"fieldConfig": { "defaults": { "unit": "reqps" }, "overrides": [] },
|
||||
"datasource": { "type": "prometheus", "uid": "prometheus" },
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
# Static landing container (R3). Serves the public landing page and the built
|
||||
# Static landing container. Serves the public landing page and the built
|
||||
# assets it references at /; the game SPA (/app/, /telegram/) and the Connect
|
||||
# edge stay on the gateway. The contour caddy routes the catch-all here, so
|
||||
# stray public paths are absorbed by static file serving and never reach the Go
|
||||
|
||||
+78
-78
@@ -28,10 +28,10 @@ Three executables plus per-platform side-services:
|
||||
- **`ui`** — pure-HTML5 client (plain Svelte 5 + TypeScript + Vite, static build;
|
||||
no SvelteKit). Talks to `backend` only through `gateway` over Connect-RPC +
|
||||
FlatBuffers, with the edge TS bindings generated from the **same** `edge.proto`
|
||||
and `scrabble.fbs` and committed under `ui/src/gen/`. The **playable slice**
|
||||
(Stage 7) covers auth, "my games", auto-match, the board (play/pass/exchange/
|
||||
and `scrabble.fbs` and committed under `ui/src/gen/`. The client covers auth,
|
||||
"my games", auto-match, the board (play/pass/exchange/
|
||||
resign), hint, word-check, chat/nudge, the live stream, i18n (en/ru) and a profile
|
||||
view; the social/account/history surfaces follow in Stage 8. There is no board on
|
||||
view, plus the social/account/history surfaces. There is no board on
|
||||
the wire — the client **reconstructs the 15×15 board by replaying the move
|
||||
journal** (§9.1) and renders board, tiles, premium squares and effects as pure
|
||||
CSS + Unicode (no image/font/SVG assets). Tiles are placed by Pointer-Events drag
|
||||
@@ -41,7 +41,7 @@ Three executables plus per-platform side-services:
|
||||
Embeddable in platform webviews; packageable to native (iOS/Android) via Capacitor.
|
||||
The client uses a mobile-app shell (a growing nav bar; content pinned to the bottom),
|
||||
a one-line **announcement banner** under the nav (a client-side mock rotation, **gated off
|
||||
in the build until polished after release**, Stage 17 — a server-driven channel later, §10),
|
||||
in the build until polished after release** — a server-driven channel later, §10),
|
||||
and a client **board-style** setting (bonus-label
|
||||
mode). The visual/interaction design system is documented in
|
||||
[`UI_DESIGN.md`](UI_DESIGN.md).
|
||||
@@ -89,7 +89,7 @@ dropped). Horizontal scaling is explicit future work.
|
||||
auth operations are unauthenticated and return the minted token. A unary
|
||||
operation's domain outcome rides back in `ExecuteResponse.result_code` (HTTP
|
||||
200); only edge failures (rate limit, missing session, unknown type, internal)
|
||||
surface as Connect error codes. The client (Stage 17) treats a connectivity edge failure as
|
||||
surface as Connect error codes. The client treats a connectivity edge failure as
|
||||
**state, not a per-call toast**: a transport `unavailable` or a `rate_limited` flips a global
|
||||
`online` signal that drives a header **"Connecting…"** spinner and softly disables proactive
|
||||
actions, and the transport **auto-retries with capped exponential backoff** — every op on a
|
||||
@@ -98,7 +98,7 @@ dropped). Horizontal scaling is explicit future work.
|
||||
response was lost — its button is disabled while offline and the player re-issues it on
|
||||
reconnect). A reachability watcher (a lightweight `profile.get` probe) clears the signal when no
|
||||
other traffic is in flight; the live `Subscribe` stream's drop/recovery feeds the same signal.
|
||||
**Edge hardening (R3):** every request body on the public listener is capped at
|
||||
**Edge hardening:** every request body on the public listener is capped at
|
||||
`GATEWAY_MAX_BODY_BYTES` (default 1 MiB — far above any legitimate payload), both at the HTTP
|
||||
layer (`http.MaxBytesReader`) and as the Connect per-message read limit, so an oversized
|
||||
`Execute` is refused (`resource_exhausted`) without buffering. The h2c server carries explicit
|
||||
@@ -106,8 +106,7 @@ dropped). Horizontal scaling is explicit future work.
|
||||
`Subscribe` stream plus a few unary calls) and a 3-minute connection `IdleTimeout` (a live
|
||||
`Subscribe` stream keeps its connection active, so only abandoned connections are reaped); the
|
||||
`http.Server` sets only `ReadHeaderTimeout` (10 s) — Read/WriteTimeout would kill the stream.
|
||||
R7 revisits the exact values under load.
|
||||
- **Alphabet on the wire (Stage 13)**: live play exchanges **alphabet indices**, not
|
||||
- **Alphabet on the wire**: live play exchanges **alphabet indices**, not
|
||||
concrete letters. The rack (`StateView.rack`), the `SubmitPlay`/`Evaluate` tiles, the
|
||||
`Exchange` tiles and the `CheckWord` word are `ubyte` indices into the variant's alphabet
|
||||
(a blank is the sentinel index **255**). The client is **alphabet-agnostic**: on a
|
||||
@@ -139,7 +138,7 @@ arrive from a platform rather than completing a mandatory registration).
|
||||
bootstrap — then mints a **thin opaque server session token** (`session_id`). First
|
||||
Telegram contact seeds the new account's language (from the launch `language_code`)
|
||||
and display name (§4).
|
||||
- **Service language & variant gating (Stage 15).** The connector hosts **one bot per
|
||||
- **Service language & variant gating.** The connector hosts **one bot per
|
||||
service language** (`en`/`ru`), each its own token + game channel; the same Telegram
|
||||
user id spans both. `ValidateInitData` tries each token in turn and returns the
|
||||
validating bot's **service language** and its **supported-languages set**. The set
|
||||
@@ -151,7 +150,7 @@ arrive from a platform rather than completing a mandatory registration).
|
||||
**persisted** per account (`accounts.service_language`, updated on every Telegram
|
||||
login — last-login-wins) and routes the user's out-of-app push back through the right
|
||||
bot (§10) — **except a game event, which routes by the game's own language** (its variant →
|
||||
en/ru, Stage 17), so a game's notification always comes from the game's bot rather than the
|
||||
en/ru), so a game's notification always comes from the game's bot rather than the
|
||||
recipient's latest login bot. The service language is distinct from `preferred_language` (the
|
||||
interface language) and from a game's variant language. Non-Telegram logins (web / email / guest) carry the
|
||||
gateway's default set (`GATEWAY_DEFAULT_SUPPORTED_LANGUAGES`, all variants by default).
|
||||
@@ -169,8 +168,11 @@ arrive from a platform rather than completing a mandatory registration).
|
||||
row is a technical necessity (the `sessions` and `game_players` foreign keys
|
||||
require one, the same way the robot pool is durable), not a profile: no
|
||||
friends, statistics or history are kept for it, and it is restricted to
|
||||
auto-match. Platform and email users are auto-provisioned **durable** accounts
|
||||
with an identity. (Reaping abandoned guest rows is deferred — PLAN.md TODO-3.)
|
||||
auto-match. A background **guest reaper** deletes an abandoned guest — flagged
|
||||
`is_guest`, holding no game seat, older than `BACKEND_GUEST_RETENTION` — on a
|
||||
`BACKEND_GUEST_REAP_INTERVAL` sweep, so transient guest rows do not accumulate.
|
||||
Platform and email users are auto-provisioned **durable** accounts with an
|
||||
identity.
|
||||
|
||||
## 4. Accounts, identities, linking & merge
|
||||
|
||||
@@ -179,15 +181,15 @@ arrive from a platform rather than completing a mandatory registration).
|
||||
a platform auto-provisions a durable account bound to that platform identity.
|
||||
Concretely, platform and email identities share one `identities` table keyed by
|
||||
a unique `(kind, external_id)`; email is an identity with `kind=email` and a
|
||||
`confirmed` flag. A synthetic `kind='robot'` identity (Stage 5) backs each pooled
|
||||
robot opponent (§7). The **email confirm-code flow** (Stage 4) binds an email to the
|
||||
`confirmed` flag. A synthetic `kind='robot'` identity backs each pooled
|
||||
robot opponent (§7). The **email confirm-code flow** binds an email to the
|
||||
authenticated account: a 6-digit code (stored only as a SHA-256 hash, 15-minute
|
||||
TTL, ≤ 5 attempts) is sent through a `Mailer` seam (an SMTP relay, or a
|
||||
development log mailer when none is configured) and, once verified, attaches a
|
||||
confirmed email identity. Accounts and identities use application-generated
|
||||
**UUIDv7** primary keys. A service flag `paid_account` (lifetime one-time
|
||||
payment; no purchase flow yet) is carried on the account and ORed on a merge.
|
||||
- **Linking** (Stage 11) is initiated from an authenticated profile and proves
|
||||
- **Linking** is initiated from an authenticated profile and proves
|
||||
control of the identity before attaching it: **email** through the confirm-code
|
||||
flow, **Telegram** through the web **Login Widget** (validated by the connector,
|
||||
HMAC under `SHA-256(bot_token)` — distinct from Mini App initData; the gateway
|
||||
@@ -210,7 +212,7 @@ arrive from a platform rather than completing a mandatory registration).
|
||||
already has a **durable** owner: then the durable account wins, the guest's active
|
||||
games move into it, the guest is retired, and a **fresh session** is minted for the
|
||||
durable account (the client switches to it). The secondary's sessions are revoked
|
||||
(§3). High blast-radius; an isolated, well-tested stage.
|
||||
(§3). High blast-radius; isolated and well-tested.
|
||||
|
||||
## 5. Game engine integration (`scrabble-solver`)
|
||||
|
||||
@@ -241,8 +243,7 @@ Key points:
|
||||
boot version plus each subdirectory). In-flight games keep their pinned version;
|
||||
new games use the latest. (The solver is published as a versioned module and the
|
||||
dictionaries ship as a separate versioned **release artifact** from the
|
||||
`scrabble-dictionary` repo — TODO-1/TODO-2, Stage 14; the runtime contract above is
|
||||
unchanged.)
|
||||
`scrabble-dictionary` repo; the runtime contract above is unchanged.)
|
||||
- Move generation/validation/scoring use `Solver.GenerateMoves` (ranked),
|
||||
`Solver.ValidatePlay` and `Solver.ScorePlay`; board mutation uses
|
||||
`scrabble.Apply`. The engine adds its own deterministic, seeded tile **bag**
|
||||
@@ -258,7 +259,7 @@ Key points:
|
||||
unconditionally the other player in a two-player game. A player may resign **on the
|
||||
opponent's turn** (a forfeit is not a turn-scoped move): `engine.ResignSeat(seat)`
|
||||
resigns that player's own seat whoever is to move, and the game domain skips the turn
|
||||
check for resign (Stage 17). The engine exposes a
|
||||
check for resign. The engine exposes a
|
||||
decoded, solver-free API (`SubmitPlay`/`SubmitExchange`/`EvaluatePlay`/
|
||||
`HintView`/`Hand`) so `internal/game` drives it without importing the solver.
|
||||
- The **game domain** (`internal/game`) owns everything the engine does not —
|
||||
@@ -326,7 +327,7 @@ behaviour on every scan and after a restart — the same philosophy as journal
|
||||
replay. A pool of durable accounts — each a `kind='robot'` identity (§4), keyed
|
||||
`robot-<lang>-<index>` and provisioned at startup with **chat blocked but friend
|
||||
requests open** — a request to a robot is accepted as pending and expires unanswered
|
||||
(the robot never responds), mirroring a human who ignores it (Stage 17); the chat
|
||||
(the robot never responds), mirroring a human who ignores it; the chat
|
||||
block backs the human-like names (there is no DM surface; chat is per-game). Names are
|
||||
**composed per language** from a first-name pool (32 full + 32 colloquial forms) and
|
||||
a surname pool (gender-agreed for Russian) in one of three forms (first only /
|
||||
@@ -352,12 +353,12 @@ English game the Latin pool.
|
||||
rather than running anti-phase; on a daytime nudge it replies near the move's lower
|
||||
band; it proactively nudges the idle human on a **lengthening, randomized schedule** — the
|
||||
first ~60-90 min into the turn, each later reminder spaced further out toward 1-6 h — so a long
|
||||
wait gets a handful of increasingly-spaced nudges rather than an hourly stream (Stage 17).
|
||||
wait gets a handful of increasingly-spaced nudges rather than an hourly stream.
|
||||
- **Observability**: robot accounts accrue ordinary statistics (§9) — the
|
||||
authoritative balance metric (target ≈ 40% robot wins) — and a
|
||||
`robot_games_finished_total` OTel counter plus a per-finish log give a live view.
|
||||
The **admin game card** surfaces each robot seat's per-game play-to-win intent (from
|
||||
the seed) and, on the robot's turn, its deterministic **next-move ETA** (Stage 17).
|
||||
the seed) and, on the robot's turn, its deterministic **next-move ETA**.
|
||||
|
||||
## 8. Lobby & social
|
||||
|
||||
@@ -371,8 +372,8 @@ English game the Latin pool.
|
||||
`Poll` remains as a fallback for a client that is not currently streaming.
|
||||
**Cancel** (`POST /lobby/cancel`) removes the player from the pool and drops any
|
||||
pending matched result, so a cancelled quick-match is dequeued rather than left for
|
||||
the reaper to robot-substitute (Stage 17).
|
||||
- **Friends** (Stage 8): two add paths over one `friendships` table. A **one-time
|
||||
the reaper to robot-substitute.
|
||||
- **Friends**: two add paths over one `friendships` table. A **one-time
|
||||
code** the to-be-added player issues (a `friend_codes` row: 6-digit numeric,
|
||||
SHA-256-hashed, **12 h** TTL, one live code per issuer, single-use, redeem
|
||||
rate-limited) is redeemed by the other player to become friends immediately.
|
||||
@@ -382,7 +383,7 @@ English game the Latin pool.
|
||||
remembered (`status='declined'`) and blocks further requests from that sender,
|
||||
unless they hand them a code, which overrides it. The requester's own cancel still
|
||||
deletes the row; blocking someone severs an existing friendship. (Discovery by
|
||||
friend list or platform deep-link arrives with Stage 9 / TODO-5.)
|
||||
friend list or platform deep-link is future work.)
|
||||
- **Block**: two independent **global** account toggles (`block_chat`,
|
||||
`block_friend_requests`) **plus** a **per-user block list**. A per-user block is
|
||||
applied mutually: it hides the pair's chat from each other and refuses friend
|
||||
@@ -395,25 +396,25 @@ English game the Latin pool.
|
||||
and **validated on input** — links, email addresses and phone numbers (including
|
||||
lightly obfuscated forms) are rejected, since the chat is for quick reactions,
|
||||
not contact exchange. Each message stores the sender's IP (forwarded by the
|
||||
gateway in Stage 6) for moderation. A sender who has disabled chat cannot post,
|
||||
gateway) for moderation. A sender who has disabled chat cannot post,
|
||||
and messages from a blocked sender are hidden from the viewer. The operator console
|
||||
has a **Messages** section (Stage 17) that lists posted messages (nudges excluded)
|
||||
has a **Messages** section that lists posted messages (nudges excluded)
|
||||
newest-first with the sender's resolved name, **source** (guest / robot / oldest
|
||||
identity kind), IP and game, searchable by sender name / external-id glob masks and
|
||||
pinnable to one game or sender (linked from the game and user cards).
|
||||
- **Nudge**: folded into the chat as a `nudge` message kind. The player awaiting
|
||||
the opponent may nudge **once per hour per game**; it is not allowed on one's own
|
||||
turn. The platform-native delivery is wired with the gateway / platform
|
||||
side-service (Stage 6 / 8).
|
||||
turn. The platform-native delivery runs through the gateway and the platform
|
||||
side-service.
|
||||
- **Profile**: `preferred_language` (en/ru, edited in Settings), display name, email
|
||||
(confirm-code binding, see §4), **timezone**, the daily **away window** and the
|
||||
block toggles — all editable through `account.UpdateProfile`, which validates them
|
||||
(Stage 8): a display name is Unicode letters joined by single ` `/`.`/`_`
|
||||
block toggles — all editable through `account.UpdateProfile`, which validates them:
|
||||
a display name is Unicode letters joined by single ` `/`.`/`_`
|
||||
separators (no leading/trailing/adjacent separators, ≤ 32 runes); the timezone is a
|
||||
fixed `±HH:MM` **UTC offset** (or a legacy IANA name) resolved by `account.ResolveZone`
|
||||
for the sweeper and the robot's sleep (a fixed offset trades DST for a simple
|
||||
picker); the away window is at most **12 h** (midnight-wrap aware). Linked platform
|
||||
accounts and merge are Stage 11.
|
||||
accounts and merge are covered in §4.
|
||||
|
||||
## 9. Persistence
|
||||
|
||||
@@ -423,23 +424,22 @@ English game the Latin pool.
|
||||
into `internal/postgres/jet` and committed, regenerated by `cmd/jetgen`).
|
||||
Migrations are embedded SQL applied with `pressly/goose/v3` at startup. Primary
|
||||
keys are application-generated **UUIDv7**.
|
||||
- Tables: `accounts` (durable internal accounts; Stage 3 added the away-window
|
||||
columns `away_start`/`away_end` and the hint wallet `hint_balance`; Stage 6's
|
||||
migration `00005` added the `is_guest` flag for ephemeral guest rows; Stage 9's
|
||||
migration `00007` added the `notifications_in_app_only` out-of-app push toggle;
|
||||
Stage 11's migration `00009` added the `paid_account` service flag and the
|
||||
merge-tombstone columns `merged_into`/`merged_at`),
|
||||
`identities` (platform/email/robot identities, unique `(kind, external_id)`;
|
||||
Stage 5's migration `00004` admits the `robot` kind),
|
||||
`sessions` (revoke-only opaque-token hashes), the Stage 3 game tables
|
||||
`games` (Stage 4 added the `dropout_tiles` disposition column), `game_players`,
|
||||
- Tables: `accounts` (durable internal accounts, carrying the away-window
|
||||
columns `away_start`/`away_end`, the hint wallet `hint_balance`, the `is_guest`
|
||||
flag for ephemeral guest rows, the `notifications_in_app_only` out-of-app push
|
||||
toggle, the `paid_account` service flag and the merge-tombstone columns
|
||||
`merged_into`/`merged_at`),
|
||||
`identities` (platform/email/robot identities, unique `(kind, external_id)`,
|
||||
the `kind` admitting `robot`),
|
||||
`sessions` (revoke-only opaque-token hashes), the game tables
|
||||
`games` (carrying the `dropout_tiles` disposition column), `game_players`,
|
||||
`game_moves` (the move journal), `complaints` and `account_stats`, and the
|
||||
Stage 4 social/lobby tables `friendships` (the request/accept graph), `blocks`
|
||||
social/lobby tables `friendships` (the request/accept graph, its status admitting
|
||||
`declined`), `blocks`
|
||||
(per-user blocks), `chat_messages` (per-game chat and nudges), `email_confirmations`
|
||||
(pending confirm-codes) and `game_invitations` / `game_invitation_invitees`
|
||||
(friend-game invitations). Stage 8's migration `00006` widened the `friendships`
|
||||
status to admit `declined` and added `friend_codes` (one-time add-a-friend codes).
|
||||
Stage 17 added `game_drafts` (a player's in-progress rack order + board composition per
|
||||
(pending confirm-codes), `game_invitations` / `game_invitation_invitees`
|
||||
(friend-game invitations), `friend_codes` (one-time add-a-friend codes),
|
||||
`game_drafts` (a player's in-progress rack order + board composition per
|
||||
game) and `game_hidden` (`(account_id, game_id)` rows that drop a finished game from one
|
||||
account's own lobby list, leaving it visible to the other players — finished-only and
|
||||
irreversible by design, so there is no un-hide).
|
||||
@@ -479,18 +479,18 @@ exposes none): the standard Poslfit dialect (UTF-8, `#player`/`#lexicon`
|
||||
pragmas, `8G`/`H8` coordinates, lower-case blanks, `.` pass-throughs, `-TILES`
|
||||
exchanges), plus `#note` lines for resignations and timeouts, which the standard
|
||||
does not cover. **GCG export is offered only on a finished game** (`game.ErrGameActive`
|
||||
otherwise, Stage 8), so an in-progress journal is never leaked mid-play; the client
|
||||
otherwise), so an in-progress journal is never leaked mid-play; the client
|
||||
shares the `.gcg` file via the Web Share API where available, else downloads it.
|
||||
|
||||
The Stage 13 alphabet-on-the-wire change does **not** touch this invariant: the live edge
|
||||
The alphabet-on-the-wire transport does **not** touch this invariant: the live edge
|
||||
exchanges alphabet indices, but the persisted journal (and everything derived from it —
|
||||
replay, history, GCG) keeps the decoded concrete letters described above, so an archived
|
||||
game still replays with the variant's `rules.Alphabet` alone, independent of any dictionary.
|
||||
|
||||
## 10. Notifications
|
||||
|
||||
Two channels: the **in-app live stream** (delivered from Stage 6) and
|
||||
**platform-native push** (out-of-app, via the platform side-service — Stage 9).
|
||||
Two channels: the **in-app live stream** and
|
||||
**platform-native push** (out-of-app, via the platform side-service).
|
||||
The backend emits notification intents through an in-process hub
|
||||
(`internal/notify`, a `Publisher` seam installed on the game, social and lobby
|
||||
services); a single backend→gateway **gRPC server-stream** (`Push.Subscribe`,
|
||||
@@ -501,16 +501,16 @@ robot-driver and timeout-sweeper moves emit too; opponent-moved goes to **every
|
||||
including the mover**, so the mover's own other devices and their lobby refresh — it is
|
||||
in-app only, so the actor gets no out-of-app push for their own move), **chat-message** and **nudge**
|
||||
(from the social service), **match-found** (from the matchmaker, §8), and **notify**
|
||||
(Stage 8 — a lightweight "re-poll" signal carrying a sub-kind: friend-request,
|
||||
(a lightweight "re-poll" signal carrying a sub-kind: friend-request,
|
||||
friend-added, friend-declined, invitation or game-started; emitted on a friend-request,
|
||||
on answering one (accept → friend-added, decline → friend-declined — to the original
|
||||
requester, so a game screen watching that opponent re-derives its "add to friends" state,
|
||||
Stage 17), and on an invitation create or its game start). Stage 17 added **game-over** (emitted to every
|
||||
requester, so a game screen watching that opponent re-derives its "add to friends" state),
|
||||
and on an invitation create or its game start). **game-over** is emitted to every
|
||||
seat from the same game commit when a game finishes — any path: a closing play, all-pass,
|
||||
resign or timeout) and **enriched your-turn** so the out-of-app push reads in full: it now
|
||||
resign or timeout — and **your-turn** is enriched so the out-of-app push reads in full: it
|
||||
also carries the mover's display name, their last action and the main word of a scoring play,
|
||||
and a **recipient-first** running score line (e.g. `120:95:80`, the reader's score first).
|
||||
**R4 enriched the in-app stream into a delta channel** so the client renders from the event
|
||||
The in-app stream is a **delta channel** so the client renders from the event
|
||||
without a follow-up `game.state`: **opponent-moved** carries the committed move plus the post-move
|
||||
summary (per-seat scores, whose turn, move count, status) and the bag size, which the client
|
||||
applies to its per-game cache keyed on the **move count** — idempotent (a re-delivered or own-move
|
||||
@@ -526,12 +526,12 @@ verbatim. A client that is not currently streaming falls back to the matchmaker'
|
||||
match-found — the client polls **only while the stream is down**, since a live stream delivers
|
||||
match-found itself; for the lobby **notification badge** (incoming friend requests + open
|
||||
invitations) the client re-polls on the `notify` event and on lobby open / focus, covering a push
|
||||
missed while the app was hidden. **Out-of-app platform push** (Stage 9) is a fallback
|
||||
missed while the app was hidden. **Out-of-app platform push** is a fallback
|
||||
the **gateway** routes from the same firehose: for an event whose recipient has **no
|
||||
live in-app stream** it resolves the backend `/internal/push-target` (their Telegram
|
||||
`external_id`, the **service language** — the bot they last signed in through, falling
|
||||
back to the interface language — and the `notifications_in_app_only` flag). A **game** event,
|
||||
however, carries the **game's own language** on the push (Stage 17), and the gateway routes by
|
||||
however, carries the **game's own language** on the push, and the gateway routes by
|
||||
that instead of the service language — so a game's notification always comes from the game's bot,
|
||||
not the recipient's latest-login bot. It then asks the **Telegram connector** to deliver a
|
||||
localized message with a Mini App deep-link button — only when the recipient has a Telegram
|
||||
@@ -560,19 +560,19 @@ promotions) is future work and would deliver short markdown messages (text + lin
|
||||
and the gateway↔connector calls. The OTLP **Collector** (OTLP/gRPC → Prometheus
|
||||
metrics + Tempo traces), **Prometheus** (15d), **Tempo** (72h) and **Grafana**
|
||||
(provisioned datasources + dashboards, behind the caddy `/_gm/grafana` Basic-Auth)
|
||||
are stood up with the deploy (`deploy/`, Stage 16); the default exporter stays
|
||||
are stood up with the deploy (`deploy/`); the default exporter stays
|
||||
`none`, so CI needs no collector. The contour also runs **cAdvisor** (per-container
|
||||
CPU/memory/network) and **postgres_exporter** (connections, cache-hit ratio,
|
||||
transactions, db size), scraped by Prometheus and surfaced on the **Scrabble —
|
||||
Resources** Grafana dashboard (R2), so the pre-release stress runs capture a resource
|
||||
Resources** Grafana dashboard, which captures a resource
|
||||
baseline; these export directly in Prometheus format (not through the collector).
|
||||
- Per-request server-side timing via gin middleware from day one (the access log
|
||||
carries method, route, status, latency and the active trace id). A
|
||||
client-measured RTT piggybacked on the next request is a later enhancement.
|
||||
- Domain/operational metrics (Stage 12), recorded through the meter and invisible
|
||||
- Domain/operational metrics, recorded through the meter and invisible
|
||||
until an exporter is configured: histograms `game_replay_duration` (journal
|
||||
rebuild on a cache miss), `game_move_validate_duration` and `game_move_duration`
|
||||
(Stage 17 — a seat's think time per committed move, attributed by `variant` and a
|
||||
(a seat's think time per committed move, attributed by `variant` and a
|
||||
`phase` of opening/middle/endgame; it aggregates **all** seats including robots,
|
||||
whose synthetic timing dominates the tail, so per-human analysis lives in the admin
|
||||
console, below); counters `games_started_total`, `games_abandoned_total` (a
|
||||
@@ -581,18 +581,18 @@ promotions) is future work and would deliver short markdown messages (text + lin
|
||||
`edge_request_duration` (the UI-perceived roundtrip, by `message_type`/`result`);
|
||||
and Go runtime/heap metrics. Game-scoped metrics carry a `variant` attribute
|
||||
(scrabble_en/scrabble_ru/erudit_ru).
|
||||
- Per-user move-time analytics (Stage 17) are **offline**, derived in the admin
|
||||
- Per-user move-time analytics are **offline**, derived in the admin
|
||||
console from the move journal (`game_moves.created_at` deltas, the first move from
|
||||
the game's creation), not Prometheus labels (which an `account_id` would explode):
|
||||
the user list shows each account's min/avg/max think time, and the user-detail page
|
||||
draws a zero-JS inline-SVG chart of min/mean/max by the player's move number.
|
||||
- User metrics (Stage 16): a backend counter `accounts_created_total` (`kind` =
|
||||
- User metrics: a backend counter `accounts_created_total` (`kind` =
|
||||
telegram/email/guest; robots are a provisioned pool, not users, and are excluded)
|
||||
and a gateway **in-memory** observable gauge `active_users` (`window` = 24h/7d) —
|
||||
distinct accounts that performed an authenticated edge action in the window. The
|
||||
gauge is single-process by design (single-instance MVP, §10): it is correct for one
|
||||
gateway, resets on restart, and is a live operational figure, not a billing count.
|
||||
- **Rate-limit observability (R3):** every limiter rejection increments the gateway
|
||||
- **Rate-limit observability:** every limiter rejection increments the gateway
|
||||
counter `gateway_rate_limited_total` (`class` = user/public/email/admin — aggregate
|
||||
only, honouring the no-per-user-label discipline above) and logs one **Debug** line;
|
||||
a gateway reporter drains the per-key rejection tracker every 30 s, emits one **Warn**
|
||||
@@ -617,19 +617,19 @@ promotions) is future work and would deliver short markdown messages (text + lin
|
||||
|
||||
| Concern | Enforced by |
|
||||
| --- | --- |
|
||||
| Public rate limiting / anti-abuse | gateway (per-IP public/email/admin classes, per-user authenticated class; a request body cap of `GATEWAY_MAX_BODY_BYTES`; rejections are metered, summarised to the backend and surfaced in the admin console with a conservative reversible auto-flag — R3, §11) |
|
||||
| Public rate limiting / anti-abuse | gateway (per-IP public/email/admin classes, per-user authenticated class; a request body cap of `GATEWAY_MAX_BODY_BYTES`; rejections are metered, summarised to the backend and surfaced in the admin console with a conservative reversible auto-flag — §11) |
|
||||
| Telegram initData validation (bot-token HMAC) | the Telegram connector; the gateway delegates it over gRPC, so the bot token lives only in the connector |
|
||||
| Session minting; email-code / guest validation | gateway (with backend) |
|
||||
| Session → `user_id` resolution, `X-User-ID` injection | gateway |
|
||||
| Authorisation, ownership, state transitions | backend (`X-User-ID` is the sole identity input) |
|
||||
| Admin authentication | a single Basic-Auth gate on `/_gm/*`, forwarded **verbatim** to the backend's server-rendered admin console (and, in the deployed contour, routing `/_gm/grafana/*` to Grafana). In the deploy the **caddy** owns this gate (§13); a local non-caddy run uses the gateway's own `GATEWAY_ADMIN_*` proxy, which the per-IP admin limiter class guards ahead of its Basic-Auth (R3) — the caddy-fronted path has no limiter (stock caddy), an accepted gap. The backend trusts the proxy (no admin principal) and guards its state-changing POSTs with a **same-origin** check — the console's CSRF defence. No operator identity is tracked |
|
||||
| Admin authentication | a single Basic-Auth gate on `/_gm/*`, forwarded **verbatim** to the backend's server-rendered admin console (and, in the deployed contour, routing `/_gm/grafana/*` to Grafana). In the deploy the **caddy** owns this gate (§13); a local non-caddy run uses the gateway's own `GATEWAY_ADMIN_*` proxy, which the per-IP admin limiter class guards ahead of its Basic-Auth — the caddy-fronted path has no limiter (stock caddy), an accepted gap. The backend trusts the proxy (no admin principal) and guards its state-changing POSTs with a **same-origin** check — the console's CSRF defence. No operator identity is tracked |
|
||||
| backend ↔ gateway ↔ connector trust | the network (only gateway may reach backend; the connector serves unauthenticated gRPC on the internal segment) |
|
||||
|
||||
This is an explicit, accepted MVP risk: compromise of the gateway↔backend
|
||||
network segment defeats backend authentication. Mitigated by network isolation;
|
||||
mutual auth is a future hardening step.
|
||||
|
||||
**Short numeric codes** (email confirm-codes and Stage 8 friend codes) are stored
|
||||
**Short numeric codes** (email confirm-codes and friend codes) are stored
|
||||
only as SHA-256 hashes and are short-lived and single-use. The unauthenticated
|
||||
email path carries a tight per-IP sub-limit (5 / 10 min); the **friend-code redeem**
|
||||
is authenticated, so it rides the per-user limit (300 / min) and is further bounded
|
||||
@@ -645,7 +645,7 @@ Single public origin, path-routed. The Vite build has two entries: a lightweight
|
||||
(`go:embed`, baked in by a node stage in `gateway/Dockerfile`) and serves it at
|
||||
`/app/` (web) and `/telegram/` (the Telegram Mini App; outside Telegram that path
|
||||
redirects to the root — the client-side guard); a stray hit on the gateway's `/`
|
||||
308-redirects to `/app/`. The **landing** ships in its own static container (R3): the
|
||||
308-redirects to `/app/`. The **landing** ships in its own static container: the
|
||||
`landing` target of `gateway/Dockerfile` (caddy:2-alpine + the same Vite build,
|
||||
`deploy/landing/Caddyfile`) serves it at `/`, so stray public traffic is absorbed by
|
||||
static file serving and never reaches the Go edge. Hash-named `/assets/*` are served
|
||||
@@ -671,23 +671,23 @@ network (project-scoped DNS); only caddy joins the shared external `edge` networ
|
||||
(alias `scrabble`).
|
||||
|
||||
Two contours, two secret/variable prefixes (`TEST_` / `PROD_`):
|
||||
- **Test** (Stage 16): auto-deploys on a PR into — or a push to — `development`
|
||||
- **Test**: auto-deploys on a PR into — or a push to — `development`
|
||||
(`.gitea/workflows/ci.yaml` → `docker compose up -d --build` on the Gitea runner
|
||||
host, then `GET /` + `GET /app/` probes through caddy — the landing container and
|
||||
the gateway, R3). The host caddy terminates TLS and
|
||||
the gateway). The host caddy terminates TLS and
|
||||
forwards the domain to `scrabble:80`, so the in-compose caddy serves plain HTTP
|
||||
(`CADDY_SITE_ADDRESS=:80`). The in-compose caddy **trusts X-Forwarded-For from
|
||||
private-range upstreams** (`trusted_proxies private_ranges`), so the real client IP —
|
||||
used for chat-moderation logging and the gateway's per-IP rate limiting — survives the
|
||||
host-caddy hop; in prod (no host caddy) public clients are untrusted and Caddy uses the
|
||||
real peer, so the single config is correct and spoof-safe in both contours (Stage 17).
|
||||
- **Prod** (Stage 18): a manual SSH deploy after `development → master`. There is no
|
||||
real peer, so the single config is correct and spoof-safe in both contours.
|
||||
- **Prod**: a manual SSH deploy after `development → master`. There is no
|
||||
host caddy, so the contour ships its own caddy terminating TLS — set
|
||||
`CADDY_SITE_ADDRESS` to the domain and the caddy does its own ACME.
|
||||
|
||||
## 14. CI & branches
|
||||
|
||||
- **Two long-lived branches** (Stage 16): **`development`** is the integration
|
||||
- **Two long-lived branches**: **`development`** is the integration
|
||||
trunk and **`master`** the production trunk; `feature/*` branches are cut from
|
||||
`development` and PR back into it (the genesis commit necessarily landed on
|
||||
`master`). A commit to a `feature/*` branch triggers nothing.
|
||||
@@ -695,18 +695,18 @@ Two contours, two secret/variable prefixes (`TEST_` / `PROD_`):
|
||||
suite on a PR into `development`/`master` and on a push to `development`. Its
|
||||
`unit` (gofmt/vet/build/unit-test), `integration` (Postgres-backed `integration`
|
||||
tag, testcontainers `postgres:17-alpine`, Ryuk off, serial) and `ui`
|
||||
(check/unit/build/bundle-budget/e2e) jobs are **path-conditional** (Stage 17 — a
|
||||
(check/unit/build/bundle-budget/e2e) jobs are **path-conditional** (a
|
||||
`changes` job filters by changed paths), and an always-running **`gate`** job
|
||||
aggregates them (passing when each succeeded or was **skipped**) and is the single
|
||||
branch-protection required check (`CI / gate`), so a path-skipped job never blocks
|
||||
a merge.
|
||||
- A gated **`deploy`** job auto-rolls the **test contour** on a PR into — or a push
|
||||
to — `development` (`docker compose up -d --build` on the runner host), then probes
|
||||
the gateway (`GET /`) **and the Telegram connector's liveness** (Stage 17 —
|
||||
the gateway (`GET /`) **and the Telegram connector's liveness** (via
|
||||
`docker inspect`: running, not restarting, stable restart count, with a
|
||||
VPN-handshake grace period, since the connector has no public ingress and a
|
||||
crash-loop is otherwise invisible). A PR into `master` is test-only; the prod
|
||||
deploy is the manual Stage 18 workflow. Secrets/variables are prefixed
|
||||
deploy is the manual workflow. Secrets/variables are prefixed
|
||||
`TEST_`/`PROD_` per contour.
|
||||
- The engine consumes `scrabble-solver` as a **published, versioned module**
|
||||
(`gitea.iliadenisov.ru/developer/scrabble-solver`, pinned in `backend/go.mod`); both Go
|
||||
@@ -714,6 +714,6 @@ Two contours, two secret/variable prefixes (`TEST_` / `PROD_`):
|
||||
(no public proxy/checksum DB, no sibling clone). The dictionaries ship as a **release
|
||||
artifact** from the `scrabble-dictionary` repo; the workflows download
|
||||
`scrabble-dawg-<DICT_VERSION>.tar.gz` and point the engine tests at it via
|
||||
`BACKEND_DICT_DIR` (TODO-1/TODO-2 discharged in Stage 14).
|
||||
`BACKEND_DICT_DIR`.
|
||||
- After any push, the run is watched to green before a stage is declared done
|
||||
(`python3 ~/.claude/bin/gitea-ci-watch.py`).
|
||||
|
||||
+19
-20
@@ -4,18 +4,17 @@ Per-domain user stories: what each user-visible operation does. This is the
|
||||
starting point for any change request that touches behaviour. The English
|
||||
version is authoritative; [`FUNCTIONAL_ru.md`](FUNCTIONAL_ru.md) is a mirror for
|
||||
the project owner — mirror every point edit in the same patch (translate only
|
||||
the changed paragraphs). Sections deepen as stages land; *(Stage N)* marks where
|
||||
the detail is authored.
|
||||
the changed paragraphs).
|
||||
|
||||
## Domains
|
||||
|
||||
### Client app *(Stage 7 / 8)*
|
||||
The web/app client (Svelte + Vite) realizes these stories. The **playable slice**
|
||||
(Stage 7) covers signing in (guest or email), the "my games" lobby, starting an
|
||||
### Client app
|
||||
The web/app client (Svelte + Vite) realizes these stories. It
|
||||
covers signing in (guest or email), the "my games" lobby, starting an
|
||||
auto-match, playing the board (place tiles by drag or tap, pass, exchange, resign),
|
||||
the top-1 hint, the unlimited word-check with complaint, per-game chat and nudge,
|
||||
real-time in-app updates, switching interface language (en/ru) and theme, and a
|
||||
read-only profile. **Stage 8** adds managing friends (including one-time friend
|
||||
read-only profile. It also handles managing friends (including one-time friend
|
||||
codes) and blocks, friend-game invitations, editing the profile and binding an
|
||||
email, the statistics screen, and the in-game history viewer with GCG export.
|
||||
Settings also pick the board's bonus-label style (beginner / classic / none). A hint **lays the suggested tiles on the board** for the player to confirm and
|
||||
@@ -26,7 +25,7 @@ theme, and links to the matching per-language Telegram channel; the game itself
|
||||
`/app/` (web) and `/telegram/` (the Telegram Mini App). The landing's theme is ephemeral
|
||||
(it follows the system scheme, not the saved preference); its language choice is saved.
|
||||
|
||||
### Identity & sessions *(Stage 1 / 6 / 9 / 15)*
|
||||
### Identity & sessions
|
||||
A player arrives from a platform (Telegram first), via email login, or as an
|
||||
ephemeral guest. The gateway validates the credential once and mints a thin
|
||||
session token; the backend resolves it to an internal `user_id`. A **Telegram Mini
|
||||
@@ -56,7 +55,7 @@ pause until it is back (a server-data screen still opens, with the spinner, and
|
||||
reconnect), and pending reads resume on their own — the interface stays usable instead of
|
||||
flashing a red banner each time.
|
||||
|
||||
### Accounts, linking & merge *(Stage 1 / 11)*
|
||||
### Accounts, linking & merge
|
||||
First platform contact auto-provisions a durable account. From the profile a player
|
||||
links an email (via a confirm code) or their Telegram (via the web sign-in); a guest
|
||||
who links their first identity becomes a durable account. The "already taken" status
|
||||
@@ -68,12 +67,12 @@ when a guest links an identity that already has a durable account, where the dur
|
||||
account is kept and the guest's games move into it. A merge is blocked only while the
|
||||
two accounts share a game still in progress.
|
||||
|
||||
### Lobby & matchmaking *(Stage 4 / 15)*
|
||||
### Lobby & matchmaking
|
||||
Bottom tab menu: **my games**, **profile**. The **my games** list groups games into three
|
||||
sections — *your turn*, *opponent's turn* and *finished* (empty sections are hidden) — and
|
||||
orders them so the games awaiting your move come first, the longest-waiting on top, while
|
||||
opponent-turn and finished games are most-recent first; it renders as a compact,
|
||||
line-separated list (Stage 17). You can **remove a finished game from your own list**:
|
||||
line-separated list. You can **remove a finished game from your own list**:
|
||||
swipe a finished row left (or, on desktop, tap its **⋮**) to reveal a **❌**, then tap it.
|
||||
The removal is per-account and permanent — the game disappears only from your list and stays
|
||||
in the other players' lists, and there is no undo. The game types offered on **New Game** are
|
||||
@@ -84,13 +83,13 @@ unrestricted). Variants are shown by their **display name** — both Scrabble va
|
||||
the same name titles the in-game screen. This gates only **starting** a new game — both auto-match and a friend
|
||||
invitation — so a player still sees and plays existing games of any language. Auto-match
|
||||
(always 2 players) joins a per-variant pool and is paired with the next waiting human;
|
||||
after 10 s with no human the robot substitutes (the robot arrives in Stage 5). Friend games (2–4) are
|
||||
after 10 s with no human the robot substitutes. Friend games (2–4) are
|
||||
formed by inviting players from the friend list (an invitation, like a friend code,
|
||||
is shareable as a Telegram deep link that opens it directly): the inviter chooses the
|
||||
settings and the game starts once every invitee has accepted — any decline cancels it, and an unanswered invitation
|
||||
expires after seven days.
|
||||
|
||||
### Playing a game *(Stage 3)*
|
||||
### Playing a game
|
||||
Place tiles, pass, exchange, or resign. A play is validated against the game's
|
||||
dictionary at submit time and scored; an unlimited preview reports what a
|
||||
tentative move would score and whether it is legal. The dictionary check tool is
|
||||
@@ -111,7 +110,7 @@ and restored on return (including on another device); a player may **arrange til
|
||||
the opponent's turn**, but that draft is position-only — the score preview and submission
|
||||
stay available only on the player's own turn.
|
||||
|
||||
### Robot opponent *(Stage 5)*
|
||||
### Robot opponent
|
||||
When auto-match finds no human within ten seconds, a robot opponent takes the empty
|
||||
seat so the game starts without waiting. It is meant to feel like a person: it
|
||||
decides once per game whether to play to win (about 40% of the time, so the human
|
||||
@@ -123,7 +122,7 @@ carries a human-like, language-appropriate name (a Russian game draws mostly Rus
|
||||
names); it does not chat, and **silently ignores friend requests** — a request to a
|
||||
robot stays pending and expires, exactly like a human who never responds.
|
||||
|
||||
### Social: friends, block, chat, nudge *(Stage 4 / 8)*
|
||||
### Social: friends, block, chat, nudge
|
||||
Become friends in two ways: redeem a **one-time code** the other player issues (six
|
||||
digits, valid for twelve hours), or send a **request to someone you have played
|
||||
with** — they accept, ignore it (a request lapses after thirty days and can then be
|
||||
@@ -132,7 +131,7 @@ a code). Cancelling your own pending request withdraws it; unfriending removes t
|
||||
friendship. In a game, an **add to friends** item for each opponent mirrors the live
|
||||
relationship: it reads *request sent* (disabled) while a request is pending or was
|
||||
declined, and *in friends* once accepted — updating in place the moment the opponent
|
||||
answers, and staying correct across reloads (Stage 17). Block globally — switch off incoming chat
|
||||
answers, and staying correct across reloads. Block globally — switch off incoming chat
|
||||
and/or friend requests — and block individual players (a per-user block hides that
|
||||
person's chat and stops requests and game invitations both ways; it also ends any
|
||||
existing friendship). Per-game chat is for quick reactions: messages are short
|
||||
@@ -142,16 +141,16 @@ nudge is part of the game chat); the out-of-app push is delivered via the platfo
|
||||
Chat and the word-check tool open as their **own screens** (with a back to the game), and a
|
||||
new chat message raises an **unread badge** on the game's menu until the chat is opened.
|
||||
|
||||
### Profile & settings *(Stage 4 / 8)*
|
||||
### Profile & settings
|
||||
Edit the display name (letters joined by a single space / "." / "_" separator, with an
|
||||
optional trailing ".", up to 32 characters and at most 5 special characters — the "." / "_"
|
||||
punctuation, spaces aside), the timezone (chosen as a UTC offset), the
|
||||
daily away window (on a 10-minute grid, at most 12 hours, wrapping midnight) and the
|
||||
block toggles. The profile form is edited inline (no separate edit mode). Linking
|
||||
an email or Telegram and merging accounts are covered under "Accounts, linking &
|
||||
merge" (Stage 11).
|
||||
merge".
|
||||
|
||||
### History & statistics *(Stage 3 / 8)*
|
||||
### History & statistics
|
||||
Finished games are archived in a dictionary-independent form and exportable to
|
||||
GCG; the export is offered **only once a game is finished** (exporting a live game
|
||||
would leak the move journal), and the client shares the `.gcg` file where the
|
||||
@@ -159,7 +158,7 @@ platform supports it, otherwise downloads it. Statistics (durable accounts only)
|
||||
wins, losses, draws, max points in a game, and max points for a single move (the
|
||||
best play, which already includes every word it formed plus the all-tiles bonus).
|
||||
|
||||
### Administration *(Stage 10)*
|
||||
### Administration
|
||||
Operators reach a server-rendered admin console at `${DOMAIN}/_gm` — the backend
|
||||
renders it; the gateway gates it with HTTP Basic Auth on its public listener and
|
||||
proxies it verbatim. The console lists and inspects **users** (profile, statistics,
|
||||
@@ -173,7 +172,7 @@ applied after a reload). When a Telegram connector is configured an operator can
|
||||
State-changing actions are protected by a same-origin check; the console tracks no
|
||||
operator identity.
|
||||
|
||||
The console also surfaces **rate-limit abuse** (R3): a **Throttled** page lists the
|
||||
The console also surfaces **rate-limit abuse**: a **Throttled** page lists the
|
||||
recently throttled users/IPs the gateway reported (an in-memory window — it resets on
|
||||
a backend restart) and the accounts currently carrying the soft **high-rate flag**. An
|
||||
account sustaining rejections past a tunable threshold is flagged automatically —
|
||||
|
||||
+19
-20
@@ -3,18 +3,17 @@
|
||||
Пользовательские сценарии по доменам: что делает каждая видимая пользователю
|
||||
операция. Это зеркало [`FUNCTIONAL.md`](FUNCTIONAL.md) для владельца проекта;
|
||||
**авторитетна английская версия**. Любую точечную правку переносим в том же
|
||||
патче (переводим только изменённые абзацы). Разделы наполняются по мере этапов;
|
||||
*(Stage N)* помечает, где пишется детализация.
|
||||
патче (переводим только изменённые абзацы).
|
||||
|
||||
## Домены
|
||||
|
||||
### Клиентское приложение *(Stage 7 / 8)*
|
||||
Веб/приложение-клиент (Svelte + Vite) воплощает эти истории. **Играбельный срез**
|
||||
(Stage 7) покрывает вход (гость или email), лобби «мои игры», старт авто-подбора,
|
||||
### Клиентское приложение
|
||||
Веб/приложение-клиент (Svelte + Vite) воплощает эти истории. Он
|
||||
покрывает вход (гость или email), лобби «мои игры», старт авто-подбора,
|
||||
игру на доске (постановка фишек перетаскиванием или тапом, пас, обмен, сдача),
|
||||
top-1 подсказку, безлимитную проверку слова с жалобой, чат и nudge в партии,
|
||||
обновления в реальном времени, переключение языка интерфейса (en/ru) и темы и
|
||||
профиль только для чтения. **Stage 8** добавляет управление друзьями (в т.ч.
|
||||
профиль только для чтения. Он также включает управление друзьями (в т.ч.
|
||||
одноразовые коды-приглашения) и блоками, дружеские приглашения в игру,
|
||||
редактирование профиля и привязку email, экран статистики и просмотр истории
|
||||
партии с экспортом GCG. В настройках также выбирается стиль подписей бонус-клеток
|
||||
@@ -27,7 +26,7 @@ top-1 подсказку, безлимитную проверку слова с
|
||||
`/app/` (веб) и `/telegram/` (Telegram Mini App). Тема на странице эфемерна (берётся из
|
||||
системной настройки, а не из сохранённой), выбор языка сохраняется.
|
||||
|
||||
### Личность и сессии *(Stage 1 / 6 / 9 / 15)*
|
||||
### Личность и сессии
|
||||
Игрок приходит с платформы (сначала Telegram), через email-вход или как
|
||||
эфемерный гость. Gateway один раз валидирует доступ и выдаёт тонкий
|
||||
session-токен; backend сопоставляет его с внутренним `user_id`. Запуск **Telegram
|
||||
@@ -58,7 +57,7 @@ nudge) приходят от бота **этой партии** — по язы
|
||||
подгружается при реконнекте), а незавершённые чтения возобновляются сами — интерфейс остаётся
|
||||
рабочим вместо красного баннера каждый раз.
|
||||
|
||||
### Аккаунты, привязка и слияние *(Stage 1 / 11)*
|
||||
### Аккаунты, привязка и слияние
|
||||
Первый контакт с платформы заводит постоянный аккаунт. Из профиля игрок
|
||||
привязывает email (по confirm-коду) или свой Telegram (через веб-вход); гость,
|
||||
привязавший первую личность, становится постоянным аккаунтом. Факт «личность уже
|
||||
@@ -70,12 +69,12 @@ nudge) приходят от бота **этой партии** — по язы
|
||||
тогда сохраняется постоянный аккаунт, а игры гостя переходят в него. Слияние
|
||||
запрещено, только пока у аккаунтов есть общая незавершённая игра.
|
||||
|
||||
### Лобби и подбор *(Stage 4 / 15)*
|
||||
### Лобби и подбор
|
||||
Нижнее tab-меню: **мои игры**, **профиль**. Список **мои игры** разбит на три секции —
|
||||
*твой ход*, *ход соперника* и *завершённые* (пустые секции скрыты) — и упорядочен так,
|
||||
что игры, ждущие твоего хода, идут первыми, дольше всего ждущие сверху, а игры на ходу
|
||||
соперника и завершённые — самые свежие сверху; отображается компактным списком с
|
||||
линиями-разделителями (Stage 17). Завершённую партию можно **убрать из своего списка**:
|
||||
линиями-разделителями. Завершённую партию можно **убрать из своего списка**:
|
||||
проведи по строке завершённой партии влево (или, на десктопе, нажми её **⋮**), чтобы открыть
|
||||
**❌**, и нажми её. Удаление действует только для твоего аккаунта и необратимо — партия
|
||||
исчезает лишь из твоего списка и остаётся в списках других игроков, отмены нет. Типы партий
|
||||
@@ -88,13 +87,13 @@ nudge) приходят от бота **этой партии** — по язы
|
||||
приглашение друга, — поэтому игрок по-прежнему видит и играет существующие игры на
|
||||
любом языке. Авто-подбор (всегда 2 игрока)
|
||||
встаёт в пул по варианту и сводится со следующим ожидающим человеком; через 10 с
|
||||
без человека подставляется робот (робот — в Stage 5). Игры с друзьями (2–4)
|
||||
без человека подставляется робот. Игры с друзьями (2–4)
|
||||
формируются приглашением игроков из списка друзей (приглашение, как и код друга,
|
||||
можно отправить deep-link'ом в Telegram, который откроет его сразу): инициатор
|
||||
выбирает настройки, и партия стартует, когда приняли все приглашённые — любой отказ отменяет приглашение, а без
|
||||
ответа приглашение протухает через семь дней.
|
||||
|
||||
### Игровой процесс *(Stage 3)*
|
||||
### Игровой процесс
|
||||
Выкладывание фишек, пас, обмен или сдача. Ход проверяется по словарю партии при
|
||||
сдаче и считается; безлимитный предпросмотр сообщает, сколько принёс бы
|
||||
предполагаемый ход и легален ли он. Инструмент проверки слова безлимитный и
|
||||
@@ -115,7 +114,7 @@ nudge) приходят от бота **этой партии** — по язы
|
||||
**раскладывать фишки и в ход соперника**, но такой черновик только позиционный —
|
||||
предпросмотр счёта и отправка доступны лишь в собственный ход.
|
||||
|
||||
### Робот-соперник *(Stage 5)*
|
||||
### Робот-соперник
|
||||
Если авто-подбор не находит человека за десять секунд, свободное место занимает
|
||||
робот-соперник, и партия стартует без ожидания. Он задуман неотличимым от человека:
|
||||
один раз за партию решает, играть ли на победу (примерно в 40% случаев, так что
|
||||
@@ -127,7 +126,7 @@ nudge) приходят от бота **этой партии** — по язы
|
||||
**молча игнорирует заявки в друзья** — заявка роботу остаётся в ожидании и истекает,
|
||||
ровно как у человека, который не отвечает.
|
||||
|
||||
### Социальное: друзья, блок, чат, nudge *(Stage 4 / 8)*
|
||||
### Социальное: друзья, блок, чат, nudge
|
||||
Подружиться можно двумя способами: погасить **одноразовый код**, который выпускает
|
||||
другой игрок (шесть цифр, действует двенадцать часов), либо отправить **заявку
|
||||
тому, с кем вы играли** — он принимает, игнорирует (заявка истекает через тридцать
|
||||
@@ -136,7 +135,7 @@ nudge) приходят от бота **этой партии** — по язы
|
||||
снимает её; удаление расторгает дружбу. В партии пункт меню **в друзья** для каждого
|
||||
соперника отражает живое отношение: он показывает *заявка отправлена* (неактивный),
|
||||
пока заявка висит или была отклонена, и *в друзьях* после принятия — обновляясь на месте
|
||||
в момент ответа соперника и оставаясь верным после перезагрузки (Stage 17). Глобальная блокировка — отключить входящие
|
||||
в момент ответа соперника и оставаясь верным после перезагрузки. Глобальная блокировка — отключить входящие
|
||||
чат и/или заявки —
|
||||
и блокировка конкретного игрока (пер-юзер блок скрывает его чат и запрещает заявки
|
||||
и приглашения в игру в обе стороны, а также расторгает уже имеющуюся дружбу). Чат
|
||||
@@ -147,16 +146,16 @@ push доставляется через платформу.
|
||||
Чат и инструмент проверки слова открываются **отдельными экранами** (с кнопкой «назад» в
|
||||
партию), а новое сообщение в чате рисует **бейдж непрочитанного** на меню партии до открытия чата.
|
||||
|
||||
### Профиль и настройки *(Stage 4 / 8)*
|
||||
### Профиль и настройки
|
||||
Редактирование отображаемого имени (буквы, разделённые одиночным пробелом / «.» /
|
||||
«_», с необязательной завершающей «.», до 32 символов и не более 5 спецсимволов —
|
||||
пунктуации «.» / «_», пробелы не в счёт), таймзоны (выбор смещения от
|
||||
UTC), суточного окна отсутствия (away; сетка по 10 минут, не более 12 часов, с
|
||||
переходом через полночь) и переключателей блокировок. Форма профиля редактируется
|
||||
сразу (без отдельного режима редактирования). Привязка email и Telegram, а также
|
||||
слияние аккаунтов вынесены в раздел «Аккаунты, привязка и слияние» (Stage 11).
|
||||
слияние аккаунтов вынесены в раздел «Аккаунты, привязка и слияние».
|
||||
|
||||
### История и статистика *(Stage 3 / 8)*
|
||||
### История и статистика
|
||||
Завершённые партии архивируются в независимом от словаря виде и экспортируются
|
||||
в GCG; экспорт доступен **только после завершения партии** (экспорт идущей партии
|
||||
раскрыл бы журнал ходов), и клиент делится файлом `.gcg` там, где платформа это
|
||||
@@ -164,7 +163,7 @@ UTC), суточного окна отсутствия (away; сетка по 10
|
||||
победы, поражения, ничьи, макс. очков за партию и макс. очков за один ход (лучший
|
||||
ход, уже включающий все образованные им слова и бонус за все фишки).
|
||||
|
||||
### Администрирование *(Stage 10)*
|
||||
### Администрирование
|
||||
Оператор открывает серверную админ-консоль по адресу `${DOMAIN}/_gm` — её рендерит
|
||||
backend; gateway закрывает её HTTP Basic Auth на публичном порту и проксирует
|
||||
один-в-один. В консоли можно смотреть **пользователей** (профиль, статистика,
|
||||
@@ -177,7 +176,7 @@ identity, их игры) и **игры** (сводка + места), разби
|
||||
Telegram-identity) или **отправить пост в игровой канал**. Изменяющие действия
|
||||
защищены проверкой same-origin; личность оператора не отслеживается.
|
||||
|
||||
Консоль также показывает **злоупотребление лимитами** (R3): страница **Throttled**
|
||||
Консоль также показывает **злоупотребление лимитами**: страница **Throttled**
|
||||
перечисляет недавно затроттленных пользователей/IP по отчётам gateway (окно в памяти —
|
||||
сбрасывается при рестарте backend) и аккаунты с действующим мягким **high-rate
|
||||
флагом**. Аккаунт, устойчиво превышающий настраиваемый порог отказов, помечается
|
||||
|
||||
+22
-22
@@ -9,19 +9,19 @@ tests or touching CI.
|
||||
Every functional change ships with regression coverage. Run:
|
||||
`go test -count=1 ./backend/... ./pkg/... ./gateway/...` (the module list grows
|
||||
with the workspace).
|
||||
- **Integration** *(Stage 1+)* — Postgres-backed tests behind the `integration`
|
||||
- **Integration** — Postgres-backed tests behind the `integration`
|
||||
build tag spin a throwaway `postgres:17-alpine` via `testcontainers-go`. They
|
||||
live in `backend/internal/inttest` and run with
|
||||
`go test -tags=integration -count=1 -p=1 ./backend/...` (needs Docker), guarded
|
||||
by a separate CI workflow (`integration.yaml`; Ryuk disabled, serial). Slow.
|
||||
- **UI** *(introduced with the UI in Stage 7)* — Vitest (unit) + Playwright
|
||||
(e2e), mirroring the chosen plain-Svelte + Vite toolchain. Stage 8 adds Vitest for
|
||||
the new FlatBuffers codecs (friend list, invitation, stats), the win-rate
|
||||
- **UI** — Vitest (unit) + Playwright
|
||||
(e2e), mirroring the chosen plain-Svelte + Vite toolchain. Vitest covers
|
||||
the FlatBuffers codecs (friend list, invitation, stats), the win-rate
|
||||
derivation and the GCG share/download choice, plus Playwright specs against the
|
||||
mock for the friends screen (code issue/redeem, accept a request), the lobby
|
||||
invitations section, the stats screen, profile editing, and the GCG export's
|
||||
finished-only visibility.
|
||||
- **Engine** *(Stage 2+)* — correctness of scoring and move generation is owned
|
||||
- **Engine** — correctness of scoring and move generation is owned
|
||||
by `scrabble-solver`'s own GCG-backed tests. `backend/internal/engine` adds, on
|
||||
top of the embedded solver: per-variant smoke tests (load all three committed
|
||||
DAWGs and validate a known word, including Эрудит), bag draw/return determinism
|
||||
@@ -32,33 +32,33 @@ tests or touching CI.
|
||||
win/loss rule** (the resigner keeps their score yet loses). The engine tests
|
||||
read the DAWGs from `BACKEND_DICT_DIR` (or the sibling `scrabble-solver`
|
||||
checkout) and fail loudly when it is absent.
|
||||
- **Game domain** *(Stage 3+)* — `backend/internal/game` adds pure unit tests
|
||||
- **Game domain** — `backend/internal/game` adds pure unit tests
|
||||
(the GCG writer, the away-window / effective-deadline boundaries, the hint
|
||||
budget, the live-game cache and per-game lock, payload round-trips) plus
|
||||
Postgres-backed integration tests in `inttest` (full lifecycle to a natural
|
||||
end, **journal-replay equivalence**, the turn-timeout sweep with away-window
|
||||
grace, resign win/loss and statistics, the hint allowance-then-wallet policy,
|
||||
word-check and complaint capture, and per-game-lock serialisation). Stage 4 adds
|
||||
word-check and complaint capture, and per-game-lock serialisation). It also covers
|
||||
the engine's **multi-player drop-out** cases (continue after one resign,
|
||||
last-survivor win, the tile-disposition bag effect) and a domain integration test
|
||||
for a 3-player **timeout that continues**. The engine also gains a `Candidates`
|
||||
ranked/decoded test (Stage 5).
|
||||
- **Social & lobby** *(Stage 4+)* — `backend/internal/social` unit-tests the chat
|
||||
for a 3-player **timeout that continues**, and the engine's `Candidates`
|
||||
ranked/decoded test.
|
||||
- **Social & lobby** — `backend/internal/social` unit-tests the chat
|
||||
**content filter** (links/emails/phones plus obfuscated forms) and
|
||||
`backend/internal/lobby` unit-tests the in-memory **matchmaker** (FIFO pairing,
|
||||
cancel, per-variant pools, plus the Stage 5 **robot substitution** reaper and
|
||||
cancel, per-variant pools, plus the **robot substitution** reaper and
|
||||
`Poll` delivery) with fake game-creator and robot-provider seams. Postgres-backed
|
||||
`inttest` covers the friend request/accept lifecycle with the block/toggle guards,
|
||||
the per-user block (and its severing of friendships), chat post/list with the IP,
|
||||
content and block-visibility rules, the nudge turn/rate-limit rules, the
|
||||
invitation flow (all-accept starts the game, decline cancels, lazy expiry,
|
||||
inviter-only cancel), and the email confirm-code flow (request/confirm, taken
|
||||
email, expiry and attempt-cap) with a fixture mailer. Stage 8 adds the
|
||||
email, expiry and attempt-cap) with a fixture mailer. It also covers the
|
||||
**befriend-an-opponent** gate (a request needs a shared game), the **permanent
|
||||
decline** and 30-day re-send rule, the **one-time friend code** (issue/redeem,
|
||||
self/single-use, decline-bypass), `ListInvitations`, the zero-value `GetStats`, and
|
||||
the GCG **finished-only** gate.
|
||||
- **Robot** *(Stage 5+)* — `backend/internal/robot` unit-tests the pure strategy:
|
||||
- **Robot** — `backend/internal/robot` unit-tests the pure strategy:
|
||||
the ≈ 40% play-to-win split over many seeds, the right-skewed move-delay
|
||||
(bounds, ~10-min median, determinism), the margin selection (win/lose, in-band
|
||||
and out-of-band fallbacks, no-play exchange/pass), the sleep window with drift
|
||||
@@ -66,7 +66,7 @@ tests or touching CI.
|
||||
drives a robot through a full auto-match to a natural end (asserting a robot
|
||||
statistics row), the matchmaker substitution end-to-end (enqueue → reap →
|
||||
`[human, robot]`, discoverable via `Poll`), and a proactive 12-hour nudge.
|
||||
- **Gateway & contracts** *(Stage 6+)* — `backend/internal/notify` unit-tests the
|
||||
- **Gateway & contracts** — `backend/internal/notify` unit-tests the
|
||||
hub fan-out (delivery, overflow drop, unsubscribe) and the FlatBuffers event
|
||||
constructors (payload round-trip). `gateway/...` unit-tests are hermetic (no
|
||||
real network — an `httptest` fake backend and fixtures): the Telegram initData
|
||||
@@ -76,20 +76,20 @@ tests or touching CI.
|
||||
unsubscribe), the transcode round-trips (FlatBuffers↔JSON, X-User-ID
|
||||
forwarding, nested GameView, domain-code surfacing), the admin Basic-Auth
|
||||
reverse proxy (401 / forward), and a full Connect `Execute` path end to end
|
||||
(guest auth, unauthenticated rejection, unknown message type). **R3** adds the
|
||||
(guest auth, unauthenticated rejection, unknown message type). The
|
||||
edge-hardening cases: an oversized `Execute` payload is refused
|
||||
(`resource_exhausted`, the `GATEWAY_MAX_BODY_BYTES` cap), a limiter rejection
|
||||
lands in `gateway_rate_limited_total{class}` and the rejection tracker
|
||||
(drain/aggregate unit tests), the report POST reaches
|
||||
`/api/v1/internal/ratelimit/report` with the agreed JSON shape, the `/_gm`
|
||||
mount is 429-guarded by the per-IP admin class, and the gateway's `/`
|
||||
308-redirects to `/app/` (the landing left the embed). The backend gains
|
||||
308-redirects to `/app/` (the landing left the embed). The backend covers
|
||||
the **guest** lifecycle (a guest plays an auto-match to a natural end yet accrues
|
||||
no statistics) and the **email-as-login** flow (request/verify, returning user)
|
||||
in `inttest`. Stage 8 adds gateway transcode round-trips for the new social/account
|
||||
in `inttest`. Gateway transcode round-trips cover the social/account
|
||||
operations (friends list, friend code issue/redeem, invitation create, stats, GCG,
|
||||
the profile-update away round-trip) and a `notify`-event constructor round-trip.
|
||||
- **Admin & dictionary ops** *(Stage 10)* — `backend/internal/adminconsole` unit-tests
|
||||
- **Admin & dictionary ops** — `backend/internal/adminconsole` unit-tests
|
||||
the template renderer over every page plus the embedded asset; `backend/internal/engine`
|
||||
adds the **dictionary hot-reload** cases (`LoadAvailable` loads only the present
|
||||
variants, `OpenWithVersions` scans version subdirectories, a reload registers a new
|
||||
@@ -99,13 +99,13 @@ tests or touching CI.
|
||||
404 when not). Postgres-backed `inttest` drives the **complaint resolution →
|
||||
dictionary-change pipeline** (file → resolve with a disposition → pending change → mark
|
||||
applied), the admin **list/count** read queries, and the **/_gm console over HTTP**
|
||||
(pages render; a resolve POST needs a same-origin header). **R3** adds `ratewatch`
|
||||
(pages render; a resolve POST needs a same-origin header). `ratewatch` has
|
||||
unit tests (window accumulation, the auto-flag threshold + expiry, the bounded
|
||||
episode map), the account-store **high-rate flag round-trip** (set-once / clear /
|
||||
re-flag) and a console flow in `inttest`: a gateway report auto-flags the account,
|
||||
the **Throttled** page shows the episode and the flagged queue, the user card
|
||||
carries the marker and the CSRF-guarded **Clear** reverses it.
|
||||
- **Observability & performance** *(Stage 12)* — `pkg/telemetry` unit-tests the exporter
|
||||
- **Observability & performance** — `pkg/telemetry` unit-tests the exporter
|
||||
selection (`none`/`stdout`/`otlp` build providers; OTLP constructs with no collector;
|
||||
the nil-runtime fallback). The domain metrics are exercised through a manual
|
||||
`sdkmetric` reader: `backend/internal/game` and `…/social` assert the counters and
|
||||
@@ -115,7 +115,7 @@ tests or touching CI.
|
||||
`otlp` now accepted, an unsupported exporter rejected) and the guest-reaper knobs.
|
||||
Postgres-backed `inttest` drives the **guest reaper** end to end (an abandoned guest is
|
||||
reaped; a too-young guest, a seated guest and a durable account are kept).
|
||||
- **Load test & resource baseline** *(R2)* — a reusable `loadtest/` module
|
||||
- **Load test & resource baseline** — a reusable `loadtest/` module
|
||||
(`scrabble/loadtest`) is the pre-release stress harness. It **seeds** a large account
|
||||
population with pre-created sessions directly in Postgres (token hashes matching
|
||||
`backend/internal/session`), **drives** virtual players through the edge protocol —
|
||||
@@ -128,7 +128,7 @@ tests or touching CI.
|
||||
engine tests do). It is **not** part of the per-PR suite's behavioural assertions: it
|
||||
runs ad hoc as a one-shot container against the contour, producing a trip report (bugs
|
||||
+ a resource baseline) read off the **cAdvisor + postgres_exporter** Grafana dashboard
|
||||
added to the contour in R2. See [`../loadtest/README.md`](../loadtest/README.md).
|
||||
on the contour. See [`../loadtest/README.md`](../loadtest/README.md).
|
||||
|
||||
## Principles
|
||||
|
||||
|
||||
+11
-11
@@ -5,7 +5,7 @@ Visual and interaction conventions for the `ui` client. Behaviour lives in
|
||||
points this doc references) lives in [`ARCHITECTURE.md`](ARCHITECTURE.md). The client
|
||||
is **pure HTML5/CSS + Unicode** — no image/font/SVG assets; icons are CSS shapes or
|
||||
emoji glyphs. Tokens are CSS custom properties (`ui/src/app.css`), light/dark via
|
||||
`prefers-color-scheme` or an explicit Settings choice, and **Telegram-themed** (Stage 9):
|
||||
`prefers-color-scheme` or an explicit Settings choice, and **Telegram-themed**:
|
||||
on a Telegram Mini App launch — the app is served under `/telegram/` and detects the
|
||||
launch by `Telegram.WebApp.initData` — the SDK's `themeParams` override the tokens at
|
||||
runtime; opened outside Telegram, the `/telegram/` path redirects to the site root.
|
||||
@@ -33,14 +33,14 @@ Login uses `Screen`.
|
||||
emoji icon over a tiny truncated label. A press highlights a rounded **square** behind
|
||||
the icon (slightly larger than it) until release; spacing keeps adjacent labels from
|
||||
touching. No text selection on nav / tab-bar / buttons (`user-select: none`).
|
||||
- **Screen transitions** (Stage 17, `App.svelte`): navigation slides directionally — a
|
||||
- **Screen transitions** (`App.svelte`): navigation slides directionally — a
|
||||
screen entered from the lobby flies in from the right; returning to the lobby reveals it
|
||||
from the left (back). Transitions are local (so they do not play on first load) and
|
||||
collapse to nothing under reduce-motion. Per-game and lobby in-memory caches
|
||||
(`lib/gamecache.ts`, `lib/lobbycache.ts`) render a re-opened game or the lobby instantly
|
||||
and refresh in the background, removing the blank-loading flash and the lobby's "draw-in"
|
||||
on lobby ↔ game navigation.
|
||||
- **Telegram integration** (Stage 17, `lib/telegram.ts`): inside the Mini App the colour
|
||||
- **Telegram integration** (`lib/telegram.ts`): inside the Mini App the colour
|
||||
scheme is forced from `Telegram.WebApp.colorScheme` (over the OS `prefers-color-scheme`,
|
||||
which leaks into the Telegram Desktop webview and otherwise fights it) and the Settings
|
||||
theme switcher is hidden; the nav bar takes Telegram's background and `setHeaderColor` /
|
||||
@@ -67,7 +67,7 @@ Login uses `Screen`.
|
||||
preventDefault fires only for two touches, so one-finger scroll stays native, and a second
|
||||
finger aborts an in-progress drag). The pan is **interpolated toward a pre-clamped target**
|
||||
as the real width grows/shrinks, so it magnifies evenly A→B instead of lurching and snapping
|
||||
back near the edges (Stage 17). It **recentres only on a zoom-in** — placing a 2nd+ tile or
|
||||
back near the edges. It **recentres only on a zoom-in** — placing a 2nd+ tile or
|
||||
hovering a dragged tile never jumps the board. On touch the first tile placement auto-zooms
|
||||
in centred on the target, and **holding a dragged tile over a cell ~1 s** auto-zooms there
|
||||
the first time. The swipe-to-open-history gesture stays dropped (it fought native scroll);
|
||||
@@ -78,15 +78,15 @@ Login uses `Screen`.
|
||||
cell is **highlighted as a drop target** (an accent ring). A pending tile is taken back by a
|
||||
**double-tap** or by **dragging it back onto the rack** (unzoomed board only — when zoomed
|
||||
the one-finger gesture scrolls). A single tap no longer recalls (too easy to trigger); a
|
||||
recalled tile returns to its original rack slot (Stage 17).
|
||||
- **Players plaque & history** (`Game.svelte`, Stage 17): the seats above the board share
|
||||
recalled tile returns to its original rack slot.
|
||||
- **Players plaque & history** (`Game.svelte`): the seats above the board share
|
||||
the width evenly; the seat whose turn it is is **raised** (a drop shadow on its sides)
|
||||
while the others read **sunk in** (an inset shadow). A tap anywhere on the plaque toggles
|
||||
the **move history** — a fixed-height slide-down drawer whose bottom border (and its
|
||||
shadow) pins to the board as the board slides down, instead of tracking the table as
|
||||
moves accumulate; its scrollbar gutter is reserved so the centred word column does not
|
||||
jitter. A move's row lists every word it formed (the main word first).
|
||||
- **Vertical fit & keyboard** (Stage 17): when the game does not fit the viewport, only the
|
||||
- **Vertical fit & keyboard**: when the game does not fit the viewport, only the
|
||||
board area scrolls vertically (`Screen` `column` mode; the score bar, status, rack and tab
|
||||
bar stay fixed), while zoom keeps its own scroll. The check-word dialog opens in
|
||||
`Modal` keyboard-overlay mode — the small sheet is top-anchored and the soft keyboard
|
||||
@@ -99,7 +99,7 @@ Login uses `Screen`.
|
||||
- **Bonus-square labels** — a Settings choice (`boardlabels.ts`): `beginner` shows a
|
||||
split `3×` / `word` (localized слово/буква), `classic` a single `3W` / `3С`, `none`
|
||||
nothing. Default **beginner**.
|
||||
- **Grid lines** — a Settings toggle, **default off** (Stage 17). Off: a **gapless
|
||||
- **Grid lines** — a Settings toggle, **default off**. Off: a **gapless
|
||||
checkerboard** — plain cells alternate two shades, and tiles get rounded corners with a
|
||||
soft right-side shadow so adjacent gapless tiles still read apart, reclaiming ~14px of
|
||||
board width. On: the classic lined grid, where the inter-cell gap shows a contrasting
|
||||
@@ -110,7 +110,7 @@ Login uses `Screen`.
|
||||
- **HoldConfirm** (`components/HoldConfirm.svelte`): the shared press-and-hold control. A
|
||||
short tap opens a small popover above the button; a ~0.7 s hold runs the primary action
|
||||
immediately. Used by the **Skip** and **Hint** tabs (each confirmed by an **Ok ✅** popover).
|
||||
- **MakeMove / Reset** (Stage 17): when ≥1 tile is pending the rack collapses its used slots
|
||||
- **MakeMove / Reset**: when ≥1 tile is pending the rack collapses its used slots
|
||||
and shifts left, a **borderless ✅ icon button** (styled like a tab, not a filled accent
|
||||
button) beside the rack commits the move — no popover, and disabled while the pending word
|
||||
is known illegal; the 🔀 Shuffle tab is replaced by a **↩️ Reset** tab.
|
||||
@@ -123,7 +123,7 @@ Login uses `Screen`.
|
||||
|
||||
## Announcement banner (`components/AdBanner.svelte`, `lib/banner.ts`)
|
||||
|
||||
A one-line inset strip under the nav bar, drawn on a dedicated `--ad-bg` token (Stage 17) —
|
||||
A one-line inset strip under the nav bar, drawn on a dedicated `--ad-bg` token —
|
||||
a subtle accent, a touch darker than the surroundings in the light theme and a touch lighter
|
||||
in the dark theme, mapped to Telegram's `secondary_bg_color` inside the Mini App. Content is
|
||||
minimal markdown (text + links, escaped + linkified). A parameterised **rotator** drives messages: a fitting message
|
||||
@@ -138,7 +138,7 @@ Lobby rows show two lines (opponents, then result + score) with a large place-ba
|
||||
on the right: Victory 🏆 / Defeat 🥈 / Draw 🏅, and for 3–4-player games II 🥈 / III 🥉 /
|
||||
IV 🏅; active games show Your move 🟢 / Opponent's move ⏳; invitations use 💌.
|
||||
|
||||
## Social, account & history surfaces (Stage 8)
|
||||
## Social, account & history surfaces
|
||||
|
||||
- **Friends** (`screens/Friends.svelte`, from the lobby menu): an "add a friend" block
|
||||
pairing a code **input** with a **Show my code** action that reveals a large 6-digit
|
||||
|
||||
+2
-2
@@ -42,7 +42,7 @@ COPY ui ./
|
||||
RUN pnpm build
|
||||
|
||||
# --- landing -------------------------------------------------------------------
|
||||
# The public landing page as its own static container (R3): the same Vite build
|
||||
# The public landing page as its own static container: the same Vite build
|
||||
# served by caddy at /, so stray public traffic is absorbed by static file
|
||||
# serving and never reaches the Go edge.
|
||||
FROM caddy:2-alpine AS landing
|
||||
@@ -58,7 +58,7 @@ COPY gateway ./gateway
|
||||
|
||||
# Replace the committed placeholder with the freshly built UI before compiling, so
|
||||
# go:embed bakes the real bundle into the binary. The landing shell ships in the
|
||||
# landing image, not in the gateway (R3).
|
||||
# landing image, not in the gateway.
|
||||
RUN rm -rf gateway/internal/webui/dist
|
||||
COPY --from=ui /ui/dist gateway/internal/webui/dist
|
||||
RUN rm gateway/internal/webui/dist/landing.html
|
||||
|
||||
+13
-13
@@ -23,7 +23,7 @@ proto/edge/v1/ # Connect envelope contract (committed generated Go)
|
||||
internal/config/ # GATEWAY_* env config
|
||||
internal/backendclient/ # typed REST client (+ X-User-ID) and push gRPC client
|
||||
internal/session/ # in-memory session cache (LRU/TTL, backend fallback)
|
||||
internal/ratelimit/ # token-bucket limiter (golang.org/x/time/rate) + the rejection tracker (R3)
|
||||
internal/ratelimit/ # token-bucket limiter (golang.org/x/time/rate) + the rejection tracker
|
||||
internal/connector/ # gRPC client to the Telegram connector (initData validate, out-of-app push) + routing
|
||||
internal/push/ # live-event fan-out hub (per-user client streams)
|
||||
internal/transcode/ # FlatBuffers<->REST bridge + message_type registry
|
||||
@@ -50,21 +50,21 @@ failures become Connect error codes.
|
||||
out-of-app push to that connector for recipients with no live in-app stream
|
||||
(ARCHITECTURE.md §10). When `GATEWAY_CONNECTOR_ADDR` is unset, both are disabled.
|
||||
|
||||
The Stage 6 message-type slice: `auth.telegram`, `auth.guest`,
|
||||
The message-type catalog: `auth.telegram`, `auth.guest`,
|
||||
`auth.email.request`, `auth.email.login`, `profile.get`, `game.submit_play`,
|
||||
`game.state`, `lobby.enqueue`, `lobby.poll`, `chat.post`; live events
|
||||
`your_turn`, `opponent_moved`, `chat_message`, `nudge`, `match_found` (R4 enriched the game events —
|
||||
and `game_over`/`notify` — to carry the state delta the client applies without a `game.state`
|
||||
refetch). Stage 7
|
||||
added the play-loop ops; **Stage 8** added the social/account/history ops —
|
||||
`game.state`, `lobby.enqueue`, `lobby.poll`, `chat.post` and the play-loop ops;
|
||||
live events
|
||||
`your_turn`, `opponent_moved`, `chat_message`, `nudge`, `match_found` (the game events —
|
||||
and `game_over`/`notify` — carry the state delta the client applies without a `game.state`
|
||||
refetch). The social/account/history ops —
|
||||
`friends.*` (list/incoming/request/respond/cancel/unfriend/code.issue/code.redeem),
|
||||
`blocks.*`, `invitation.*` (list/create/accept/decline/cancel), `profile.update`,
|
||||
`stats.get`, `game.gcg`, and the `notify` live event — all via the identical
|
||||
transcode pattern (`transcode_social.go`). **Stage 11** added account linking & merge
|
||||
`stats.get`, `game.gcg`, and the `notify` live event — go through the identical
|
||||
transcode pattern (`transcode_social.go`). Account linking & merge
|
||||
— `link.email.request/confirm/merge` and `link.telegram.confirm/merge`
|
||||
(`transcode_link.go`); the telegram ops validate the **Login Widget** payload via the
|
||||
connector (`ValidateLoginWidget`) and forward the trusted `external_id`. These
|
||||
**superseded** the Stage 8 `email.bind.*` ops, which were removed.
|
||||
**superseded** the former `email.bind.*` ops, which were removed.
|
||||
|
||||
## Configuration
|
||||
|
||||
@@ -81,13 +81,13 @@ connector (`ValidateLoginWidget`) and forward the trusted `external_id`. These
|
||||
| `GATEWAY_SESSION_TTL` | `10m` | cached session lifetime |
|
||||
| `GATEWAY_SESSION_CACHE_MAX` | `50000` | cached session cap |
|
||||
| `GATEWAY_PUSH_HEARTBEAT_INTERVAL` | `10s` | live-stream keep-alive (an immediate heartbeat also fires on open, under the ~15s edge idle timeout) |
|
||||
| `GATEWAY_MAX_BODY_BYTES` | `1048576` | caps one request body and one Connect message read; an oversized Execute is refused with `resource_exhausted` (R3) |
|
||||
| `GATEWAY_MAX_BODY_BYTES` | `1048576` | caps one request body and one Connect message read; an oversized Execute is refused with `resource_exhausted` |
|
||||
| `GATEWAY_SERVICE_NAME` | `scrabble-gateway` | OpenTelemetry `service.name` |
|
||||
| `GATEWAY_OTEL_TRACES_EXPORTER` | `none` | `none`, `stdout` or `otlp` (gRPC; endpoint from `OTEL_EXPORTER_OTLP_*`) |
|
||||
| `GATEWAY_OTEL_METRICS_EXPORTER` | `none` | `none`, `stdout` or `otlp` |
|
||||
|
||||
Rate-limit defaults (built-in): public 30/min·IP (burst 10), authenticated
|
||||
300/min·user (burst 80, raised in Stage 17 for multi-device play), admin
|
||||
300/min·user (burst 80, sized for multi-device play), admin
|
||||
60/min·IP (burst 20, guarding the `/_gm` mount ahead of its Basic-Auth),
|
||||
email-code 5/10 min·IP (burst 2).
|
||||
|
||||
@@ -95,7 +95,7 @@ Every rejection increments `gateway_rate_limited_total{class}`
|
||||
(`user`/`public`/`email`/`admin`) and logs one Debug line; a reporter drains the
|
||||
per-key rejection tracker every 30 s, emits a Warn summary per throttled key and
|
||||
posts the report to the backend (`/api/v1/internal/ratelimit/report`), feeding
|
||||
the admin console's throttled view and the high-rate auto-flag (R3).
|
||||
the admin console's throttled view and the high-rate auto-flag.
|
||||
|
||||
## Run
|
||||
|
||||
|
||||
@@ -42,10 +42,10 @@ const (
|
||||
// readHeaderTimeout bounds reading one request's headers on the public
|
||||
// listener (a slowloris guard). Bodies and long-lived streams are governed by
|
||||
// the h2c settings in connectsrv — Read/WriteTimeout stay unset on purpose,
|
||||
// they would kill the Subscribe stream (R3).
|
||||
// they would kill the Subscribe stream.
|
||||
readHeaderTimeout = 10 * time.Second
|
||||
// throttleReportInterval is the cadence of the rate-limiter rejection
|
||||
// summary: the Warn log per throttled key and the report to the backend (R3).
|
||||
// summary: the Warn log per throttled key and the report to the backend.
|
||||
throttleReportInterval = 30 * time.Second
|
||||
)
|
||||
|
||||
@@ -281,7 +281,7 @@ func deliverOutOfApp(ctx context.Context, backend *backendclient.Client, conn *c
|
||||
return
|
||||
}
|
||||
// A game event carries its own language, so the push comes from the game's bot rather than
|
||||
// the recipient's last-login bot (Stage 17); other events fall back to the service language.
|
||||
// the recipient's last-login bot; other events fall back to the service language.
|
||||
lang := target.Language
|
||||
if gameLang != "" {
|
||||
lang = gameLang
|
||||
|
||||
@@ -35,7 +35,7 @@ type ProfileResp struct {
|
||||
NotificationsInAppOnly bool `json:"notifications_in_app_only"`
|
||||
}
|
||||
|
||||
// LinkResultResp is the result of an account link/merge step (Stage 11). Status is
|
||||
// LinkResultResp is the result of an account link/merge step. Status is
|
||||
// "linked", "merge_required" (the secondary_* fields summarise the other account) or
|
||||
// "merged". Token is a switched-session token (a guest initiator's durable
|
||||
// counterpart won); Profile is the surviving/active account's profile.
|
||||
@@ -50,7 +50,7 @@ type LinkResultResp struct {
|
||||
}
|
||||
|
||||
// TileJSON is one tile in a decoded move response (history, move result, hint); its Letter
|
||||
// is a concrete character (Stage 13 keeps the move journal in letters).
|
||||
// is a concrete character (the move journal is kept in letters).
|
||||
type TileJSON struct {
|
||||
Row int `json:"row"`
|
||||
Col int `json:"col"`
|
||||
@@ -58,7 +58,7 @@ type TileJSON struct {
|
||||
Blank bool `json:"blank"`
|
||||
}
|
||||
|
||||
// PlayTileJSON is one inbound tile to place, addressed by alphabet index (Stage 13). For a
|
||||
// PlayTileJSON is one inbound tile to place, addressed by alphabet index. For a
|
||||
// blank, Letter is the designated letter's index and Blank is true.
|
||||
type PlayTileJSON struct {
|
||||
Row int `json:"row"`
|
||||
@@ -107,7 +107,7 @@ type GameResp struct {
|
||||
}
|
||||
|
||||
// MoveResultResp is the outcome of a committed move. Rack carries the actor's refilled rack as
|
||||
// wire alphabet indices and BagLen the bag size after the draw (R4).
|
||||
// wire alphabet indices and BagLen the bag size after the draw.
|
||||
type MoveResultResp struct {
|
||||
Move MoveRecordResp `json:"move"`
|
||||
Game GameResp `json:"game"`
|
||||
@@ -116,14 +116,14 @@ type MoveResultResp struct {
|
||||
}
|
||||
|
||||
// AlphabetEntryJSON is one letter of a variant's alphabet (its index, concrete letter and
|
||||
// tile value), present in StateResp only when the client requested it (Stage 13).
|
||||
// tile value), present in StateResp only when the client requested it.
|
||||
type AlphabetEntryJSON struct {
|
||||
Index int `json:"index"`
|
||||
Letter string `json:"letter"`
|
||||
Value int `json:"value"`
|
||||
}
|
||||
|
||||
// StateResp is a player's view of a game. Rack carries wire alphabet indices (Stage 13);
|
||||
// StateResp is a player's view of a game. Rack carries wire alphabet indices;
|
||||
// Alphabet is present only when the request asked for it.
|
||||
type StateResp struct {
|
||||
Game GameResp `json:"game"`
|
||||
@@ -224,7 +224,7 @@ func (c *Client) Profile(ctx context.Context, userID string) (ProfileResp, error
|
||||
}
|
||||
|
||||
// SubmitPlay commits a placement on the player's turn. The tiles are addressed by alphabet
|
||||
// index (Stage 13).
|
||||
// index.
|
||||
func (c *Client) SubmitPlay(ctx context.Context, userID, gameID, dir string, tiles []PlayTileJSON) (MoveResultResp, error) {
|
||||
var out MoveResultResp
|
||||
body := map[string]any{"dir": dir, "tiles": tiles}
|
||||
@@ -233,7 +233,7 @@ func (c *Client) SubmitPlay(ctx context.Context, userID, gameID, dir string, til
|
||||
}
|
||||
|
||||
// GameState returns the player's view of a game. When includeAlphabet is set the backend
|
||||
// embeds the variant's alphabet table (Stage 13); the client asks for it on a per-variant
|
||||
// embeds the variant's alphabet table; the client asks for it on a per-variant
|
||||
// cache miss only.
|
||||
func (c *Client) GameState(ctx context.Context, userID, gameID string, includeAlphabet bool) (StateResp, error) {
|
||||
var out StateResp
|
||||
@@ -320,7 +320,7 @@ func (c *Client) Pass(ctx context.Context, userID, gameID string) (MoveResultRes
|
||||
}
|
||||
|
||||
// Exchange swaps the chosen rack tiles back into the bag. Tiles are wire alphabet indices
|
||||
// (Stage 13; a blank is engine.BlankIndex).
|
||||
// (a blank is engine.BlankIndex).
|
||||
func (c *Client) Exchange(ctx context.Context, userID, gameID string, tiles []int) (MoveResultResp, error) {
|
||||
var out MoveResultResp
|
||||
err := c.do(ctx, http.MethodPost, c.gamePath(gameID, "/exchange"), userID, "",
|
||||
@@ -342,7 +342,7 @@ func (c *Client) Hint(ctx context.Context, userID, gameID string) (HintResultRes
|
||||
return out, err
|
||||
}
|
||||
|
||||
// GetDraft returns the player's saved composition for a game (Stage 17) as the backend's
|
||||
// GetDraft returns the player's saved composition for a game as the backend's
|
||||
// raw JSON body. The gateway forwards it verbatim, never interpreting its shape.
|
||||
func (c *Client) GetDraft(ctx context.Context, userID, gameID string) (json.RawMessage, error) {
|
||||
var out json.RawMessage
|
||||
@@ -350,21 +350,21 @@ func (c *Client) GetDraft(ctx context.Context, userID, gameID string) (json.RawM
|
||||
return out, err
|
||||
}
|
||||
|
||||
// SaveDraft upserts the player's composition for a game (Stage 17). body is the client's
|
||||
// SaveDraft upserts the player's composition for a game. body is the client's
|
||||
// {rack_order, board_tiles} JSON, forwarded verbatim — a json.RawMessage marshals as-is, so
|
||||
// there is no double-encode.
|
||||
func (c *Client) SaveDraft(ctx context.Context, userID, gameID string, body json.RawMessage) error {
|
||||
return c.do(ctx, http.MethodPut, c.gamePath(gameID, "/draft"), userID, "", body, nil)
|
||||
}
|
||||
|
||||
// HideGame hides a finished game from the caller's own games list (Stage 17). The action is
|
||||
// HideGame hides a finished game from the caller's own games list. The action is
|
||||
// per-account and irreversible; the game stays visible to the other players.
|
||||
func (c *Client) HideGame(ctx context.Context, userID, gameID string) error {
|
||||
return c.do(ctx, http.MethodPost, c.gamePath(gameID, "/hide"), userID, "", struct{}{}, nil)
|
||||
}
|
||||
|
||||
// Evaluate previews a tentative play's legality and score. The tiles are addressed by
|
||||
// alphabet index (Stage 13).
|
||||
// alphabet index.
|
||||
func (c *Client) Evaluate(ctx context.Context, userID, gameID, dir string, tiles []PlayTileJSON) (EvalResultResp, error) {
|
||||
var out EvalResultResp
|
||||
err := c.do(ctx, http.MethodPost, c.gamePath(gameID, "/evaluate"), userID, "",
|
||||
@@ -373,7 +373,7 @@ func (c *Client) Evaluate(ctx context.Context, userID, gameID, dir string, tiles
|
||||
}
|
||||
|
||||
// CheckWord looks a word up in the game's pinned dictionary. The word is carried as
|
||||
// repeated ?idx= alphabet indices (Stage 13); the backend echoes the decoded concrete word.
|
||||
// repeated ?idx= alphabet indices; the backend echoes the decoded concrete word.
|
||||
func (c *Client) CheckWord(ctx context.Context, userID, gameID string, word []int) (WordCheckResp, error) {
|
||||
var out WordCheckResp
|
||||
q := url.Values{}
|
||||
|
||||
@@ -6,7 +6,7 @@ import (
|
||||
"net/url"
|
||||
)
|
||||
|
||||
// The Stage 8 response structs and client methods mirror the backend's social,
|
||||
// The response structs and client methods mirror the backend's social,
|
||||
// account and history JSON DTOs. The transcode layer maps them to FlatBuffers.
|
||||
|
||||
// AccountRefResp is a referenced account with its display name resolved.
|
||||
@@ -249,7 +249,7 @@ func (c *Client) LinkEmailRequest(ctx context.Context, userID, email string) err
|
||||
}
|
||||
|
||||
// LinkEmailConfirm verifies the code and binds a free email or reports a required
|
||||
// merge (Stage 11).
|
||||
// merge.
|
||||
func (c *Client) LinkEmailConfirm(ctx context.Context, userID, email, code string) (LinkResultResp, error) {
|
||||
var out LinkResultResp
|
||||
err := c.do(ctx, http.MethodPost, "/api/v1/user/link/email/confirm", userID, "",
|
||||
@@ -257,7 +257,7 @@ func (c *Client) LinkEmailConfirm(ctx context.Context, userID, email, code strin
|
||||
return out, err
|
||||
}
|
||||
|
||||
// LinkEmailMerge re-verifies the code and performs the merge (Stage 11).
|
||||
// LinkEmailMerge re-verifies the code and performs the merge.
|
||||
func (c *Client) LinkEmailMerge(ctx context.Context, userID, email, code string) (LinkResultResp, error) {
|
||||
var out LinkResultResp
|
||||
err := c.do(ctx, http.MethodPost, "/api/v1/user/link/email/merge", userID, "",
|
||||
@@ -266,7 +266,7 @@ func (c *Client) LinkEmailMerge(ctx context.Context, userID, email, code string)
|
||||
}
|
||||
|
||||
// LinkTelegram attaches a gateway-validated Telegram identity to the caller or
|
||||
// reports a required merge (Stage 11).
|
||||
// reports a required merge.
|
||||
func (c *Client) LinkTelegram(ctx context.Context, userID, externalID string) (LinkResultResp, error) {
|
||||
var out LinkResultResp
|
||||
err := c.do(ctx, http.MethodPost, "/api/v1/user/link/telegram", userID, "",
|
||||
@@ -275,7 +275,7 @@ func (c *Client) LinkTelegram(ctx context.Context, userID, externalID string) (L
|
||||
}
|
||||
|
||||
// LinkTelegramMerge merges the account owning a gateway-validated Telegram identity
|
||||
// into the caller's (Stage 11).
|
||||
// into the caller's.
|
||||
func (c *Client) LinkTelegramMerge(ctx context.Context, userID, externalID string) (LinkResultResp, error) {
|
||||
var out LinkResultResp
|
||||
err := c.do(ctx, http.MethodPost, "/api/v1/user/link/telegram/merge", userID, "",
|
||||
|
||||
@@ -129,7 +129,7 @@ func (c *Client) SubscribePush(ctx context.Context, gatewayID string) (grpc.Serv
|
||||
// ReportRateLimited posts the gateway's periodic rate-limiter rejection summary
|
||||
// to the backend, which feeds the admin console's throttled view and the
|
||||
// high-rate auto-flag. The endpoint carries no user identity: like
|
||||
// sessions/resolve it rides the trusted internal segment (R3).
|
||||
// sessions/resolve it rides the trusted internal segment.
|
||||
func (c *Client) ReportRateLimited(ctx context.Context, windowSeconds int, entries []ratelimit.Rejection) error {
|
||||
body := struct {
|
||||
WindowSeconds int `json:"window_seconds"`
|
||||
|
||||
@@ -76,13 +76,13 @@ const (
|
||||
defaultBackendTimeout = 5 * time.Second
|
||||
defaultSessionTTL = 10 * time.Minute
|
||||
defaultSessionCacheMax = 50000
|
||||
defaultPushHeartbeatInterval = 10 * time.Second // under the ~15 s edge idle timeout (Stage 17)
|
||||
defaultPushHeartbeatInterval = 10 * time.Second // under the ~15 s edge idle timeout
|
||||
defaultServiceName = "scrabble-gateway"
|
||||
)
|
||||
|
||||
// DefaultMaxBodyBytes is the default request-body cap (GATEWAY_MAX_BODY_BYTES):
|
||||
// 1 MiB — far above any legitimate edge payload (drafts and chat are a few KB)
|
||||
// yet small enough to stop a cheap memory-amplification upload (R3).
|
||||
// yet small enough to stop a cheap memory-amplification upload.
|
||||
const DefaultMaxBodyBytes = 1 << 20
|
||||
|
||||
// supportedLanguages is the set of game languages a service may declare for the
|
||||
@@ -98,8 +98,8 @@ func DefaultRateLimit() RateLimitConfig {
|
||||
PublicPerMinute: 30, PublicBurst: 10,
|
||||
// Per-user (not per-IP): one user may run several devices, each holding a
|
||||
// Subscribe stream and reloading state on every live event, so the authenticated
|
||||
// budget is generous (a per-user cap cannot DoS the service). Raised in Stage 17
|
||||
// after multi-device play tripped the old 120/40.
|
||||
// budget is generous (a per-user cap cannot DoS the service). It is raised
|
||||
// because multi-device play tripped the old 120/40.
|
||||
UserPerMinute: 300, UserBurst: 80,
|
||||
AdminPerMinute: 60, AdminBurst: 20,
|
||||
EmailPer10Min: 5, EmailBurst: 2,
|
||||
|
||||
@@ -84,7 +84,7 @@ func (c *Client) ValidateInitData(ctx context.Context, initData string) (User, e
|
||||
|
||||
// ValidateLoginWidget verifies Telegram Login Widget data and returns the user
|
||||
// identity, mapping a connector InvalidArgument to ErrInvalidLoginWidget. It backs
|
||||
// the link.telegram edge operation (Stage 11).
|
||||
// the link.telegram edge operation.
|
||||
func (c *Client) ValidateLoginWidget(ctx context.Context, data string) (User, error) {
|
||||
resp, err := c.c.ValidateLoginWidget(ctx, &telegramv1.ValidateLoginWidgetRequest{Data: data})
|
||||
if err != nil {
|
||||
|
||||
@@ -7,7 +7,7 @@ import (
|
||||
|
||||
// TestPeerIP covers the client-IP extraction the chat-moderation IP and the per-IP rate
|
||||
// limiter both rely on: the first X-Forwarded-For hop (the real client, once Caddy is
|
||||
// configured to trust its upstream), falling back to the connection peer (Stage 17).
|
||||
// configured to trust its upstream), falling back to the connection peer.
|
||||
func TestPeerIP(t *testing.T) {
|
||||
tests := []struct {
|
||||
name string
|
||||
|
||||
@@ -35,7 +35,7 @@ import (
|
||||
const heartbeatKind = "heartbeat"
|
||||
|
||||
// Limiter classes, the `class` attribute of gateway_rate_limited_total and the
|
||||
// class field of the periodic rejection report (R3).
|
||||
// class field of the periodic rejection report.
|
||||
const (
|
||||
classUser = "user"
|
||||
classPublic = "public"
|
||||
@@ -43,13 +43,13 @@ const (
|
||||
classAdmin = "admin"
|
||||
)
|
||||
|
||||
// Explicit h2c server sizing (R3, after the R2 stress run questioned the
|
||||
// implicit defaults).
|
||||
// Explicit h2c server sizing, made explicit rather than relying on the
|
||||
// implicit defaults.
|
||||
const (
|
||||
// h2cMaxConcurrentStreams bounds the open streams per client connection — the
|
||||
// x/net default made explicit. A real client holds one Subscribe stream plus a
|
||||
// few unary calls; only a synthetic load multiplexing many players over one
|
||||
// transport approaches it. R7 revisits the sizing.
|
||||
// transport approaches it.
|
||||
h2cMaxConcurrentStreams = 250
|
||||
// h2cIdleTimeout closes a connection with no open streams. A live Subscribe
|
||||
// stream keeps its connection active, so long-lived clients are unaffected;
|
||||
@@ -151,7 +151,7 @@ func (s *Server) HTTPHandler() http.Handler {
|
||||
// working over h2c (docs/ARCHITECTURE.md §12). In the deployed contour the
|
||||
// front caddy owns the /_gm Basic-Auth and Grafana routing; this mount serves
|
||||
// a non-caddy (local) setup. The per-IP admin limiter class guards it —
|
||||
// notably a Basic-Auth brute force (R3).
|
||||
// notably a Basic-Auth brute force.
|
||||
mux.Handle("/_gm/", s.limitAdmin(s.adminProxy))
|
||||
} else {
|
||||
// With the console disabled here, keep /_gm a 404 so the SPA catch-all below
|
||||
@@ -162,14 +162,14 @@ func (s *Server) HTTPHandler() http.Handler {
|
||||
// Mini App) — the single-origin model (docs/ARCHITECTURE.md §13). Both sit below
|
||||
// the h2c wrap so the Connect edge (a more specific prefix) keeps priority, and
|
||||
// each mount falls back to the app shell (index.html) for the hash router. The
|
||||
// public landing moved to its own static container behind the contour caddy
|
||||
// (R3), so the catch-all redirects a stray root hit to the app shell — which
|
||||
// public landing lives in its own static container behind the contour caddy,
|
||||
// so the catch-all redirects a stray root hit to the app shell — which
|
||||
// keeps a local no-caddy run usable.
|
||||
mux.Handle("/telegram/", webui.Handler("/telegram/", "index.html"))
|
||||
mux.Handle("/app/", webui.Handler("/app/", "index.html"))
|
||||
mux.Handle("/", http.RedirectHandler("/app/", http.StatusPermanentRedirect))
|
||||
// Every request body on the public listener is capped (the admin proxy POSTs
|
||||
// included); the h2c server carries explicit stream/idle sizing (R3).
|
||||
// included); the h2c server carries explicit stream/idle sizing.
|
||||
return h2c.NewHandler(maxBodyHandler(s.maxBodyBytes, mux), &http2.Server{
|
||||
MaxConcurrentStreams: h2cMaxConcurrentStreams,
|
||||
IdleTimeout: h2cIdleTimeout,
|
||||
@@ -264,7 +264,7 @@ func (s *Server) Subscribe(ctx context.Context, req *connect.Request[edgev1.Subs
|
||||
// Send an immediate heartbeat so the stream's first byte flushes through the proxy chain
|
||||
// right away and resets edge/client idle timers, instead of the connection sitting silent
|
||||
// until the first tick — which otherwise raced a ~15 s idle timeout and forced a reconnect
|
||||
// every interval (Stage 17).
|
||||
// every interval.
|
||||
if err := stream.Send(&edgev1.Event{Kind: heartbeatKind}); err != nil {
|
||||
return err
|
||||
}
|
||||
@@ -294,7 +294,7 @@ func (s *Server) Subscribe(ctx context.Context, req *connect.Request[edgev1.Subs
|
||||
// noteRateLimited accounts one limiter rejection: the aggregate counter, the
|
||||
// per-rejection Debug line and the periodic-report tracker. The operational
|
||||
// signal is the reporter's Warn summary; per-rejection logging stays at Debug so
|
||||
// a rejection flood cannot flood the log (R3).
|
||||
// a rejection flood cannot flood the log.
|
||||
func (s *Server) noteRateLimited(ctx context.Context, class, key, msgType string) {
|
||||
s.metrics.recordRateLimited(ctx, class)
|
||||
s.tracker.Add(class, key)
|
||||
@@ -315,7 +315,7 @@ func (s *Server) rejectRateLimited(ctx context.Context, class, key, msgType stri
|
||||
// of its Basic-Auth check (a credential brute force is exactly what it bounds).
|
||||
// It covers the gateway-fronted /_gm mount; in the deployed contour /_gm reaches
|
||||
// the backend through caddy, whose Basic-Auth has no limiter (stock caddy) — see
|
||||
// docs/ARCHITECTURE.md §12 (R3).
|
||||
// docs/ARCHITECTURE.md §12.
|
||||
func (s *Server) limitAdmin(next http.Handler) http.Handler {
|
||||
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
ip := peerIP(r.RemoteAddr, r.Header)
|
||||
@@ -340,8 +340,8 @@ func (s *Server) resolve(ctx context.Context, h http.Header) (string, error) {
|
||||
// An unknown or expired token (a backend 4xx) is the client's problem and
|
||||
// stays silent; anything else — a resolve timeout, a refused connection, a
|
||||
// backend 5xx — is an infra failure misread as "unauthenticated" by the
|
||||
// client, so surface the cause (the transient resolves seen under load in
|
||||
// the R2 stress run). The token itself is never logged.
|
||||
// client, so surface the cause (the transient resolves seen under load).
|
||||
// The token itself is never logged.
|
||||
var apiErr *backendclient.APIError
|
||||
if !errors.As(err, &apiErr) || apiErr.Status >= http.StatusInternalServerError {
|
||||
s.log.Warn("session resolve failed", zap.Error(err))
|
||||
|
||||
@@ -85,7 +85,7 @@ func TestExecuteAuthedRequiresSession(t *testing.T) {
|
||||
|
||||
// TestExecuteRateLimitedTracked verifies a limiter rejection returns
|
||||
// ResourceExhausted and lands in the rejection tracker under the public class,
|
||||
// keyed by the client IP (R3).
|
||||
// keyed by the client IP.
|
||||
func TestExecuteRateLimitedTracked(t *testing.T) {
|
||||
backendSrv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
_, _ = w.Write([]byte(`{"token":"tok","user_id":"u-1","is_guest":true,"display_name":"Guest"}`))
|
||||
@@ -135,7 +135,7 @@ func TestExecuteRateLimitedTracked(t *testing.T) {
|
||||
}
|
||||
|
||||
// TestAdminMountRateLimited verifies the /_gm mount is guarded by the per-IP
|
||||
// admin limiter class ahead of the proxy's Basic-Auth (R3).
|
||||
// admin limiter class ahead of the proxy's Basic-Auth.
|
||||
func TestAdminMountRateLimited(t *testing.T) {
|
||||
backendSrv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {}))
|
||||
defer backendSrv.Close()
|
||||
@@ -181,7 +181,7 @@ func TestAdminMountRateLimited(t *testing.T) {
|
||||
|
||||
// TestExecuteOversizedPayloadRejected verifies the request-body cap: an Execute
|
||||
// message above GATEWAY_MAX_BODY_BYTES is refused at the edge without reaching
|
||||
// the backend (R3).
|
||||
// the backend.
|
||||
func TestExecuteOversizedPayloadRejected(t *testing.T) {
|
||||
client, cleanup := newEdge(t, func(w http.ResponseWriter, r *http.Request) {
|
||||
t.Error("backend must not be called for an oversized payload")
|
||||
@@ -198,7 +198,7 @@ func TestExecuteOversizedPayloadRejected(t *testing.T) {
|
||||
}
|
||||
|
||||
// TestRootRedirectsToApp verifies the gateway no longer serves a landing at "/"
|
||||
// (it lives in the landing container since R3): a stray root hit is redirected
|
||||
// (it lives in the landing container): a stray root hit is redirected
|
||||
// to the app shell.
|
||||
func TestRootRedirectsToApp(t *testing.T) {
|
||||
front := httptest.NewServer(connectsrv.NewServer(connectsrv.Deps{}).HTTPHandler())
|
||||
|
||||
@@ -5,6 +5,7 @@ import (
|
||||
|
||||
"scrabble/gateway/internal/backendclient"
|
||||
fb "scrabble/pkg/fbs/scrabblefb"
|
||||
"scrabble/pkg/wire"
|
||||
)
|
||||
|
||||
// The encoders build the FlatBuffers response payloads from the backend's typed
|
||||
@@ -80,7 +81,7 @@ func encodeProfile(p backendclient.ProfileResp) []byte {
|
||||
return b.FinishedBytes()
|
||||
}
|
||||
|
||||
// encodeLinkResult builds a LinkResult payload (Stage 11). A switched-session token
|
||||
// encodeLinkResult builds a LinkResult payload. A switched-session token
|
||||
// (a guest initiator whose durable counterpart won) is carried as a nested Session
|
||||
// for the client to adopt; it is omitted otherwise. supportedLangs is the variant
|
||||
// gating set for that switched session — the link flows run on the web, so it is the
|
||||
@@ -138,50 +139,28 @@ func encodeMoveResult(r backendclient.MoveResultResp) []byte {
|
||||
}
|
||||
|
||||
// encodeState builds a StateView payload. The rack is a vector of alphabet indices and the
|
||||
// alphabet display table is included only when the backend returned it (Stage 13: the
|
||||
// alphabet display table is included only when the backend returned it (the
|
||||
// client requests it on a per-variant cache miss).
|
||||
func encodeState(s backendclient.StateResp) []byte {
|
||||
b := flatbuffers.NewBuilder(512)
|
||||
game := buildGameView(b, s.Game)
|
||||
rackBytes := make([]byte, len(s.Rack))
|
||||
for i, v := range s.Rack {
|
||||
rackBytes[i] = byte(v)
|
||||
}
|
||||
rack := b.CreateByteVector(rackBytes)
|
||||
hasAlphabet := len(s.Alphabet) > 0
|
||||
var alphabet flatbuffers.UOffsetT
|
||||
if hasAlphabet {
|
||||
alphabet = buildAlphabet(b, s.Alphabet)
|
||||
}
|
||||
fb.StateViewStart(b)
|
||||
fb.StateViewAddGame(b, game)
|
||||
fb.StateViewAddSeat(b, int32(s.Seat))
|
||||
fb.StateViewAddRack(b, rack)
|
||||
fb.StateViewAddBagLen(b, int32(s.BagLen))
|
||||
fb.StateViewAddHintsRemaining(b, int32(s.HintsRemaining))
|
||||
if hasAlphabet {
|
||||
fb.StateViewAddAlphabet(b, alphabet)
|
||||
}
|
||||
b.Finish(fb.StateViewEnd(b))
|
||||
b.Finish(wire.BuildStateView(b, toWireState(s)))
|
||||
return b.FinishedBytes()
|
||||
}
|
||||
|
||||
// buildAlphabet builds the AlphabetEntry vector for a StateView and returns its offset.
|
||||
func buildAlphabet(b *flatbuffers.Builder, entries []backendclient.AlphabetEntryJSON) flatbuffers.UOffsetT {
|
||||
offs := make([]flatbuffers.UOffsetT, len(entries))
|
||||
for i, e := range entries {
|
||||
letter := b.CreateString(e.Letter)
|
||||
fb.AlphabetEntryStart(b)
|
||||
fb.AlphabetEntryAddIndex(b, byte(e.Index))
|
||||
fb.AlphabetEntryAddLetter(b, letter)
|
||||
fb.AlphabetEntryAddValue(b, int32(e.Value))
|
||||
offs[i] = fb.AlphabetEntryEnd(b)
|
||||
// toWireState maps a StateResp to the shared wire.StateView.
|
||||
func toWireState(s backendclient.StateResp) wire.StateView {
|
||||
alphabet := make([]wire.AlphabetEntry, len(s.Alphabet))
|
||||
for i, e := range s.Alphabet {
|
||||
alphabet[i] = wire.AlphabetEntry{Index: e.Index, Letter: e.Letter, Value: e.Value}
|
||||
}
|
||||
fb.StateViewStartAlphabetVector(b, len(offs))
|
||||
for i := len(offs) - 1; i >= 0; i-- {
|
||||
b.PrependUOffsetT(offs[i])
|
||||
return wire.StateView{
|
||||
Game: toWireGame(s.Game),
|
||||
Seat: s.Seat,
|
||||
Rack: s.Rack,
|
||||
BagLen: s.BagLen,
|
||||
HintsRemaining: s.HintsRemaining,
|
||||
Alphabet: alphabet,
|
||||
}
|
||||
return b.EndVector(len(offs))
|
||||
}
|
||||
|
||||
// encodeMatch builds a MatchResult payload.
|
||||
@@ -328,80 +307,55 @@ func encodeChatList(r backendclient.ChatListResp) []byte {
|
||||
|
||||
// buildGameView builds a GameView table and returns its offset.
|
||||
func buildGameView(b *flatbuffers.Builder, g backendclient.GameResp) flatbuffers.UOffsetT {
|
||||
seatOffs := make([]flatbuffers.UOffsetT, len(g.Seats))
|
||||
return wire.BuildGameView(b, toWireGame(g))
|
||||
}
|
||||
|
||||
// toWireGame maps a GameResp to the shared wire.GameView.
|
||||
func toWireGame(g backendclient.GameResp) wire.GameView {
|
||||
seats := make([]wire.SeatView, len(g.Seats))
|
||||
for i, s := range g.Seats {
|
||||
aid := b.CreateString(s.AccountID)
|
||||
dname := b.CreateString(s.DisplayName)
|
||||
fb.SeatViewStart(b)
|
||||
fb.SeatViewAddSeat(b, int32(s.Seat))
|
||||
fb.SeatViewAddAccountId(b, aid)
|
||||
fb.SeatViewAddScore(b, int32(s.Score))
|
||||
fb.SeatViewAddHintsUsed(b, int32(s.HintsUsed))
|
||||
fb.SeatViewAddIsWinner(b, s.IsWinner)
|
||||
fb.SeatViewAddDisplayName(b, dname)
|
||||
seatOffs[i] = fb.SeatViewEnd(b)
|
||||
seats[i] = wire.SeatView{
|
||||
Seat: s.Seat,
|
||||
AccountID: s.AccountID,
|
||||
Score: s.Score,
|
||||
HintsUsed: s.HintsUsed,
|
||||
IsWinner: s.IsWinner,
|
||||
DisplayName: s.DisplayName,
|
||||
}
|
||||
}
|
||||
fb.GameViewStartSeatsVector(b, len(seatOffs))
|
||||
for i := len(seatOffs) - 1; i >= 0; i-- {
|
||||
b.PrependUOffsetT(seatOffs[i])
|
||||
return wire.GameView{
|
||||
ID: g.ID,
|
||||
Variant: g.Variant,
|
||||
DictVersion: g.DictVersion,
|
||||
Status: g.Status,
|
||||
Players: g.Players,
|
||||
ToMove: g.ToMove,
|
||||
TurnTimeoutSecs: g.TurnTimeoutSecs,
|
||||
MoveCount: g.MoveCount,
|
||||
EndReason: g.EndReason,
|
||||
Seats: seats,
|
||||
LastActivityUnix: g.LastActivityUnix,
|
||||
}
|
||||
seats := b.EndVector(len(seatOffs))
|
||||
|
||||
id := b.CreateString(g.ID)
|
||||
variant := b.CreateString(g.Variant)
|
||||
dictVer := b.CreateString(g.DictVersion)
|
||||
status := b.CreateString(g.Status)
|
||||
endReason := b.CreateString(g.EndReason)
|
||||
|
||||
fb.GameViewStart(b)
|
||||
fb.GameViewAddId(b, id)
|
||||
fb.GameViewAddVariant(b, variant)
|
||||
fb.GameViewAddDictVersion(b, dictVer)
|
||||
fb.GameViewAddStatus(b, status)
|
||||
fb.GameViewAddPlayers(b, int32(g.Players))
|
||||
fb.GameViewAddToMove(b, int32(g.ToMove))
|
||||
fb.GameViewAddTurnTimeoutSecs(b, int32(g.TurnTimeoutSecs))
|
||||
fb.GameViewAddMoveCount(b, int32(g.MoveCount))
|
||||
fb.GameViewAddEndReason(b, endReason)
|
||||
fb.GameViewAddSeats(b, seats)
|
||||
fb.GameViewAddLastActivityUnix(b, g.LastActivityUnix)
|
||||
return fb.GameViewEnd(b)
|
||||
}
|
||||
|
||||
// buildMoveRecord builds a MoveRecord table and returns its offset.
|
||||
func buildMoveRecord(b *flatbuffers.Builder, m backendclient.MoveRecordResp) flatbuffers.UOffsetT {
|
||||
tileOffs := make([]flatbuffers.UOffsetT, len(m.Tiles))
|
||||
tiles := make([]wire.TileRecord, len(m.Tiles))
|
||||
for i, t := range m.Tiles {
|
||||
letter := b.CreateString(t.Letter)
|
||||
fb.TileRecordStart(b)
|
||||
fb.TileRecordAddRow(b, int32(t.Row))
|
||||
fb.TileRecordAddCol(b, int32(t.Col))
|
||||
fb.TileRecordAddLetter(b, letter)
|
||||
fb.TileRecordAddBlank(b, t.Blank)
|
||||
tileOffs[i] = fb.TileRecordEnd(b)
|
||||
tiles[i] = wire.TileRecord{Row: t.Row, Col: t.Col, Letter: t.Letter, Blank: t.Blank}
|
||||
}
|
||||
fb.MoveRecordStartTilesVector(b, len(tileOffs))
|
||||
for i := len(tileOffs) - 1; i >= 0; i-- {
|
||||
b.PrependUOffsetT(tileOffs[i])
|
||||
}
|
||||
tiles := b.EndVector(len(tileOffs))
|
||||
|
||||
words := buildStringVector(b, m.Words, fb.MoveRecordStartWordsVector)
|
||||
|
||||
action := b.CreateString(m.Action)
|
||||
dir := b.CreateString(m.Dir)
|
||||
fb.MoveRecordStart(b)
|
||||
fb.MoveRecordAddPlayer(b, int32(m.Player))
|
||||
fb.MoveRecordAddAction(b, action)
|
||||
fb.MoveRecordAddDir(b, dir)
|
||||
fb.MoveRecordAddMainRow(b, int32(m.MainRow))
|
||||
fb.MoveRecordAddMainCol(b, int32(m.MainCol))
|
||||
fb.MoveRecordAddTiles(b, tiles)
|
||||
fb.MoveRecordAddWords(b, words)
|
||||
fb.MoveRecordAddCount(b, int32(m.Count))
|
||||
fb.MoveRecordAddScore(b, int32(m.Score))
|
||||
fb.MoveRecordAddTotal(b, int32(m.Total))
|
||||
return fb.MoveRecordEnd(b)
|
||||
return wire.BuildMoveRecord(b, wire.MoveRecord{
|
||||
Player: m.Player,
|
||||
Action: m.Action,
|
||||
Dir: m.Dir,
|
||||
MainRow: m.MainRow,
|
||||
MainCol: m.MainCol,
|
||||
Tiles: tiles,
|
||||
Words: m.Words,
|
||||
Count: m.Count,
|
||||
Score: m.Score,
|
||||
Total: m.Total,
|
||||
})
|
||||
}
|
||||
|
||||
// buildStringVector builds a vector of strings using the table-specific
|
||||
|
||||
@@ -5,19 +5,15 @@ import (
|
||||
|
||||
"scrabble/gateway/internal/backendclient"
|
||||
fb "scrabble/pkg/fbs/scrabblefb"
|
||||
"scrabble/pkg/wire"
|
||||
)
|
||||
|
||||
// Stage 8 encoders: friends, blocks, invitations, statistics and GCG. They follow
|
||||
// Social encoders: friends, blocks, invitations, statistics and GCG. They follow
|
||||
// encode.go's bottom-up rule (build every string/child vector before the table).
|
||||
|
||||
// buildAccountRef builds an AccountRef table and returns its offset.
|
||||
func buildAccountRef(b *flatbuffers.Builder, r backendclient.AccountRefResp) flatbuffers.UOffsetT {
|
||||
id := b.CreateString(r.AccountID)
|
||||
name := b.CreateString(r.DisplayName)
|
||||
fb.AccountRefStart(b)
|
||||
fb.AccountRefAddAccountId(b, id)
|
||||
fb.AccountRefAddDisplayName(b, name)
|
||||
return fb.AccountRefEnd(b)
|
||||
return wire.BuildAccountRef(b, wire.AccountRef{AccountID: r.AccountID, DisplayName: r.DisplayName})
|
||||
}
|
||||
|
||||
// buildAccountRefVector builds a [AccountRef] vector using the table-specific
|
||||
@@ -110,43 +106,28 @@ func encodeStats(r backendclient.StatsResp) []byte {
|
||||
|
||||
// buildInvitation builds an Invitation table and returns its offset.
|
||||
func buildInvitation(b *flatbuffers.Builder, inv backendclient.InvitationResp) flatbuffers.UOffsetT {
|
||||
inviteeOffs := make([]flatbuffers.UOffsetT, len(inv.Invitees))
|
||||
invitees := make([]wire.InvitationInvitee, len(inv.Invitees))
|
||||
for i, iv := range inv.Invitees {
|
||||
aid := b.CreateString(iv.AccountID)
|
||||
name := b.CreateString(iv.DisplayName)
|
||||
resp := b.CreateString(iv.Response)
|
||||
fb.InvitationInviteeStart(b)
|
||||
fb.InvitationInviteeAddAccountId(b, aid)
|
||||
fb.InvitationInviteeAddDisplayName(b, name)
|
||||
fb.InvitationInviteeAddSeat(b, int32(iv.Seat))
|
||||
fb.InvitationInviteeAddResponse(b, resp)
|
||||
inviteeOffs[i] = fb.InvitationInviteeEnd(b)
|
||||
invitees[i] = wire.InvitationInvitee{
|
||||
AccountID: iv.AccountID,
|
||||
DisplayName: iv.DisplayName,
|
||||
Seat: iv.Seat,
|
||||
Response: iv.Response,
|
||||
}
|
||||
}
|
||||
fb.InvitationStartInviteesVector(b, len(inviteeOffs))
|
||||
for i := len(inviteeOffs) - 1; i >= 0; i-- {
|
||||
b.PrependUOffsetT(inviteeOffs[i])
|
||||
}
|
||||
invitees := b.EndVector(len(inviteeOffs))
|
||||
|
||||
inviter := buildAccountRef(b, inv.Inviter)
|
||||
id := b.CreateString(inv.ID)
|
||||
variant := b.CreateString(inv.Variant)
|
||||
dropout := b.CreateString(inv.DropoutTiles)
|
||||
status := b.CreateString(inv.Status)
|
||||
gameID := b.CreateString(inv.GameID)
|
||||
fb.InvitationStart(b)
|
||||
fb.InvitationAddId(b, id)
|
||||
fb.InvitationAddInviter(b, inviter)
|
||||
fb.InvitationAddInvitees(b, invitees)
|
||||
fb.InvitationAddVariant(b, variant)
|
||||
fb.InvitationAddTurnTimeoutSecs(b, int32(inv.TurnTimeoutSecs))
|
||||
fb.InvitationAddHintsAllowed(b, inv.HintsAllowed)
|
||||
fb.InvitationAddHintsPerPlayer(b, int32(inv.HintsPerPlayer))
|
||||
fb.InvitationAddDropoutTiles(b, dropout)
|
||||
fb.InvitationAddStatus(b, status)
|
||||
fb.InvitationAddGameId(b, gameID)
|
||||
fb.InvitationAddExpiresAtUnix(b, inv.ExpiresAtUnix)
|
||||
return fb.InvitationEnd(b)
|
||||
return wire.BuildInvitation(b, wire.Invitation{
|
||||
ID: inv.ID,
|
||||
Inviter: wire.AccountRef{AccountID: inv.Inviter.AccountID, DisplayName: inv.Inviter.DisplayName},
|
||||
Invitees: invitees,
|
||||
Variant: inv.Variant,
|
||||
TurnTimeoutSecs: inv.TurnTimeoutSecs,
|
||||
HintsAllowed: inv.HintsAllowed,
|
||||
HintsPerPlayer: inv.HintsPerPlayer,
|
||||
DropoutTiles: inv.DropoutTiles,
|
||||
Status: inv.Status,
|
||||
GameID: inv.GameID,
|
||||
ExpiresAtUnix: inv.ExpiresAtUnix,
|
||||
})
|
||||
}
|
||||
|
||||
// encodeInvitation builds an Invitation payload.
|
||||
|
||||
@@ -2,7 +2,7 @@
|
||||
// maps to a handler that decodes the FlatBuffers request payload, calls the
|
||||
// backend over REST, and encodes the FlatBuffers response. The registry is the
|
||||
// authoritative message_type catalog; new operations are added here following the
|
||||
// same pattern (PLAN.md Stage 6 vertical slice).
|
||||
// same vertical-slice pattern.
|
||||
package transcode
|
||||
|
||||
import (
|
||||
@@ -69,7 +69,7 @@ type Registry struct {
|
||||
}
|
||||
|
||||
// TelegramValidator validates Telegram credentials via the connector side-service:
|
||||
// Mini App launch data (auth) and Login Widget data (linking, Stage 11).
|
||||
// Mini App launch data (auth) and Login Widget data (linking).
|
||||
// *connector.Client implements it; a nil value disables the telegram auth and
|
||||
// telegram-link paths.
|
||||
type TelegramValidator interface {
|
||||
@@ -115,8 +115,8 @@ func NewRegistry(backend *backendclient.Client, tg TelegramValidator, defaultLan
|
||||
r.ops[MsgDraftGet] = Op{Handler: getDraftHandler(backend), Auth: true}
|
||||
r.ops[MsgDraftSave] = Op{Handler: saveDraftHandler(backend), Auth: true}
|
||||
r.ops[MsgGameHide] = Op{Handler: hideGameHandler(backend), Auth: true}
|
||||
registerStage8(r, backend)
|
||||
registerStage11(r, backend, tg, defaultLanguages)
|
||||
registerSocialOps(r, backend)
|
||||
registerLinkOps(r, backend, tg, defaultLanguages)
|
||||
return r
|
||||
}
|
||||
|
||||
@@ -264,7 +264,7 @@ func chatPostHandler(backend *backendclient.Client) Handler {
|
||||
}
|
||||
}
|
||||
|
||||
// decodeTiles reads the index-addressed tiles to place from a SubmitPlayRequest (Stage 13).
|
||||
// decodeTiles reads the index-addressed tiles to place from a SubmitPlayRequest.
|
||||
func decodeTiles(in *fb.SubmitPlayRequest) []backendclient.PlayTileJSON {
|
||||
n := in.TilesLength()
|
||||
tiles := make([]backendclient.PlayTileJSON, 0, n)
|
||||
@@ -282,7 +282,7 @@ func decodeTiles(in *fb.SubmitPlayRequest) []backendclient.PlayTileJSON {
|
||||
return tiles
|
||||
}
|
||||
|
||||
// decodeEvalTiles reads the index-addressed tentative tiles from an EvalRequest (Stage 13).
|
||||
// decodeEvalTiles reads the index-addressed tentative tiles from an EvalRequest.
|
||||
func decodeEvalTiles(in *fb.EvalRequest) []backendclient.PlayTileJSON {
|
||||
n := in.TilesLength()
|
||||
tiles := make([]backendclient.PlayTileJSON, 0, n)
|
||||
@@ -301,7 +301,7 @@ func decodeEvalTiles(in *fb.EvalRequest) []backendclient.PlayTileJSON {
|
||||
}
|
||||
|
||||
// bytesToInts widens a FlatBuffers ubyte vector (an alphabet-index list) to []int for the
|
||||
// backend JSON edge (Stage 13: rack-exchange tiles and the word-check query).
|
||||
// backend JSON edge (rack-exchange tiles and the word-check query).
|
||||
func bytesToInts(bs []byte) []int {
|
||||
out := make([]int, len(bs))
|
||||
for i, b := range bs {
|
||||
@@ -429,7 +429,7 @@ func nudgeHandler(backend *backendclient.Client) Handler {
|
||||
}
|
||||
}
|
||||
|
||||
// getDraftHandler returns the player's saved composition (Stage 17). It reuses
|
||||
// getDraftHandler returns the player's saved composition. It reuses
|
||||
// GameActionRequest for the game id and wraps the backend's raw JSON in a DraftView.
|
||||
func getDraftHandler(backend *backendclient.Client) Handler {
|
||||
return func(ctx context.Context, req Request) ([]byte, error) {
|
||||
@@ -442,7 +442,7 @@ func getDraftHandler(backend *backendclient.Client) Handler {
|
||||
}
|
||||
}
|
||||
|
||||
// saveDraftHandler upserts the player's composition (Stage 17), forwarding the opaque JSON
|
||||
// saveDraftHandler upserts the player's composition, forwarding the opaque JSON
|
||||
// string verbatim. It echoes an empty DraftView as a well-formed acknowledgement.
|
||||
func saveDraftHandler(backend *backendclient.Client) Handler {
|
||||
return func(ctx context.Context, req Request) ([]byte, error) {
|
||||
@@ -454,7 +454,7 @@ func saveDraftHandler(backend *backendclient.Client) Handler {
|
||||
}
|
||||
}
|
||||
|
||||
// hideGameHandler hides a finished game from the caller's own list (Stage 17). It reuses
|
||||
// hideGameHandler hides a finished game from the caller's own list. It reuses
|
||||
// GameActionRequest for the game id and echoes an Ack.
|
||||
func hideGameHandler(backend *backendclient.Client) Handler {
|
||||
return func(ctx context.Context, req Request) ([]byte, error) {
|
||||
|
||||
@@ -15,7 +15,7 @@ import (
|
||||
|
||||
// TestGameStateIncludesAlphabet checks the include_alphabet flag reaches the backend and
|
||||
// the returned alphabet table plus the index rack (a blank is 255) are encoded into the
|
||||
// StateView (Stage 13).
|
||||
// StateView.
|
||||
func TestGameStateIncludesAlphabet(t *testing.T) {
|
||||
backend, cleanup := fakeBackend(t, func(w http.ResponseWriter, r *http.Request) {
|
||||
if got := r.URL.Query().Get("include_alphabet"); got != "true" {
|
||||
@@ -85,7 +85,7 @@ func TestGameStateOmitsAlphabetByDefault(t *testing.T) {
|
||||
}
|
||||
|
||||
// TestSubmitPlayForwardsIndexTiles checks PlayTile indices reach the backend as integer
|
||||
// letter fields in the JSON body, blank flag preserved (Stage 13).
|
||||
// letter fields in the JSON body, blank flag preserved.
|
||||
func TestSubmitPlayForwardsIndexTiles(t *testing.T) {
|
||||
var body struct {
|
||||
Dir string `json:"dir"`
|
||||
@@ -135,7 +135,7 @@ func TestSubmitPlayForwardsIndexTiles(t *testing.T) {
|
||||
}
|
||||
|
||||
// TestCheckWordForwardsIndices checks the word-check query rides as repeated ?idx= params
|
||||
// and the decoded concrete word echoes back (Stage 13).
|
||||
// and the decoded concrete word echoes back.
|
||||
func TestCheckWordForwardsIndices(t *testing.T) {
|
||||
backend, cleanup := fakeBackend(t, func(w http.ResponseWriter, r *http.Request) {
|
||||
if got := r.URL.Query()["idx"]; len(got) != 3 || got[0] != "2" || got[2] != "19" {
|
||||
@@ -167,7 +167,7 @@ func TestCheckWordForwardsIndices(t *testing.T) {
|
||||
}
|
||||
|
||||
// TestExchangeForwardsIndices checks rack-exchange indices (blank 255) reach the backend
|
||||
// body (Stage 13).
|
||||
// body.
|
||||
func TestExchangeForwardsIndices(t *testing.T) {
|
||||
var body struct {
|
||||
Tiles []int `json:"tiles"`
|
||||
|
||||
@@ -13,7 +13,7 @@ import (
|
||||
)
|
||||
|
||||
// TestDraftSaveForwardsRawJSON checks the save handler forwards the client's composition JSON
|
||||
// to the backend verbatim (the "no double-encode" contract, Stage 17) with the user header.
|
||||
// to the backend verbatim (the "no double-encode" contract) with the user header.
|
||||
func TestDraftSaveForwardsRawJSON(t *testing.T) {
|
||||
const body = `{"rack_order":"1,0","board_tiles":[{"row":7,"col":7,"letter":"Q","blank":false}]}`
|
||||
backend, cleanup := fakeBackend(t, func(w http.ResponseWriter, r *http.Request) {
|
||||
|
||||
@@ -7,7 +7,7 @@ import (
|
||||
fb "scrabble/pkg/fbs/scrabblefb"
|
||||
)
|
||||
|
||||
// Stage 11 account linking & merge message types. The email ops carry the costly-
|
||||
// Account linking & merge message types. The email ops carry the costly-
|
||||
// email rate flag; the telegram ops validate Login Widget data through the
|
||||
// connector (registered only when the connector is configured). All are
|
||||
// authenticated. The merge ops are the explicit irreversible step, gated in the UI
|
||||
@@ -20,11 +20,11 @@ const (
|
||||
MsgLinkTelegramMerge = "link.telegram.merge"
|
||||
)
|
||||
|
||||
// registerStage11 adds the linking & merge operations. The telegram ops need the
|
||||
// registerLinkOps adds the linking & merge operations. The telegram ops need the
|
||||
// connector's Login Widget validator, so they are registered only when tg is set.
|
||||
// supportedLangs is the variant gating set for a switched link session (the link
|
||||
// flows run on the web, so the gateway default set).
|
||||
func registerStage11(r *Registry, backend *backendclient.Client, tg TelegramValidator, supportedLangs []string) {
|
||||
func registerLinkOps(r *Registry, backend *backendclient.Client, tg TelegramValidator, supportedLangs []string) {
|
||||
r.ops[MsgLinkEmailRequest] = Op{Handler: linkEmailRequestHandler(backend), Auth: true, Email: true}
|
||||
r.ops[MsgLinkEmailConfirm] = Op{Handler: linkEmailConfirmHandler(backend, supportedLangs), Auth: true, Email: true}
|
||||
r.ops[MsgLinkEmailMerge] = Op{Handler: linkEmailMergeHandler(backend, supportedLangs), Auth: true, Email: true}
|
||||
|
||||
@@ -7,9 +7,9 @@ import (
|
||||
fb "scrabble/pkg/fbs/scrabblefb"
|
||||
)
|
||||
|
||||
// Stage 8 message types: friends (incl. the one-time code path), per-user blocks,
|
||||
// Message types: friends (incl. the one-time code path), per-user blocks,
|
||||
// friend-game invitations, profile editing + email binding, statistics and GCG
|
||||
// export. All are authenticated. Registered by registerStage8 from NewRegistry.
|
||||
// export. All are authenticated. Registered by registerSocialOps from NewRegistry.
|
||||
const (
|
||||
MsgFriendsList = "friends.list"
|
||||
MsgFriendsIncoming = "friends.incoming"
|
||||
@@ -33,9 +33,9 @@ const (
|
||||
MsgGameGCG = "game.gcg"
|
||||
)
|
||||
|
||||
// registerStage8 adds the Stage 8 social, account and history operations to the
|
||||
// registerSocialOps adds the social, account and history operations to the
|
||||
// registry (all authenticated; the email-bind ops carry the costly-email flag).
|
||||
func registerStage8(r *Registry, backend *backendclient.Client) {
|
||||
func registerSocialOps(r *Registry, backend *backendclient.Client) {
|
||||
r.ops[MsgFriendsList] = Op{Handler: friendsListHandler(backend), Auth: true}
|
||||
r.ops[MsgFriendsIncoming] = Op{Handler: friendsIncomingHandler(backend), Auth: true}
|
||||
r.ops[MsgFriendsOutgoing] = Op{Handler: friendsOutgoingHandler(backend), Auth: true}
|
||||
|
||||
@@ -151,7 +151,7 @@ func gameActionPayload(gameID string) []byte {
|
||||
}
|
||||
|
||||
// TestHideGameForwardsToBackend checks game.hide reuses GameActionRequest, POSTs to the
|
||||
// game's /hide endpoint with the caller's id, and echoes an Ack (Stage 17).
|
||||
// game's /hide endpoint with the caller's id, and echoes an Ack.
|
||||
func TestHideGameForwardsToBackend(t *testing.T) {
|
||||
var hit bool
|
||||
backend, cleanup := fakeBackend(t, func(w http.ResponseWriter, r *http.Request) {
|
||||
|
||||
@@ -3,13 +3,13 @@
|
||||
// The committed dist/ holds only a placeholder index.html so the gateway module
|
||||
// compiles with a plain `go build` (and in CI) without a UI build. The production
|
||||
// gateway image replaces dist/ with the real Vite build — minus landing.html, which
|
||||
// ships in the separate landing container since R3 — before compiling (see
|
||||
// ships in the separate landing container — before compiling (see
|
||||
// gateway/Dockerfile), so the binary ships the UI inside it. Because Vite is built
|
||||
// with a relative asset base, one build serves under any path: the game SPA is
|
||||
// mounted at /app/ (web) and /telegram/ (the Telegram Mini App) — the single-origin
|
||||
// model in docs/ARCHITECTURE.md §13.
|
||||
//
|
||||
// Caching (Stage 17): Vite emits hash-named files under assets/, so those are immutable and
|
||||
// Caching: Vite emits hash-named files under assets/, so those are immutable and
|
||||
// cached hard (a reload/relaunch is a cache hit, not a re-download); the HTML shells carry
|
||||
// no-cache so a new deploy is picked up immediately.
|
||||
package webui
|
||||
|
||||
+3
-3
@@ -1,6 +1,6 @@
|
||||
# loadtest — R2 stress harness
|
||||
# loadtest — stress harness
|
||||
|
||||
Reusable load harness for the pre-release stress pass (`PRERELEASE.md` R2/R7). It
|
||||
Reusable load harness for the pre-release stress pass. It
|
||||
seeds a large account population with pre-created sessions, drives virtual players
|
||||
through the **gateway edge protocol** in realistic games, hammers the rate limiter,
|
||||
and prints a trip-report summary. It stays in the repo for repeats.
|
||||
@@ -91,4 +91,4 @@ runs unconditionally.
|
||||
|
||||
The harness shares the host CPU with the contour, so the early-pass resource baseline
|
||||
is read with the harness's own container series in mind; a cleaner number on separate
|
||||
hardware is an R7 goal. The moderate ramp keeps the generator from being the bottleneck.
|
||||
hardware is future work. The moderate ramp keeps the generator from being the bottleneck.
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
// Command loadtest is the R2 reusable load harness. It seeds a large account
|
||||
// Command loadtest is the reusable load harness. It seeds a large account
|
||||
// population with pre-created sessions directly in the backend Postgres, then drives
|
||||
// virtual players through the gateway edge protocol (real games assembled via
|
||||
// invitations, legal moves generated locally by the embedded solver), and a
|
||||
|
||||
@@ -18,11 +18,11 @@ import (
|
||||
"scrabble/loadtest/internal/edge"
|
||||
)
|
||||
|
||||
// blankIndex is the rack/exchange sentinel for a blank tile on the wire (Stage 13).
|
||||
// blankIndex is the rack/exchange sentinel for a blank tile on the wire.
|
||||
const blankIndex = 255
|
||||
|
||||
// variantSpec maps an edge variant label to its ruleset constructor and committed
|
||||
// DAWG filename (the descriptive names kept by R1).
|
||||
// DAWG filename (using descriptive names).
|
||||
type variantSpec struct {
|
||||
ruleset func() *rules.Ruleset
|
||||
dawg string
|
||||
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user