R1: schema & naming reset — squash migrations, rename variants #31

Merged
developer merged 1 commits from feature/r1-schema-naming-reset into development 2026-06-09 14:09:31 +00:00
54 changed files with 688 additions and 675 deletions
+2
View File
@@ -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 - [`PLAN.md`](PLAN.md) — staged plan + **stage tracker** + per-stage *open
details to interview*. details to interview*.
- [`PRERELEASE.md`](PRERELEASE.md) — pre-release hardening tracker (phases R1R7
before Stage 18); same per-phase *interview + bake-back* discipline as `PLAN.md`.
- [`docs/ARCHITECTURE.md`](docs/ARCHITECTURE.md) — architecture, transport, - [`docs/ARCHITECTURE.md`](docs/ARCHITECTURE.md) — architecture, transport,
security, the decision record. Always describes current state. security, the decision record. Always describes current state.
- [`docs/FUNCTIONAL.md`](docs/FUNCTIONAL.md) (+ [`_ru`](docs/FUNCTIONAL_ru.md) - [`docs/FUNCTIONAL.md`](docs/FUNCTIONAL.md) (+ [`_ru`](docs/FUNCTIONAL_ru.md)
+219
View File
@@ -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 35 concurrent 24-player games, exercising submit-play / pass / exchange / nudge /
chat / check-word / draft-move / profile-save through the **edge protocol**, in
**realistic** (under rate limits) and **saturation** (ramp) modes; plus a separate
**gateway-hammer** that deliberately exceeds limits to verify the limiter holds and measure
its cost. Add **cAdvisor + postgres_exporter** to `deploy/docker-compose.yml` and a Grafana
resource dashboard. Run the **early pass** against the freshly-wiped contour; produce a
**trip report** (logic/concurrency bugs + a resource baseline) that feeds R3 and R6.
- Critical files: new `loadtest/`, `deploy/docker-compose.yml`, `deploy/observability/*`,
`docs/TESTING.md`.
- Open details: the scale ramp steps; the move-selection policy (a mid-ranked solver move
for realistic game progress); run duration; the pass/fail bar.
### R3 — Edge hardening *(TODO 2 + 8 + 3)*
Add a **request-body size cap** at the gateway h2c mux / `Execute` (e.g. ~1 MB). Add
**rate-limit observability**: a `gateway_rate_limited_total{class}` counter + a structured
log per rejection; an **aggregate** Grafana panel (request rate + rejection rate — spikes
visible without per-user label cardinality, honouring the Stage 12/17 discipline); an
**admin-console view** of recently throttled users/IPs (in-memory ring buffer, single-
instance, reset-on-restart, like the `active_users` gauge). Add the **conservative
auto-flag**: when a user is *sustained*-throttled past a tunable threshold, set a soft,
reversible `account.flagged_high_rate_at` marker (baked into the R1 baseline) surfaced in the
admin user list/detail — **no auto-ban**; the operator clears it. Split the **landing** into
its own static container (`deploy/` + a Caddyfile route `/` → landing) and drop
`landing.html` from the gateway `go:embed`.
- Critical files: `gateway/internal/connectsrv/server.go`, `gateway/internal/ratelimit/`,
`gateway/internal/connectsrv/metrics.go`, `backend/internal/adminconsole/`,
`deploy/caddy/Caddyfile`, `deploy/docker-compose.yml`, `gateway/internal/webui/`.
- Open details: the auto-flag threshold/window + whether the marker is persisted vs
in-memory; the landing image base (caddy vs nginx).
### R4 — Push enrichment + kill the last poll *(TODO 4 + 5)*
Replace `lobby.poll` with the existing `match_found` push (keep the poll as a ws-down
fallback). Enrich `your_turn`/`opponent_moved`/`notify` to carry the state payload so the UI
renders from the event without a follow-up `game.state` (removes the lobby↔game nav latency
the owner noticed). Wire-contract change: `pkg/fbs` event payloads → backend `notify` emit →
UI stream consumers (`ui/src/lib/app.svelte.ts`), with the per-game cache as the landing
spot; regenerate FB.
- Critical files: `pkg/fbs/scrabble.fbs`, `backend/internal/notify/events.go`,
`ui/src/lib/{app.svelte,transport}.ts`, `ui/src/screens/NewGame.svelte`.
- Open details: which events carry full vs delta payloads; the fallback-poll cadence when the
stream is down.
### R5 — Bundle slimming *(TODO 6)*
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 ./<module>/...`, `go vet`, `gofmt -l .` clean, `go test -count=1 ./<module>/...`;
UI: `pnpm check && pnpm test:unit && pnpm build`; the integration suite
(`-tags integration`) for DB/schema changes; `docker compose config` for deploy changes;
green CI on the PR + a healthy contour deploy.
- `R1`: prove the squashed baseline yields a schema identical to the 12-migration chain
(integration suite on a fresh DB) **before** deleting the old files.
- `R2`/`R7`: the harness runs end-to-end against the contour; the trip report lists concrete
defects + a resource profile from the Grafana cAdvisor/postgres_exporter panels.
## Refinements logged during implementation
- **R1** (interview + implementation):
- **Variant labels** `english`/`russian_scrabble`/`erudit`**`scrabble_en`/`scrabble_ru`/`erudit_ru`**
across the backend (`engine.Variant.String`/`ParseVariant`; the `games`/`game_invitations` `variant`
CHECK in the baseline; GCG `#lexicon` and the `variant` metric attribute both flow from `String`),
the wire (`pkg/fbs` `variant` is a `string` field — values change with **no FlatBuffers regen**) and
the UI (`model.ts` union, `variants.ts` records, `codec`/`premiums`/mocks/tests, the admin
`dictionary.gohtml`). **Kept:** the Go enum identifiers (`VariantEnglish`…, internal) and the i18n
display keys (`new.english`/`new.russian`/`new.erudit`, display-only). `complaints.variant` stays
free-text (no CHECK, as before).
- **dawg filenames kept descriptive** (`en_sowpods`/`ru_scrabble`/`ru_erudit`) — only the registry's
`Variant` key carries the rename, so `registry.go`, the published `scrabble-solver` fixtures and the
dictionary release artifact are untouched (decouples the three repos).
- **Migrations squashed** 12 → one hand-written `00001_baseline.sql`. Verified by a
`pg_dump --schema-only` diff (the chain vs the baseline are **identical** but for the two intended
variant-CHECK values) plus the green integration suite. **No data migration** (no production data).
- **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).
+10 -7
View File
@@ -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 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 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 seed a new account's language and display name from the launch fields, and adds
migration `00007` (`accounts.notifications_in_app_only`, default true). the `accounts.notifications_in_app_only` flag (default true).
Migration `00005` adds `accounts.is_guest`: an ephemeral guest is a durable row `accounts.is_guest` marks an ephemeral guest a durable row
with no identity, excluded from statistics. **Stage 10** adds the server-rendered with no identity, excluded from statistics. **Stage 10** adds the server-rendered
**admin console** at `/_gm` (`internal/adminconsole` + `internal/server/handlers_admin_console.go`; **admin console** at `/_gm` (`internal/adminconsole` + `internal/server/handlers_admin_console.go`;
the gateway fronts it with Basic-Auth and a same-origin guard protects its POSTs), the the gateway fronts it with Basic-Auth and a same-origin guard protects its POSTs), the
**complaint resolution** lifecycle (migration `00008` adds `disposition`/`resolution_note`/ **complaint resolution** lifecycle (the `complaints` `disposition`/`resolution_note`/
`resolved_at`/`applied_in_version` + the `status` CHECK) feeding a dictionary-change `resolved_at`/`applied_in_version` columns + the `status` CHECK) feeding a dictionary-change
pipeline, dictionary **hot-reload** from `BACKEND_DICT_DIR/<version>/` pipeline, dictionary **hot-reload** from `BACKEND_DICT_DIR/<version>/`
(`engine.OpenWithVersions` / `Registry.LoadAvailable`), and operator **broadcasts** via a (`engine.OpenWithVersions` / `Registry.LoadAvailable`), and operator **broadcasts** via a
backend Telegram-connector client (`internal/connector`, `BACKEND_CONNECTOR_ADDR`) — each backend Telegram-connector client (`internal/connector`, `BACKEND_CONNECTOR_ADDR`) — each
broadcast picks the delivering bot by an operator-chosen language. **Stage 15** adds broadcast picks the delivering bot by an operator-chosen language. **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 user last signed in through, written on every login and returned by
`/internal/push-target` (falling back to `preferred_language`) so out-of-app push routes `/internal/push-target` (falling back to `preferred_language`) so out-of-app push routes
to the right bot. The shared wire contracts live in the sibling [`../pkg`](../pkg) module. to the right bot. The shared wire contracts live in the sibling [`../pkg`](../pkg) module.
@@ -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. 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 The current account is primary, except a guest initiator whose linked identity has a
durable owner — then the durable account wins and a fresh session is minted for it. durable owner — then the durable account wins and a fresh session is minted for it.
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). Stage 8 `email.bind.*` edge surface (the `RequestCode`/`ConfirmCode` primitives stay).
## Package layout ## Package layout
@@ -176,7 +176,10 @@ warmed.
## Migrations & generated code ## Migrations & generated code
Migrations are plain goose SQL under `internal/postgres/migrations` (sequential 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): regenerate the committed go-jet code (needs Docker):
```sh ```sh
+5 -5
View File
@@ -20,15 +20,15 @@ func TestRendererRendersEveryPage(t *testing.T) {
data any data any
want string 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"}, {"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"}, {"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"}, {"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: "english", Seats: []SeatRow{{Seat: 0, DisplayName: "Kaya"}}}, "Seats"}, {"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"}, {"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"}, {"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"}, {"complaint_detail", ComplaintDetailView{ID: "c1", Word: "qi", Variant: "scrabble_en"}, "Resolve"},
{"dictionary", DictionaryView{Variants: []VariantVersions{{Variant: "english", Latest: "v1", Versions: []string{"v1"}}}, Changes: []DictChangeRow{{Variant: "english", Word: "qi", Action: "add"}}}, "Hot-reload"}, {"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"}, {"broadcast", BroadcastView{ConnectorEnabled: true}, "Post to the game channel"},
{"message", MessageView{Heading: "Done", Body: "ok", Back: "/_gm/"}, "Done"}, {"message", MessageView{Heading: "Done", Body: "ok", Back: "/_gm/"}, "Done"},
} }
@@ -30,9 +30,9 @@
<form class="form" method="post" action="/_gm/dictionary/changes/apply"> <form class="form" method="post" action="/_gm/dictionary/changes/apply">
<label>Mark applied for variant <label>Mark applied for variant
<select name="variant"> <select name="variant">
<option value="english">english</option> <option value="scrabble_en">scrabble_en</option>
<option value="russian_scrabble">russian_scrabble</option> <option value="scrabble_ru">scrabble_ru</option>
<option value="erudit">erudit</option> <option value="erudit_ru">erudit_ru</option>
</select> </select>
</label> </label>
<label>In version <input type="text" name="version" placeholder="v2" required></label> <label>In version <input type="text" name="version" placeholder="v2" required></label>
+6 -6
View File
@@ -12,7 +12,7 @@ import (
func TestAlphabetTableEnglish(t *testing.T) { func TestAlphabetTableEnglish(t *testing.T) {
tab, err := AlphabetTable(VariantEnglish) tab, err := AlphabetTable(VariantEnglish)
if err != nil { if err != nil {
t.Fatalf("AlphabetTable(english): %v", err) t.Fatalf("AlphabetTable(scrabble_en): %v", err)
} }
if len(tab) != 26 { if len(tab) != 26 {
t.Fatalf("size = %d, want 26", len(tab)) t.Fatalf("size = %d, want 26", len(tab))
@@ -40,23 +40,23 @@ func TestAlphabetTableEnglish(t *testing.T) {
func TestAlphabetTableRussianVariants(t *testing.T) { func TestAlphabetTableRussianVariants(t *testing.T) {
ru, err := AlphabetTable(VariantRussianScrabble) ru, err := AlphabetTable(VariantRussianScrabble)
if err != nil { if err != nil {
t.Fatalf("AlphabetTable(russian_scrabble): %v", err) t.Fatalf("AlphabetTable(scrabble_ru): %v", err)
} }
er, err := AlphabetTable(VariantErudit) er, err := AlphabetTable(VariantErudit)
if err != nil { if err != nil {
t.Fatalf("AlphabetTable(erudit): %v", err) t.Fatalf("AlphabetTable(erudit_ru): %v", err)
} }
if len(ru) != 33 || len(er) != 33 { if len(ru) != 33 || len(er) != 33 {
t.Fatalf("sizes = %d/%d, want 33/33", len(ru), len(er)) t.Fatalf("sizes = %d/%d, want 33/33", len(ru), len(er))
} }
if ru[0].Letter != "а" || ru[0].Value != 1 { 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 { 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 { 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 != "я" { if ru[32].Letter != "я" || er[32].Letter != "я" {
t.Errorf("last letter = %q/%q, want я/я", ru[32].Letter, er[32].Letter) t.Errorf("last letter = %q/%q, want я/я", ru[32].Letter, er[32].Letter)
+4 -4
View File
@@ -168,10 +168,10 @@ func TestRegistryLookup(t *testing.T) {
word string word string
want bool want bool
}{ }{
{"english hit", VariantEnglish, "cat", true}, {"scrabble_en hit", VariantEnglish, "cat", true},
{"english miss", VariantEnglish, "zzzz", false}, {"scrabble_en miss", VariantEnglish, "zzzz", false},
{"russian hit", VariantRussianScrabble, "кот", true}, {"scrabble_ru hit", VariantRussianScrabble, "кот", true},
{"erudit hit", VariantErudit, "кот", true}, {"erudit_ru hit", VariantErudit, "кот", true},
} }
for _, tc := range cases { for _, tc := range cases {
t.Run(tc.name, func(t *testing.T) { t.Run(tc.name, func(t *testing.T) {
+3 -3
View File
@@ -38,11 +38,11 @@ const (
func (v Variant) String() string { func (v Variant) String() string {
switch v { switch v {
case VariantEnglish: case VariantEnglish:
return "english" return "scrabble_en"
case VariantRussianScrabble: case VariantRussianScrabble:
return "russian_scrabble" return "scrabble_ru"
case VariantErudit: case VariantErudit:
return "erudit" return "erudit_ru"
} }
return "unknown" return "unknown"
} }
+1 -1
View File
@@ -60,7 +60,7 @@ func TestRegistryValidatesKnownWords(t *testing.T) {
func TestRegistryUnknownLookups(t *testing.T) { func TestRegistryUnknownLookups(t *testing.T) {
reg, err := Open(testDictDir(), testVersion, VariantEnglish) reg, err := Open(testDictDir(), testVersion, VariantEnglish)
if err != nil { if err != nil {
t.Fatalf("open english-only registry: %v", err) t.Fatalf("open scrabble_en-only registry: %v", err)
} }
defer reg.Close() defer reg.Close()
+7 -7
View File
@@ -45,13 +45,13 @@ func TestLoadAvailableLoadsPresentSkipsAbsent(t *testing.T) {
t.Fatalf("load available: %v", err) t.Fatalf("load available: %v", err)
} }
if len(loaded) != 1 || loaded[0] != VariantEnglish { 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 { 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) { 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 { 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) latest, _, err := reg.Latest(VariantEnglish)
if err != nil { if err != nil {
t.Fatalf("latest english: %v", err) t.Fatalf("latest scrabble_en: %v", err)
} }
if latest != "v2" { 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 { 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)
} }
} }
+1 -1
View File
@@ -40,7 +40,7 @@ func TestWriteGCG(t *testing.T) {
"#character-encoding UTF-8", "#character-encoding UTF-8",
"#player1 p1 Alice", "#player1 p1 Alice",
"#player2 p2 Bob", "#player2 p2 Bob",
"#lexicon english/v1", "#lexicon scrabble_en/v1",
"#title game 00000000-0000-7000-8000-000000000001", "#title game 00000000-0000-7000-8000-000000000001",
">p1: CATSER? 8H CAT +10 10", ">p1: CATSER? 8H CAT +10 10",
">p2: AS?E I8 .s +2 2", ">p2: AS?E I8 .s +2 2",
+1 -1
View File
@@ -94,7 +94,7 @@ func TestGameCacheEviction(t *testing.T) {
cur := time.Unix(1_700_000_000, 0) cur := time.Unix(1_700_000_000, 0)
cache := newGameCache(time.Hour, func() time.Time { return cur }) cache := newGameCache(time.Hour, func() time.Time { return cur })
id := uuid.New() id := uuid.New()
cache.put(id, nil, "english") cache.put(id, nil, "scrabble_en")
if _, ok := cache.get(id); !ok { if _, ok := cache.get(id); !ok {
t.Fatal("game must be resident after put") t.Fatal("game must be resident after put")
} }
+1 -1
View File
@@ -16,7 +16,7 @@ import (
const meterName = "scrabble/backend/game" const meterName = "scrabble/backend/game"
// gameMetrics holds the game domain's operational instruments. Every game-scoped // 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 // instruments default to no-ops (see defaultGameMetrics), so recording is always
// safe; SetMetrics installs the real meter during startup wiring. // safe; SetMetrics installs the real meter during startup wiring.
type gameMetrics struct { type gameMetrics struct {
+4 -4
View File
@@ -35,11 +35,11 @@ func TestGameMetrics(t *testing.T) {
} }
started := counterByAttr(t, rm, "games_started_total", "variant") started := counterByAttr(t, rm, "games_started_total", "variant")
if started["english"] != 2 || started["russian_scrabble"] != 1 { if started["scrabble_en"] != 2 || started["scrabble_ru"] != 1 {
t.Errorf("games_started_total = %v, want english:2 russian_scrabble:1", started) 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 { if abandoned := counterByAttr(t, rm, "games_abandoned_total", "variant"); abandoned["erudit_ru"] != 1 {
t.Errorf("games_abandoned_total = %v, want erudit:1", abandoned) t.Errorf("games_abandoned_total = %v, want erudit_ru:1", abandoned)
} }
if c := histogramCount(t, rm, "game_replay_duration"); c != 1 { if c := histogramCount(t, rm, "game_replay_duration"); c != 1 {
t.Errorf("game_replay_duration observations = %d, want 1", c) t.Errorf("game_replay_duration observations = %d, want 1", c)
+1 -1
View File
@@ -33,7 +33,7 @@ func TestMoveDurationAnalytics(t *testing.T) {
t0 := time.Date(2026, 1, 1, 12, 0, 0, 0, time.UTC) t0 := time.Date(2026, 1, 1, 12, 0, 0, 0, time.UTC)
if _, err := testDB.ExecContext(ctx, if _, err := testDB.ExecContext(ctx,
`INSERT INTO backend.games (game_id, variant, dict_version, seed, players, turn_timeout_secs, created_at) `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) t.Fatalf("insert game: %v", err)
} }
if _, err := testDB.ExecContext(ctx, if _, err := testDB.ExecContext(ctx,
+1 -1
View File
@@ -477,7 +477,7 @@ func TestGameVariant(t *testing.T) {
t.Fatalf("create: %v", err) t.Fatalf("create: %v", err)
} }
if v, err := svc.GameVariant(ctx, g.ID); err != nil || v != engine.VariantEnglish { 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) { if _, err := svc.GameVariant(ctx, uuid.New()); !errors.Is(err, game.ErrNotFound) {
t.Errorf("GameVariant(unknown) = %v, want ErrNotFound", err) t.Errorf("GameVariant(unknown) = %v, want ErrNotFound", err)
+1 -1
View File
@@ -90,7 +90,7 @@ func TestRobotPoolProvisionsRobotAccounts(t *testing.T) {
t.Errorf("picked account %s is not a robot identity", id) t.Errorf("picked account %s is not a robot identity", id)
} }
if ru, err := r.Pick(engine.VariantRussianScrabble); err != nil || !isRobotAccount(t, ru) { 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) acc, err := account.NewStore(testDB).GetByID(ctx, id)
if err != nil { if err != nil {
+1 -1
View File
@@ -37,7 +37,7 @@ var gooseMu sync.Mutex
// ApplyMigrations runs every pending Up migration embedded in the backend // ApplyMigrations runs every pending Up migration embedded in the backend
// binary against db. The schema is created upfront so goose's bookkeeping table // 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 // (`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. // schema with IF NOT EXISTS, so the double-create is idempotent.
// //
// The apply is retried on transient connection errors. Both steps are // The apply is retried on transient connection errors. Both steps are
@@ -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;
@@ -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;
@@ -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;
@@ -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;
@@ -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'));
@@ -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;
@@ -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'));
@@ -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;
@@ -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;
@@ -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;
@@ -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;
@@ -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;
@@ -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;
+6 -6
View File
@@ -75,14 +75,14 @@ func TestPickVariantRouting(t *testing.T) {
s := &Service{poolEN: []uuid.UUID{enID}, poolRU: []uuid.UUID{ruID}} s := &Service{poolEN: []uuid.UUID{enID}, poolRU: []uuid.UUID{ruID}}
for i := 0; i < 200; i++ { for i := 0; i < 200; i++ {
if got, err := s.Pick(engine.VariantEnglish); err != nil || got != enID { 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 var en, ru int
for i := 0; i < 4000; i++ { for i := 0; i < 4000; i++ {
got, err := s.Pick(engine.VariantRussianScrabble) got, err := s.Pick(engine.VariantRussianScrabble)
if err != nil { if err != nil {
t.Fatalf("russian Pick: %v", err) t.Fatalf("scrabble_ru Pick: %v", err)
} }
switch got { switch got {
case enID: case enID:
@@ -92,14 +92,14 @@ func TestPickVariantRouting(t *testing.T) {
} }
} }
if ru <= en { 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 { if en == 0 {
t.Errorf("some Latin names should appear in Russian games (got 0 of 4000)") t.Errorf("some Latin names should appear in Russian games (got 0 of 4000)")
} }
// Эрудит routes like Russian Scrabble. // Эрудит routes like Russian Scrabble.
if _, err := s.Pick(engine.VariantErudit); err != nil { 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) { func TestPickFallback(t *testing.T) {
id := uuid.New() id := uuid.New()
if got, err := (&Service{poolEN: []uuid.UUID{id}}).Pick(engine.VariantRussianScrabble); err != nil || got != id { 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 { 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) { if _, err := (&Service{}).Pick(engine.VariantEnglish); !errors.Is(err, ErrNoRobotAvailable) {
t.Errorf("empty pool err = %v, want ErrNoRobotAvailable", err) t.Errorf("empty pool err = %v, want ErrNoRobotAvailable", err)
+1 -1
View File
@@ -91,7 +91,7 @@ func TestGameDTOFromGame(t *testing.T) {
Seats: []game.Seat{{Seat: 0, AccountID: aid, Score: 12}}, Seats: []game.Seat{{Seat: 0, AccountID: aid, Score: 12}},
} }
dto := gameDTOFromGame(g) 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) t.Fatalf("game dto mismatch: %+v", dto)
} }
if len(dto.Seats) != 1 || dto.Seats[0].AccountID != aid.String() || dto.Seats[0].Score != 12 { if len(dto.Seats) != 1 || dto.Seats[0].AccountID != aid.String() || dto.Seats[0].Score != 12 {
+1 -1
View File
@@ -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 `robot_games_finished_total`; an observable gauge `game_cache_active`; the gateway
`edge_request_duration` (the UI-perceived roundtrip, by `message_type`/`result`); `edge_request_duration` (the UI-perceived roundtrip, by `message_type`/`result`);
and Go runtime/heap metrics. Game-scoped metrics carry a `variant` attribute and Go runtime/heap metrics. Game-scoped metrics carry a `variant` attribute
(english/russian_scrabble/erudit). (scrabble_en/scrabble_ru/erudit_ru).
- Per-user move-time analytics (Stage 17) are **offline**, derived in the admin - 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 console from the move journal (`game_moves.created_at` deltas, the first move from
the game's creation), not Prometheus labels (which an `account_id` would explode): the game's creation), not Prometheus labels (which an `account_id` would explode):
@@ -21,7 +21,7 @@ func TestGameStateIncludesAlphabet(t *testing.T) {
if got := r.URL.Query().Get("include_alphabet"); got != "true" { if got := r.URL.Query().Get("include_alphabet"); got != "true" {
t.Errorf("include_alphabet query = %q, want true", got) 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() defer cleanup()
@@ -60,7 +60,7 @@ func TestGameStateOmitsAlphabetByDefault(t *testing.T) {
if r.URL.Query().Get("include_alphabet") == "true" { if r.URL.Query().Get("include_alphabet") == "true" {
t.Error("include_alphabet should be unset") 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() defer cleanup()
@@ -153,7 +153,7 @@ func TestInvitationCreateRoundTrip(t *testing.T) {
if r.URL.Path != "/api/v1/user/invitations" { if r.URL.Path != "/api/v1/user/invitations" {
t.Errorf("unexpected path %q", r.URL.Path) 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() defer cleanup()
@@ -165,7 +165,7 @@ func TestInvitationCreateRoundTrip(t *testing.T) {
fb.CreateInvitationRequestStartInviteeIdsVector(b, 1) fb.CreateInvitationRequestStartInviteeIdsVector(b, 1)
b.PrependUOffsetT(inviteeID) b.PrependUOffsetT(inviteeID)
ids := b.EndVector(1) ids := b.EndVector(1)
variant := b.CreateString("english") variant := b.CreateString("scrabble_en")
dropout := b.CreateString("remove") dropout := b.CreateString("remove")
fb.CreateInvitationRequestStart(b) fb.CreateInvitationRequestStart(b)
fb.CreateInvitationRequestAddInviteeIds(b, ids) fb.CreateInvitationRequestAddInviteeIds(b, ids)
@@ -181,7 +181,7 @@ func TestInvitationCreateRoundTrip(t *testing.T) {
t.Fatalf("handler: %v", err) t.Fatalf("handler: %v", err)
} }
inv := fb.GetRootAsInvitation(payload, 0) 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()) 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" { if iv := inv.Inviter(nil); iv == nil || string(iv.DisplayName()) != "Me" {
+5 -5
View File
@@ -59,7 +59,7 @@ func TestGameStateRoundTripForwardsUserID(t *testing.T) {
if r.URL.Path != "/api/v1/user/games/g-1/state" { if r.URL.Path != "/api/v1/user/games/g-1/state" {
t.Errorf("unexpected path %q", r.URL.Path) 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() 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()) t.Fatalf("state decoded wrong: bag=%d rack=%d hints=%d", st.BagLen(), st.RackLength(), st.HintsRemaining())
} }
game := st.Game(nil) 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) t.Fatalf("nested game decoded wrong: %+v", game)
} }
} }
func TestEnqueueRoundTripEncodesMatch(t *testing.T) { func TestEnqueueRoundTripEncodesMatch(t *testing.T) {
backend, cleanup := fakeBackend(t, func(w http.ResponseWriter, r *http.Request) { backend, cleanup := fakeBackend(t, func(w http.ResponseWriter, r *http.Request) {
_, _ = 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() defer cleanup()
@@ -96,7 +96,7 @@ func TestEnqueueRoundTripEncodesMatch(t *testing.T) {
op, _ := reg.Lookup(transcode.MsgLobbyEnqueue) op, _ := reg.Lookup(transcode.MsgLobbyEnqueue)
b := flatbuffers.NewBuilder(32) b := flatbuffers.NewBuilder(32)
v := b.CreateString("english") v := b.CreateString("scrabble_en")
fb.EnqueueRequestStart(b) fb.EnqueueRequestStart(b)
fb.EnqueueRequestAddVariant(b, v) fb.EnqueueRequestAddVariant(b, v)
b.Finish(fb.EnqueueRequestEnd(b)) b.Finish(fb.EnqueueRequestEnd(b))
@@ -191,7 +191,7 @@ func TestGamesListRoundTripDecodesSeatNames(t *testing.T) {
if r.URL.Path != "/api/v1/user/games" { if r.URL.Path != "/api/v1/user/games" {
t.Errorf("unexpected path %q", r.URL.Path) 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() defer cleanup()
+1 -1
View File
@@ -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 page.getByRole('checkbox').first().check(); // pick a friend
await expect(send).toBeDisabled(); // still no game type 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 expect(send).toBeEnabled();
await send.click(); // the mock creates it and returns to the lobby await send.click(); // the mock creates it and returns to the lobby
+1 -1
View File
@@ -12,7 +12,7 @@
// complaint, off the board so the soft keyboard never relayouts the play area. // complaint, off the board so the soft keyboard never relayouts the play area.
let { id }: { id: string } = $props(); let { id }: { id: string } = $props();
let variant = $state<Variant>('english'); let variant = $state<Variant>('scrabble_en');
let word = $state(''); let word = $state('');
let result = $state<{ word: string; legal: boolean } | null>(null); let result = $state<{ word: string; legal: boolean } | null>(null);
let cooling = $state(false); let cooling = $state(false);
+1 -1
View File
@@ -57,7 +57,7 @@
let resignOpen = $state(false); let resignOpen = $state(false);
let drag = $state<{ letter: string; blank: boolean; x: number; y: number; touch: boolean } | null>(null); 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 board = $derived(replay(moves));
const premium = $derived(premiumGrid(variant)); const premium = $derived(premiumGrid(variant));
const ctr = $derived(centre(variant)); const ctr = $derived(centre(variant));
+20 -20
View File
@@ -12,37 +12,37 @@ import {
// The cache module is per-file-isolated by vitest, so only what these tests seed exists. // The cache module is per-file-isolated by vitest, so only what these tests seed exists.
describe('alphabet cache (Stage 13)', () => { describe('alphabet cache (Stage 13)', () => {
it('upper-cases letters for display and maps indices and values case-insensitively', () => { 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: 0, letter: 'a', value: 1 },
{ index: 16, letter: 'q', value: 10 }, { index: 16, letter: 'q', value: 10 },
]); ]);
expect(hasAlphabet('english')).toBe(true); expect(hasAlphabet('scrabble_en')).toBe(true);
expect(letterForIndex('english', 0)).toBe('A'); expect(letterForIndex('scrabble_en', 0)).toBe('A');
expect(letterForIndex('english', 16)).toBe('Q'); expect(letterForIndex('scrabble_en', 16)).toBe('Q');
expect(indexForLetter('english', 'a')).toBe(0); expect(indexForLetter('scrabble_en', 'a')).toBe(0);
expect(indexForLetter('english', 'Q')).toBe(16); expect(indexForLetter('scrabble_en', 'Q')).toBe(16);
expect(valueForLetter('english', 'a')).toBe(1); expect(valueForLetter('scrabble_en', 'a')).toBe(1);
expect(valueForLetter('english', 'Q')).toBe(10); expect(valueForLetter('scrabble_en', 'Q')).toBe(10);
}); });
it('handles the blank sentinel and unknown letters/indices', () => { it('handles the blank sentinel and unknown letters/indices', () => {
setAlphabet('english', [{ index: 0, letter: 'a', value: 1 }]); setAlphabet('scrabble_en', [{ index: 0, letter: 'a', value: 1 }]);
expect(letterForIndex('english', BLANK_INDEX)).toBe('?'); expect(letterForIndex('scrabble_en', BLANK_INDEX)).toBe('?');
expect(indexForLetter('english', '?')).toBe(BLANK_INDEX); expect(indexForLetter('scrabble_en', '?')).toBe(BLANK_INDEX);
expect(valueForLetter('english', '?')).toBe(0); expect(valueForLetter('scrabble_en', '?')).toBe(0);
expect(letterForIndex('english', 99)).toBe(''); // out of range expect(letterForIndex('scrabble_en', 99)).toBe(''); // out of range
expect(valueForLetter('english', 'Z')).toBe(0); // not in this alphabet expect(valueForLetter('scrabble_en', 'Z')).toBe(0); // not in this alphabet
expect(() => indexForLetter('english', 'Z')).toThrow(); expect(() => indexForLetter('scrabble_en', 'Z')).toThrow();
}); });
it('lists the alphabet for the blank chooser and is empty for an uncached variant', () => { 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: 0, letter: 'a', value: 1 },
{ index: 1, letter: 'b', value: 3 }, { index: 1, letter: 'b', value: 3 },
]); ]);
expect(alphabetLetters('english')).toEqual(['A', 'B']); expect(alphabetLetters('scrabble_en')).toEqual(['A', 'B']);
expect(hasAlphabet('erudit')).toBe(false); expect(hasAlphabet('erudit_ru')).toBe(false);
expect(alphabetLetters('erudit')).toEqual([]); expect(alphabetLetters('erudit_ru')).toEqual([]);
expect(valueForLetter('erudit', 'A')).toBe(0); expect(valueForLetter('erudit_ru', 'A')).toBe(0);
}); });
}); });
+10 -10
View File
@@ -36,7 +36,7 @@ describe('codec', () => {
}); });
it('encodes a SubmitPlayRequest with alphabet indices (Stage 13)', () => { it('encodes a SubmitPlayRequest with alphabet indices (Stage 13)', () => {
setAlphabet('english', [ setAlphabet('scrabble_en', [
{ index: 0, letter: 'a', value: 1 }, { index: 0, letter: 'a', value: 1 },
{ index: 1, letter: 'b', value: 3 }, { index: 1, letter: 'b', value: 3 },
]); ]);
@@ -48,7 +48,7 @@ describe('codec', () => {
{ row: 7, col: 7, letter: 'A', blank: false }, { row: 7, col: 7, letter: 'A', blank: false },
{ row: 7, col: 8, letter: 'B', blank: true }, { row: 7, col: 8, letter: 'B', blank: true },
], ],
'english', 'scrabble_en',
); );
const r = fb.SubmitPlayRequest.getRootAsSubmitPlayRequest(new ByteBuffer(buf)); const r = fb.SubmitPlayRequest.getRootAsSubmitPlayRequest(new ByteBuffer(buf));
expect(r.gameId()).toBe('g1'); expect(r.gameId()).toBe('g1');
@@ -95,7 +95,7 @@ describe('codec', () => {
const seat = fb.SeatView.endSeatView(b); const seat = fb.SeatView.endSeatView(b);
const seats = fb.GameView.createSeatsVector(b, [seat]); const seats = fb.GameView.createSeatsVector(b, [seat]);
const id = b.createString('g1'); const id = b.createString('g1');
const variant = b.createString('english'); const variant = b.createString('scrabble_en');
const dv = b.createString('v1'); const dv = b.createString('v1');
const status = b.createString('active'); const status = b.createString('active');
const er = b.createString(''); const er = b.createString('');
@@ -242,7 +242,7 @@ describe('codec', () => {
const invitees = fb.Invitation.createInviteesVector(b, [invitee]); const invitees = fb.Invitation.createInviteesVector(b, [invitee]);
const id = b.createString('i-1'); const id = b.createString('i-1');
const variant = b.createString('english'); const variant = b.createString('scrabble_en');
const dropout = b.createString('remove'); const dropout = b.createString('remove');
const status = b.createString('pending'); const status = b.createString('pending');
const gid = b.createString(''); const gid = b.createString('');
@@ -264,7 +264,7 @@ describe('codec', () => {
expect(inv.inviter).toEqual({ accountId: 'u-1', displayName: 'Me' }); expect(inv.inviter).toEqual({ accountId: 'u-1', displayName: 'Me' });
expect(inv.invitees).toHaveLength(1); expect(inv.invitees).toHaveLength(1);
expect(inv.invitees[0]).toEqual({ accountId: 'inv-1', displayName: 'Friend', seat: 1, response: 'pending' }); 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. // the whole table), so they are independent of order.
describe('codec — alphabet on the wire (Stage 13)', () => { describe('codec — alphabet on the wire (Stage 13)', () => {
it('encodes an ExchangeRequest as alphabet indices, blank as the sentinel', () => { it('encodes an ExchangeRequest as alphabet indices, blank as the sentinel', () => {
setAlphabet('english', [ setAlphabet('scrabble_en', [
{ index: 0, letter: 'a', value: 1 }, { index: 0, letter: 'a', value: 1 },
{ index: 1, letter: 'b', value: 3 }, { index: 1, letter: 'b', value: 3 },
]); ]);
const r = fb.ExchangeRequest.getRootAsExchangeRequest( 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.tilesLength()).toBe(2);
expect(r.tiles(0)).toBe(0); 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', () => { it('encodes a CheckWordRequest as alphabet indices', () => {
setAlphabet('english', [ setAlphabet('scrabble_en', [
{ index: 0, letter: 'a', value: 1 }, { index: 0, letter: 'a', value: 1 },
{ index: 2, letter: 'c', value: 3 }, { index: 2, letter: 'c', value: 3 },
{ index: 19, letter: 't', value: 1 }, { index: 19, letter: 't', value: 1 },
]); ]);
const r = fb.CheckWordRequest.getRootAsCheckWordRequest( 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.wordLength()).toBe(3);
expect([r.word(0), r.word(1), r.word(2)]).toEqual([2, 0, 19]); 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); fb.StateView.addAlphabet(b, alpha);
b.finish(fb.StateView.endStateView(b)); 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. // the embedded table is cached under it and the rack [0, blank] decodes to letters.
const sv = decodeStateView(b.asUint8Array()); const sv = decodeStateView(b.asUint8Array());
expect(sv.rack).toEqual(['A', '?']); expect(sv.rack).toEqual(['A', '?']);
+2 -2
View File
@@ -325,7 +325,7 @@ export function decodeProfile(buf: Uint8Array): Profile {
export function decodeStateView(buf: Uint8Array): StateView { export function decodeStateView(buf: Uint8Array): StateView {
const v = fb.StateView.getRootAsStateView(new ByteBuffer(buf)); const v = fb.StateView.getRootAsStateView(new ByteBuffer(buf));
const g = v.game(); 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 // 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). // decode the index rack to display letters with it (Stage 13).
if (v.alphabetLength() > 0) { if (v.alphabetLength() > 0) {
@@ -681,7 +681,7 @@ export function decodeGcg(buf: Uint8Array): GcgExport {
function emptyGame(): GameView { function emptyGame(): GameView {
return { return {
id: '', id: '',
variant: 'english', variant: 'scrabble_en',
dictVersion: '', dictVersion: '',
status: '', status: '',
players: 0, players: 0,
+1 -1
View File
@@ -15,7 +15,7 @@ const seat = (s: number, accountId: string): Seat => ({
function game(id: string, status: GameView['status'], toMove: number, lastActivityUnix: number): GameView { function game(id: string, status: GameView['status'], toMove: number, lastActivityUnix: number): GameView {
return { return {
id, id,
variant: 'english', variant: 'scrabble_en',
dictVersion: 'v1', dictVersion: 'v1',
status, status,
players: 2, players: 2,
+3 -3
View File
@@ -10,11 +10,11 @@ import type { Variant } from '../model';
// "letter+value" tokens in alphabet-index order. English Latin a..z; Russian а..я incl. ё; // "letter+value" tokens in alphabet-index order. English Latin a..z; Russian а..я incl. ё;
// Эрудит а..я incl. ё=0. // Эрудит а..я incl. ё=0.
const SPECS: Record<Variant, string> = { const SPECS: Record<Variant, string> = {
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', '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', 'а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', 'а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',
}; };
+3 -3
View File
@@ -61,9 +61,9 @@ function emptyLinked(): LinkResult {
} }
const POOL: Record<Variant, string> = { const POOL: Record<Variant, string> = {
english: 'AAAAEEEEIIIOONNRRTTLLSSUDGBCMPFHVWYK', scrabble_en: 'AAAAEEEEIIIOONNRRTTLLSSUDGBCMPFHVWYK',
russian_scrabble: 'ОООААЕЕИИННТТСРРВЛКМДПУЯЫЬГЗБ', scrabble_ru: 'ОООААЕЕИИННТТСРРВЛКМДПУЯЫЬГЗБ',
erudit: 'ОООААЕЕИИННТТСРРВЛКМДПУЯЫЬГЗБ', erudit_ru: 'ОООААЕЕИИННТТСРРВЛКМДПУЯЫЬГЗБ',
}; };
function draw(variant: Variant, n: number): string[] { function draw(variant: Variant, n: number): string[] {
+5 -5
View File
@@ -57,7 +57,7 @@ export function mockInvitations(): Invitation[] {
id: 'inv1', id: 'inv1',
inviter: { accountId: 'kaya', displayName: 'Kaya' }, inviter: { accountId: 'kaya', displayName: 'Kaya' },
invitees: [{ accountId: ME, displayName: 'You', seat: 1, response: 'pending' }], invitees: [{ accountId: ME, displayName: 'You', seat: 1, response: 'pending' }],
variant: 'english', variant: 'scrabble_en',
turnTimeoutSecs: 86400, turnTimeoutSecs: 86400,
hintsAllowed: true, hintsAllowed: true,
hintsPerPlayer: 1, hintsPerPlayer: 1,
@@ -105,7 +105,7 @@ export interface MockGame {
chat: ChatMessage[]; 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[] = [ const G1_MOVES: MoveRecord[] = [
play(0, 'H', [ play(0, 'H', [
@@ -135,7 +135,7 @@ function activeGame(): MockGame {
return { return {
view: { view: {
id: 'g1', id: 'g1',
variant: 'english', variant: 'scrabble_en',
dictVersion: 'v1', dictVersion: 'v1',
status: 'active', status: 'active',
players: 2, players: 2,
@@ -169,7 +169,7 @@ function finishedG2(): MockGame {
return { return {
view: { view: {
id: 'g2', id: 'g2',
variant: 'english', variant: 'scrabble_en',
dictVersion: 'v1', dictVersion: 'v1',
status: 'finished', status: 'finished',
players: 2, players: 2,
@@ -204,7 +204,7 @@ function finishedG3(): MockGame {
return { return {
view: { view: {
id: 'g3', id: 'g3',
variant: 'russian_scrabble', variant: 'scrabble_ru',
dictVersion: 'v1', dictVersion: 'v1',
status: 'finished', status: 'finished',
players: 2, players: 2,
+1 -1
View File
@@ -3,7 +3,7 @@
// FlatBuffers) and the mock transport speak this model, so the UI never touches // FlatBuffers) and the mock transport speak this model, so the UI never touches
// generated wire code directly. // generated wire code directly.
export type Variant = 'english' | 'russian_scrabble' | 'erudit'; export type Variant = 'scrabble_en' | 'scrabble_ru' | 'erudit_ru';
/** Backend game status strings. */ /** Backend game status strings. */
export type GameStatus = 'active' | 'finished' | string; export type GameStatus = 'active' | 'finished' | string;
+10 -10
View File
@@ -1,13 +1,13 @@
import { describe, expect, it } from 'vitest'; import { describe, expect, it } from 'vitest';
import { BOARD_SIZE, centre, premiumGrid } from './premiums'; import { BOARD_SIZE, centre, premiumGrid } from './premiums';
// Premium-square geometry parity with scrabble-solver/rules/rules.go: english/russian // Premium-square geometry parity with scrabble-solver/rules/rules.go: scrabble_en/scrabble_ru
// share standardBoard (centre is a double word); erudit shares the geometry but a // 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 // non-doubling centre. Tile-value and alphabet parity moved to the Go engine test
// (backend/internal/engine AlphabetTable) in Stage 13 — the server now owns that table. // (backend/internal/engine AlphabetTable) in Stage 13 — the server now owns that table.
describe('premium layout', () => { describe('premium layout', () => {
it('is a 15x15 grid with TW corners', () => { 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.length).toBe(BOARD_SIZE);
expect(g[0].length).toBe(BOARD_SIZE); expect(g[0].length).toBe(BOARD_SIZE);
for (const [r, c] of [ for (const [r, c] of [
@@ -20,16 +20,16 @@ describe('premium layout', () => {
} }
}); });
it('doubles the centre for standard variants but not for erudit', () => { it('doubles the centre for standard variants but not for erudit_ru', () => {
expect(centre('english')).toEqual({ row: 7, col: 7 }); expect(centre('scrabble_en')).toEqual({ row: 7, col: 7 });
expect(premiumGrid('english')[7][7]).toBe('DW'); expect(premiumGrid('scrabble_en')[7][7]).toBe('DW');
expect(premiumGrid('russian_scrabble')[7][7]).toBe('DW'); expect(premiumGrid('scrabble_ru')[7][7]).toBe('DW');
expect(centre('erudit')).toEqual({ row: 7, col: 7 }); expect(centre('erudit_ru')).toEqual({ row: 7, col: 7 });
expect(premiumGrid('erudit')[7][7]).toBe(''); expect(premiumGrid('erudit_ru')[7][7]).toBe('');
}); });
it('keeps the standard premium counts', () => { 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; const count = (p: string) => flat.filter((x) => x === p).length;
expect(count('TW')).toBe(8); expect(count('TW')).toBe(8);
expect(count('TL')).toBe(12); expect(count('TL')).toBe(12);
+1 -1
View File
@@ -51,7 +51,7 @@ const eruditBoard = [
]; ];
function template(variant: Variant): string[] { function template(variant: Variant): string[] {
return variant === 'erudit' ? eruditBoard : standardBoard; return variant === 'erudit_ru' ? eruditBoard : standardBoard;
} }
function premiumOf(ch: string): Premium { function premiumOf(ch: string): Premium {
+1 -1
View File
@@ -14,7 +14,7 @@ const seat = (s: number, accountId: string, score: number, isWinner = false): Se
function game(seats: Seat[], status = 'finished', toMove = 0): GameView { function game(seats: Seat[], status = 'finished', toMove = 0): GameView {
return { return {
id: 'g', id: 'g',
variant: 'english', variant: 'scrabble_en',
dictVersion: 'v1', dictVersion: 'v1',
status, status,
players: seats.length, players: seats.length,
+3 -3
View File
@@ -9,14 +9,14 @@ describe('availableVariants', () => {
}); });
it('offers only English for an en-only service', () => { 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', () => { 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', () => { 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']);
}); });
}); });
+10 -10
View File
@@ -17,9 +17,9 @@ export interface VariantOption {
// two never collide whatever the UI language); Erudit is localized "Erudite"/"Эрудит" // two never collide whatever the UI language); Erudit is localized "Erudite"/"Эрудит"
// (Stage 17). // (Stage 17).
export const ALL_VARIANTS: VariantOption[] = [ export const ALL_VARIANTS: VariantOption[] = [
{ id: 'english', label: 'new.english' }, { id: 'scrabble_en', label: 'new.english' },
{ id: 'russian_scrabble', label: 'new.russian' }, { id: 'scrabble_ru', label: 'new.russian' },
{ id: 'erudit', label: 'new.erudit' }, { id: 'erudit_ru', label: 'new.erudit' },
]; ];
// variantNameKey returns the i18n key for a variant's display name (used by the in-game // 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 // 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. // buttons (bag size, the ё rule, bonus differences), sourced from the engine rulesets.
export const VARIANT_RULES: Record<Variant, MessageKey> = { export const VARIANT_RULES: Record<Variant, MessageKey> = {
english: 'new.rulesEnglish', scrabble_en: 'new.rulesEnglish',
russian_scrabble: 'new.rulesRussian', scrabble_ru: 'new.rulesRussian',
erudit: 'new.rulesErudit', erudit_ru: 'new.rulesErudit',
}; };
// VARIANT_FLAG is the flag shown on a variant button: an emoji for the Scrabble variants; // 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. // Erudit uses the bundled USSR flag SVG (public/flag-ussr.svg), so its entry is empty.
export const VARIANT_FLAG: Record<Variant, string> = { export const VARIANT_FLAG: Record<Variant, string> = {
english: '🇺🇸', scrabble_en: '🇺🇸',
russian_scrabble: '🇷🇺', scrabble_ru: '🇷🇺',
erudit: '', erudit_ru: '',
}; };
// VARIANT_LANGUAGE maps each variant to its game language. en -> English; // VARIANT_LANGUAGE maps each variant to its game language. en -> English;
// ru -> Russian + Эрудит. // ru -> Russian + Эрудит.
export const VARIANT_LANGUAGE: Record<Variant, 'en' | 'ru'> = { english: 'en', russian_scrabble: 'ru', erudit: 'ru' }; export const VARIANT_LANGUAGE: Record<Variant, 'en' | 'ru'> = { scrabble_en: 'en', scrabble_ru: 'ru', erudit_ru: 'ru' };
// availableVariants gates ALL_VARIANTS by the session's supported languages. An empty // 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 // or absent set is ungated (a web/legacy session without a declared set), returning
+3 -3
View File
@@ -144,9 +144,9 @@
} }
const variantKey: Record<string, MessageKey> = { const variantKey: Record<string, MessageKey> = {
english: 'new.english', scrabble_en: 'new.english',
russian_scrabble: 'new.russian', scrabble_ru: 'new.russian',
erudit: 'new.erudit', erudit_ru: 'new.erudit',
}; };
</script> </script>