From 26aa1545478c0b0aa696aa75b5d0cf34e951e188 Mon Sep 17 00:00:00 2001 From: Ilia Denisov Date: Tue, 9 Jun 2026 12:09:50 +0200 Subject: [PATCH] =?UTF-8?q?R1:=20schema=20&=20naming=20reset=20=E2=80=94?= =?UTF-8?q?=20squash=20migrations,=20rename=20variants?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Squash the 12 goose migrations into one 00001_baseline.sql (there is no prod data; verified schema-identical to the chain via a pg_dump diff + the green integration suite) and rename the game-variant labels english/russian_scrabble/erudit -> scrabble_en/scrabble_ru/erudit_ru across the backend, the FlatBuffers wire values and the UI. dawg filenames and the Go enum identifiers are unchanged; the i18n display keys are kept. Adds PRERELEASE.md (the R1-R7 pre-release tracker), linked from CLAUDE.md. Contour DB wipe and the scrabble-dictionary tidy are follow-ups. --- CLAUDE.md | 2 + PRERELEASE.md | 219 ++++++++++++ backend/README.md | 17 +- backend/internal/adminconsole/render_test.go | 10 +- .../templates/pages/dictionary.gohtml | 6 +- backend/internal/engine/alphabet_test.go | 12 +- backend/internal/engine/domain_test.go | 8 +- backend/internal/engine/engine.go | 6 +- backend/internal/engine/registry_test.go | 2 +- backend/internal/engine/reload_test.go | 14 +- backend/internal/game/gcg_test.go | 2 +- backend/internal/game/helpers_test.go | 2 +- backend/internal/game/metrics.go | 2 +- backend/internal/game/metrics_test.go | 8 +- backend/internal/inttest/analytics_test.go | 2 +- backend/internal/inttest/game_test.go | 2 +- backend/internal/inttest/robot_test.go | 2 +- backend/internal/postgres/migrate.go | 2 +- .../postgres/migrations/00001_baseline.sql | 323 ++++++++++++++++++ .../postgres/migrations/00001_init.sql | 60 ---- .../postgres/migrations/00002_game.sql | 133 -------- .../postgres/migrations/00003_social.sql | 136 -------- .../postgres/migrations/00004_robot.sql | 15 - .../postgres/migrations/00005_guest.sql | 14 - .../migrations/00006_friend_codes.sql | 45 --- .../00007_telegram_notifications.sql | 17 - .../00008_complaints_resolution.sql | 30 -- .../migrations/00009_account_merge.sql | 24 -- .../migrations/00010_service_language.sql | 21 -- .../postgres/migrations/00011_game_drafts.sql | 21 -- .../postgres/migrations/00012_game_hidden.sql | 18 - backend/internal/robot/names_test.go | 12 +- backend/internal/server/dto_test.go | 2 +- docs/ARCHITECTURE.md | 2 +- .../transcode/transcode_alphabet_test.go | 4 +- .../transcode/transcode_social_test.go | 6 +- gateway/internal/transcode/transcode_test.go | 10 +- ui/e2e/social.spec.ts | 2 +- ui/src/game/CheckScreen.svelte | 2 +- ui/src/game/Game.svelte | 2 +- ui/src/lib/alphabet.test.ts | 40 +-- ui/src/lib/codec.test.ts | 20 +- ui/src/lib/codec.ts | 4 +- ui/src/lib/lobbysort.test.ts | 2 +- ui/src/lib/mock/alphabet.ts | 6 +- ui/src/lib/mock/client.ts | 6 +- ui/src/lib/mock/data.ts | 10 +- ui/src/lib/model.ts | 2 +- ui/src/lib/premiums.test.ts | 20 +- ui/src/lib/premiums.ts | 2 +- ui/src/lib/result.test.ts | 2 +- ui/src/lib/variants.test.ts | 6 +- ui/src/lib/variants.ts | 20 +- ui/src/screens/Lobby.svelte | 6 +- 54 files changed, 688 insertions(+), 675 deletions(-) create mode 100644 PRERELEASE.md create mode 100644 backend/internal/postgres/migrations/00001_baseline.sql delete mode 100644 backend/internal/postgres/migrations/00001_init.sql delete mode 100644 backend/internal/postgres/migrations/00002_game.sql delete mode 100644 backend/internal/postgres/migrations/00003_social.sql delete mode 100644 backend/internal/postgres/migrations/00004_robot.sql delete mode 100644 backend/internal/postgres/migrations/00005_guest.sql delete mode 100644 backend/internal/postgres/migrations/00006_friend_codes.sql delete mode 100644 backend/internal/postgres/migrations/00007_telegram_notifications.sql delete mode 100644 backend/internal/postgres/migrations/00008_complaints_resolution.sql delete mode 100644 backend/internal/postgres/migrations/00009_account_merge.sql delete mode 100644 backend/internal/postgres/migrations/00010_service_language.sql delete mode 100644 backend/internal/postgres/migrations/00011_game_drafts.sql delete mode 100644 backend/internal/postgres/migrations/00012_game_hidden.sql diff --git a/CLAUDE.md b/CLAUDE.md index f798924..ede6bc0 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -8,6 +8,8 @@ conversation memory — is the source of continuity. Keep it that way. - [`PLAN.md`](PLAN.md) — staged plan + **stage tracker** + per-stage *open details to interview*. +- [`PRERELEASE.md`](PRERELEASE.md) — pre-release hardening tracker (phases R1–R7 + before Stage 18); same per-phase *interview + bake-back* discipline as `PLAN.md`. - [`docs/ARCHITECTURE.md`](docs/ARCHITECTURE.md) — architecture, transport, security, the decision record. Always describes current state. - [`docs/FUNCTIONAL.md`](docs/FUNCTIONAL.md) (+ [`_ru`](docs/FUNCTIONAL_ru.md) diff --git a/PRERELEASE.md b/PRERELEASE.md new file mode 100644 index 0000000..66338e2 --- /dev/null +++ b/PRERELEASE.md @@ -0,0 +1,219 @@ +# Pre-release plan — hardening before Stage 18 + +Living tracker for the pre-release hardening pass that runs **before Stage 18** (the +prod cutover). Same discipline as [`PLAN.md`](PLAN.md): one phase per session, +**interview the owner on the open details** at the start of each phase, bake every +decision back into `PLAN.md` / `docs/` / the affected `README`s / Go Doc comments in +the **same** PR, get CI green, then mark the phase done. Phases run as +`feature/* → development` PRs (the Stage 16 branch model); the owner approves+merges. + +**Why now:** the system is feature-complete through Stage 17 and the test contour is +green, but there is **no prod data yet** — schema, wire labels and the dictionary +layout can still change for free. These phases spend that one-time freedom and harden +the edge before prod. Each phase maps back to the owner's raw pre-release TODO list +(numbers in the tracker). + +## Phase tracker + +| # | Phase | Raw TODOs | Status | +|---|-------|-----------|--------| +| R1 | Schema & naming reset | 1 + 10 | **in review** | +| R2 | Stress harness + contour observability + early run | 9a | todo | +| R3 | Edge hardening | 2 + 8 + 3 | todo | +| R4 | Push enrichment + kill the last poll | 4 + 5 | todo | +| R5 | Bundle slimming | 6 | todo | +| R6 | Refactor + docs reconciliation + de-staging | 7 | todo | +| R7 | Final stress run + tuning | 9b | todo | +| → | Stage 18 — prod contour deploy | — | see [`PLAN.md`](PLAN.md) | + +## Key findings (these reshaped the raw list — read before starting a phase) + +- **R1 (TODO 1 + 10) is one cheap moment, now.** Squashing the 12 goose migrations is + safe precisely because there is no prod data and the contour DB is wiped. Folding the + new variant labels (`scrabble_ru`/`scrabble_en`/`erudit_ru`) into that single baseline + makes the rename need **no data migration and no back-compat mapping**. Today's labels + (`english`/`russian_scrabble`/`erudit`) are persisted in `games.variant`, + `game_invitations.variant`, in `pkg/fbs` and the UI — ~100 files, but a mechanical sweep + on a clean DB. +- **R4 (TODO 4 + 5): the app is already push-first.** Game state refreshes on + `your_turn`/`opponent_moved`, the lobby on `notify`, chat on `chat_message`. The **only** + genuine periodic server poll is `lobby.poll` (matchmaking, 2.5 s, + `ui/src/screens/NewGame.svelte`). What remains is killing that one poll **and** enriching + push events to carry payloads so the UI stops re-fetching after each signal. +- **R3 (TODO 2): identity forgery is already mitigated.** Identity is always derived from + the session (`Authorization: Bearer` → `X-User-ID`); the client cannot inject identity, + the backend re-validates resource ownership, Telegram initData is HMAC-checked. The real + gaps are a missing **request-body size limit** (cheap DoS) and **invisible rate-limit + rejections** (no log/metric/admin view — that is TODO 8). Static landing serving is **not** + covered by the gateway token bucket (it only guards `Execute`). +- **R6 (TODO 7) scale:** ~431 `Stage N` references across ~104 files (incl. the file name + `backend/internal/inttest/stage6_test.go`). Code is the source of truth; `docs/` describe + current state; `PLAN.md` keeps the decision history. + +## Locked decisions (owner interview) + +- **Stress test (TODO 9):** **early + final** runs. Driver = **edge protocol** (Connect/FB + through the gateway, moves generated by the solver) **plus a separate gateway-hammer** + saturation test. Pacing = **realistic (under limits) + saturation (ramp to the knee)**. + Resource metrics = **add cAdvisor + postgres_exporter to the contour** (today only + Go-runtime metrics exist). The harness stays in the repo for repeats. +- **Push (TODO 4 + 5):** **both** — kill `lobby.poll` (use the existing `match_found`, keep + poll as the ws-down fallback) **and** enrich push events with payloads. +- **Refactor (TODO 7):** **hygiene + structural changes by a reviewed list** — + behaviour-preserving, test-gated, contentious items surfaced to the owner before applying. +- **Landing (TODO 3):** **separate static container** behind the project caddy + (`/` → landing, `/app/` + `/telegram/` → gateway); drop `landing.html` from the gateway + `go:embed`. +- **Rate-abuse (TODO 8):** metric + Grafana + admin view **plus a conservative auto-flag** — + a *soft, reversible* "suspected high-rate" marker for operator review, tunable threshold, + **no auto-ban**. + +## Phases + +Each phase: read this tracker + the relevant `docs/`, **interview the owner on the open +details below**, implement within scope, then update the tracker + docs/code and get CI +green before marking it done. + +### R1 — Schema & naming reset *(TODO 1 + 10)* — first +Squash `backend/internal/postgres/migrations/00001..00012` into one `00001_baseline.sql` +(method: `pg_dump --schema-only` from a fully-migrated DB → wrap as the goose baseline → +prove a fresh migrate yields a schema identical to the 12-migration chain via the +integration suite → delete the old files; keep goose). Bake the new variant labels into the +baseline. Propagate `scrabble_ru`/`scrabble_en`/`erudit_ru` through the backend +(`engine.Variant`/`ParseVariant`, `registry.dictFiles`, the CHECK values), the wire +(`pkg/fbs` `variant:string`, regenerate FB) and the UI (`lib/model.ts` union, `variants.ts`, +fixtures, premium/alphabet keys, tests); i18n display keys stay display-only. Tidy +`../scrabble-dictionary` to a single source→dawg build point and align the dawg artifact +names to the new labels (crosses into `../scrabble-solver`'s committed fixtures — keep them +byte-identical). After merge, **wipe the contour DB** (drop the volume) so it re-provisions +on the next deploy. +- Critical files: `backend/internal/postgres/migrations/`, + `backend/internal/engine/{engine,registry}.go`, `pkg/fbs/scrabble.fbs`, + `ui/src/lib/{model,variants}.ts`, `../scrabble-dictionary/{Makefile,cmd/builddict,…}`. +- Open details to interview: the exact dawg filename scheme; whether the dict-repo tidy is + one PR or split; how to script the contour DB wipe in the deploy. + +### R2 — Stress harness + contour observability + early run *(TODO 9, part 1)* +Build the reusable load harness as a new `loadtest` module in `go.work` (reuses `pkg/fbs`, +`connect-go`, and `scrabble-solver` for legal-move generation): a seeder that inserts +**1000 guest + 10000 durable** accounts with pre-created sessions (token hashes) directly in +the DB and hands the plaintext tokens to the client; a driver that runs N virtual users, +each in 3–5 concurrent 2–4-player games, exercising submit-play / pass / exchange / nudge / +chat / check-word / draft-move / profile-save through the **edge protocol**, in +**realistic** (under rate limits) and **saturation** (ramp) modes; plus a separate +**gateway-hammer** that deliberately exceeds limits to verify the limiter holds and measure +its cost. Add **cAdvisor + postgres_exporter** to `deploy/docker-compose.yml` and a Grafana +resource dashboard. Run the **early pass** against the freshly-wiped contour; produce a +**trip report** (logic/concurrency bugs + a resource baseline) that feeds R3 and R6. +- Critical files: new `loadtest/`, `deploy/docker-compose.yml`, `deploy/observability/*`, + `docs/TESTING.md`. +- Open details: the scale ramp steps; the move-selection policy (a mid-ranked solver move + for realistic game progress); run duration; the pass/fail bar. + +### R3 — Edge hardening *(TODO 2 + 8 + 3)* +Add a **request-body size cap** at the gateway h2c mux / `Execute` (e.g. ~1 MB). Add +**rate-limit observability**: a `gateway_rate_limited_total{class}` counter + a structured +log per rejection; an **aggregate** Grafana panel (request rate + rejection rate — spikes +visible without per-user label cardinality, honouring the Stage 12/17 discipline); an +**admin-console view** of recently throttled users/IPs (in-memory ring buffer, single- +instance, reset-on-restart, like the `active_users` gauge). Add the **conservative +auto-flag**: when a user is *sustained*-throttled past a tunable threshold, set a soft, +reversible `account.flagged_high_rate_at` marker (baked into the R1 baseline) surfaced in the +admin user list/detail — **no auto-ban**; the operator clears it. Split the **landing** into +its own static container (`deploy/` + a Caddyfile route `/` → landing) and drop +`landing.html` from the gateway `go:embed`. +- Critical files: `gateway/internal/connectsrv/server.go`, `gateway/internal/ratelimit/`, + `gateway/internal/connectsrv/metrics.go`, `backend/internal/adminconsole/`, + `deploy/caddy/Caddyfile`, `deploy/docker-compose.yml`, `gateway/internal/webui/`. +- Open details: the auto-flag threshold/window + whether the marker is persisted vs + in-memory; the landing image base (caddy vs nginx). + +### R4 — Push enrichment + kill the last poll *(TODO 4 + 5)* +Replace `lobby.poll` with the existing `match_found` push (keep the poll as a ws-down +fallback). Enrich `your_turn`/`opponent_moved`/`notify` to carry the state payload so the UI +renders from the event without a follow-up `game.state` (removes the lobby↔game nav latency +the owner noticed). Wire-contract change: `pkg/fbs` event payloads → backend `notify` emit → +UI stream consumers (`ui/src/lib/app.svelte.ts`), with the per-game cache as the landing +spot; regenerate FB. +- Critical files: `pkg/fbs/scrabble.fbs`, `backend/internal/notify/events.go`, + `ui/src/lib/{app.svelte,transport}.ts`, `ui/src/screens/NewGame.svelte`. +- Open details: which events carry full vs delta payloads; the fallback-poll cadence when the + stream is down. + +### R5 — Bundle slimming *(TODO 6)* +Lazy-load secondary screens (Friends/Stats/Settings/About/Profile) and i18n catalogs by +language via dynamic imports; re-measure against the existing 100 KB-gzip budget +(`ui/scripts/bundle-size.mjs`, ~82 KB today). If the win is marginal, stop — acceptable per +the owner. +- Critical files: `ui/src/App.svelte`, `ui/vite.config.ts`, `ui/src/lib/i18n/`. + +### R6 — Refactor + docs reconciliation + de-staging *(TODO 7)* — near last +Behaviour-preserving only. Three separable, separately-committed passes: (a) mechanical +**de-staging** — remove `Stage N`/`TODO-N` references from code, comments and service +READMEs (rename `stage6_test.go`); (b) **docs↔code reconciliation** — reconcile +`docs/ARCHITECTURE.md` / `docs/FUNCTIONAL.md`(+`_ru`) against the code-as-truth, fixing drift +and Go Doc comments; (c) **structural changes by a reviewed list** — surface a list of +proposed optimizations / test-suite consolidations to the owner, apply only the approved, +behaviour-preserving, test-gated ones. The full suite + the final stress run (R7) are the +regression gate. Incorporates the early-run (R2) bug fixes not already shipped. +- Open details: the structural-changes list itself (owner-approved before applying); the test + consolidation targets. + +### R7 — Final stress run + tuning *(TODO 9, part 2)* — before Stage 18 +Re-run the R2 harness against the final, refactored system on a clean contour; analyse +resource consumption across **all** components (gateway, backend, Postgres, the +metrics/observability stack, docker log volume) and agree the tuning (pool sizes, rate +limits, cache TTLs, container limits, GOMAXPROCS, log levels). Apply the agreed tuning; record +the methodology + results in the repo. + +→ **Stage 18** (prod contour) then proceeds per [`PLAN.md`](PLAN.md). + +## Sequencing rationale + +`R1` first (cheapest now; everything builds on the final schema/naming and the stress test +must run against it). `R2` builds the harness and runs the **early** pass to surface bugs and +a resource baseline that feed `R3` and `R6`. `R3`/`R4`/`R5` harden and improve the system. +`R6` (de-stage + reconcile + structural) runs near the end so it sweeps settled code once and +benefits from all accumulated bug knowledge. `R7` validates the final system and tunes it. +Then Stage 18. + +## Regression-safety discipline (cross-cutting) + +- Every phase is a `feature/* → development` PR; CI (`unit` + `integration` + `ui` behind the + `CI / gate` check) must be green before the owner merges; watch the post-merge contour + deploy with `gitea-ci-watch.py`. +- `R6` structural changes are behaviour-preserving, test-gated, and split from the mechanical + sweeps; contentious items are owner-approved first. +- The two stress runs (`R2` early, `R7` final) are the system-level regression gate. + +## Verification (per phase) + +- `go build .//...`, `go vet`, `gofmt -l .` clean, `go test -count=1 .//...`; + UI: `pnpm check && pnpm test:unit && pnpm build`; the integration suite + (`-tags integration`) for DB/schema changes; `docker compose config` for deploy changes; + green CI on the PR + a healthy contour deploy. +- `R1`: prove the squashed baseline yields a schema identical to the 12-migration chain + (integration suite on a fresh DB) **before** deleting the old files. +- `R2`/`R7`: the harness runs end-to-end against the contour; the trip report lists concrete + defects + a resource profile from the Grafana cAdvisor/postgres_exporter panels. + +## Refinements logged during implementation + +- **R1** (interview + implementation): + - **Variant labels** `english`/`russian_scrabble`/`erudit` → **`scrabble_en`/`scrabble_ru`/`erudit_ru`** + across the backend (`engine.Variant.String`/`ParseVariant`; the `games`/`game_invitations` `variant` + CHECK in the baseline; GCG `#lexicon` and the `variant` metric attribute both flow from `String`), + the wire (`pkg/fbs` `variant` is a `string` field — values change with **no FlatBuffers regen**) and + the UI (`model.ts` union, `variants.ts` records, `codec`/`premiums`/mocks/tests, the admin + `dictionary.gohtml`). **Kept:** the Go enum identifiers (`VariantEnglish`…, internal) and the i18n + display keys (`new.english`/`new.russian`/`new.erudit`, display-only). `complaints.variant` stays + free-text (no CHECK, as before). + - **dawg filenames kept descriptive** (`en_sowpods`/`ru_scrabble`/`ru_erudit`) — only the registry's + `Variant` key carries the rename, so `registry.go`, the published `scrabble-solver` fixtures and the + dictionary release artifact are untouched (decouples the three repos). + - **Migrations squashed** 12 → one hand-written `00001_baseline.sql`. Verified by a + `pg_dump --schema-only` diff (the chain vs the baseline are **identical** but for the two intended + variant-CHECK values) plus the green integration suite. **No data migration** (no production data). + - **Follow-ups:** post-merge, drop the contour `postgres-data` volume on the runner host so the next + deploy provisions a clean DB on the new baseline; the **`scrabble-dictionary` tidy** ships as its own + PR in that repo (sources consolidated to a single build point; byte-identical artifact). diff --git a/backend/README.md b/backend/README.md index 8c32500..03453ce 100644 --- a/backend/README.md +++ b/backend/README.md @@ -71,18 +71,18 @@ gateway. Stage 9 adds the gateway-only `POST /api/v1/internal/push-target` (a us 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 -migration `00007` (`accounts.notifications_in_app_only`, default true). -Migration `00005` adds `accounts.is_guest`: an ephemeral guest is a durable row +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 **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 (migration `00008` adds `disposition`/`resolution_note`/ -`resolved_at`/`applied_in_version` + the `status` CHECK) feeding a dictionary-change +**complaint resolution** lifecycle (the `complaints` `disposition`/`resolution_note`/ +`resolved_at`/`applied_in_version` columns + the `status` CHECK) feeding a dictionary-change pipeline, dictionary **hot-reload** from `BACKEND_DICT_DIR//` (`engine.OpenWithVersions` / `Registry.LoadAvailable`), and operator **broadcasts** via a backend Telegram-connector client (`internal/connector`, `BACKEND_CONNECTOR_ADDR`) — each broadcast picks the delivering bot by an operator-chosen language. **Stage 15** adds -migration `00010` (`accounts.service_language`): the language tag of the bot a Telegram +`accounts.service_language`: 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. @@ -96,7 +96,7 @@ friends/blocks de-duplicated, the secondary kept as a `merged_into` tombstone (s shared finished game's foreign keys hold); a shared **active** game blocks the merge. 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. -Migration `00009` adds `paid_account`/`merged_into`/`merged_at`. This supersedes the +The `accounts.paid_account`/`merged_into`/`merged_at` columns back this. This supersedes the Stage 8 `email.bind.*` edge surface (the `RequestCode`/`ConfirmCode` primitives stay). ## Package layout @@ -176,7 +176,10 @@ warmed. ## Migrations & generated code Migrations are plain goose SQL under `internal/postgres/migrations` (sequential -`NNNNN_name.sql`), embedded and applied at startup. After changing the schema, +`NNNNN_name.sql`), embedded and applied at startup. The incremental history was +squashed into a single `00001_baseline.sql` before the first production deploy +(there was no production data); new schema changes append as `00002_*` onward. +After changing the schema, regenerate the committed go-jet code (needs Docker): ```sh diff --git a/backend/internal/adminconsole/render_test.go b/backend/internal/adminconsole/render_test.go index ab98a2c..4bdffad 100644 --- a/backend/internal/adminconsole/render_test.go +++ b/backend/internal/adminconsole/render_test.go @@ -20,15 +20,15 @@ func TestRendererRendersEveryPage(t *testing.T) { data any want string }{ - {"dashboard", DashboardView{Accounts: 3, Variants: []VariantVersions{{Variant: "english", Latest: "v1", Versions: []string{"v1"}}}}, "Dashboard"}, + {"dashboard", DashboardView{Accounts: 3, Variants: []VariantVersions{{Variant: "scrabble_en", Latest: "v1", Versions: []string{"v1"}}}}, "Dashboard"}, {"users", UsersView{Items: []UserRow{{ID: "a1", DisplayName: "Kaya"}}, Pager: NewPager(1, 50, 1)}, "Kaya"}, {"user_detail", UserDetailView{ID: "a1", DisplayName: "Kaya", HasStats: true, Stats: StatsRow{Wins: 2}, TelegramID: "123", ConnectorEnabled: true}, "Send Telegram message"}, - {"games", GamesView{Items: []GameRow{{ID: "g1", Variant: "english", Status: "active"}}, Status: "active", Pager: NewPager(1, 50, 1)}, "g1"}, - {"game_detail", GameDetailView{ID: "g1", Variant: "english", Seats: []SeatRow{{Seat: 0, DisplayName: "Kaya"}}}, "Seats"}, + {"games", GamesView{Items: []GameRow{{ID: "g1", Variant: "scrabble_en", Status: "active"}}, Status: "active", Pager: NewPager(1, 50, 1)}, "g1"}, + {"game_detail", GameDetailView{ID: "g1", Variant: "scrabble_en", Seats: []SeatRow{{Seat: 0, DisplayName: "Kaya"}}}, "Seats"}, {"complaints", ComplaintsView{Items: []ComplaintRow{{ID: "c1", Word: "qi", Status: "open"}}, Status: "open", Pager: NewPager(1, 50, 1)}, "qi"}, {"messages", MessagesView{Items: []MessageRow{{ID: "m1", SenderID: "a1", SenderName: "Kaya", Source: "telegram", Body: "good luck", GameID: "g1"}}, Pager: NewPager(1, 50, 1)}, "good luck"}, - {"complaint_detail", ComplaintDetailView{ID: "c1", Word: "qi", Variant: "english"}, "Resolve"}, - {"dictionary", DictionaryView{Variants: []VariantVersions{{Variant: "english", Latest: "v1", Versions: []string{"v1"}}}, Changes: []DictChangeRow{{Variant: "english", Word: "qi", Action: "add"}}}, "Hot-reload"}, + {"complaint_detail", ComplaintDetailView{ID: "c1", Word: "qi", Variant: "scrabble_en"}, "Resolve"}, + {"dictionary", DictionaryView{Variants: []VariantVersions{{Variant: "scrabble_en", Latest: "v1", Versions: []string{"v1"}}}, Changes: []DictChangeRow{{Variant: "scrabble_en", Word: "qi", Action: "add"}}}, "Hot-reload"}, {"broadcast", BroadcastView{ConnectorEnabled: true}, "Post to the game channel"}, {"message", MessageView{Heading: "Done", Body: "ok", Back: "/_gm/"}, "Done"}, } diff --git a/backend/internal/adminconsole/templates/pages/dictionary.gohtml b/backend/internal/adminconsole/templates/pages/dictionary.gohtml index aff7aa8..fb637f2 100644 --- a/backend/internal/adminconsole/templates/pages/dictionary.gohtml +++ b/backend/internal/adminconsole/templates/pages/dictionary.gohtml @@ -30,9 +30,9 @@
diff --git a/backend/internal/engine/alphabet_test.go b/backend/internal/engine/alphabet_test.go index ea16d6c..afb3368 100644 --- a/backend/internal/engine/alphabet_test.go +++ b/backend/internal/engine/alphabet_test.go @@ -12,7 +12,7 @@ import ( func TestAlphabetTableEnglish(t *testing.T) { tab, err := AlphabetTable(VariantEnglish) if err != nil { - t.Fatalf("AlphabetTable(english): %v", err) + t.Fatalf("AlphabetTable(scrabble_en): %v", err) } if len(tab) != 26 { t.Fatalf("size = %d, want 26", len(tab)) @@ -40,23 +40,23 @@ func TestAlphabetTableEnglish(t *testing.T) { func TestAlphabetTableRussianVariants(t *testing.T) { ru, err := AlphabetTable(VariantRussianScrabble) if err != nil { - t.Fatalf("AlphabetTable(russian_scrabble): %v", err) + t.Fatalf("AlphabetTable(scrabble_ru): %v", err) } er, err := AlphabetTable(VariantErudit) if err != nil { - t.Fatalf("AlphabetTable(erudit): %v", err) + t.Fatalf("AlphabetTable(erudit_ru): %v", err) } if len(ru) != 33 || len(er) != 33 { t.Fatalf("sizes = %d/%d, want 33/33", len(ru), len(er)) } if ru[0].Letter != "а" || ru[0].Value != 1 { - t.Errorf("russian entry 0 = %q/%d, want а/1", ru[0].Letter, ru[0].Value) + t.Errorf("scrabble_ru entry 0 = %q/%d, want а/1", ru[0].Letter, ru[0].Value) } if ru[6].Letter != "ё" || ru[6].Value != 3 { - t.Errorf("russian ё (entry 6) = %q/%d, want ё/3", ru[6].Letter, ru[6].Value) + t.Errorf("scrabble_ru ё (entry 6) = %q/%d, want ё/3", ru[6].Letter, ru[6].Value) } if er[6].Letter != "ё" || er[6].Value != 0 { - t.Errorf("erudit ё (entry 6) = %q/%d, want ё/0", er[6].Letter, er[6].Value) + t.Errorf("erudit_ru ё (entry 6) = %q/%d, want ё/0", er[6].Letter, er[6].Value) } if ru[32].Letter != "я" || er[32].Letter != "я" { t.Errorf("last letter = %q/%q, want я/я", ru[32].Letter, er[32].Letter) diff --git a/backend/internal/engine/domain_test.go b/backend/internal/engine/domain_test.go index 56571d1..974b895 100644 --- a/backend/internal/engine/domain_test.go +++ b/backend/internal/engine/domain_test.go @@ -168,10 +168,10 @@ func TestRegistryLookup(t *testing.T) { word string want bool }{ - {"english hit", VariantEnglish, "cat", true}, - {"english miss", VariantEnglish, "zzzz", false}, - {"russian hit", VariantRussianScrabble, "кот", true}, - {"erudit hit", VariantErudit, "кот", true}, + {"scrabble_en hit", VariantEnglish, "cat", true}, + {"scrabble_en miss", VariantEnglish, "zzzz", false}, + {"scrabble_ru hit", VariantRussianScrabble, "кот", true}, + {"erudit_ru hit", VariantErudit, "кот", true}, } for _, tc := range cases { t.Run(tc.name, func(t *testing.T) { diff --git a/backend/internal/engine/engine.go b/backend/internal/engine/engine.go index dba62a6..c28a396 100644 --- a/backend/internal/engine/engine.go +++ b/backend/internal/engine/engine.go @@ -38,11 +38,11 @@ const ( func (v Variant) String() string { switch v { case VariantEnglish: - return "english" + return "scrabble_en" case VariantRussianScrabble: - return "russian_scrabble" + return "scrabble_ru" case VariantErudit: - return "erudit" + return "erudit_ru" } return "unknown" } diff --git a/backend/internal/engine/registry_test.go b/backend/internal/engine/registry_test.go index 60d8c06..7716912 100644 --- a/backend/internal/engine/registry_test.go +++ b/backend/internal/engine/registry_test.go @@ -60,7 +60,7 @@ func TestRegistryValidatesKnownWords(t *testing.T) { func TestRegistryUnknownLookups(t *testing.T) { reg, err := Open(testDictDir(), testVersion, VariantEnglish) if err != nil { - t.Fatalf("open english-only registry: %v", err) + t.Fatalf("open scrabble_en-only registry: %v", err) } defer reg.Close() diff --git a/backend/internal/engine/reload_test.go b/backend/internal/engine/reload_test.go index bf55772..50a1c7c 100644 --- a/backend/internal/engine/reload_test.go +++ b/backend/internal/engine/reload_test.go @@ -45,13 +45,13 @@ func TestLoadAvailableLoadsPresentSkipsAbsent(t *testing.T) { t.Fatalf("load available: %v", err) } if len(loaded) != 1 || loaded[0] != VariantEnglish { - t.Fatalf("loaded = %v, want [english]", loaded) + t.Fatalf("loaded = %v, want [scrabble_en]", loaded) } if _, err := reg.Solver(VariantEnglish, "v2"); err != nil { - t.Errorf("english v2 solver: %v", err) + t.Errorf("scrabble_en v2 solver: %v", err) } if _, err := reg.Solver(VariantRussianScrabble, "v2"); !errors.Is(err, ErrUnknownVariant) { - t.Errorf("russian v2 should be absent: got %v", err) + t.Errorf("scrabble_ru v2 should be absent: got %v", err) } } @@ -77,17 +77,17 @@ func TestOpenWithVersionsScansSubdirs(t *testing.T) { } } if got := reg.Versions(VariantEnglish); len(got) != 2 { - t.Errorf("english versions = %v, want two", got) + t.Errorf("scrabble_en versions = %v, want two", got) } latest, _, err := reg.Latest(VariantEnglish) if err != nil { - t.Fatalf("latest english: %v", err) + t.Fatalf("latest scrabble_en: %v", err) } if latest != "v2" { - t.Errorf("latest english = %q, want v2", latest) + t.Errorf("latest scrabble_en = %q, want v2", latest) } if got := reg.Versions(VariantRussianScrabble); len(got) != 1 { - t.Errorf("russian versions = %v, want one (no v2 file)", got) + t.Errorf("scrabble_ru versions = %v, want one (no v2 file)", got) } } diff --git a/backend/internal/game/gcg_test.go b/backend/internal/game/gcg_test.go index f00cdd2..8cc732c 100644 --- a/backend/internal/game/gcg_test.go +++ b/backend/internal/game/gcg_test.go @@ -40,7 +40,7 @@ func TestWriteGCG(t *testing.T) { "#character-encoding UTF-8", "#player1 p1 Alice", "#player2 p2 Bob", - "#lexicon english/v1", + "#lexicon scrabble_en/v1", "#title game 00000000-0000-7000-8000-000000000001", ">p1: CATSER? 8H CAT +10 10", ">p2: AS?E I8 .s +2 2", diff --git a/backend/internal/game/helpers_test.go b/backend/internal/game/helpers_test.go index 80561d7..80509e0 100644 --- a/backend/internal/game/helpers_test.go +++ b/backend/internal/game/helpers_test.go @@ -94,7 +94,7 @@ func TestGameCacheEviction(t *testing.T) { cur := time.Unix(1_700_000_000, 0) cache := newGameCache(time.Hour, func() time.Time { return cur }) id := uuid.New() - cache.put(id, nil, "english") + cache.put(id, nil, "scrabble_en") if _, ok := cache.get(id); !ok { t.Fatal("game must be resident after put") } diff --git a/backend/internal/game/metrics.go b/backend/internal/game/metrics.go index b9596d3..7b7b511 100644 --- a/backend/internal/game/metrics.go +++ b/backend/internal/game/metrics.go @@ -16,7 +16,7 @@ import ( const meterName = "scrabble/backend/game" // gameMetrics holds the game domain's operational instruments. Every game-scoped -// measurement carries a "variant" attribute (english/russian/erudit). The +// measurement carries a "variant" attribute (scrabble_en/scrabble_ru/erudit_ru). The // instruments default to no-ops (see defaultGameMetrics), so recording is always // safe; SetMetrics installs the real meter during startup wiring. type gameMetrics struct { diff --git a/backend/internal/game/metrics_test.go b/backend/internal/game/metrics_test.go index cd1d5c5..f781f61 100644 --- a/backend/internal/game/metrics_test.go +++ b/backend/internal/game/metrics_test.go @@ -35,11 +35,11 @@ func TestGameMetrics(t *testing.T) { } started := counterByAttr(t, rm, "games_started_total", "variant") - if started["english"] != 2 || started["russian_scrabble"] != 1 { - t.Errorf("games_started_total = %v, want english:2 russian_scrabble:1", started) + if started["scrabble_en"] != 2 || started["scrabble_ru"] != 1 { + t.Errorf("games_started_total = %v, want scrabble_en:2 scrabble_ru:1", started) } - if abandoned := counterByAttr(t, rm, "games_abandoned_total", "variant"); abandoned["erudit"] != 1 { - t.Errorf("games_abandoned_total = %v, want erudit:1", abandoned) + if abandoned := counterByAttr(t, rm, "games_abandoned_total", "variant"); abandoned["erudit_ru"] != 1 { + t.Errorf("games_abandoned_total = %v, want erudit_ru:1", abandoned) } if c := histogramCount(t, rm, "game_replay_duration"); c != 1 { t.Errorf("game_replay_duration observations = %d, want 1", c) diff --git a/backend/internal/inttest/analytics_test.go b/backend/internal/inttest/analytics_test.go index 4697dd8..6a5fca6 100644 --- a/backend/internal/inttest/analytics_test.go +++ b/backend/internal/inttest/analytics_test.go @@ -33,7 +33,7 @@ func TestMoveDurationAnalytics(t *testing.T) { t0 := time.Date(2026, 1, 1, 12, 0, 0, 0, time.UTC) if _, err := testDB.ExecContext(ctx, `INSERT INTO backend.games (game_id, variant, dict_version, seed, players, turn_timeout_secs, created_at) - VALUES ($1,'english','v1',1,2,86400,$2)`, gid, t0); err != nil { + VALUES ($1,'scrabble_en','v1',1,2,86400,$2)`, gid, t0); err != nil { t.Fatalf("insert game: %v", err) } if _, err := testDB.ExecContext(ctx, diff --git a/backend/internal/inttest/game_test.go b/backend/internal/inttest/game_test.go index 9706f13..45bdbe5 100644 --- a/backend/internal/inttest/game_test.go +++ b/backend/internal/inttest/game_test.go @@ -477,7 +477,7 @@ func TestGameVariant(t *testing.T) { t.Fatalf("create: %v", err) } if v, err := svc.GameVariant(ctx, g.ID); err != nil || v != engine.VariantEnglish { - t.Fatalf("GameVariant = %v, %v; want english, nil", v, err) + t.Fatalf("GameVariant = %v, %v; want scrabble_en, nil", v, err) } if _, err := svc.GameVariant(ctx, uuid.New()); !errors.Is(err, game.ErrNotFound) { t.Errorf("GameVariant(unknown) = %v, want ErrNotFound", err) diff --git a/backend/internal/inttest/robot_test.go b/backend/internal/inttest/robot_test.go index c079b70..e7160ca 100644 --- a/backend/internal/inttest/robot_test.go +++ b/backend/internal/inttest/robot_test.go @@ -90,7 +90,7 @@ func TestRobotPoolProvisionsRobotAccounts(t *testing.T) { t.Errorf("picked account %s is not a robot identity", id) } if ru, err := r.Pick(engine.VariantRussianScrabble); err != nil || !isRobotAccount(t, ru) { - t.Errorf("russian pick = (%s, %v), want a robot account", ru, err) + t.Errorf("scrabble_ru pick = (%s, %v), want a robot account", ru, err) } acc, err := account.NewStore(testDB).GetByID(ctx, id) if err != nil { diff --git a/backend/internal/postgres/migrate.go b/backend/internal/postgres/migrate.go index 5bb640a..54b7659 100644 --- a/backend/internal/postgres/migrate.go +++ b/backend/internal/postgres/migrate.go @@ -37,7 +37,7 @@ var gooseMu sync.Mutex // ApplyMigrations runs every pending Up migration embedded in the backend // binary against db. The schema is created upfront so goose's bookkeeping table // (`goose_db_version`, scoped to the DSN search_path) has somewhere to land -// before the first migration runs; migration 00001_init.sql re-asserts the +// before the first migration runs; the baseline migration re-asserts the // schema with IF NOT EXISTS, so the double-create is idempotent. // // The apply is retried on transient connection errors. Both steps are diff --git a/backend/internal/postgres/migrations/00001_baseline.sql b/backend/internal/postgres/migrations/00001_baseline.sql new file mode 100644 index 0000000..7a63bef --- /dev/null +++ b/backend/internal/postgres/migrations/00001_baseline.sql @@ -0,0 +1,323 @@ +-- +goose Up +-- Baseline schema for the Scrabble backend service, consolidating the incremental +-- migration history into a single starting point (there is no production data yet, +-- so the squash carries no data migration). Every backend object lives in the +-- `backend` schema; it is created here so a fresh database can apply this migration, +-- and search_path is pinned for the rest of the file so unqualified CREATE +-- statements land in `backend`. Production also pins search_path via +-- BACKEND_POSTGRES_DSN. +CREATE SCHEMA IF NOT EXISTS backend; +SET search_path = backend, pg_catalog; + +-- Durable internal accounts. A guest is a durable row with is_guest set and no +-- identity, excluded from profile/friends/stats/history. The away window (one +-- interval per day, in the account's time_zone) is honoured by the turn-timeout +-- sweeper and the robot's sleep; hint_balance is the purchasable-hint wallet. +-- service_language records the language tag of the bot a Telegram user last +-- authenticated through (out-of-app push routing), distinct from preferred_language +-- (the interface language). merged_into/merged_at turn a merged-away secondary into +-- an audit tombstone; paid_account is a forward-looking one-time-payment marker. +CREATE TABLE accounts ( + account_id uuid PRIMARY KEY, + display_name text NOT NULL DEFAULT '', + preferred_language text NOT NULL DEFAULT 'en', + time_zone text NOT NULL DEFAULT 'UTC', + block_chat boolean NOT NULL DEFAULT false, + block_friend_requests boolean NOT NULL DEFAULT false, + created_at timestamptz NOT NULL DEFAULT now(), + updated_at timestamptz NOT NULL DEFAULT now(), + away_start time NOT NULL DEFAULT '00:00', + away_end time NOT NULL DEFAULT '07:00', + hint_balance integer NOT NULL DEFAULT 0, + is_guest boolean NOT NULL DEFAULT false, + notifications_in_app_only boolean NOT NULL DEFAULT true, + paid_account boolean NOT NULL DEFAULT false, + merged_into uuid REFERENCES accounts (account_id) ON DELETE SET NULL, + merged_at timestamptz, + service_language text CHECK (service_language IN ('en', 'ru')), + CONSTRAINT accounts_preferred_language_chk CHECK (preferred_language IN ('en', 'ru')), + CONSTRAINT accounts_hint_balance_chk CHECK (hint_balance >= 0) +); + +-- Platform and email identities attached to an account. external_id is the platform +-- user id (kind='telegram'), the email address (kind='email') or the robot name +-- (kind='robot'); confirmed flips true once an email confirm-code is verified. +CREATE TABLE identities ( + identity_id uuid PRIMARY KEY, + account_id uuid NOT NULL REFERENCES accounts (account_id) ON DELETE CASCADE, + kind text NOT NULL, + external_id text NOT NULL, + confirmed boolean NOT NULL DEFAULT false, + created_at timestamptz NOT NULL DEFAULT now(), + CONSTRAINT identities_kind_chk CHECK (kind IN ('telegram', 'email', 'robot')), + CONSTRAINT identities_kind_external_id_key UNIQUE (kind, external_id) +); +CREATE INDEX identities_account_idx ON identities (account_id); + +-- Opaque server sessions. token_hash is the hex-encoded SHA-256 of the bearer token; +-- the plaintext token is never stored. Sessions are revoke-only (no TTL): status +-- moves active -> revoked and revoked_at is stamped. +CREATE TABLE sessions ( + session_id uuid PRIMARY KEY, + account_id uuid NOT NULL REFERENCES accounts (account_id) ON DELETE CASCADE, + token_hash text NOT NULL, + status text NOT NULL DEFAULT 'active', + created_at timestamptz NOT NULL DEFAULT now(), + last_seen_at timestamptz, + revoked_at timestamptz, + CONSTRAINT sessions_status_chk CHECK (status IN ('active', 'revoked')), + CONSTRAINT sessions_token_hash_key UNIQUE (token_hash) +); +CREATE INDEX sessions_account_idx ON sessions (account_id); + +-- One match. The live position is event-sourced: this row carries the pinned +-- dictionary, the bag seed and the denormalised turn cursor the sweeper needs, while +-- game_moves is the append-only journal the in-memory engine.Game is replayed from +-- (docs/ARCHITECTURE.md §9). turn_timeout_secs is the per-game move clock; its allowed +-- values are enforced in Go. variant uses engine.Variant's stable labels. +CREATE TABLE games ( + game_id uuid PRIMARY KEY, + variant text NOT NULL, + dict_version text NOT NULL, + seed bigint NOT NULL, + status text NOT NULL DEFAULT 'active', + players smallint NOT NULL, + to_move smallint NOT NULL DEFAULT 0, + turn_started_at timestamptz NOT NULL DEFAULT now(), + turn_timeout_secs integer NOT NULL, + hints_allowed boolean NOT NULL DEFAULT true, + hints_per_player smallint NOT NULL DEFAULT 1, + move_count integer NOT NULL DEFAULT 0, + end_reason text, + created_at timestamptz NOT NULL DEFAULT now(), + updated_at timestamptz NOT NULL DEFAULT now(), + finished_at timestamptz, + dropout_tiles text NOT NULL DEFAULT 'remove', + CONSTRAINT games_variant_chk CHECK (variant IN ('scrabble_en', 'scrabble_ru', 'erudit_ru')), + CONSTRAINT games_status_chk CHECK (status IN ('active', 'finished')), + CONSTRAINT games_players_chk CHECK (players BETWEEN 2 AND 4), + CONSTRAINT games_to_move_chk CHECK (to_move >= 0 AND to_move < players), + CONSTRAINT games_turn_timeout_chk CHECK (turn_timeout_secs > 0), + CONSTRAINT games_hints_per_player_chk CHECK (hints_per_player >= 0), + CONSTRAINT games_end_reason_chk CHECK ( + end_reason IS NULL OR end_reason IN ('out_of_tiles', 'scoreless', 'resign', 'timeout') + ), + CONSTRAINT games_dropout_tiles_chk CHECK (dropout_tiles IN ('remove', 'return')) +); +-- The sweeper scans active games oldest-turn-first; a partial index keeps it off the +-- finished archive. +CREATE INDEX games_active_idx ON games (turn_started_at) WHERE status = 'active'; + +-- Seats in turn order (seat 0 moves first), one row per player. account_id is a +-- durable account. score is the running/final score, is_winner is stamped on finish +-- (false for every seat on a draw), hints_used counts the per-game allowance consumed +-- before the profile wallet. +CREATE TABLE game_players ( + game_id uuid NOT NULL REFERENCES games (game_id) ON DELETE CASCADE, + seat smallint NOT NULL, + account_id uuid NOT NULL REFERENCES accounts (account_id), + score integer NOT NULL DEFAULT 0, + hints_used smallint NOT NULL DEFAULT 0, + is_winner boolean NOT NULL DEFAULT false, + PRIMARY KEY (game_id, seat), + CONSTRAINT game_players_account_key UNIQUE (game_id, account_id) +); +CREATE INDEX game_players_account_idx ON game_players (account_id); + +-- The append-only, dictionary-independent move journal (docs/ARCHITECTURE.md §9.1). +-- seq orders the moves from 0. payload holds the decoded values needed to both replay +-- the game through the engine and emit GCG without a dictionary. score / running_total +-- / exchanged_count are lifted out for cheap history rendering. +CREATE TABLE game_moves ( + game_id uuid NOT NULL REFERENCES games (game_id) ON DELETE CASCADE, + seq integer NOT NULL, + seat smallint NOT NULL, + action text NOT NULL, + score integer NOT NULL DEFAULT 0, + running_total integer NOT NULL DEFAULT 0, + exchanged_count smallint NOT NULL DEFAULT 0, + payload text NOT NULL DEFAULT '{}', + created_at timestamptz NOT NULL DEFAULT now(), + PRIMARY KEY (game_id, seq), + CONSTRAINT game_moves_action_chk CHECK (action IN ('play', 'pass', 'exchange', 'resign', 'timeout')) +); + +-- Word-check complaints captured in the context of a game's pinned dictionary. The +-- admin review queue resolves them with a disposition that also feeds the offline +-- dictionary-rebuild pipeline: an accepted complaint records whether the word is to be +-- added or removed, and is marked applied once a rebuilt version is hot-reloaded. +CREATE TABLE complaints ( + complaint_id uuid PRIMARY KEY, + complainant_id uuid NOT NULL REFERENCES accounts (account_id), + game_id uuid NOT NULL REFERENCES games (game_id) ON DELETE CASCADE, + variant text NOT NULL, + dict_version text NOT NULL, + word text NOT NULL, + was_valid boolean NOT NULL, + note text NOT NULL DEFAULT '', + status text NOT NULL DEFAULT 'open', + created_at timestamptz NOT NULL DEFAULT now(), + disposition text NOT NULL DEFAULT '', + resolution_note text NOT NULL DEFAULT '', + resolved_at timestamptz, + applied_in_version text NOT NULL DEFAULT '', + CONSTRAINT complaints_status_chk CHECK (status IN ('open', 'resolved')), + CONSTRAINT complaints_disposition_chk + CHECK (disposition IN ('', 'reject', 'accept_add', 'accept_remove')) +); +CREATE INDEX complaints_status_idx ON complaints (status); + +-- Per-account lifetime statistics, recomputed incrementally on each game finish. +-- Guests have no durable stats. A draw increments draws only. max_word_points is the +-- best single move score (folding in every word the move formed and the all-tiles bonus). +CREATE TABLE account_stats ( + account_id uuid PRIMARY KEY REFERENCES accounts (account_id) ON DELETE CASCADE, + wins integer NOT NULL DEFAULT 0, + losses integer NOT NULL DEFAULT 0, + draws integer NOT NULL DEFAULT 0, + max_game_points integer NOT NULL DEFAULT 0, + max_word_points integer NOT NULL DEFAULT 0, + updated_at timestamptz NOT NULL DEFAULT now() +); + +-- The friend graph. A row is created by the requester as 'pending' and flipped to +-- 'accepted' by the addressee; an explicit 'declined' is remembered (anti-spam), +-- while cancelling or unfriending deletes the row. Friendship is symmetric. +CREATE TABLE friendships ( + requester_id uuid NOT NULL REFERENCES accounts (account_id) ON DELETE CASCADE, + addressee_id uuid NOT NULL REFERENCES accounts (account_id) ON DELETE CASCADE, + status text NOT NULL DEFAULT 'pending', + created_at timestamptz NOT NULL DEFAULT now(), + responded_at timestamptz, + PRIMARY KEY (requester_id, addressee_id), + CONSTRAINT friendships_status_chk CHECK (status IN ('pending', 'accepted', 'declined')), + CONSTRAINT friendships_distinct_chk CHECK (requester_id <> addressee_id) +); +CREATE INDEX friendships_addressee_idx ON friendships (addressee_id); + +-- Per-user blocks. The effect is applied mutually by the social checks (a block in +-- either direction suppresses chat visibility and prevents requests/invitations). +CREATE TABLE blocks ( + blocker_id uuid NOT NULL REFERENCES accounts (account_id) ON DELETE CASCADE, + blocked_id uuid NOT NULL REFERENCES accounts (account_id) ON DELETE CASCADE, + created_at timestamptz NOT NULL DEFAULT now(), + PRIMARY KEY (blocker_id, blocked_id), + CONSTRAINT blocks_distinct_chk CHECK (blocker_id <> blocked_id) +); +CREATE INDEX blocks_blocked_idx ON blocks (blocked_id); + +-- Per-game chat. A nudge ("it's your move") is a kind='nudge' row with an empty body, +-- so one journal carries both chatter and nudges. body is capped at 60 runes (enforced +-- again in Go, where the content filter also rejects links/emails/phone numbers). +-- sender_ip holds the gateway-forwarded client IP as a validated string. Chat is part +-- of the game archive and cascades away only with its game. +CREATE TABLE chat_messages ( + message_id uuid PRIMARY KEY, + game_id uuid NOT NULL REFERENCES games (game_id) ON DELETE CASCADE, + sender_id uuid NOT NULL REFERENCES accounts (account_id), + kind text NOT NULL DEFAULT 'message', + body text NOT NULL DEFAULT '', + sender_ip text, + created_at timestamptz NOT NULL DEFAULT now(), + CONSTRAINT chat_messages_kind_chk CHECK (kind IN ('message', 'nudge')), + CONSTRAINT chat_messages_body_len_chk CHECK (char_length(body) <= 60), + CONSTRAINT chat_messages_nudge_empty_chk CHECK (kind <> 'nudge' OR body = '') +); +CREATE INDEX chat_messages_game_idx ON chat_messages (game_id, created_at); +-- Backs the once-per-hour nudge rate-limit lookup (latest nudge by a sender). +CREATE INDEX chat_messages_nudge_idx ON chat_messages (game_id, sender_id, created_at) + WHERE kind = 'nudge'; + +-- Pending email confirm-codes. code_hash is the hex-encoded SHA-256 of the 6-digit code +-- (the plaintext is never stored); expires_at bounds the TTL and attempts caps brute +-- force. A row is consumed (consumed_at stamped) on success. +CREATE TABLE email_confirmations ( + confirmation_id uuid PRIMARY KEY, + account_id uuid NOT NULL REFERENCES accounts (account_id) ON DELETE CASCADE, + email text NOT NULL, + code_hash text NOT NULL, + expires_at timestamptz NOT NULL, + attempts smallint NOT NULL DEFAULT 0, + consumed_at timestamptz, + created_at timestamptz NOT NULL DEFAULT now(), + CONSTRAINT email_confirmations_attempts_chk CHECK (attempts >= 0) +); +CREATE INDEX email_confirmations_account_idx ON email_confirmations (account_id); + +-- A friend-game invitation. The inviter (seat 0) proposes the game settings to 1..3 +-- invitees; the game starts only when every invitee has accepted, and any decline +-- cancels the whole invitation. Lazily expired after expires_at (no background sweep). +-- game_id is set when the game is started. +CREATE TABLE game_invitations ( + invitation_id uuid PRIMARY KEY, + inviter_id uuid NOT NULL REFERENCES accounts (account_id) ON DELETE CASCADE, + variant text NOT NULL, + turn_timeout_secs integer NOT NULL, + hints_allowed boolean NOT NULL DEFAULT true, + hints_per_player smallint NOT NULL DEFAULT 1, + dropout_tiles text NOT NULL DEFAULT 'remove', + status text NOT NULL DEFAULT 'pending', + game_id uuid REFERENCES games (game_id) ON DELETE SET NULL, + expires_at timestamptz NOT NULL, + created_at timestamptz NOT NULL DEFAULT now(), + updated_at timestamptz NOT NULL DEFAULT now(), + CONSTRAINT game_invitations_variant_chk CHECK (variant IN ('scrabble_en', 'scrabble_ru', 'erudit_ru')), + CONSTRAINT game_invitations_dropout_tiles_chk CHECK (dropout_tiles IN ('remove', 'return')), + CONSTRAINT game_invitations_status_chk CHECK (status IN ('pending', 'declined', 'cancelled', 'expired', 'started')), + CONSTRAINT game_invitations_turn_timeout_chk CHECK (turn_timeout_secs > 0), + CONSTRAINT game_invitations_hints_per_player_chk CHECK (hints_per_player >= 0) +); +CREATE INDEX game_invitations_inviter_idx ON game_invitations (inviter_id); + +-- One row per invitee (the inviter is implicit seat 0). seat is the invitee's seat in +-- the started game (1..3, in invitation order). response tracks each invitee's decision. +CREATE TABLE game_invitation_invitees ( + invitation_id uuid NOT NULL REFERENCES game_invitations (invitation_id) ON DELETE CASCADE, + account_id uuid NOT NULL REFERENCES accounts (account_id) ON DELETE CASCADE, + seat smallint NOT NULL, + response text NOT NULL DEFAULT 'pending', + responded_at timestamptz, + PRIMARY KEY (invitation_id, account_id), + CONSTRAINT game_invitation_invitees_response_chk CHECK (response IN ('pending', 'accepted', 'declined')), + CONSTRAINT game_invitation_invitees_seat_chk CHECK (seat BETWEEN 1 AND 3) +); +CREATE INDEX game_invitation_invitees_account_idx ON game_invitation_invitees (account_id); + +-- One-time friend codes. The player who wants to be added issues a 6-digit code; +-- whoever enters it becomes their friend. Only the SHA-256 hash is stored; expires_at +-- bounds the 12h TTL and consumed_at marks single use. At most one live code per issuer. +CREATE TABLE friend_codes ( + code_id uuid PRIMARY KEY, + account_id uuid NOT NULL REFERENCES accounts (account_id) ON DELETE CASCADE, + code_hash text NOT NULL, + expires_at timestamptz NOT NULL, + consumed_at timestamptz, + created_at timestamptz NOT NULL DEFAULT now() +); +CREATE INDEX friend_codes_account_idx ON friend_codes (account_id); +CREATE INDEX friend_codes_code_hash_idx ON friend_codes (code_hash); + +-- Per-(game, account) draft the server persists across reloads and devices: the +-- player's preferred rack tile order and the tiles laid on the board but not yet +-- submitted. board_tiles is reset when an opponent's committed move overlaps a cell. +CREATE TABLE game_drafts ( + game_id uuid NOT NULL REFERENCES games (game_id) ON DELETE CASCADE, + account_id uuid NOT NULL REFERENCES accounts (account_id) ON DELETE CASCADE, + rack_order text NOT NULL DEFAULT '', + board_tiles jsonb NOT NULL DEFAULT '[]', + updated_at timestamptz NOT NULL DEFAULT now(), + PRIMARY KEY (game_id, account_id) +); + +-- Per-account hidden games. A row hides game_id from account_id's own "my games" list, +-- leaving it visible to the other players. Only finished games are hidden, and the +-- action is irreversible by design (there is no un-hide). +CREATE TABLE game_hidden ( + account_id uuid NOT NULL REFERENCES accounts (account_id) ON DELETE CASCADE, + game_id uuid NOT NULL REFERENCES games (game_id) ON DELETE CASCADE, + created_at timestamptz NOT NULL DEFAULT now(), + PRIMARY KEY (account_id, game_id) +); + +-- +goose Down +DROP SCHEMA IF EXISTS backend CASCADE; diff --git a/backend/internal/postgres/migrations/00001_init.sql b/backend/internal/postgres/migrations/00001_init.sql deleted file mode 100644 index 85c5f16..0000000 --- a/backend/internal/postgres/migrations/00001_init.sql +++ /dev/null @@ -1,60 +0,0 @@ --- +goose Up --- Initial schema for the Scrabble backend service: durable accounts, their --- platform/email identities, and opaque server sessions. --- --- Every backend table lives in the `backend` schema. The schema is created here --- so a fresh database can apply this migration, and search_path is pinned for --- the rest of the migration so the CREATE statements land in `backend` without --- qualifying every object. Production also pins search_path via --- BACKEND_POSTGRES_DSN. -CREATE SCHEMA IF NOT EXISTS backend; -SET search_path = backend, pg_catalog; - --- Durable internal accounts. Guests are session-only and never reach this table. -CREATE TABLE accounts ( - account_id uuid PRIMARY KEY, - display_name text NOT NULL DEFAULT '', - preferred_language text NOT NULL DEFAULT 'en', - time_zone text NOT NULL DEFAULT 'UTC', - block_chat boolean NOT NULL DEFAULT false, - block_friend_requests boolean NOT NULL DEFAULT false, - created_at timestamptz NOT NULL DEFAULT now(), - updated_at timestamptz NOT NULL DEFAULT now(), - CONSTRAINT accounts_preferred_language_chk CHECK (preferred_language IN ('en', 'ru')) -); - --- Platform and email identities attached to an account. external_id is the --- platform user id (kind='telegram') or the email address (kind='email'); --- confirmed flips true once an email confirm-code is verified (later stages). -CREATE TABLE identities ( - identity_id uuid PRIMARY KEY, - account_id uuid NOT NULL REFERENCES accounts (account_id) ON DELETE CASCADE, - kind text NOT NULL, - external_id text NOT NULL, - confirmed boolean NOT NULL DEFAULT false, - created_at timestamptz NOT NULL DEFAULT now(), - CONSTRAINT identities_kind_chk CHECK (kind IN ('telegram', 'email')), - CONSTRAINT identities_kind_external_id_key UNIQUE (kind, external_id) -); -CREATE INDEX identities_account_idx ON identities (account_id); - --- Opaque server sessions. token_hash is the hex-encoded SHA-256 of the bearer --- token; the plaintext token is never stored. Sessions are revoke-only (no --- TTL): status moves active -> revoked and revoked_at is stamped. -CREATE TABLE sessions ( - session_id uuid PRIMARY KEY, - account_id uuid NOT NULL REFERENCES accounts (account_id) ON DELETE CASCADE, - token_hash text NOT NULL, - status text NOT NULL DEFAULT 'active', - created_at timestamptz NOT NULL DEFAULT now(), - last_seen_at timestamptz, - revoked_at timestamptz, - CONSTRAINT sessions_status_chk CHECK (status IN ('active', 'revoked')), - CONSTRAINT sessions_token_hash_key UNIQUE (token_hash) -); -CREATE INDEX sessions_account_idx ON sessions (account_id); - --- +goose Down -DROP TABLE sessions; -DROP TABLE identities; -DROP TABLE accounts; diff --git a/backend/internal/postgres/migrations/00002_game.sql b/backend/internal/postgres/migrations/00002_game.sql deleted file mode 100644 index 42c41fb..0000000 --- a/backend/internal/postgres/migrations/00002_game.sql +++ /dev/null @@ -1,133 +0,0 @@ --- +goose Up --- Stage 3 game domain: the per-game lifecycle, the dictionary-independent move --- journal, word-check complaints and per-account statistics, plus two account --- columns the game domain needs. -SET search_path = backend, pg_catalog; - --- Extend accounts with the per-user away window (one interval per day, in the --- account's local time_zone) honoured by the turn-timeout sweeper -- and, in a --- later stage, by the robot's sleep -- and a hint wallet (purchasable hints; the --- purchase flow lands later, so the balance defaults to empty). Profile editing --- of the away window arrives with the profile surface (Stage 4). -ALTER TABLE accounts - ADD COLUMN away_start time NOT NULL DEFAULT '00:00', - ADD COLUMN away_end time NOT NULL DEFAULT '07:00', - ADD COLUMN hint_balance integer NOT NULL DEFAULT 0, - ADD CONSTRAINT accounts_hint_balance_chk CHECK (hint_balance >= 0); - --- One match. The live position is event-sourced: this row carries the pinned --- dictionary, the bag seed and the denormalised turn cursor the sweeper needs, --- while game_moves is the append-only journal the in-memory engine.Game is --- replayed from (docs/ARCHITECTURE.md §9). turn_timeout_secs is the per-game move --- clock; its allowed values are enforced in Go. variant uses engine.Variant's --- stable labels. -CREATE TABLE games ( - game_id uuid PRIMARY KEY, - variant text NOT NULL, - dict_version text NOT NULL, - seed bigint NOT NULL, - status text NOT NULL DEFAULT 'active', - players smallint NOT NULL, - to_move smallint NOT NULL DEFAULT 0, - turn_started_at timestamptz NOT NULL DEFAULT now(), - turn_timeout_secs integer NOT NULL, - hints_allowed boolean NOT NULL DEFAULT true, - hints_per_player smallint NOT NULL DEFAULT 1, - move_count integer NOT NULL DEFAULT 0, - end_reason text, - created_at timestamptz NOT NULL DEFAULT now(), - updated_at timestamptz NOT NULL DEFAULT now(), - finished_at timestamptz, - CONSTRAINT games_variant_chk CHECK (variant IN ('english', 'russian_scrabble', 'erudit')), - CONSTRAINT games_status_chk CHECK (status IN ('active', 'finished')), - CONSTRAINT games_players_chk CHECK (players BETWEEN 2 AND 4), - CONSTRAINT games_to_move_chk CHECK (to_move >= 0 AND to_move < players), - CONSTRAINT games_turn_timeout_chk CHECK (turn_timeout_secs > 0), - CONSTRAINT games_hints_per_player_chk CHECK (hints_per_player >= 0), - CONSTRAINT games_end_reason_chk CHECK ( - end_reason IS NULL OR end_reason IN ('out_of_tiles', 'scoreless', 'resign', 'timeout') - ) -); --- The sweeper scans active games oldest-turn-first; a partial index keeps it --- off the finished archive. -CREATE INDEX games_active_idx ON games (turn_started_at) WHERE status = 'active'; - --- Seats in turn order (seat 0 moves first), one row per player. account_id is a --- durable account (guests and robots are revisited when they arrive). score is --- the running/final score, is_winner is stamped on finish (false for every seat --- on a draw), hints_used counts the per-game allowance consumed before the --- profile wallet. -CREATE TABLE game_players ( - game_id uuid NOT NULL REFERENCES games (game_id) ON DELETE CASCADE, - seat smallint NOT NULL, - account_id uuid NOT NULL REFERENCES accounts (account_id), - score integer NOT NULL DEFAULT 0, - hints_used smallint NOT NULL DEFAULT 0, - is_winner boolean NOT NULL DEFAULT false, - PRIMARY KEY (game_id, seat), - CONSTRAINT game_players_account_key UNIQUE (game_id, account_id) -); -CREATE INDEX game_players_account_idx ON game_players (account_id); - --- The append-only, dictionary-independent move journal (docs/ARCHITECTURE.md --- §9.1). seq orders the moves from 0. payload holds the decoded values needed to --- both replay the game through the engine and emit GCG without a dictionary: the --- acting rack, and for a play its direction, placed tiles and formed words; for --- an exchange the swapped tiles. score / running_total / exchanged_count are --- lifted out for cheap history rendering. -CREATE TABLE game_moves ( - game_id uuid NOT NULL REFERENCES games (game_id) ON DELETE CASCADE, - seq integer NOT NULL, - seat smallint NOT NULL, - action text NOT NULL, - score integer NOT NULL DEFAULT 0, - running_total integer NOT NULL DEFAULT 0, - exchanged_count smallint NOT NULL DEFAULT 0, - payload text NOT NULL DEFAULT '{}', - created_at timestamptz NOT NULL DEFAULT now(), - PRIMARY KEY (game_id, seq), - CONSTRAINT game_moves_action_chk CHECK (action IN ('play', 'pass', 'exchange', 'resign', 'timeout')) -); - --- Word-check complaints captured in the context of a game's pinned dictionary. --- The admin review queue and the resolution lifecycle land in Stage 9, which --- owns the status state machine; Stage 3 only ever writes 'open'. -CREATE TABLE complaints ( - complaint_id uuid PRIMARY KEY, - complainant_id uuid NOT NULL REFERENCES accounts (account_id), - game_id uuid NOT NULL REFERENCES games (game_id) ON DELETE CASCADE, - variant text NOT NULL, - dict_version text NOT NULL, - word text NOT NULL, - was_valid boolean NOT NULL, - note text NOT NULL DEFAULT '', - status text NOT NULL DEFAULT 'open', - created_at timestamptz NOT NULL DEFAULT now() -); -CREATE INDEX complaints_status_idx ON complaints (status); - --- Per-account lifetime statistics, recomputed incrementally on each game finish. --- Guests have no durable account and never appear here. A draw increments draws --- only (neither wins nor losses). max_word_points is the best single move score --- (which already folds in every word the move formed and the all-tiles bonus). -CREATE TABLE account_stats ( - account_id uuid PRIMARY KEY REFERENCES accounts (account_id) ON DELETE CASCADE, - wins integer NOT NULL DEFAULT 0, - losses integer NOT NULL DEFAULT 0, - draws integer NOT NULL DEFAULT 0, - max_game_points integer NOT NULL DEFAULT 0, - max_word_points integer NOT NULL DEFAULT 0, - updated_at timestamptz NOT NULL DEFAULT now() -); - --- +goose Down -DROP TABLE account_stats; -DROP TABLE complaints; -DROP TABLE game_moves; -DROP TABLE game_players; -DROP TABLE games; -ALTER TABLE accounts - DROP CONSTRAINT accounts_hint_balance_chk, - DROP COLUMN hint_balance, - DROP COLUMN away_end, - DROP COLUMN away_start; diff --git a/backend/internal/postgres/migrations/00003_social.sql b/backend/internal/postgres/migrations/00003_social.sql deleted file mode 100644 index 1a7fa5e..0000000 --- a/backend/internal/postgres/migrations/00003_social.sql +++ /dev/null @@ -1,136 +0,0 @@ --- +goose Up --- Stage 4 lobby & social: the friend graph, per-user blocks, per-game chat (with --- nudge folded in as a message kind), email confirm-codes, and friend-game --- invitations -- plus the per-game drop-out tile disposition the multi-player --- engine needs. Matchmaking is an in-memory pool and persists nothing. -SET search_path = backend, pg_catalog; - --- The disposition of a dropped-out player's tiles in a game with three or more --- seats (docs/ARCHITECTURE.md §6), chosen at creation: 'remove' burns them --- (default), 'return' puts them back in the bag. Moot for a two-player game, --- which ends on the first drop-out. engine.DropoutTiles owns the stable labels. -ALTER TABLE games - ADD COLUMN dropout_tiles text NOT NULL DEFAULT 'remove', - ADD CONSTRAINT games_dropout_tiles_chk CHECK (dropout_tiles IN ('remove', 'return')); - --- The friend graph. A row is created by the requester as 'pending' and flipped to --- 'accepted' by the addressee; declining, cancelling or unfriending deletes the --- row. Friendship is symmetric: a player's friends are the accepted rows in --- either direction. A pair has at most one row (guarded in Go against either --- direction existing). -CREATE TABLE friendships ( - requester_id uuid NOT NULL REFERENCES accounts (account_id) ON DELETE CASCADE, - addressee_id uuid NOT NULL REFERENCES accounts (account_id) ON DELETE CASCADE, - status text NOT NULL DEFAULT 'pending', - created_at timestamptz NOT NULL DEFAULT now(), - responded_at timestamptz, - PRIMARY KEY (requester_id, addressee_id), - CONSTRAINT friendships_status_chk CHECK (status IN ('pending', 'accepted')), - CONSTRAINT friendships_distinct_chk CHECK (requester_id <> addressee_id) -); -CREATE INDEX friendships_addressee_idx ON friendships (addressee_id); - --- Per-user blocks. blocker_id has blocked blocked_id; the effect is applied --- mutually by the social checks (a block in either direction suppresses chat --- visibility and prevents requests/invitations between the pair). -CREATE TABLE blocks ( - blocker_id uuid NOT NULL REFERENCES accounts (account_id) ON DELETE CASCADE, - blocked_id uuid NOT NULL REFERENCES accounts (account_id) ON DELETE CASCADE, - created_at timestamptz NOT NULL DEFAULT now(), - PRIMARY KEY (blocker_id, blocked_id), - CONSTRAINT blocks_distinct_chk CHECK (blocker_id <> blocked_id) -); -CREATE INDEX blocks_blocked_idx ON blocks (blocked_id); - --- Per-game chat. A nudge ("it's your move") is a kind='nudge' row with an empty --- body, so one journal carries both chatter and nudges. body is capped at 60 --- runes (enforced again in Go on input, where the content filter also rejects --- links/emails/phone numbers). sender_ip holds the gateway-forwarded client IP as --- a validated string (text, not inet, to avoid go-jet literal friction; the --- gateway populates it in Stage 6). Chat is part of the game archive and is never --- purged; it cascades away only with its game. -CREATE TABLE chat_messages ( - message_id uuid PRIMARY KEY, - game_id uuid NOT NULL REFERENCES games (game_id) ON DELETE CASCADE, - sender_id uuid NOT NULL REFERENCES accounts (account_id), - kind text NOT NULL DEFAULT 'message', - body text NOT NULL DEFAULT '', - sender_ip text, - created_at timestamptz NOT NULL DEFAULT now(), - CONSTRAINT chat_messages_kind_chk CHECK (kind IN ('message', 'nudge')), - CONSTRAINT chat_messages_body_len_chk CHECK (char_length(body) <= 60), - CONSTRAINT chat_messages_nudge_empty_chk CHECK (kind <> 'nudge' OR body = '') -); -CREATE INDEX chat_messages_game_idx ON chat_messages (game_id, created_at); --- Backs the once-per-hour nudge rate-limit lookup (latest nudge by a sender). -CREATE INDEX chat_messages_nudge_idx ON chat_messages (game_id, sender_id, created_at) - WHERE kind = 'nudge'; - --- Pending email confirm-codes. code_hash is the hex-encoded SHA-256 of the --- 6-digit code (the plaintext is never stored, matching the session model); --- expires_at bounds the TTL and attempts caps brute force. A row is consumed --- (consumed_at stamped) on success. A re-request deletes the prior pending row --- for the same (account, lowercased email) and inserts a fresh one. -CREATE TABLE email_confirmations ( - confirmation_id uuid PRIMARY KEY, - account_id uuid NOT NULL REFERENCES accounts (account_id) ON DELETE CASCADE, - email text NOT NULL, - code_hash text NOT NULL, - expires_at timestamptz NOT NULL, - attempts smallint NOT NULL DEFAULT 0, - consumed_at timestamptz, - created_at timestamptz NOT NULL DEFAULT now(), - CONSTRAINT email_confirmations_attempts_chk CHECK (attempts >= 0) -); -CREATE INDEX email_confirmations_account_idx ON email_confirmations (account_id); - --- A friend-game invitation. The inviter (seat 0) proposes the game settings to --- 1..3 invitees; the game starts only when every invitee has accepted, and any --- decline cancels the whole invitation. Lazily expired after expires_at (no --- background sweep). game_id is set when the game is started. -CREATE TABLE game_invitations ( - invitation_id uuid PRIMARY KEY, - inviter_id uuid NOT NULL REFERENCES accounts (account_id) ON DELETE CASCADE, - variant text NOT NULL, - turn_timeout_secs integer NOT NULL, - hints_allowed boolean NOT NULL DEFAULT true, - hints_per_player smallint NOT NULL DEFAULT 1, - dropout_tiles text NOT NULL DEFAULT 'remove', - status text NOT NULL DEFAULT 'pending', - game_id uuid REFERENCES games (game_id) ON DELETE SET NULL, - expires_at timestamptz NOT NULL, - created_at timestamptz NOT NULL DEFAULT now(), - updated_at timestamptz NOT NULL DEFAULT now(), - CONSTRAINT game_invitations_variant_chk CHECK (variant IN ('english', 'russian_scrabble', 'erudit')), - CONSTRAINT game_invitations_dropout_tiles_chk CHECK (dropout_tiles IN ('remove', 'return')), - CONSTRAINT game_invitations_status_chk CHECK (status IN ('pending', 'declined', 'cancelled', 'expired', 'started')), - CONSTRAINT game_invitations_turn_timeout_chk CHECK (turn_timeout_secs > 0), - CONSTRAINT game_invitations_hints_per_player_chk CHECK (hints_per_player >= 0) -); -CREATE INDEX game_invitations_inviter_idx ON game_invitations (inviter_id); - --- One row per invitee (the inviter is implicit seat 0). seat is the invitee's --- seat in the started game (1..3, in invitation order). response tracks each --- invitee's pending/accepted/declined decision. -CREATE TABLE game_invitation_invitees ( - invitation_id uuid NOT NULL REFERENCES game_invitations (invitation_id) ON DELETE CASCADE, - account_id uuid NOT NULL REFERENCES accounts (account_id) ON DELETE CASCADE, - seat smallint NOT NULL, - response text NOT NULL DEFAULT 'pending', - responded_at timestamptz, - PRIMARY KEY (invitation_id, account_id), - CONSTRAINT game_invitation_invitees_response_chk CHECK (response IN ('pending', 'accepted', 'declined')), - CONSTRAINT game_invitation_invitees_seat_chk CHECK (seat BETWEEN 1 AND 3) -); -CREATE INDEX game_invitation_invitees_account_idx ON game_invitation_invitees (account_id); - --- +goose Down -DROP TABLE game_invitation_invitees; -DROP TABLE game_invitations; -DROP TABLE email_confirmations; -DROP TABLE chat_messages; -DROP TABLE blocks; -DROP TABLE friendships; -ALTER TABLE games - DROP CONSTRAINT games_dropout_tiles_chk, - DROP COLUMN dropout_tiles; diff --git a/backend/internal/postgres/migrations/00004_robot.sql b/backend/internal/postgres/migrations/00004_robot.sql deleted file mode 100644 index 8d33521..0000000 --- a/backend/internal/postgres/migrations/00004_robot.sql +++ /dev/null @@ -1,15 +0,0 @@ --- +goose Up --- Stage 5 robot opponent: admit a 'robot' identity kind so the robot pool can be --- provisioned as durable accounts (one identity row per named robot). This widens --- the identities kind CHECK only; no table or column changes, so the generated --- jet code is unaffected. -SET search_path = backend, pg_catalog; - -ALTER TABLE identities DROP CONSTRAINT identities_kind_chk; -ALTER TABLE identities ADD CONSTRAINT identities_kind_chk CHECK (kind IN ('telegram', 'email', 'robot')); - --- +goose Down -SET search_path = backend, pg_catalog; - -ALTER TABLE identities DROP CONSTRAINT identities_kind_chk; -ALTER TABLE identities ADD CONSTRAINT identities_kind_chk CHECK (kind IN ('telegram', 'email')); diff --git a/backend/internal/postgres/migrations/00005_guest.sql b/backend/internal/postgres/migrations/00005_guest.sql deleted file mode 100644 index f0f3207..0000000 --- a/backend/internal/postgres/migrations/00005_guest.sql +++ /dev/null @@ -1,14 +0,0 @@ --- +goose Up --- Stage 6 gateway edge: mark ephemeral guest accounts. A guest is a durable --- account row -- the sessions and game_players foreign keys both require one -- --- that carries no identity and no profile, friends, stats or history; is_guest --- gates that exclusion (statistics recompute skips guest seats). This adds a --- column, so the generated jet code is regenerated (cmd/jetgen). -SET search_path = backend, pg_catalog; - -ALTER TABLE accounts ADD COLUMN is_guest boolean NOT NULL DEFAULT false; - --- +goose Down -SET search_path = backend, pg_catalog; - -ALTER TABLE accounts DROP COLUMN is_guest; diff --git a/backend/internal/postgres/migrations/00006_friend_codes.sql b/backend/internal/postgres/migrations/00006_friend_codes.sql deleted file mode 100644 index f9b5f93..0000000 --- a/backend/internal/postgres/migrations/00006_friend_codes.sql +++ /dev/null @@ -1,45 +0,0 @@ --- +goose Up --- Stage 8 social UI: two changes to the friend graph. --- --- 1. A declined friend request is now remembered permanently (status 'declined') --- instead of deleting the row, so a recipient's explicit "no" blocks the same --- requester from re-sending (anti-spam). An ignored request still lazily --- expires (30 days, computed from created_at in Go) and can then be re-sent; a --- one-time friend code from the same person bypasses a prior decline. This --- widens friendships_status_chk; the Stage 4 "declining deletes the row" rule --- is superseded (cancelling by the requester still deletes). --- --- 2. friend_codes backs the code-redeem add-a-friend path: the player who wants to --- be added issues a one-time 6-digit numeric code; whoever enters it becomes --- their friend immediately. Only the hex-encoded SHA-256 of the code is stored --- (the plaintext is never persisted, matching the session and email-code --- models); expires_at bounds the 12h TTL and consumed_at marks single use. At --- most one live code exists per issuer (issuing a new one clears the prior --- unconsumed code, enforced in Go). This adds a table, so the generated jet code --- is regenerated (cmd/jetgen). -SET search_path = backend, pg_catalog; - -ALTER TABLE friendships - DROP CONSTRAINT friendships_status_chk, - ADD CONSTRAINT friendships_status_chk CHECK (status IN ('pending', 'accepted', 'declined')); - -CREATE TABLE friend_codes ( - code_id uuid PRIMARY KEY, - account_id uuid NOT NULL REFERENCES accounts (account_id) ON DELETE CASCADE, - code_hash text NOT NULL, - expires_at timestamptz NOT NULL, - consumed_at timestamptz, - created_at timestamptz NOT NULL DEFAULT now() -); --- Backs "clear the issuer's prior live code" on issue. -CREATE INDEX friend_codes_account_idx ON friend_codes (account_id); --- Backs the redeem lookup by code hash. -CREATE INDEX friend_codes_code_hash_idx ON friend_codes (code_hash); - --- +goose Down -SET search_path = backend, pg_catalog; - -DROP TABLE friend_codes; -ALTER TABLE friendships - DROP CONSTRAINT friendships_status_chk, - ADD CONSTRAINT friendships_status_chk CHECK (status IN ('pending', 'accepted')); diff --git a/backend/internal/postgres/migrations/00007_telegram_notifications.sql b/backend/internal/postgres/migrations/00007_telegram_notifications.sql deleted file mode 100644 index 72c0353..0000000 --- a/backend/internal/postgres/migrations/00007_telegram_notifications.sql +++ /dev/null @@ -1,17 +0,0 @@ --- +goose Up --- Stage 9 Telegram integration: a per-account toggle that confines notifications --- to the in-app live stream. When notifications_in_app_only is true (the default), --- the platform side-service (Telegram) sends no out-of-app push; turning it off --- opts into out-of-app push, which the gateway delivers only while the account has --- no live in-app stream, so the in-app and platform channels never duplicate. Adds --- a column, so the generated jet code is regenerated (cmd/jetgen). -SET search_path = backend, pg_catalog; - -ALTER TABLE accounts - ADD COLUMN notifications_in_app_only boolean NOT NULL DEFAULT true; - --- +goose Down -SET search_path = backend, pg_catalog; - -ALTER TABLE accounts - DROP COLUMN notifications_in_app_only; diff --git a/backend/internal/postgres/migrations/00008_complaints_resolution.sql b/backend/internal/postgres/migrations/00008_complaints_resolution.sql deleted file mode 100644 index 08d3eba..0000000 --- a/backend/internal/postgres/migrations/00008_complaints_resolution.sql +++ /dev/null @@ -1,30 +0,0 @@ --- +goose Up --- Stage 10 admin & dictionary ops: the word-check complaint resolution lifecycle. --- Stage 3 created complaints with a free-form status (only ever 'open'); the admin --- review queue (this stage) resolves them with a disposition that also feeds the --- offline dictionary-rebuild pipeline: an accepted complaint records whether the --- word should be added or removed, and is marked applied once a rebuilt dictionary --- version is hot-reloaded. No operator identity is recorded (the gateway gates the --- console behind Basic-Auth; the backend keeps no admin principal). Adds columns, so --- the generated jet code is regenerated (cmd/jetgen). -SET search_path = backend, pg_catalog; - -ALTER TABLE complaints - ADD COLUMN disposition text NOT NULL DEFAULT '', - ADD COLUMN resolution_note text NOT NULL DEFAULT '', - ADD COLUMN resolved_at timestamptz, - ADD COLUMN applied_in_version text NOT NULL DEFAULT '', - ADD CONSTRAINT complaints_status_chk CHECK (status IN ('open', 'resolved')), - ADD CONSTRAINT complaints_disposition_chk - CHECK (disposition IN ('', 'reject', 'accept_add', 'accept_remove')); - --- +goose Down -SET search_path = backend, pg_catalog; - -ALTER TABLE complaints - DROP CONSTRAINT complaints_disposition_chk, - DROP CONSTRAINT complaints_status_chk, - DROP COLUMN applied_in_version, - DROP COLUMN resolved_at, - DROP COLUMN resolution_note, - DROP COLUMN disposition; diff --git a/backend/internal/postgres/migrations/00009_account_merge.sql b/backend/internal/postgres/migrations/00009_account_merge.sql deleted file mode 100644 index b388982..0000000 --- a/backend/internal/postgres/migrations/00009_account_merge.sql +++ /dev/null @@ -1,24 +0,0 @@ --- +goose Up --- Stage 11 account linking & merge: retire a secondary account into a primary one. --- merged_into/merged_at turn the secondary into an audit tombstone (its identities --- are repointed and its non-shared rows transferred to the primary, but the row is --- kept so the no-cascade game_players/chat/complaints foreign keys of any shared --- finished game stay valid). merged_into self-references accounts and is SET NULL on --- delete so a future guest reaper (PLAN.md TODO-3) can still remove a primary. --- paid_account is a forward-looking lifetime one-time-payment marker (no purchase --- flow yet); the merge ORs it so a paid status is never lost. Adds columns, so the --- generated jet code is regenerated (cmd/jetgen). -SET search_path = backend, pg_catalog; - -ALTER TABLE accounts - ADD COLUMN paid_account boolean NOT NULL DEFAULT false, - ADD COLUMN merged_into uuid REFERENCES accounts (account_id) ON DELETE SET NULL, - ADD COLUMN merged_at timestamptz; - --- +goose Down -SET search_path = backend, pg_catalog; - -ALTER TABLE accounts - DROP COLUMN merged_at, - DROP COLUMN merged_into, - DROP COLUMN paid_account; diff --git a/backend/internal/postgres/migrations/00010_service_language.sql b/backend/internal/postgres/migrations/00010_service_language.sql deleted file mode 100644 index a230a6a..0000000 --- a/backend/internal/postgres/migrations/00010_service_language.sql +++ /dev/null @@ -1,21 +0,0 @@ --- +goose Up --- Stage 15 dual Telegram bots: service_language records the language tag of the bot --- a Telegram user last authenticated through (their last ValidateInitData). It is --- updated on every Telegram login — new and existing accounts — and routes the --- user's out-of-app push back through the right bot. It is distinct from --- preferred_language (the interface language) and from a game's variant language. --- Nullable: an account that has never signed in through a tagged bot (legacy, --- email-only or guest) has no value, and push routing falls back to --- preferred_language. Adds a column, so the generated jet code is regenerated --- (cmd/jetgen). -SET search_path = backend, pg_catalog; - -ALTER TABLE accounts - ADD COLUMN service_language text - CHECK (service_language IN ('en', 'ru')); - --- +goose Down -SET search_path = backend, pg_catalog; - -ALTER TABLE accounts - DROP COLUMN service_language; diff --git a/backend/internal/postgres/migrations/00011_game_drafts.sql b/backend/internal/postgres/migrations/00011_game_drafts.sql deleted file mode 100644 index b6bf77c..0000000 --- a/backend/internal/postgres/migrations/00011_game_drafts.sql +++ /dev/null @@ -1,21 +0,0 @@ --- +goose Up --- Stage 17: a per-(game, account) draft the server persists across reloads and devices — --- the player's preferred rack tile order (#4) and the tiles they have laid on the board but --- not yet submitted (#5/#6). board_tiles is reset when an opponent's committed move overlaps --- one of its cells (the draft can no longer be placed). Queried with raw SQL, so no --- generated jet code is needed. -SET search_path = backend, pg_catalog; - -CREATE TABLE game_drafts ( - game_id uuid NOT NULL REFERENCES games (game_id) ON DELETE CASCADE, - account_id uuid NOT NULL REFERENCES accounts (account_id) ON DELETE CASCADE, - rack_order text NOT NULL DEFAULT '', - board_tiles jsonb NOT NULL DEFAULT '[]', - updated_at timestamptz NOT NULL DEFAULT now(), - PRIMARY KEY (game_id, account_id) -); - --- +goose Down -SET search_path = backend, pg_catalog; - -DROP TABLE game_drafts; diff --git a/backend/internal/postgres/migrations/00012_game_hidden.sql b/backend/internal/postgres/migrations/00012_game_hidden.sql deleted file mode 100644 index 02452a4..0000000 --- a/backend/internal/postgres/migrations/00012_game_hidden.sql +++ /dev/null @@ -1,18 +0,0 @@ --- +goose Up --- Stage 17: per-account hidden games. A row hides game_id from account_id's own "my games" --- lobby list, leaving it visible to the other players. Only finished games are hidden, and the --- action is irreversible by design (there is no un-hide). Queried with raw SQL, so no generated --- jet code is needed. -SET search_path = backend, pg_catalog; - -CREATE TABLE game_hidden ( - account_id uuid NOT NULL REFERENCES accounts (account_id) ON DELETE CASCADE, - game_id uuid NOT NULL REFERENCES games (game_id) ON DELETE CASCADE, - created_at timestamptz NOT NULL DEFAULT now(), - PRIMARY KEY (account_id, game_id) -); - --- +goose Down -SET search_path = backend, pg_catalog; - -DROP TABLE game_hidden; diff --git a/backend/internal/robot/names_test.go b/backend/internal/robot/names_test.go index 7925b8c..81f2bb8 100644 --- a/backend/internal/robot/names_test.go +++ b/backend/internal/robot/names_test.go @@ -75,14 +75,14 @@ func TestPickVariantRouting(t *testing.T) { s := &Service{poolEN: []uuid.UUID{enID}, poolRU: []uuid.UUID{ruID}} for i := 0; i < 200; i++ { if got, err := s.Pick(engine.VariantEnglish); err != nil || got != enID { - t.Fatalf("english Pick = (%v, %v), want (%v, nil)", got, err, enID) + t.Fatalf("scrabble_en Pick = (%v, %v), want (%v, nil)", got, err, enID) } } var en, ru int for i := 0; i < 4000; i++ { got, err := s.Pick(engine.VariantRussianScrabble) if err != nil { - t.Fatalf("russian Pick: %v", err) + t.Fatalf("scrabble_ru Pick: %v", err) } switch got { case enID: @@ -92,14 +92,14 @@ func TestPickVariantRouting(t *testing.T) { } } if ru <= en { - t.Errorf("russian names should dominate a Russian game: ru=%d en=%d", ru, en) + t.Errorf("scrabble_ru names should dominate a Russian game: ru=%d en=%d", ru, en) } if en == 0 { t.Errorf("some Latin names should appear in Russian games (got 0 of 4000)") } // Эрудит routes like Russian Scrabble. if _, err := s.Pick(engine.VariantErudit); err != nil { - t.Errorf("erudit Pick: %v", err) + t.Errorf("erudit_ru Pick: %v", err) } } @@ -108,10 +108,10 @@ func TestPickVariantRouting(t *testing.T) { func TestPickFallback(t *testing.T) { id := uuid.New() if got, err := (&Service{poolEN: []uuid.UUID{id}}).Pick(engine.VariantRussianScrabble); err != nil || got != id { - t.Errorf("russian fallback to EN = (%v, %v), want (%v, nil)", got, err, id) + t.Errorf("scrabble_ru fallback to EN = (%v, %v), want (%v, nil)", got, err, id) } if got, err := (&Service{poolRU: []uuid.UUID{id}}).Pick(engine.VariantEnglish); err != nil || got != id { - t.Errorf("english fallback to RU = (%v, %v), want (%v, nil)", got, err, id) + t.Errorf("scrabble_en fallback to RU = (%v, %v), want (%v, nil)", got, err, id) } if _, err := (&Service{}).Pick(engine.VariantEnglish); !errors.Is(err, ErrNoRobotAvailable) { t.Errorf("empty pool err = %v, want ErrNoRobotAvailable", err) diff --git a/backend/internal/server/dto_test.go b/backend/internal/server/dto_test.go index a5d2c66..39d6281 100644 --- a/backend/internal/server/dto_test.go +++ b/backend/internal/server/dto_test.go @@ -91,7 +91,7 @@ func TestGameDTOFromGame(t *testing.T) { Seats: []game.Seat{{Seat: 0, AccountID: aid, Score: 12}}, } dto := gameDTOFromGame(g) - if dto.ID != gid.String() || dto.Variant != "english" || dto.ToMove != 1 || dto.TurnTimeoutSecs != 86400 { + if dto.ID != gid.String() || dto.Variant != "scrabble_en" || dto.ToMove != 1 || dto.TurnTimeoutSecs != 86400 { t.Fatalf("game dto mismatch: %+v", dto) } if len(dto.Seats) != 1 || dto.Seats[0].AccountID != aid.String() || dto.Seats[0].Score != 12 { diff --git a/docs/ARCHITECTURE.md b/docs/ARCHITECTURE.md index 9328acd..1a922cd 100644 --- a/docs/ARCHITECTURE.md +++ b/docs/ARCHITECTURE.md @@ -556,7 +556,7 @@ promotions) is future work and would deliver short markdown messages (text + lin `robot_games_finished_total`; an observable gauge `game_cache_active`; the gateway `edge_request_duration` (the UI-perceived roundtrip, by `message_type`/`result`); and Go runtime/heap metrics. Game-scoped metrics carry a `variant` attribute - (english/russian_scrabble/erudit). + (scrabble_en/scrabble_ru/erudit_ru). - Per-user move-time analytics (Stage 17) 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): diff --git a/gateway/internal/transcode/transcode_alphabet_test.go b/gateway/internal/transcode/transcode_alphabet_test.go index 61c73f2..3eca9a8 100644 --- a/gateway/internal/transcode/transcode_alphabet_test.go +++ b/gateway/internal/transcode/transcode_alphabet_test.go @@ -21,7 +21,7 @@ func TestGameStateIncludesAlphabet(t *testing.T) { if got := r.URL.Query().Get("include_alphabet"); got != "true" { t.Errorf("include_alphabet query = %q, want true", got) } - _, _ = w.Write([]byte(`{"game":{"id":"g-1","variant":"english","status":"active","players":2,"to_move":0,"seats":[]},"seat":0,"rack":[0,255],"bag_len":50,"hints_remaining":0,"alphabet":[{"index":0,"letter":"a","value":1},{"index":1,"letter":"b","value":3}]}`)) + _, _ = w.Write([]byte(`{"game":{"id":"g-1","variant":"scrabble_en","status":"active","players":2,"to_move":0,"seats":[]},"seat":0,"rack":[0,255],"bag_len":50,"hints_remaining":0,"alphabet":[{"index":0,"letter":"a","value":1},{"index":1,"letter":"b","value":3}]}`)) }) defer cleanup() @@ -60,7 +60,7 @@ func TestGameStateOmitsAlphabetByDefault(t *testing.T) { if r.URL.Query().Get("include_alphabet") == "true" { t.Error("include_alphabet should be unset") } - _, _ = w.Write([]byte(`{"game":{"id":"g-1","variant":"english","status":"active","players":2,"to_move":0,"seats":[]},"seat":0,"rack":[2,0,19],"bag_len":50,"hints_remaining":0}`)) + _, _ = w.Write([]byte(`{"game":{"id":"g-1","variant":"scrabble_en","status":"active","players":2,"to_move":0,"seats":[]},"seat":0,"rack":[2,0,19],"bag_len":50,"hints_remaining":0}`)) }) defer cleanup() diff --git a/gateway/internal/transcode/transcode_social_test.go b/gateway/internal/transcode/transcode_social_test.go index c5e01fb..a31e9ba 100644 --- a/gateway/internal/transcode/transcode_social_test.go +++ b/gateway/internal/transcode/transcode_social_test.go @@ -153,7 +153,7 @@ func TestInvitationCreateRoundTrip(t *testing.T) { if r.URL.Path != "/api/v1/user/invitations" { t.Errorf("unexpected path %q", r.URL.Path) } - _, _ = w.Write([]byte(`{"id":"i-1","inviter":{"account_id":"u-1","display_name":"Me"},"invitees":[{"account_id":"inv-1","display_name":"Friend","seat":1,"response":"pending"}],"variant":"english","turn_timeout_secs":86400,"hints_allowed":true,"hints_per_player":1,"dropout_tiles":"remove","status":"pending","expires_at_unix":42}`)) + _, _ = w.Write([]byte(`{"id":"i-1","inviter":{"account_id":"u-1","display_name":"Me"},"invitees":[{"account_id":"inv-1","display_name":"Friend","seat":1,"response":"pending"}],"variant":"scrabble_en","turn_timeout_secs":86400,"hints_allowed":true,"hints_per_player":1,"dropout_tiles":"remove","status":"pending","expires_at_unix":42}`)) }) defer cleanup() @@ -165,7 +165,7 @@ func TestInvitationCreateRoundTrip(t *testing.T) { fb.CreateInvitationRequestStartInviteeIdsVector(b, 1) b.PrependUOffsetT(inviteeID) ids := b.EndVector(1) - variant := b.CreateString("english") + variant := b.CreateString("scrabble_en") dropout := b.CreateString("remove") fb.CreateInvitationRequestStart(b) fb.CreateInvitationRequestAddInviteeIds(b, ids) @@ -181,7 +181,7 @@ func TestInvitationCreateRoundTrip(t *testing.T) { t.Fatalf("handler: %v", err) } inv := fb.GetRootAsInvitation(payload, 0) - if string(inv.Id()) != "i-1" || inv.InviteesLength() != 1 || string(inv.Variant()) != "english" { + if string(inv.Id()) != "i-1" || inv.InviteesLength() != 1 || string(inv.Variant()) != "scrabble_en" { t.Fatalf("invitation decoded wrong: id=%q invitees=%d variant=%q", inv.Id(), inv.InviteesLength(), inv.Variant()) } if iv := inv.Inviter(nil); iv == nil || string(iv.DisplayName()) != "Me" { diff --git a/gateway/internal/transcode/transcode_test.go b/gateway/internal/transcode/transcode_test.go index eada209..aeb0d9b 100644 --- a/gateway/internal/transcode/transcode_test.go +++ b/gateway/internal/transcode/transcode_test.go @@ -59,7 +59,7 @@ func TestGameStateRoundTripForwardsUserID(t *testing.T) { if r.URL.Path != "/api/v1/user/games/g-1/state" { t.Errorf("unexpected path %q", r.URL.Path) } - _, _ = w.Write([]byte(`{"game":{"id":"g-1","variant":"english","status":"active","players":2,"to_move":1,"seats":[{"seat":0,"account_id":"u-7","score":5}]},"seat":0,"rack":[0,1],"bag_len":80,"hints_remaining":1}`)) + _, _ = w.Write([]byte(`{"game":{"id":"g-1","variant":"scrabble_en","status":"active","players":2,"to_move":1,"seats":[{"seat":0,"account_id":"u-7","score":5}]},"seat":0,"rack":[0,1],"bag_len":80,"hints_remaining":1}`)) }) defer cleanup() @@ -81,14 +81,14 @@ func TestGameStateRoundTripForwardsUserID(t *testing.T) { t.Fatalf("state decoded wrong: bag=%d rack=%d hints=%d", st.BagLen(), st.RackLength(), st.HintsRemaining()) } game := st.Game(nil) - if game == nil || string(game.Id()) != "g-1" || string(game.Variant()) != "english" || game.ToMove() != 1 { + if game == nil || string(game.Id()) != "g-1" || string(game.Variant()) != "scrabble_en" || game.ToMove() != 1 { t.Fatalf("nested game decoded wrong: %+v", game) } } func TestEnqueueRoundTripEncodesMatch(t *testing.T) { backend, cleanup := fakeBackend(t, func(w http.ResponseWriter, r *http.Request) { - _, _ = w.Write([]byte(`{"matched":true,"game":{"id":"g-9","variant":"english","status":"active","players":2,"to_move":0,"seats":[]}}`)) + _, _ = w.Write([]byte(`{"matched":true,"game":{"id":"g-9","variant":"scrabble_en","status":"active","players":2,"to_move":0,"seats":[]}}`)) }) defer cleanup() @@ -96,7 +96,7 @@ func TestEnqueueRoundTripEncodesMatch(t *testing.T) { op, _ := reg.Lookup(transcode.MsgLobbyEnqueue) b := flatbuffers.NewBuilder(32) - v := b.CreateString("english") + v := b.CreateString("scrabble_en") fb.EnqueueRequestStart(b) fb.EnqueueRequestAddVariant(b, v) b.Finish(fb.EnqueueRequestEnd(b)) @@ -191,7 +191,7 @@ func TestGamesListRoundTripDecodesSeatNames(t *testing.T) { if r.URL.Path != "/api/v1/user/games" { t.Errorf("unexpected path %q", r.URL.Path) } - _, _ = w.Write([]byte(`{"games":[{"id":"g-1","variant":"english","status":"active","players":2,"to_move":0,"last_activity_unix":1717000000,"seats":[{"seat":0,"account_id":"u-9","display_name":"You","score":10},{"seat":1,"account_id":"a-1","display_name":"Ann","score":7}]}]}`)) + _, _ = w.Write([]byte(`{"games":[{"id":"g-1","variant":"scrabble_en","status":"active","players":2,"to_move":0,"last_activity_unix":1717000000,"seats":[{"seat":0,"account_id":"u-9","display_name":"You","score":10},{"seat":1,"account_id":"a-1","display_name":"Ann","score":7}]}]}`)) }) defer cleanup() diff --git a/ui/e2e/social.spec.ts b/ui/e2e/social.spec.ts index f9756f2..072202c 100644 --- a/ui/e2e/social.spec.ts +++ b/ui/e2e/social.spec.ts @@ -131,7 +131,7 @@ test('play with friends: a game type is required to send an invitation', async ( await page.getByRole('checkbox').first().check(); // pick a friend await expect(send).toBeDisabled(); // still no game type - await page.locator('.field select').first().selectOption('english'); + await page.locator('.field select').first().selectOption('scrabble_en'); await expect(send).toBeEnabled(); await send.click(); // the mock creates it and returns to the lobby diff --git a/ui/src/game/CheckScreen.svelte b/ui/src/game/CheckScreen.svelte index 410fd7a..e59e0d2 100644 --- a/ui/src/game/CheckScreen.svelte +++ b/ui/src/game/CheckScreen.svelte @@ -12,7 +12,7 @@ // complaint, off the board so the soft keyboard never relayouts the play area. let { id }: { id: string } = $props(); - let variant = $state('english'); + let variant = $state('scrabble_en'); let word = $state(''); let result = $state<{ word: string; legal: boolean } | null>(null); let cooling = $state(false); diff --git a/ui/src/game/Game.svelte b/ui/src/game/Game.svelte index d10477a..b31b234 100644 --- a/ui/src/game/Game.svelte +++ b/ui/src/game/Game.svelte @@ -57,7 +57,7 @@ let resignOpen = $state(false); let drag = $state<{ letter: string; blank: boolean; x: number; y: number; touch: boolean } | null>(null); - const variant = $derived(view?.game.variant ?? 'english'); + const variant = $derived(view?.game.variant ?? 'scrabble_en'); const board = $derived(replay(moves)); const premium = $derived(premiumGrid(variant)); const ctr = $derived(centre(variant)); diff --git a/ui/src/lib/alphabet.test.ts b/ui/src/lib/alphabet.test.ts index 1bdfce1..61c7991 100644 --- a/ui/src/lib/alphabet.test.ts +++ b/ui/src/lib/alphabet.test.ts @@ -12,37 +12,37 @@ import { // The cache module is per-file-isolated by vitest, so only what these tests seed exists. describe('alphabet cache (Stage 13)', () => { it('upper-cases letters for display and maps indices and values case-insensitively', () => { - setAlphabet('english', [ + setAlphabet('scrabble_en', [ { index: 0, letter: 'a', value: 1 }, { index: 16, letter: 'q', value: 10 }, ]); - expect(hasAlphabet('english')).toBe(true); - expect(letterForIndex('english', 0)).toBe('A'); - expect(letterForIndex('english', 16)).toBe('Q'); - expect(indexForLetter('english', 'a')).toBe(0); - expect(indexForLetter('english', 'Q')).toBe(16); - expect(valueForLetter('english', 'a')).toBe(1); - expect(valueForLetter('english', 'Q')).toBe(10); + expect(hasAlphabet('scrabble_en')).toBe(true); + expect(letterForIndex('scrabble_en', 0)).toBe('A'); + expect(letterForIndex('scrabble_en', 16)).toBe('Q'); + expect(indexForLetter('scrabble_en', 'a')).toBe(0); + expect(indexForLetter('scrabble_en', 'Q')).toBe(16); + expect(valueForLetter('scrabble_en', 'a')).toBe(1); + expect(valueForLetter('scrabble_en', 'Q')).toBe(10); }); it('handles the blank sentinel and unknown letters/indices', () => { - setAlphabet('english', [{ index: 0, letter: 'a', value: 1 }]); - expect(letterForIndex('english', BLANK_INDEX)).toBe('?'); - expect(indexForLetter('english', '?')).toBe(BLANK_INDEX); - expect(valueForLetter('english', '?')).toBe(0); - expect(letterForIndex('english', 99)).toBe(''); // out of range - expect(valueForLetter('english', 'Z')).toBe(0); // not in this alphabet - expect(() => indexForLetter('english', 'Z')).toThrow(); + setAlphabet('scrabble_en', [{ index: 0, letter: 'a', value: 1 }]); + expect(letterForIndex('scrabble_en', BLANK_INDEX)).toBe('?'); + expect(indexForLetter('scrabble_en', '?')).toBe(BLANK_INDEX); + expect(valueForLetter('scrabble_en', '?')).toBe(0); + expect(letterForIndex('scrabble_en', 99)).toBe(''); // out of range + expect(valueForLetter('scrabble_en', 'Z')).toBe(0); // not in this alphabet + expect(() => indexForLetter('scrabble_en', 'Z')).toThrow(); }); it('lists the alphabet for the blank chooser and is empty for an uncached variant', () => { - setAlphabet('english', [ + setAlphabet('scrabble_en', [ { index: 0, letter: 'a', value: 1 }, { index: 1, letter: 'b', value: 3 }, ]); - expect(alphabetLetters('english')).toEqual(['A', 'B']); - expect(hasAlphabet('erudit')).toBe(false); - expect(alphabetLetters('erudit')).toEqual([]); - expect(valueForLetter('erudit', 'A')).toBe(0); + expect(alphabetLetters('scrabble_en')).toEqual(['A', 'B']); + expect(hasAlphabet('erudit_ru')).toBe(false); + expect(alphabetLetters('erudit_ru')).toEqual([]); + expect(valueForLetter('erudit_ru', 'A')).toBe(0); }); }); diff --git a/ui/src/lib/codec.test.ts b/ui/src/lib/codec.test.ts index f08f55a..a52e93c 100644 --- a/ui/src/lib/codec.test.ts +++ b/ui/src/lib/codec.test.ts @@ -36,7 +36,7 @@ describe('codec', () => { }); it('encodes a SubmitPlayRequest with alphabet indices (Stage 13)', () => { - setAlphabet('english', [ + setAlphabet('scrabble_en', [ { index: 0, letter: 'a', value: 1 }, { index: 1, letter: 'b', value: 3 }, ]); @@ -48,7 +48,7 @@ describe('codec', () => { { row: 7, col: 7, letter: 'A', blank: false }, { row: 7, col: 8, letter: 'B', blank: true }, ], - 'english', + 'scrabble_en', ); const r = fb.SubmitPlayRequest.getRootAsSubmitPlayRequest(new ByteBuffer(buf)); expect(r.gameId()).toBe('g1'); @@ -95,7 +95,7 @@ describe('codec', () => { const seat = fb.SeatView.endSeatView(b); const seats = fb.GameView.createSeatsVector(b, [seat]); const id = b.createString('g1'); - const variant = b.createString('english'); + const variant = b.createString('scrabble_en'); const dv = b.createString('v1'); const status = b.createString('active'); const er = b.createString(''); @@ -242,7 +242,7 @@ describe('codec', () => { const invitees = fb.Invitation.createInviteesVector(b, [invitee]); const id = b.createString('i-1'); - const variant = b.createString('english'); + const variant = b.createString('scrabble_en'); const dropout = b.createString('remove'); const status = b.createString('pending'); const gid = b.createString(''); @@ -264,7 +264,7 @@ describe('codec', () => { expect(inv.inviter).toEqual({ accountId: 'u-1', displayName: 'Me' }); expect(inv.invitees).toHaveLength(1); expect(inv.invitees[0]).toEqual({ accountId: 'inv-1', displayName: 'Friend', seat: 1, response: 'pending' }); - expect(inv.variant).toBe('english'); + expect(inv.variant).toBe('scrabble_en'); }); }); @@ -273,12 +273,12 @@ describe('codec', () => { // the whole table), so they are independent of order. describe('codec — alphabet on the wire (Stage 13)', () => { it('encodes an ExchangeRequest as alphabet indices, blank as the sentinel', () => { - setAlphabet('english', [ + setAlphabet('scrabble_en', [ { index: 0, letter: 'a', value: 1 }, { index: 1, letter: 'b', value: 3 }, ]); const r = fb.ExchangeRequest.getRootAsExchangeRequest( - new ByteBuffer(encodeExchange('g1', ['A', '?'], 'english')), + new ByteBuffer(encodeExchange('g1', ['A', '?'], 'scrabble_en')), ); expect(r.tilesLength()).toBe(2); expect(r.tiles(0)).toBe(0); @@ -286,13 +286,13 @@ describe('codec — alphabet on the wire (Stage 13)', () => { }); it('encodes a CheckWordRequest as alphabet indices', () => { - setAlphabet('english', [ + setAlphabet('scrabble_en', [ { index: 0, letter: 'a', value: 1 }, { index: 2, letter: 'c', value: 3 }, { index: 19, letter: 't', value: 1 }, ]); const r = fb.CheckWordRequest.getRootAsCheckWordRequest( - new ByteBuffer(encodeCheckWord('g1', 'CAT', 'english')), + new ByteBuffer(encodeCheckWord('g1', 'CAT', 'scrabble_en')), ); expect(r.wordLength()).toBe(3); expect([r.word(0), r.word(1), r.word(2)]).toEqual([2, 0, 19]); @@ -330,7 +330,7 @@ describe('codec — alphabet on the wire (Stage 13)', () => { fb.StateView.addAlphabet(b, alpha); b.finish(fb.StateView.endStateView(b)); - // No GameView on the buffer, so decode falls back to the default variant 'english'; + // No GameView on the buffer, so decode falls back to the default variant 'scrabble_en'; // the embedded table is cached under it and the rack [0, blank] decodes to letters. const sv = decodeStateView(b.asUint8Array()); expect(sv.rack).toEqual(['A', '?']); diff --git a/ui/src/lib/codec.ts b/ui/src/lib/codec.ts index b5e69ca..a65c151 100644 --- a/ui/src/lib/codec.ts +++ b/ui/src/lib/codec.ts @@ -325,7 +325,7 @@ export function decodeProfile(buf: Uint8Array): Profile { export function decodeStateView(buf: Uint8Array): StateView { const v = fb.StateView.getRootAsStateView(new ByteBuffer(buf)); const g = v.game(); - const variant = (g ? s(g.variant()) : 'english') as Variant; + const variant = (g ? s(g.variant()) : 'scrabble_en') as Variant; // Cache the alphabet table when the server included it (a per-variant cache miss), then // decode the index rack to display letters with it (Stage 13). if (v.alphabetLength() > 0) { @@ -681,7 +681,7 @@ export function decodeGcg(buf: Uint8Array): GcgExport { function emptyGame(): GameView { return { id: '', - variant: 'english', + variant: 'scrabble_en', dictVersion: '', status: '', players: 0, diff --git a/ui/src/lib/lobbysort.test.ts b/ui/src/lib/lobbysort.test.ts index 3f5b9f9..3446d6d 100644 --- a/ui/src/lib/lobbysort.test.ts +++ b/ui/src/lib/lobbysort.test.ts @@ -15,7 +15,7 @@ const seat = (s: number, accountId: string): Seat => ({ function game(id: string, status: GameView['status'], toMove: number, lastActivityUnix: number): GameView { return { id, - variant: 'english', + variant: 'scrabble_en', dictVersion: 'v1', status, players: 2, diff --git a/ui/src/lib/mock/alphabet.ts b/ui/src/lib/mock/alphabet.ts index b097558..e104848 100644 --- a/ui/src/lib/mock/alphabet.ts +++ b/ui/src/lib/mock/alphabet.ts @@ -10,11 +10,11 @@ import type { Variant } from '../model'; // "letter+value" tokens in alphabet-index order. English Latin a..z; Russian а..я incl. ё; // Эрудит а..я incl. ё=0. const SPECS: Record = { - english: + scrabble_en: 'a1 b3 c3 d2 e1 f4 g2 h4 i1 j8 k5 l1 m3 n1 o1 p3 q10 r1 s1 t1 u1 v4 w4 x8 y4 z10', - russian_scrabble: + scrabble_ru: 'а1 б3 в1 г3 д2 е1 ё3 ж5 з5 и1 й4 к2 л2 м2 н1 о1 п2 р1 с1 т1 у2 ф10 х5 ц5 ч5 ш8 щ10 ъ10 ы4 ь3 э8 ю8 я3', - erudit: + erudit_ru: 'а1 б3 в2 г3 д2 е1 ё0 ж5 з5 и1 й2 к2 л2 м2 н1 о1 п2 р2 с2 т2 у3 ф10 х5 ц10 ч5 ш10 щ10 ъ10 ы5 ь5 э10 ю10 я3', }; diff --git a/ui/src/lib/mock/client.ts b/ui/src/lib/mock/client.ts index ab4a8bc..76cdbd2 100644 --- a/ui/src/lib/mock/client.ts +++ b/ui/src/lib/mock/client.ts @@ -61,9 +61,9 @@ function emptyLinked(): LinkResult { } const POOL: Record = { - english: 'AAAAEEEEIIIOONNRRTTLLSSUDGBCMPFHVWYK', - russian_scrabble: 'ОООААЕЕИИННТТСРРВЛКМДПУЯЫЬГЗБ', - erudit: 'ОООААЕЕИИННТТСРРВЛКМДПУЯЫЬГЗБ', + scrabble_en: 'AAAAEEEEIIIOONNRRTTLLSSUDGBCMPFHVWYK', + scrabble_ru: 'ОООААЕЕИИННТТСРРВЛКМДПУЯЫЬГЗБ', + erudit_ru: 'ОООААЕЕИИННТТСРРВЛКМДПУЯЫЬГЗБ', }; function draw(variant: Variant, n: number): string[] { diff --git a/ui/src/lib/mock/data.ts b/ui/src/lib/mock/data.ts index 402f243..a50d107 100644 --- a/ui/src/lib/mock/data.ts +++ b/ui/src/lib/mock/data.ts @@ -57,7 +57,7 @@ export function mockInvitations(): Invitation[] { id: 'inv1', inviter: { accountId: 'kaya', displayName: 'Kaya' }, invitees: [{ accountId: ME, displayName: 'You', seat: 1, response: 'pending' }], - variant: 'english', + variant: 'scrabble_en', turnTimeoutSecs: 86400, hintsAllowed: true, hintsPerPlayer: 1, @@ -105,7 +105,7 @@ export interface MockGame { chat: ChatMessage[]; } -// --- active game G1: english, You (seat 0) vs Ann (seat 1), your turn --- +// --- active game G1: scrabble_en, You (seat 0) vs Ann (seat 1), your turn --- const G1_MOVES: MoveRecord[] = [ play(0, 'H', [ @@ -135,7 +135,7 @@ function activeGame(): MockGame { return { view: { id: 'g1', - variant: 'english', + variant: 'scrabble_en', dictVersion: 'v1', status: 'active', players: 2, @@ -169,7 +169,7 @@ function finishedG2(): MockGame { return { view: { id: 'g2', - variant: 'english', + variant: 'scrabble_en', dictVersion: 'v1', status: 'finished', players: 2, @@ -204,7 +204,7 @@ function finishedG3(): MockGame { return { view: { id: 'g3', - variant: 'russian_scrabble', + variant: 'scrabble_ru', dictVersion: 'v1', status: 'finished', players: 2, diff --git a/ui/src/lib/model.ts b/ui/src/lib/model.ts index 9ce89d3..a0d98fa 100644 --- a/ui/src/lib/model.ts +++ b/ui/src/lib/model.ts @@ -3,7 +3,7 @@ // FlatBuffers) and the mock transport speak this model, so the UI never touches // generated wire code directly. -export type Variant = 'english' | 'russian_scrabble' | 'erudit'; +export type Variant = 'scrabble_en' | 'scrabble_ru' | 'erudit_ru'; /** Backend game status strings. */ export type GameStatus = 'active' | 'finished' | string; diff --git a/ui/src/lib/premiums.test.ts b/ui/src/lib/premiums.test.ts index a24490d..ee63b9d 100644 --- a/ui/src/lib/premiums.test.ts +++ b/ui/src/lib/premiums.test.ts @@ -1,13 +1,13 @@ import { describe, expect, it } from 'vitest'; import { BOARD_SIZE, centre, premiumGrid } from './premiums'; -// Premium-square geometry parity with scrabble-solver/rules/rules.go: english/russian -// share standardBoard (centre is a double word); erudit shares the geometry but a +// Premium-square geometry parity with scrabble-solver/rules/rules.go: scrabble_en/scrabble_ru +// share standardBoard (centre is a double word); erudit_ru shares the geometry but a // non-doubling centre. Tile-value and alphabet parity moved to the Go engine test // (backend/internal/engine AlphabetTable) in Stage 13 — the server now owns that table. describe('premium layout', () => { it('is a 15x15 grid with TW corners', () => { - const g = premiumGrid('english'); + const g = premiumGrid('scrabble_en'); expect(g.length).toBe(BOARD_SIZE); expect(g[0].length).toBe(BOARD_SIZE); for (const [r, c] of [ @@ -20,16 +20,16 @@ describe('premium layout', () => { } }); - it('doubles the centre for standard variants but not for erudit', () => { - expect(centre('english')).toEqual({ row: 7, col: 7 }); - expect(premiumGrid('english')[7][7]).toBe('DW'); - expect(premiumGrid('russian_scrabble')[7][7]).toBe('DW'); - expect(centre('erudit')).toEqual({ row: 7, col: 7 }); - expect(premiumGrid('erudit')[7][7]).toBe(''); + it('doubles the centre for standard variants but not for erudit_ru', () => { + expect(centre('scrabble_en')).toEqual({ row: 7, col: 7 }); + expect(premiumGrid('scrabble_en')[7][7]).toBe('DW'); + expect(premiumGrid('scrabble_ru')[7][7]).toBe('DW'); + expect(centre('erudit_ru')).toEqual({ row: 7, col: 7 }); + expect(premiumGrid('erudit_ru')[7][7]).toBe(''); }); it('keeps the standard premium counts', () => { - const flat = premiumGrid('english').flat(); + const flat = premiumGrid('scrabble_en').flat(); const count = (p: string) => flat.filter((x) => x === p).length; expect(count('TW')).toBe(8); expect(count('TL')).toBe(12); diff --git a/ui/src/lib/premiums.ts b/ui/src/lib/premiums.ts index 97908c6..a2cf939 100644 --- a/ui/src/lib/premiums.ts +++ b/ui/src/lib/premiums.ts @@ -51,7 +51,7 @@ const eruditBoard = [ ]; function template(variant: Variant): string[] { - return variant === 'erudit' ? eruditBoard : standardBoard; + return variant === 'erudit_ru' ? eruditBoard : standardBoard; } function premiumOf(ch: string): Premium { diff --git a/ui/src/lib/result.test.ts b/ui/src/lib/result.test.ts index d12bb59..801a6cd 100644 --- a/ui/src/lib/result.test.ts +++ b/ui/src/lib/result.test.ts @@ -14,7 +14,7 @@ const seat = (s: number, accountId: string, score: number, isWinner = false): Se function game(seats: Seat[], status = 'finished', toMove = 0): GameView { return { id: 'g', - variant: 'english', + variant: 'scrabble_en', dictVersion: 'v1', status, players: seats.length, diff --git a/ui/src/lib/variants.test.ts b/ui/src/lib/variants.test.ts index cb80b1e..91af686 100644 --- a/ui/src/lib/variants.test.ts +++ b/ui/src/lib/variants.test.ts @@ -9,14 +9,14 @@ describe('availableVariants', () => { }); it('offers only English for an en-only service', () => { - expect(availableVariants(['en']).map((v) => v.id)).toEqual(['english']); + expect(availableVariants(['en']).map((v) => v.id)).toEqual(['scrabble_en']); }); it('offers Russian and Эрудит for a ru-only service', () => { - expect(availableVariants(['ru']).map((v) => v.id)).toEqual(['russian_scrabble', 'erudit']); + expect(availableVariants(['ru']).map((v) => v.id)).toEqual(['scrabble_ru', 'erudit_ru']); }); it('offers every variant for a bilingual service', () => { - expect(availableVariants(['en', 'ru']).map((v) => v.id)).toEqual(['english', 'russian_scrabble', 'erudit']); + expect(availableVariants(['en', 'ru']).map((v) => v.id)).toEqual(['scrabble_en', 'scrabble_ru', 'erudit_ru']); }); }); diff --git a/ui/src/lib/variants.ts b/ui/src/lib/variants.ts index 62e3144..ba69e9e 100644 --- a/ui/src/lib/variants.ts +++ b/ui/src/lib/variants.ts @@ -17,9 +17,9 @@ export interface VariantOption { // two never collide whatever the UI language); Erudit is localized "Erudite"/"Эрудит" // (Stage 17). export const ALL_VARIANTS: VariantOption[] = [ - { id: 'english', label: 'new.english' }, - { id: 'russian_scrabble', label: 'new.russian' }, - { id: 'erudit', label: 'new.erudit' }, + { id: 'scrabble_en', label: 'new.english' }, + { id: 'scrabble_ru', label: 'new.russian' }, + { id: 'erudit_ru', label: 'new.erudit' }, ]; // variantNameKey returns the i18n key for a variant's display name (used by the in-game @@ -31,22 +31,22 @@ export function variantNameKey(v: Variant): MessageKey { // VARIANT_RULES is the i18n key for each variant's one-line rules summary on the New Game // buttons (bag size, the ё rule, bonus differences), sourced from the engine rulesets. export const VARIANT_RULES: Record = { - english: 'new.rulesEnglish', - russian_scrabble: 'new.rulesRussian', - erudit: 'new.rulesErudit', + scrabble_en: 'new.rulesEnglish', + scrabble_ru: 'new.rulesRussian', + erudit_ru: 'new.rulesErudit', }; // VARIANT_FLAG is the flag shown on a variant button: an emoji for the Scrabble variants; // Erudit uses the bundled USSR flag SVG (public/flag-ussr.svg), so its entry is empty. export const VARIANT_FLAG: Record = { - english: '🇺🇸', - russian_scrabble: '🇷🇺', - erudit: '', + scrabble_en: '🇺🇸', + scrabble_ru: '🇷🇺', + erudit_ru: '', }; // VARIANT_LANGUAGE maps each variant to its game language. en -> English; // ru -> Russian + Эрудит. -export const VARIANT_LANGUAGE: Record = { english: 'en', russian_scrabble: 'ru', erudit: 'ru' }; +export const VARIANT_LANGUAGE: Record = { scrabble_en: 'en', scrabble_ru: 'ru', erudit_ru: 'ru' }; // availableVariants gates ALL_VARIANTS by the session's supported languages. An empty // or absent set is ungated (a web/legacy session without a declared set), returning diff --git a/ui/src/screens/Lobby.svelte b/ui/src/screens/Lobby.svelte index f221a04..fe3cf9a 100644 --- a/ui/src/screens/Lobby.svelte +++ b/ui/src/screens/Lobby.svelte @@ -144,9 +144,9 @@ } const variantKey: Record = { - english: 'new.english', - russian_scrabble: 'new.russian', - erudit: 'new.erudit', + scrabble_en: 'new.english', + scrabble_ru: 'new.russian', + erudit_ru: 'new.erudit', };