R1: schema & naming reset — squash migrations, rename variants #31
@@ -8,6 +8,8 @@ conversation memory — is the source of continuity. Keep it that way.
|
||||
|
||||
- [`PLAN.md`](PLAN.md) — staged plan + **stage tracker** + per-stage *open
|
||||
details to interview*.
|
||||
- [`PRERELEASE.md`](PRERELEASE.md) — pre-release hardening tracker (phases R1–R7
|
||||
before Stage 18); same per-phase *interview + bake-back* discipline as `PLAN.md`.
|
||||
- [`docs/ARCHITECTURE.md`](docs/ARCHITECTURE.md) — architecture, transport,
|
||||
security, the decision record. Always describes current state.
|
||||
- [`docs/FUNCTIONAL.md`](docs/FUNCTIONAL.md) (+ [`_ru`](docs/FUNCTIONAL_ru.md)
|
||||
|
||||
+219
@@ -0,0 +1,219 @@
|
||||
# Pre-release plan — hardening before Stage 18
|
||||
|
||||
Living tracker for the pre-release hardening pass that runs **before Stage 18** (the
|
||||
prod cutover). Same discipline as [`PLAN.md`](PLAN.md): one phase per session,
|
||||
**interview the owner on the open details** at the start of each phase, bake every
|
||||
decision back into `PLAN.md` / `docs/` / the affected `README`s / Go Doc comments in
|
||||
the **same** PR, get CI green, then mark the phase done. Phases run as
|
||||
`feature/* → development` PRs (the Stage 16 branch model); the owner approves+merges.
|
||||
|
||||
**Why now:** the system is feature-complete through Stage 17 and the test contour is
|
||||
green, but there is **no prod data yet** — schema, wire labels and the dictionary
|
||||
layout can still change for free. These phases spend that one-time freedom and harden
|
||||
the edge before prod. Each phase maps back to the owner's raw pre-release TODO list
|
||||
(numbers in the tracker).
|
||||
|
||||
## Phase tracker
|
||||
|
||||
| # | Phase | Raw TODOs | Status |
|
||||
|---|-------|-----------|--------|
|
||||
| R1 | Schema & naming reset | 1 + 10 | **in review** |
|
||||
| R2 | Stress harness + contour observability + early run | 9a | todo |
|
||||
| R3 | Edge hardening | 2 + 8 + 3 | todo |
|
||||
| R4 | Push enrichment + kill the last poll | 4 + 5 | todo |
|
||||
| R5 | Bundle slimming | 6 | todo |
|
||||
| R6 | Refactor + docs reconciliation + de-staging | 7 | todo |
|
||||
| R7 | Final stress run + tuning | 9b | todo |
|
||||
| → | Stage 18 — prod contour deploy | — | see [`PLAN.md`](PLAN.md) |
|
||||
|
||||
## Key findings (these reshaped the raw list — read before starting a phase)
|
||||
|
||||
- **R1 (TODO 1 + 10) is one cheap moment, now.** Squashing the 12 goose migrations is
|
||||
safe precisely because there is no prod data and the contour DB is wiped. Folding the
|
||||
new variant labels (`scrabble_ru`/`scrabble_en`/`erudit_ru`) into that single baseline
|
||||
makes the rename need **no data migration and no back-compat mapping**. Today's labels
|
||||
(`english`/`russian_scrabble`/`erudit`) are persisted in `games.variant`,
|
||||
`game_invitations.variant`, in `pkg/fbs` and the UI — ~100 files, but a mechanical sweep
|
||||
on a clean DB.
|
||||
- **R4 (TODO 4 + 5): the app is already push-first.** Game state refreshes on
|
||||
`your_turn`/`opponent_moved`, the lobby on `notify`, chat on `chat_message`. The **only**
|
||||
genuine periodic server poll is `lobby.poll` (matchmaking, 2.5 s,
|
||||
`ui/src/screens/NewGame.svelte`). What remains is killing that one poll **and** enriching
|
||||
push events to carry payloads so the UI stops re-fetching after each signal.
|
||||
- **R3 (TODO 2): identity forgery is already mitigated.** Identity is always derived from
|
||||
the session (`Authorization: Bearer` → `X-User-ID`); the client cannot inject identity,
|
||||
the backend re-validates resource ownership, Telegram initData is HMAC-checked. The real
|
||||
gaps are a missing **request-body size limit** (cheap DoS) and **invisible rate-limit
|
||||
rejections** (no log/metric/admin view — that is TODO 8). Static landing serving is **not**
|
||||
covered by the gateway token bucket (it only guards `Execute`).
|
||||
- **R6 (TODO 7) scale:** ~431 `Stage N` references across ~104 files (incl. the file name
|
||||
`backend/internal/inttest/stage6_test.go`). Code is the source of truth; `docs/` describe
|
||||
current state; `PLAN.md` keeps the decision history.
|
||||
|
||||
## Locked decisions (owner interview)
|
||||
|
||||
- **Stress test (TODO 9):** **early + final** runs. Driver = **edge protocol** (Connect/FB
|
||||
through the gateway, moves generated by the solver) **plus a separate gateway-hammer**
|
||||
saturation test. Pacing = **realistic (under limits) + saturation (ramp to the knee)**.
|
||||
Resource metrics = **add cAdvisor + postgres_exporter to the contour** (today only
|
||||
Go-runtime metrics exist). The harness stays in the repo for repeats.
|
||||
- **Push (TODO 4 + 5):** **both** — kill `lobby.poll` (use the existing `match_found`, keep
|
||||
poll as the ws-down fallback) **and** enrich push events with payloads.
|
||||
- **Refactor (TODO 7):** **hygiene + structural changes by a reviewed list** —
|
||||
behaviour-preserving, test-gated, contentious items surfaced to the owner before applying.
|
||||
- **Landing (TODO 3):** **separate static container** behind the project caddy
|
||||
(`/` → landing, `/app/` + `/telegram/` → gateway); drop `landing.html` from the gateway
|
||||
`go:embed`.
|
||||
- **Rate-abuse (TODO 8):** metric + Grafana + admin view **plus a conservative auto-flag** —
|
||||
a *soft, reversible* "suspected high-rate" marker for operator review, tunable threshold,
|
||||
**no auto-ban**.
|
||||
|
||||
## Phases
|
||||
|
||||
Each phase: read this tracker + the relevant `docs/`, **interview the owner on the open
|
||||
details below**, implement within scope, then update the tracker + docs/code and get CI
|
||||
green before marking it done.
|
||||
|
||||
### R1 — Schema & naming reset *(TODO 1 + 10)* — first
|
||||
Squash `backend/internal/postgres/migrations/00001..00012` into one `00001_baseline.sql`
|
||||
(method: `pg_dump --schema-only` from a fully-migrated DB → wrap as the goose baseline →
|
||||
prove a fresh migrate yields a schema identical to the 12-migration chain via the
|
||||
integration suite → delete the old files; keep goose). Bake the new variant labels into the
|
||||
baseline. Propagate `scrabble_ru`/`scrabble_en`/`erudit_ru` through the backend
|
||||
(`engine.Variant`/`ParseVariant`, `registry.dictFiles`, the CHECK values), the wire
|
||||
(`pkg/fbs` `variant:string`, regenerate FB) and the UI (`lib/model.ts` union, `variants.ts`,
|
||||
fixtures, premium/alphabet keys, tests); i18n display keys stay display-only. Tidy
|
||||
`../scrabble-dictionary` to a single source→dawg build point and align the dawg artifact
|
||||
names to the new labels (crosses into `../scrabble-solver`'s committed fixtures — keep them
|
||||
byte-identical). After merge, **wipe the contour DB** (drop the volume) so it re-provisions
|
||||
on the next deploy.
|
||||
- Critical files: `backend/internal/postgres/migrations/`,
|
||||
`backend/internal/engine/{engine,registry}.go`, `pkg/fbs/scrabble.fbs`,
|
||||
`ui/src/lib/{model,variants}.ts`, `../scrabble-dictionary/{Makefile,cmd/builddict,…}`.
|
||||
- Open details to interview: the exact dawg filename scheme; whether the dict-repo tidy is
|
||||
one PR or split; how to script the contour DB wipe in the deploy.
|
||||
|
||||
### R2 — Stress harness + contour observability + early run *(TODO 9, part 1)*
|
||||
Build the reusable load harness as a new `loadtest` module in `go.work` (reuses `pkg/fbs`,
|
||||
`connect-go`, and `scrabble-solver` for legal-move generation): a seeder that inserts
|
||||
**1000 guest + 10000 durable** accounts with pre-created sessions (token hashes) directly in
|
||||
the DB and hands the plaintext tokens to the client; a driver that runs N virtual users,
|
||||
each in 3–5 concurrent 2–4-player games, exercising submit-play / pass / exchange / nudge /
|
||||
chat / check-word / draft-move / profile-save through the **edge protocol**, in
|
||||
**realistic** (under rate limits) and **saturation** (ramp) modes; plus a separate
|
||||
**gateway-hammer** that deliberately exceeds limits to verify the limiter holds and measure
|
||||
its cost. Add **cAdvisor + postgres_exporter** to `deploy/docker-compose.yml` and a Grafana
|
||||
resource dashboard. Run the **early pass** against the freshly-wiped contour; produce a
|
||||
**trip report** (logic/concurrency bugs + a resource baseline) that feeds R3 and R6.
|
||||
- Critical files: new `loadtest/`, `deploy/docker-compose.yml`, `deploy/observability/*`,
|
||||
`docs/TESTING.md`.
|
||||
- Open details: the scale ramp steps; the move-selection policy (a mid-ranked solver move
|
||||
for realistic game progress); run duration; the pass/fail bar.
|
||||
|
||||
### R3 — Edge hardening *(TODO 2 + 8 + 3)*
|
||||
Add a **request-body size cap** at the gateway h2c mux / `Execute` (e.g. ~1 MB). Add
|
||||
**rate-limit observability**: a `gateway_rate_limited_total{class}` counter + a structured
|
||||
log per rejection; an **aggregate** Grafana panel (request rate + rejection rate — spikes
|
||||
visible without per-user label cardinality, honouring the Stage 12/17 discipline); an
|
||||
**admin-console view** of recently throttled users/IPs (in-memory ring buffer, single-
|
||||
instance, reset-on-restart, like the `active_users` gauge). Add the **conservative
|
||||
auto-flag**: when a user is *sustained*-throttled past a tunable threshold, set a soft,
|
||||
reversible `account.flagged_high_rate_at` marker (baked into the R1 baseline) surfaced in the
|
||||
admin user list/detail — **no auto-ban**; the operator clears it. Split the **landing** into
|
||||
its own static container (`deploy/` + a Caddyfile route `/` → landing) and drop
|
||||
`landing.html` from the gateway `go:embed`.
|
||||
- Critical files: `gateway/internal/connectsrv/server.go`, `gateway/internal/ratelimit/`,
|
||||
`gateway/internal/connectsrv/metrics.go`, `backend/internal/adminconsole/`,
|
||||
`deploy/caddy/Caddyfile`, `deploy/docker-compose.yml`, `gateway/internal/webui/`.
|
||||
- Open details: the auto-flag threshold/window + whether the marker is persisted vs
|
||||
in-memory; the landing image base (caddy vs nginx).
|
||||
|
||||
### R4 — Push enrichment + kill the last poll *(TODO 4 + 5)*
|
||||
Replace `lobby.poll` with the existing `match_found` push (keep the poll as a ws-down
|
||||
fallback). Enrich `your_turn`/`opponent_moved`/`notify` to carry the state payload so the UI
|
||||
renders from the event without a follow-up `game.state` (removes the lobby↔game nav latency
|
||||
the owner noticed). Wire-contract change: `pkg/fbs` event payloads → backend `notify` emit →
|
||||
UI stream consumers (`ui/src/lib/app.svelte.ts`), with the per-game cache as the landing
|
||||
spot; regenerate FB.
|
||||
- Critical files: `pkg/fbs/scrabble.fbs`, `backend/internal/notify/events.go`,
|
||||
`ui/src/lib/{app.svelte,transport}.ts`, `ui/src/screens/NewGame.svelte`.
|
||||
- Open details: which events carry full vs delta payloads; the fallback-poll cadence when the
|
||||
stream is down.
|
||||
|
||||
### R5 — Bundle slimming *(TODO 6)*
|
||||
Lazy-load secondary screens (Friends/Stats/Settings/About/Profile) and i18n catalogs by
|
||||
language via dynamic imports; re-measure against the existing 100 KB-gzip budget
|
||||
(`ui/scripts/bundle-size.mjs`, ~82 KB today). If the win is marginal, stop — acceptable per
|
||||
the owner.
|
||||
- Critical files: `ui/src/App.svelte`, `ui/vite.config.ts`, `ui/src/lib/i18n/`.
|
||||
|
||||
### R6 — Refactor + docs reconciliation + de-staging *(TODO 7)* — near last
|
||||
Behaviour-preserving only. Three separable, separately-committed passes: (a) mechanical
|
||||
**de-staging** — remove `Stage N`/`TODO-N` references from code, comments and service
|
||||
READMEs (rename `stage6_test.go`); (b) **docs↔code reconciliation** — reconcile
|
||||
`docs/ARCHITECTURE.md` / `docs/FUNCTIONAL.md`(+`_ru`) against the code-as-truth, fixing drift
|
||||
and Go Doc comments; (c) **structural changes by a reviewed list** — surface a list of
|
||||
proposed optimizations / test-suite consolidations to the owner, apply only the approved,
|
||||
behaviour-preserving, test-gated ones. The full suite + the final stress run (R7) are the
|
||||
regression gate. Incorporates the early-run (R2) bug fixes not already shipped.
|
||||
- Open details: the structural-changes list itself (owner-approved before applying); the test
|
||||
consolidation targets.
|
||||
|
||||
### R7 — Final stress run + tuning *(TODO 9, part 2)* — before Stage 18
|
||||
Re-run the R2 harness against the final, refactored system on a clean contour; analyse
|
||||
resource consumption across **all** components (gateway, backend, Postgres, the
|
||||
metrics/observability stack, docker log volume) and agree the tuning (pool sizes, rate
|
||||
limits, cache TTLs, container limits, GOMAXPROCS, log levels). Apply the agreed tuning; record
|
||||
the methodology + results in the repo.
|
||||
|
||||
→ **Stage 18** (prod contour) then proceeds per [`PLAN.md`](PLAN.md).
|
||||
|
||||
## Sequencing rationale
|
||||
|
||||
`R1` first (cheapest now; everything builds on the final schema/naming and the stress test
|
||||
must run against it). `R2` builds the harness and runs the **early** pass to surface bugs and
|
||||
a resource baseline that feed `R3` and `R6`. `R3`/`R4`/`R5` harden and improve the system.
|
||||
`R6` (de-stage + reconcile + structural) runs near the end so it sweeps settled code once and
|
||||
benefits from all accumulated bug knowledge. `R7` validates the final system and tunes it.
|
||||
Then Stage 18.
|
||||
|
||||
## Regression-safety discipline (cross-cutting)
|
||||
|
||||
- Every phase is a `feature/* → development` PR; CI (`unit` + `integration` + `ui` behind the
|
||||
`CI / gate` check) must be green before the owner merges; watch the post-merge contour
|
||||
deploy with `gitea-ci-watch.py`.
|
||||
- `R6` structural changes are behaviour-preserving, test-gated, and split from the mechanical
|
||||
sweeps; contentious items are owner-approved first.
|
||||
- The two stress runs (`R2` early, `R7` final) are the system-level regression gate.
|
||||
|
||||
## Verification (per phase)
|
||||
|
||||
- `go build ./<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
@@ -71,18 +71,18 @@ gateway. Stage 9 adds the gateway-only `POST /api/v1/internal/push-target` (a us
|
||||
Telegram `external_id`, language and `notifications_in_app_only` flag) that the gateway
|
||||
uses to route out-of-app push to the Telegram connector, extends the Telegram login to
|
||||
seed a new account's language and display name from the launch fields, and adds
|
||||
migration `00007` (`accounts.notifications_in_app_only`, default true).
|
||||
Migration `00005` adds `accounts.is_guest`: an ephemeral guest is a durable row
|
||||
the `accounts.notifications_in_app_only` flag (default true).
|
||||
`accounts.is_guest` marks an ephemeral guest — a durable row
|
||||
with no identity, excluded from statistics. **Stage 10** adds the server-rendered
|
||||
**admin console** at `/_gm` (`internal/adminconsole` + `internal/server/handlers_admin_console.go`;
|
||||
the gateway fronts it with Basic-Auth and a same-origin guard protects its POSTs), the
|
||||
**complaint resolution** lifecycle (migration `00008` adds `disposition`/`resolution_note`/
|
||||
`resolved_at`/`applied_in_version` + the `status` CHECK) feeding a dictionary-change
|
||||
**complaint resolution** lifecycle (the `complaints` `disposition`/`resolution_note`/
|
||||
`resolved_at`/`applied_in_version` columns + the `status` CHECK) feeding a dictionary-change
|
||||
pipeline, dictionary **hot-reload** from `BACKEND_DICT_DIR/<version>/`
|
||||
(`engine.OpenWithVersions` / `Registry.LoadAvailable`), and operator **broadcasts** via a
|
||||
backend Telegram-connector client (`internal/connector`, `BACKEND_CONNECTOR_ADDR`) — each
|
||||
broadcast picks the delivering bot by an operator-chosen language. **Stage 15** adds
|
||||
migration `00010` (`accounts.service_language`): the language tag of the bot a Telegram
|
||||
`accounts.service_language`: the language tag of the bot a Telegram
|
||||
user last signed in through, written on every login and returned by
|
||||
`/internal/push-target` (falling back to `preferred_language`) so out-of-app push routes
|
||||
to the right bot. The shared wire contracts live in the sibling [`../pkg`](../pkg) module.
|
||||
@@ -96,7 +96,7 @@ friends/blocks de-duplicated, the secondary kept as a `merged_into` tombstone (s
|
||||
shared finished game's foreign keys hold); a shared **active** game blocks the merge.
|
||||
The current account is primary, except a guest initiator whose linked identity has a
|
||||
durable owner — then the durable account wins and a fresh session is minted for it.
|
||||
Migration `00009` adds `paid_account`/`merged_into`/`merged_at`. This supersedes the
|
||||
The `accounts.paid_account`/`merged_into`/`merged_at` columns back this. This supersedes the
|
||||
Stage 8 `email.bind.*` edge surface (the `RequestCode`/`ConfirmCode` primitives stay).
|
||||
|
||||
## Package layout
|
||||
@@ -176,7 +176,10 @@ warmed.
|
||||
## Migrations & generated code
|
||||
|
||||
Migrations are plain goose SQL under `internal/postgres/migrations` (sequential
|
||||
`NNNNN_name.sql`), embedded and applied at startup. After changing the schema,
|
||||
`NNNNN_name.sql`), embedded and applied at startup. The incremental history was
|
||||
squashed into a single `00001_baseline.sql` before the first production deploy
|
||||
(there was no production data); new schema changes append as `00002_*` onward.
|
||||
After changing the schema,
|
||||
regenerate the committed go-jet code (needs Docker):
|
||||
|
||||
```sh
|
||||
|
||||
@@ -20,15 +20,15 @@ func TestRendererRendersEveryPage(t *testing.T) {
|
||||
data any
|
||||
want string
|
||||
}{
|
||||
{"dashboard", DashboardView{Accounts: 3, Variants: []VariantVersions{{Variant: "english", Latest: "v1", Versions: []string{"v1"}}}}, "Dashboard"},
|
||||
{"dashboard", DashboardView{Accounts: 3, Variants: []VariantVersions{{Variant: "scrabble_en", Latest: "v1", Versions: []string{"v1"}}}}, "Dashboard"},
|
||||
{"users", UsersView{Items: []UserRow{{ID: "a1", DisplayName: "Kaya"}}, Pager: NewPager(1, 50, 1)}, "Kaya"},
|
||||
{"user_detail", UserDetailView{ID: "a1", DisplayName: "Kaya", HasStats: true, Stats: StatsRow{Wins: 2}, TelegramID: "123", ConnectorEnabled: true}, "Send Telegram message"},
|
||||
{"games", GamesView{Items: []GameRow{{ID: "g1", Variant: "english", Status: "active"}}, Status: "active", Pager: NewPager(1, 50, 1)}, "g1"},
|
||||
{"game_detail", GameDetailView{ID: "g1", Variant: "english", Seats: []SeatRow{{Seat: 0, DisplayName: "Kaya"}}}, "Seats"},
|
||||
{"games", GamesView{Items: []GameRow{{ID: "g1", Variant: "scrabble_en", Status: "active"}}, Status: "active", Pager: NewPager(1, 50, 1)}, "g1"},
|
||||
{"game_detail", GameDetailView{ID: "g1", Variant: "scrabble_en", Seats: []SeatRow{{Seat: 0, DisplayName: "Kaya"}}}, "Seats"},
|
||||
{"complaints", ComplaintsView{Items: []ComplaintRow{{ID: "c1", Word: "qi", Status: "open"}}, Status: "open", Pager: NewPager(1, 50, 1)}, "qi"},
|
||||
{"messages", MessagesView{Items: []MessageRow{{ID: "m1", SenderID: "a1", SenderName: "Kaya", Source: "telegram", Body: "good luck", GameID: "g1"}}, Pager: NewPager(1, 50, 1)}, "good luck"},
|
||||
{"complaint_detail", ComplaintDetailView{ID: "c1", Word: "qi", Variant: "english"}, "Resolve"},
|
||||
{"dictionary", DictionaryView{Variants: []VariantVersions{{Variant: "english", Latest: "v1", Versions: []string{"v1"}}}, Changes: []DictChangeRow{{Variant: "english", Word: "qi", Action: "add"}}}, "Hot-reload"},
|
||||
{"complaint_detail", ComplaintDetailView{ID: "c1", Word: "qi", Variant: "scrabble_en"}, "Resolve"},
|
||||
{"dictionary", DictionaryView{Variants: []VariantVersions{{Variant: "scrabble_en", Latest: "v1", Versions: []string{"v1"}}}, Changes: []DictChangeRow{{Variant: "scrabble_en", Word: "qi", Action: "add"}}}, "Hot-reload"},
|
||||
{"broadcast", BroadcastView{ConnectorEnabled: true}, "Post to the game channel"},
|
||||
{"message", MessageView{Heading: "Done", Body: "ok", Back: "/_gm/"}, "Done"},
|
||||
}
|
||||
|
||||
@@ -30,9 +30,9 @@
|
||||
<form class="form" method="post" action="/_gm/dictionary/changes/apply">
|
||||
<label>Mark applied for variant
|
||||
<select name="variant">
|
||||
<option value="english">english</option>
|
||||
<option value="russian_scrabble">russian_scrabble</option>
|
||||
<option value="erudit">erudit</option>
|
||||
<option value="scrabble_en">scrabble_en</option>
|
||||
<option value="scrabble_ru">scrabble_ru</option>
|
||||
<option value="erudit_ru">erudit_ru</option>
|
||||
</select>
|
||||
</label>
|
||||
<label>In version <input type="text" name="version" placeholder="v2" required></label>
|
||||
|
||||
@@ -12,7 +12,7 @@ import (
|
||||
func TestAlphabetTableEnglish(t *testing.T) {
|
||||
tab, err := AlphabetTable(VariantEnglish)
|
||||
if err != nil {
|
||||
t.Fatalf("AlphabetTable(english): %v", err)
|
||||
t.Fatalf("AlphabetTable(scrabble_en): %v", err)
|
||||
}
|
||||
if len(tab) != 26 {
|
||||
t.Fatalf("size = %d, want 26", len(tab))
|
||||
@@ -40,23 +40,23 @@ func TestAlphabetTableEnglish(t *testing.T) {
|
||||
func TestAlphabetTableRussianVariants(t *testing.T) {
|
||||
ru, err := AlphabetTable(VariantRussianScrabble)
|
||||
if err != nil {
|
||||
t.Fatalf("AlphabetTable(russian_scrabble): %v", err)
|
||||
t.Fatalf("AlphabetTable(scrabble_ru): %v", err)
|
||||
}
|
||||
er, err := AlphabetTable(VariantErudit)
|
||||
if err != nil {
|
||||
t.Fatalf("AlphabetTable(erudit): %v", err)
|
||||
t.Fatalf("AlphabetTable(erudit_ru): %v", err)
|
||||
}
|
||||
if len(ru) != 33 || len(er) != 33 {
|
||||
t.Fatalf("sizes = %d/%d, want 33/33", len(ru), len(er))
|
||||
}
|
||||
if ru[0].Letter != "а" || ru[0].Value != 1 {
|
||||
t.Errorf("russian entry 0 = %q/%d, want а/1", ru[0].Letter, ru[0].Value)
|
||||
t.Errorf("scrabble_ru entry 0 = %q/%d, want а/1", ru[0].Letter, ru[0].Value)
|
||||
}
|
||||
if ru[6].Letter != "ё" || ru[6].Value != 3 {
|
||||
t.Errorf("russian ё (entry 6) = %q/%d, want ё/3", ru[6].Letter, ru[6].Value)
|
||||
t.Errorf("scrabble_ru ё (entry 6) = %q/%d, want ё/3", ru[6].Letter, ru[6].Value)
|
||||
}
|
||||
if er[6].Letter != "ё" || er[6].Value != 0 {
|
||||
t.Errorf("erudit ё (entry 6) = %q/%d, want ё/0", er[6].Letter, er[6].Value)
|
||||
t.Errorf("erudit_ru ё (entry 6) = %q/%d, want ё/0", er[6].Letter, er[6].Value)
|
||||
}
|
||||
if ru[32].Letter != "я" || er[32].Letter != "я" {
|
||||
t.Errorf("last letter = %q/%q, want я/я", ru[32].Letter, er[32].Letter)
|
||||
|
||||
@@ -168,10 +168,10 @@ func TestRegistryLookup(t *testing.T) {
|
||||
word string
|
||||
want bool
|
||||
}{
|
||||
{"english hit", VariantEnglish, "cat", true},
|
||||
{"english miss", VariantEnglish, "zzzz", false},
|
||||
{"russian hit", VariantRussianScrabble, "кот", true},
|
||||
{"erudit hit", VariantErudit, "кот", true},
|
||||
{"scrabble_en hit", VariantEnglish, "cat", true},
|
||||
{"scrabble_en miss", VariantEnglish, "zzzz", false},
|
||||
{"scrabble_ru hit", VariantRussianScrabble, "кот", true},
|
||||
{"erudit_ru hit", VariantErudit, "кот", true},
|
||||
}
|
||||
for _, tc := range cases {
|
||||
t.Run(tc.name, func(t *testing.T) {
|
||||
|
||||
@@ -38,11 +38,11 @@ const (
|
||||
func (v Variant) String() string {
|
||||
switch v {
|
||||
case VariantEnglish:
|
||||
return "english"
|
||||
return "scrabble_en"
|
||||
case VariantRussianScrabble:
|
||||
return "russian_scrabble"
|
||||
return "scrabble_ru"
|
||||
case VariantErudit:
|
||||
return "erudit"
|
||||
return "erudit_ru"
|
||||
}
|
||||
return "unknown"
|
||||
}
|
||||
|
||||
@@ -60,7 +60,7 @@ func TestRegistryValidatesKnownWords(t *testing.T) {
|
||||
func TestRegistryUnknownLookups(t *testing.T) {
|
||||
reg, err := Open(testDictDir(), testVersion, VariantEnglish)
|
||||
if err != nil {
|
||||
t.Fatalf("open english-only registry: %v", err)
|
||||
t.Fatalf("open scrabble_en-only registry: %v", err)
|
||||
}
|
||||
defer reg.Close()
|
||||
|
||||
|
||||
@@ -45,13 +45,13 @@ func TestLoadAvailableLoadsPresentSkipsAbsent(t *testing.T) {
|
||||
t.Fatalf("load available: %v", err)
|
||||
}
|
||||
if len(loaded) != 1 || loaded[0] != VariantEnglish {
|
||||
t.Fatalf("loaded = %v, want [english]", loaded)
|
||||
t.Fatalf("loaded = %v, want [scrabble_en]", loaded)
|
||||
}
|
||||
if _, err := reg.Solver(VariantEnglish, "v2"); err != nil {
|
||||
t.Errorf("english v2 solver: %v", err)
|
||||
t.Errorf("scrabble_en v2 solver: %v", err)
|
||||
}
|
||||
if _, err := reg.Solver(VariantRussianScrabble, "v2"); !errors.Is(err, ErrUnknownVariant) {
|
||||
t.Errorf("russian v2 should be absent: got %v", err)
|
||||
t.Errorf("scrabble_ru v2 should be absent: got %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -77,17 +77,17 @@ func TestOpenWithVersionsScansSubdirs(t *testing.T) {
|
||||
}
|
||||
}
|
||||
if got := reg.Versions(VariantEnglish); len(got) != 2 {
|
||||
t.Errorf("english versions = %v, want two", got)
|
||||
t.Errorf("scrabble_en versions = %v, want two", got)
|
||||
}
|
||||
latest, _, err := reg.Latest(VariantEnglish)
|
||||
if err != nil {
|
||||
t.Fatalf("latest english: %v", err)
|
||||
t.Fatalf("latest scrabble_en: %v", err)
|
||||
}
|
||||
if latest != "v2" {
|
||||
t.Errorf("latest english = %q, want v2", latest)
|
||||
t.Errorf("latest scrabble_en = %q, want v2", latest)
|
||||
}
|
||||
if got := reg.Versions(VariantRussianScrabble); len(got) != 1 {
|
||||
t.Errorf("russian versions = %v, want one (no v2 file)", got)
|
||||
t.Errorf("scrabble_ru versions = %v, want one (no v2 file)", got)
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -40,7 +40,7 @@ func TestWriteGCG(t *testing.T) {
|
||||
"#character-encoding UTF-8",
|
||||
"#player1 p1 Alice",
|
||||
"#player2 p2 Bob",
|
||||
"#lexicon english/v1",
|
||||
"#lexicon scrabble_en/v1",
|
||||
"#title game 00000000-0000-7000-8000-000000000001",
|
||||
">p1: CATSER? 8H CAT +10 10",
|
||||
">p2: AS?E I8 .s +2 2",
|
||||
|
||||
@@ -94,7 +94,7 @@ func TestGameCacheEviction(t *testing.T) {
|
||||
cur := time.Unix(1_700_000_000, 0)
|
||||
cache := newGameCache(time.Hour, func() time.Time { return cur })
|
||||
id := uuid.New()
|
||||
cache.put(id, nil, "english")
|
||||
cache.put(id, nil, "scrabble_en")
|
||||
if _, ok := cache.get(id); !ok {
|
||||
t.Fatal("game must be resident after put")
|
||||
}
|
||||
|
||||
@@ -16,7 +16,7 @@ import (
|
||||
const meterName = "scrabble/backend/game"
|
||||
|
||||
// gameMetrics holds the game domain's operational instruments. Every game-scoped
|
||||
// measurement carries a "variant" attribute (english/russian/erudit). The
|
||||
// measurement carries a "variant" attribute (scrabble_en/scrabble_ru/erudit_ru). The
|
||||
// instruments default to no-ops (see defaultGameMetrics), so recording is always
|
||||
// safe; SetMetrics installs the real meter during startup wiring.
|
||||
type gameMetrics struct {
|
||||
|
||||
@@ -35,11 +35,11 @@ func TestGameMetrics(t *testing.T) {
|
||||
}
|
||||
|
||||
started := counterByAttr(t, rm, "games_started_total", "variant")
|
||||
if started["english"] != 2 || started["russian_scrabble"] != 1 {
|
||||
t.Errorf("games_started_total = %v, want english:2 russian_scrabble:1", started)
|
||||
if started["scrabble_en"] != 2 || started["scrabble_ru"] != 1 {
|
||||
t.Errorf("games_started_total = %v, want scrabble_en:2 scrabble_ru:1", started)
|
||||
}
|
||||
if abandoned := counterByAttr(t, rm, "games_abandoned_total", "variant"); abandoned["erudit"] != 1 {
|
||||
t.Errorf("games_abandoned_total = %v, want erudit:1", abandoned)
|
||||
if abandoned := counterByAttr(t, rm, "games_abandoned_total", "variant"); abandoned["erudit_ru"] != 1 {
|
||||
t.Errorf("games_abandoned_total = %v, want erudit_ru:1", abandoned)
|
||||
}
|
||||
if c := histogramCount(t, rm, "game_replay_duration"); c != 1 {
|
||||
t.Errorf("game_replay_duration observations = %d, want 1", c)
|
||||
|
||||
@@ -33,7 +33,7 @@ func TestMoveDurationAnalytics(t *testing.T) {
|
||||
t0 := time.Date(2026, 1, 1, 12, 0, 0, 0, time.UTC)
|
||||
if _, err := testDB.ExecContext(ctx,
|
||||
`INSERT INTO backend.games (game_id, variant, dict_version, seed, players, turn_timeout_secs, created_at)
|
||||
VALUES ($1,'english','v1',1,2,86400,$2)`, gid, t0); err != nil {
|
||||
VALUES ($1,'scrabble_en','v1',1,2,86400,$2)`, gid, t0); err != nil {
|
||||
t.Fatalf("insert game: %v", err)
|
||||
}
|
||||
if _, err := testDB.ExecContext(ctx,
|
||||
|
||||
@@ -477,7 +477,7 @@ func TestGameVariant(t *testing.T) {
|
||||
t.Fatalf("create: %v", err)
|
||||
}
|
||||
if v, err := svc.GameVariant(ctx, g.ID); err != nil || v != engine.VariantEnglish {
|
||||
t.Fatalf("GameVariant = %v, %v; want english, nil", v, err)
|
||||
t.Fatalf("GameVariant = %v, %v; want scrabble_en, nil", v, err)
|
||||
}
|
||||
if _, err := svc.GameVariant(ctx, uuid.New()); !errors.Is(err, game.ErrNotFound) {
|
||||
t.Errorf("GameVariant(unknown) = %v, want ErrNotFound", err)
|
||||
|
||||
@@ -90,7 +90,7 @@ func TestRobotPoolProvisionsRobotAccounts(t *testing.T) {
|
||||
t.Errorf("picked account %s is not a robot identity", id)
|
||||
}
|
||||
if ru, err := r.Pick(engine.VariantRussianScrabble); err != nil || !isRobotAccount(t, ru) {
|
||||
t.Errorf("russian pick = (%s, %v), want a robot account", ru, err)
|
||||
t.Errorf("scrabble_ru pick = (%s, %v), want a robot account", ru, err)
|
||||
}
|
||||
acc, err := account.NewStore(testDB).GetByID(ctx, id)
|
||||
if err != nil {
|
||||
|
||||
@@ -37,7 +37,7 @@ var gooseMu sync.Mutex
|
||||
// ApplyMigrations runs every pending Up migration embedded in the backend
|
||||
// binary against db. The schema is created upfront so goose's bookkeeping table
|
||||
// (`goose_db_version`, scoped to the DSN search_path) has somewhere to land
|
||||
// before the first migration runs; migration 00001_init.sql re-asserts the
|
||||
// before the first migration runs; the baseline migration re-asserts the
|
||||
// schema with IF NOT EXISTS, so the double-create is idempotent.
|
||||
//
|
||||
// The apply is retried on transient connection errors. Both steps are
|
||||
|
||||
@@ -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;
|
||||
@@ -75,14 +75,14 @@ func TestPickVariantRouting(t *testing.T) {
|
||||
s := &Service{poolEN: []uuid.UUID{enID}, poolRU: []uuid.UUID{ruID}}
|
||||
for i := 0; i < 200; i++ {
|
||||
if got, err := s.Pick(engine.VariantEnglish); err != nil || got != enID {
|
||||
t.Fatalf("english Pick = (%v, %v), want (%v, nil)", got, err, enID)
|
||||
t.Fatalf("scrabble_en Pick = (%v, %v), want (%v, nil)", got, err, enID)
|
||||
}
|
||||
}
|
||||
var en, ru int
|
||||
for i := 0; i < 4000; i++ {
|
||||
got, err := s.Pick(engine.VariantRussianScrabble)
|
||||
if err != nil {
|
||||
t.Fatalf("russian Pick: %v", err)
|
||||
t.Fatalf("scrabble_ru Pick: %v", err)
|
||||
}
|
||||
switch got {
|
||||
case enID:
|
||||
@@ -92,14 +92,14 @@ func TestPickVariantRouting(t *testing.T) {
|
||||
}
|
||||
}
|
||||
if ru <= en {
|
||||
t.Errorf("russian names should dominate a Russian game: ru=%d en=%d", ru, en)
|
||||
t.Errorf("scrabble_ru names should dominate a Russian game: ru=%d en=%d", ru, en)
|
||||
}
|
||||
if en == 0 {
|
||||
t.Errorf("some Latin names should appear in Russian games (got 0 of 4000)")
|
||||
}
|
||||
// Эрудит routes like Russian Scrabble.
|
||||
if _, err := s.Pick(engine.VariantErudit); err != nil {
|
||||
t.Errorf("erudit Pick: %v", err)
|
||||
t.Errorf("erudit_ru Pick: %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -108,10 +108,10 @@ func TestPickVariantRouting(t *testing.T) {
|
||||
func TestPickFallback(t *testing.T) {
|
||||
id := uuid.New()
|
||||
if got, err := (&Service{poolEN: []uuid.UUID{id}}).Pick(engine.VariantRussianScrabble); err != nil || got != id {
|
||||
t.Errorf("russian fallback to EN = (%v, %v), want (%v, nil)", got, err, id)
|
||||
t.Errorf("scrabble_ru fallback to EN = (%v, %v), want (%v, nil)", got, err, id)
|
||||
}
|
||||
if got, err := (&Service{poolRU: []uuid.UUID{id}}).Pick(engine.VariantEnglish); err != nil || got != id {
|
||||
t.Errorf("english fallback to RU = (%v, %v), want (%v, nil)", got, err, id)
|
||||
t.Errorf("scrabble_en fallback to RU = (%v, %v), want (%v, nil)", got, err, id)
|
||||
}
|
||||
if _, err := (&Service{}).Pick(engine.VariantEnglish); !errors.Is(err, ErrNoRobotAvailable) {
|
||||
t.Errorf("empty pool err = %v, want ErrNoRobotAvailable", err)
|
||||
|
||||
@@ -91,7 +91,7 @@ func TestGameDTOFromGame(t *testing.T) {
|
||||
Seats: []game.Seat{{Seat: 0, AccountID: aid, Score: 12}},
|
||||
}
|
||||
dto := gameDTOFromGame(g)
|
||||
if dto.ID != gid.String() || dto.Variant != "english" || dto.ToMove != 1 || dto.TurnTimeoutSecs != 86400 {
|
||||
if dto.ID != gid.String() || dto.Variant != "scrabble_en" || dto.ToMove != 1 || dto.TurnTimeoutSecs != 86400 {
|
||||
t.Fatalf("game dto mismatch: %+v", dto)
|
||||
}
|
||||
if len(dto.Seats) != 1 || dto.Seats[0].AccountID != aid.String() || dto.Seats[0].Score != 12 {
|
||||
|
||||
@@ -556,7 +556,7 @@ promotions) is future work and would deliver short markdown messages (text + lin
|
||||
`robot_games_finished_total`; an observable gauge `game_cache_active`; the gateway
|
||||
`edge_request_duration` (the UI-perceived roundtrip, by `message_type`/`result`);
|
||||
and Go runtime/heap metrics. Game-scoped metrics carry a `variant` attribute
|
||||
(english/russian_scrabble/erudit).
|
||||
(scrabble_en/scrabble_ru/erudit_ru).
|
||||
- Per-user move-time analytics (Stage 17) are **offline**, derived in the admin
|
||||
console from the move journal (`game_moves.created_at` deltas, the first move from
|
||||
the game's creation), not Prometheus labels (which an `account_id` would explode):
|
||||
|
||||
@@ -21,7 +21,7 @@ func TestGameStateIncludesAlphabet(t *testing.T) {
|
||||
if got := r.URL.Query().Get("include_alphabet"); got != "true" {
|
||||
t.Errorf("include_alphabet query = %q, want true", got)
|
||||
}
|
||||
_, _ = w.Write([]byte(`{"game":{"id":"g-1","variant":"english","status":"active","players":2,"to_move":0,"seats":[]},"seat":0,"rack":[0,255],"bag_len":50,"hints_remaining":0,"alphabet":[{"index":0,"letter":"a","value":1},{"index":1,"letter":"b","value":3}]}`))
|
||||
_, _ = w.Write([]byte(`{"game":{"id":"g-1","variant":"scrabble_en","status":"active","players":2,"to_move":0,"seats":[]},"seat":0,"rack":[0,255],"bag_len":50,"hints_remaining":0,"alphabet":[{"index":0,"letter":"a","value":1},{"index":1,"letter":"b","value":3}]}`))
|
||||
})
|
||||
defer cleanup()
|
||||
|
||||
@@ -60,7 +60,7 @@ func TestGameStateOmitsAlphabetByDefault(t *testing.T) {
|
||||
if r.URL.Query().Get("include_alphabet") == "true" {
|
||||
t.Error("include_alphabet should be unset")
|
||||
}
|
||||
_, _ = w.Write([]byte(`{"game":{"id":"g-1","variant":"english","status":"active","players":2,"to_move":0,"seats":[]},"seat":0,"rack":[2,0,19],"bag_len":50,"hints_remaining":0}`))
|
||||
_, _ = w.Write([]byte(`{"game":{"id":"g-1","variant":"scrabble_en","status":"active","players":2,"to_move":0,"seats":[]},"seat":0,"rack":[2,0,19],"bag_len":50,"hints_remaining":0}`))
|
||||
})
|
||||
defer cleanup()
|
||||
|
||||
|
||||
@@ -153,7 +153,7 @@ func TestInvitationCreateRoundTrip(t *testing.T) {
|
||||
if r.URL.Path != "/api/v1/user/invitations" {
|
||||
t.Errorf("unexpected path %q", r.URL.Path)
|
||||
}
|
||||
_, _ = w.Write([]byte(`{"id":"i-1","inviter":{"account_id":"u-1","display_name":"Me"},"invitees":[{"account_id":"inv-1","display_name":"Friend","seat":1,"response":"pending"}],"variant":"english","turn_timeout_secs":86400,"hints_allowed":true,"hints_per_player":1,"dropout_tiles":"remove","status":"pending","expires_at_unix":42}`))
|
||||
_, _ = w.Write([]byte(`{"id":"i-1","inviter":{"account_id":"u-1","display_name":"Me"},"invitees":[{"account_id":"inv-1","display_name":"Friend","seat":1,"response":"pending"}],"variant":"scrabble_en","turn_timeout_secs":86400,"hints_allowed":true,"hints_per_player":1,"dropout_tiles":"remove","status":"pending","expires_at_unix":42}`))
|
||||
})
|
||||
defer cleanup()
|
||||
|
||||
@@ -165,7 +165,7 @@ func TestInvitationCreateRoundTrip(t *testing.T) {
|
||||
fb.CreateInvitationRequestStartInviteeIdsVector(b, 1)
|
||||
b.PrependUOffsetT(inviteeID)
|
||||
ids := b.EndVector(1)
|
||||
variant := b.CreateString("english")
|
||||
variant := b.CreateString("scrabble_en")
|
||||
dropout := b.CreateString("remove")
|
||||
fb.CreateInvitationRequestStart(b)
|
||||
fb.CreateInvitationRequestAddInviteeIds(b, ids)
|
||||
@@ -181,7 +181,7 @@ func TestInvitationCreateRoundTrip(t *testing.T) {
|
||||
t.Fatalf("handler: %v", err)
|
||||
}
|
||||
inv := fb.GetRootAsInvitation(payload, 0)
|
||||
if string(inv.Id()) != "i-1" || inv.InviteesLength() != 1 || string(inv.Variant()) != "english" {
|
||||
if string(inv.Id()) != "i-1" || inv.InviteesLength() != 1 || string(inv.Variant()) != "scrabble_en" {
|
||||
t.Fatalf("invitation decoded wrong: id=%q invitees=%d variant=%q", inv.Id(), inv.InviteesLength(), inv.Variant())
|
||||
}
|
||||
if iv := inv.Inviter(nil); iv == nil || string(iv.DisplayName()) != "Me" {
|
||||
|
||||
@@ -59,7 +59,7 @@ func TestGameStateRoundTripForwardsUserID(t *testing.T) {
|
||||
if r.URL.Path != "/api/v1/user/games/g-1/state" {
|
||||
t.Errorf("unexpected path %q", r.URL.Path)
|
||||
}
|
||||
_, _ = w.Write([]byte(`{"game":{"id":"g-1","variant":"english","status":"active","players":2,"to_move":1,"seats":[{"seat":0,"account_id":"u-7","score":5}]},"seat":0,"rack":[0,1],"bag_len":80,"hints_remaining":1}`))
|
||||
_, _ = w.Write([]byte(`{"game":{"id":"g-1","variant":"scrabble_en","status":"active","players":2,"to_move":1,"seats":[{"seat":0,"account_id":"u-7","score":5}]},"seat":0,"rack":[0,1],"bag_len":80,"hints_remaining":1}`))
|
||||
})
|
||||
defer cleanup()
|
||||
|
||||
@@ -81,14 +81,14 @@ func TestGameStateRoundTripForwardsUserID(t *testing.T) {
|
||||
t.Fatalf("state decoded wrong: bag=%d rack=%d hints=%d", st.BagLen(), st.RackLength(), st.HintsRemaining())
|
||||
}
|
||||
game := st.Game(nil)
|
||||
if game == nil || string(game.Id()) != "g-1" || string(game.Variant()) != "english" || game.ToMove() != 1 {
|
||||
if game == nil || string(game.Id()) != "g-1" || string(game.Variant()) != "scrabble_en" || game.ToMove() != 1 {
|
||||
t.Fatalf("nested game decoded wrong: %+v", game)
|
||||
}
|
||||
}
|
||||
|
||||
func TestEnqueueRoundTripEncodesMatch(t *testing.T) {
|
||||
backend, cleanup := fakeBackend(t, func(w http.ResponseWriter, r *http.Request) {
|
||||
_, _ = w.Write([]byte(`{"matched":true,"game":{"id":"g-9","variant":"english","status":"active","players":2,"to_move":0,"seats":[]}}`))
|
||||
_, _ = w.Write([]byte(`{"matched":true,"game":{"id":"g-9","variant":"scrabble_en","status":"active","players":2,"to_move":0,"seats":[]}}`))
|
||||
})
|
||||
defer cleanup()
|
||||
|
||||
@@ -96,7 +96,7 @@ func TestEnqueueRoundTripEncodesMatch(t *testing.T) {
|
||||
op, _ := reg.Lookup(transcode.MsgLobbyEnqueue)
|
||||
|
||||
b := flatbuffers.NewBuilder(32)
|
||||
v := b.CreateString("english")
|
||||
v := b.CreateString("scrabble_en")
|
||||
fb.EnqueueRequestStart(b)
|
||||
fb.EnqueueRequestAddVariant(b, v)
|
||||
b.Finish(fb.EnqueueRequestEnd(b))
|
||||
@@ -191,7 +191,7 @@ func TestGamesListRoundTripDecodesSeatNames(t *testing.T) {
|
||||
if r.URL.Path != "/api/v1/user/games" {
|
||||
t.Errorf("unexpected path %q", r.URL.Path)
|
||||
}
|
||||
_, _ = w.Write([]byte(`{"games":[{"id":"g-1","variant":"english","status":"active","players":2,"to_move":0,"last_activity_unix":1717000000,"seats":[{"seat":0,"account_id":"u-9","display_name":"You","score":10},{"seat":1,"account_id":"a-1","display_name":"Ann","score":7}]}]}`))
|
||||
_, _ = w.Write([]byte(`{"games":[{"id":"g-1","variant":"scrabble_en","status":"active","players":2,"to_move":0,"last_activity_unix":1717000000,"seats":[{"seat":0,"account_id":"u-9","display_name":"You","score":10},{"seat":1,"account_id":"a-1","display_name":"Ann","score":7}]}]}`))
|
||||
})
|
||||
defer cleanup()
|
||||
|
||||
|
||||
@@ -131,7 +131,7 @@ test('play with friends: a game type is required to send an invitation', async (
|
||||
await page.getByRole('checkbox').first().check(); // pick a friend
|
||||
await expect(send).toBeDisabled(); // still no game type
|
||||
|
||||
await page.locator('.field select').first().selectOption('english');
|
||||
await page.locator('.field select').first().selectOption('scrabble_en');
|
||||
await expect(send).toBeEnabled();
|
||||
|
||||
await send.click(); // the mock creates it and returns to the lobby
|
||||
|
||||
@@ -12,7 +12,7 @@
|
||||
// complaint, off the board so the soft keyboard never relayouts the play area.
|
||||
let { id }: { id: string } = $props();
|
||||
|
||||
let variant = $state<Variant>('english');
|
||||
let variant = $state<Variant>('scrabble_en');
|
||||
let word = $state('');
|
||||
let result = $state<{ word: string; legal: boolean } | null>(null);
|
||||
let cooling = $state(false);
|
||||
|
||||
@@ -57,7 +57,7 @@
|
||||
let resignOpen = $state(false);
|
||||
let drag = $state<{ letter: string; blank: boolean; x: number; y: number; touch: boolean } | null>(null);
|
||||
|
||||
const variant = $derived(view?.game.variant ?? 'english');
|
||||
const variant = $derived(view?.game.variant ?? 'scrabble_en');
|
||||
const board = $derived(replay(moves));
|
||||
const premium = $derived(premiumGrid(variant));
|
||||
const ctr = $derived(centre(variant));
|
||||
|
||||
+20
-20
@@ -12,37 +12,37 @@ import {
|
||||
// The cache module is per-file-isolated by vitest, so only what these tests seed exists.
|
||||
describe('alphabet cache (Stage 13)', () => {
|
||||
it('upper-cases letters for display and maps indices and values case-insensitively', () => {
|
||||
setAlphabet('english', [
|
||||
setAlphabet('scrabble_en', [
|
||||
{ index: 0, letter: 'a', value: 1 },
|
||||
{ index: 16, letter: 'q', value: 10 },
|
||||
]);
|
||||
expect(hasAlphabet('english')).toBe(true);
|
||||
expect(letterForIndex('english', 0)).toBe('A');
|
||||
expect(letterForIndex('english', 16)).toBe('Q');
|
||||
expect(indexForLetter('english', 'a')).toBe(0);
|
||||
expect(indexForLetter('english', 'Q')).toBe(16);
|
||||
expect(valueForLetter('english', 'a')).toBe(1);
|
||||
expect(valueForLetter('english', 'Q')).toBe(10);
|
||||
expect(hasAlphabet('scrabble_en')).toBe(true);
|
||||
expect(letterForIndex('scrabble_en', 0)).toBe('A');
|
||||
expect(letterForIndex('scrabble_en', 16)).toBe('Q');
|
||||
expect(indexForLetter('scrabble_en', 'a')).toBe(0);
|
||||
expect(indexForLetter('scrabble_en', 'Q')).toBe(16);
|
||||
expect(valueForLetter('scrabble_en', 'a')).toBe(1);
|
||||
expect(valueForLetter('scrabble_en', 'Q')).toBe(10);
|
||||
});
|
||||
|
||||
it('handles the blank sentinel and unknown letters/indices', () => {
|
||||
setAlphabet('english', [{ index: 0, letter: 'a', value: 1 }]);
|
||||
expect(letterForIndex('english', BLANK_INDEX)).toBe('?');
|
||||
expect(indexForLetter('english', '?')).toBe(BLANK_INDEX);
|
||||
expect(valueForLetter('english', '?')).toBe(0);
|
||||
expect(letterForIndex('english', 99)).toBe(''); // out of range
|
||||
expect(valueForLetter('english', 'Z')).toBe(0); // not in this alphabet
|
||||
expect(() => indexForLetter('english', 'Z')).toThrow();
|
||||
setAlphabet('scrabble_en', [{ index: 0, letter: 'a', value: 1 }]);
|
||||
expect(letterForIndex('scrabble_en', BLANK_INDEX)).toBe('?');
|
||||
expect(indexForLetter('scrabble_en', '?')).toBe(BLANK_INDEX);
|
||||
expect(valueForLetter('scrabble_en', '?')).toBe(0);
|
||||
expect(letterForIndex('scrabble_en', 99)).toBe(''); // out of range
|
||||
expect(valueForLetter('scrabble_en', 'Z')).toBe(0); // not in this alphabet
|
||||
expect(() => indexForLetter('scrabble_en', 'Z')).toThrow();
|
||||
});
|
||||
|
||||
it('lists the alphabet for the blank chooser and is empty for an uncached variant', () => {
|
||||
setAlphabet('english', [
|
||||
setAlphabet('scrabble_en', [
|
||||
{ index: 0, letter: 'a', value: 1 },
|
||||
{ index: 1, letter: 'b', value: 3 },
|
||||
]);
|
||||
expect(alphabetLetters('english')).toEqual(['A', 'B']);
|
||||
expect(hasAlphabet('erudit')).toBe(false);
|
||||
expect(alphabetLetters('erudit')).toEqual([]);
|
||||
expect(valueForLetter('erudit', 'A')).toBe(0);
|
||||
expect(alphabetLetters('scrabble_en')).toEqual(['A', 'B']);
|
||||
expect(hasAlphabet('erudit_ru')).toBe(false);
|
||||
expect(alphabetLetters('erudit_ru')).toEqual([]);
|
||||
expect(valueForLetter('erudit_ru', 'A')).toBe(0);
|
||||
});
|
||||
});
|
||||
|
||||
+10
-10
@@ -36,7 +36,7 @@ describe('codec', () => {
|
||||
});
|
||||
|
||||
it('encodes a SubmitPlayRequest with alphabet indices (Stage 13)', () => {
|
||||
setAlphabet('english', [
|
||||
setAlphabet('scrabble_en', [
|
||||
{ index: 0, letter: 'a', value: 1 },
|
||||
{ index: 1, letter: 'b', value: 3 },
|
||||
]);
|
||||
@@ -48,7 +48,7 @@ describe('codec', () => {
|
||||
{ row: 7, col: 7, letter: 'A', blank: false },
|
||||
{ row: 7, col: 8, letter: 'B', blank: true },
|
||||
],
|
||||
'english',
|
||||
'scrabble_en',
|
||||
);
|
||||
const r = fb.SubmitPlayRequest.getRootAsSubmitPlayRequest(new ByteBuffer(buf));
|
||||
expect(r.gameId()).toBe('g1');
|
||||
@@ -95,7 +95,7 @@ describe('codec', () => {
|
||||
const seat = fb.SeatView.endSeatView(b);
|
||||
const seats = fb.GameView.createSeatsVector(b, [seat]);
|
||||
const id = b.createString('g1');
|
||||
const variant = b.createString('english');
|
||||
const variant = b.createString('scrabble_en');
|
||||
const dv = b.createString('v1');
|
||||
const status = b.createString('active');
|
||||
const er = b.createString('');
|
||||
@@ -242,7 +242,7 @@ describe('codec', () => {
|
||||
const invitees = fb.Invitation.createInviteesVector(b, [invitee]);
|
||||
|
||||
const id = b.createString('i-1');
|
||||
const variant = b.createString('english');
|
||||
const variant = b.createString('scrabble_en');
|
||||
const dropout = b.createString('remove');
|
||||
const status = b.createString('pending');
|
||||
const gid = b.createString('');
|
||||
@@ -264,7 +264,7 @@ describe('codec', () => {
|
||||
expect(inv.inviter).toEqual({ accountId: 'u-1', displayName: 'Me' });
|
||||
expect(inv.invitees).toHaveLength(1);
|
||||
expect(inv.invitees[0]).toEqual({ accountId: 'inv-1', displayName: 'Friend', seat: 1, response: 'pending' });
|
||||
expect(inv.variant).toBe('english');
|
||||
expect(inv.variant).toBe('scrabble_en');
|
||||
});
|
||||
});
|
||||
|
||||
@@ -273,12 +273,12 @@ describe('codec', () => {
|
||||
// the whole table), so they are independent of order.
|
||||
describe('codec — alphabet on the wire (Stage 13)', () => {
|
||||
it('encodes an ExchangeRequest as alphabet indices, blank as the sentinel', () => {
|
||||
setAlphabet('english', [
|
||||
setAlphabet('scrabble_en', [
|
||||
{ index: 0, letter: 'a', value: 1 },
|
||||
{ index: 1, letter: 'b', value: 3 },
|
||||
]);
|
||||
const r = fb.ExchangeRequest.getRootAsExchangeRequest(
|
||||
new ByteBuffer(encodeExchange('g1', ['A', '?'], 'english')),
|
||||
new ByteBuffer(encodeExchange('g1', ['A', '?'], 'scrabble_en')),
|
||||
);
|
||||
expect(r.tilesLength()).toBe(2);
|
||||
expect(r.tiles(0)).toBe(0);
|
||||
@@ -286,13 +286,13 @@ describe('codec — alphabet on the wire (Stage 13)', () => {
|
||||
});
|
||||
|
||||
it('encodes a CheckWordRequest as alphabet indices', () => {
|
||||
setAlphabet('english', [
|
||||
setAlphabet('scrabble_en', [
|
||||
{ index: 0, letter: 'a', value: 1 },
|
||||
{ index: 2, letter: 'c', value: 3 },
|
||||
{ index: 19, letter: 't', value: 1 },
|
||||
]);
|
||||
const r = fb.CheckWordRequest.getRootAsCheckWordRequest(
|
||||
new ByteBuffer(encodeCheckWord('g1', 'CAT', 'english')),
|
||||
new ByteBuffer(encodeCheckWord('g1', 'CAT', 'scrabble_en')),
|
||||
);
|
||||
expect(r.wordLength()).toBe(3);
|
||||
expect([r.word(0), r.word(1), r.word(2)]).toEqual([2, 0, 19]);
|
||||
@@ -330,7 +330,7 @@ describe('codec — alphabet on the wire (Stage 13)', () => {
|
||||
fb.StateView.addAlphabet(b, alpha);
|
||||
b.finish(fb.StateView.endStateView(b));
|
||||
|
||||
// No GameView on the buffer, so decode falls back to the default variant 'english';
|
||||
// No GameView on the buffer, so decode falls back to the default variant 'scrabble_en';
|
||||
// the embedded table is cached under it and the rack [0, blank] decodes to letters.
|
||||
const sv = decodeStateView(b.asUint8Array());
|
||||
expect(sv.rack).toEqual(['A', '?']);
|
||||
|
||||
+2
-2
@@ -325,7 +325,7 @@ export function decodeProfile(buf: Uint8Array): Profile {
|
||||
export function decodeStateView(buf: Uint8Array): StateView {
|
||||
const v = fb.StateView.getRootAsStateView(new ByteBuffer(buf));
|
||||
const g = v.game();
|
||||
const variant = (g ? s(g.variant()) : 'english') as Variant;
|
||||
const variant = (g ? s(g.variant()) : 'scrabble_en') as Variant;
|
||||
// Cache the alphabet table when the server included it (a per-variant cache miss), then
|
||||
// decode the index rack to display letters with it (Stage 13).
|
||||
if (v.alphabetLength() > 0) {
|
||||
@@ -681,7 +681,7 @@ export function decodeGcg(buf: Uint8Array): GcgExport {
|
||||
function emptyGame(): GameView {
|
||||
return {
|
||||
id: '',
|
||||
variant: 'english',
|
||||
variant: 'scrabble_en',
|
||||
dictVersion: '',
|
||||
status: '',
|
||||
players: 0,
|
||||
|
||||
@@ -15,7 +15,7 @@ const seat = (s: number, accountId: string): Seat => ({
|
||||
function game(id: string, status: GameView['status'], toMove: number, lastActivityUnix: number): GameView {
|
||||
return {
|
||||
id,
|
||||
variant: 'english',
|
||||
variant: 'scrabble_en',
|
||||
dictVersion: 'v1',
|
||||
status,
|
||||
players: 2,
|
||||
|
||||
@@ -10,11 +10,11 @@ import type { Variant } from '../model';
|
||||
// "letter+value" tokens in alphabet-index order. English Latin a..z; Russian а..я incl. ё;
|
||||
// Эрудит а..я incl. ё=0.
|
||||
const SPECS: Record<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',
|
||||
russian_scrabble:
|
||||
scrabble_ru:
|
||||
'а1 б3 в1 г3 д2 е1 ё3 ж5 з5 и1 й4 к2 л2 м2 н1 о1 п2 р1 с1 т1 у2 ф10 х5 ц5 ч5 ш8 щ10 ъ10 ы4 ь3 э8 ю8 я3',
|
||||
erudit:
|
||||
erudit_ru:
|
||||
'а1 б3 в2 г3 д2 е1 ё0 ж5 з5 и1 й2 к2 л2 м2 н1 о1 п2 р2 с2 т2 у3 ф10 х5 ц10 ч5 ш10 щ10 ъ10 ы5 ь5 э10 ю10 я3',
|
||||
};
|
||||
|
||||
|
||||
@@ -61,9 +61,9 @@ function emptyLinked(): LinkResult {
|
||||
}
|
||||
|
||||
const POOL: Record<Variant, string> = {
|
||||
english: 'AAAAEEEEIIIOONNRRTTLLSSUDGBCMPFHVWYK',
|
||||
russian_scrabble: 'ОООААЕЕИИННТТСРРВЛКМДПУЯЫЬГЗБ',
|
||||
erudit: 'ОООААЕЕИИННТТСРРВЛКМДПУЯЫЬГЗБ',
|
||||
scrabble_en: 'AAAAEEEEIIIOONNRRTTLLSSUDGBCMPFHVWYK',
|
||||
scrabble_ru: 'ОООААЕЕИИННТТСРРВЛКМДПУЯЫЬГЗБ',
|
||||
erudit_ru: 'ОООААЕЕИИННТТСРРВЛКМДПУЯЫЬГЗБ',
|
||||
};
|
||||
|
||||
function draw(variant: Variant, n: number): string[] {
|
||||
|
||||
@@ -57,7 +57,7 @@ export function mockInvitations(): Invitation[] {
|
||||
id: 'inv1',
|
||||
inviter: { accountId: 'kaya', displayName: 'Kaya' },
|
||||
invitees: [{ accountId: ME, displayName: 'You', seat: 1, response: 'pending' }],
|
||||
variant: 'english',
|
||||
variant: 'scrabble_en',
|
||||
turnTimeoutSecs: 86400,
|
||||
hintsAllowed: true,
|
||||
hintsPerPlayer: 1,
|
||||
@@ -105,7 +105,7 @@ export interface MockGame {
|
||||
chat: ChatMessage[];
|
||||
}
|
||||
|
||||
// --- active game G1: english, You (seat 0) vs Ann (seat 1), your turn ---
|
||||
// --- active game G1: scrabble_en, You (seat 0) vs Ann (seat 1), your turn ---
|
||||
|
||||
const G1_MOVES: MoveRecord[] = [
|
||||
play(0, 'H', [
|
||||
@@ -135,7 +135,7 @@ function activeGame(): MockGame {
|
||||
return {
|
||||
view: {
|
||||
id: 'g1',
|
||||
variant: 'english',
|
||||
variant: 'scrabble_en',
|
||||
dictVersion: 'v1',
|
||||
status: 'active',
|
||||
players: 2,
|
||||
@@ -169,7 +169,7 @@ function finishedG2(): MockGame {
|
||||
return {
|
||||
view: {
|
||||
id: 'g2',
|
||||
variant: 'english',
|
||||
variant: 'scrabble_en',
|
||||
dictVersion: 'v1',
|
||||
status: 'finished',
|
||||
players: 2,
|
||||
@@ -204,7 +204,7 @@ function finishedG3(): MockGame {
|
||||
return {
|
||||
view: {
|
||||
id: 'g3',
|
||||
variant: 'russian_scrabble',
|
||||
variant: 'scrabble_ru',
|
||||
dictVersion: 'v1',
|
||||
status: 'finished',
|
||||
players: 2,
|
||||
|
||||
+1
-1
@@ -3,7 +3,7 @@
|
||||
// FlatBuffers) and the mock transport speak this model, so the UI never touches
|
||||
// generated wire code directly.
|
||||
|
||||
export type Variant = 'english' | 'russian_scrabble' | 'erudit';
|
||||
export type Variant = 'scrabble_en' | 'scrabble_ru' | 'erudit_ru';
|
||||
|
||||
/** Backend game status strings. */
|
||||
export type GameStatus = 'active' | 'finished' | string;
|
||||
|
||||
+10
-10
@@ -1,13 +1,13 @@
|
||||
import { describe, expect, it } from 'vitest';
|
||||
import { BOARD_SIZE, centre, premiumGrid } from './premiums';
|
||||
|
||||
// Premium-square geometry parity with scrabble-solver/rules/rules.go: english/russian
|
||||
// share standardBoard (centre is a double word); erudit shares the geometry but a
|
||||
// Premium-square geometry parity with scrabble-solver/rules/rules.go: scrabble_en/scrabble_ru
|
||||
// share standardBoard (centre is a double word); erudit_ru shares the geometry but a
|
||||
// non-doubling centre. Tile-value and alphabet parity moved to the Go engine test
|
||||
// (backend/internal/engine AlphabetTable) in Stage 13 — the server now owns that table.
|
||||
describe('premium layout', () => {
|
||||
it('is a 15x15 grid with TW corners', () => {
|
||||
const g = premiumGrid('english');
|
||||
const g = premiumGrid('scrabble_en');
|
||||
expect(g.length).toBe(BOARD_SIZE);
|
||||
expect(g[0].length).toBe(BOARD_SIZE);
|
||||
for (const [r, c] of [
|
||||
@@ -20,16 +20,16 @@ describe('premium layout', () => {
|
||||
}
|
||||
});
|
||||
|
||||
it('doubles the centre for standard variants but not for erudit', () => {
|
||||
expect(centre('english')).toEqual({ row: 7, col: 7 });
|
||||
expect(premiumGrid('english')[7][7]).toBe('DW');
|
||||
expect(premiumGrid('russian_scrabble')[7][7]).toBe('DW');
|
||||
expect(centre('erudit')).toEqual({ row: 7, col: 7 });
|
||||
expect(premiumGrid('erudit')[7][7]).toBe('');
|
||||
it('doubles the centre for standard variants but not for erudit_ru', () => {
|
||||
expect(centre('scrabble_en')).toEqual({ row: 7, col: 7 });
|
||||
expect(premiumGrid('scrabble_en')[7][7]).toBe('DW');
|
||||
expect(premiumGrid('scrabble_ru')[7][7]).toBe('DW');
|
||||
expect(centre('erudit_ru')).toEqual({ row: 7, col: 7 });
|
||||
expect(premiumGrid('erudit_ru')[7][7]).toBe('');
|
||||
});
|
||||
|
||||
it('keeps the standard premium counts', () => {
|
||||
const flat = premiumGrid('english').flat();
|
||||
const flat = premiumGrid('scrabble_en').flat();
|
||||
const count = (p: string) => flat.filter((x) => x === p).length;
|
||||
expect(count('TW')).toBe(8);
|
||||
expect(count('TL')).toBe(12);
|
||||
|
||||
@@ -51,7 +51,7 @@ const eruditBoard = [
|
||||
];
|
||||
|
||||
function template(variant: Variant): string[] {
|
||||
return variant === 'erudit' ? eruditBoard : standardBoard;
|
||||
return variant === 'erudit_ru' ? eruditBoard : standardBoard;
|
||||
}
|
||||
|
||||
function premiumOf(ch: string): Premium {
|
||||
|
||||
@@ -14,7 +14,7 @@ const seat = (s: number, accountId: string, score: number, isWinner = false): Se
|
||||
function game(seats: Seat[], status = 'finished', toMove = 0): GameView {
|
||||
return {
|
||||
id: 'g',
|
||||
variant: 'english',
|
||||
variant: 'scrabble_en',
|
||||
dictVersion: 'v1',
|
||||
status,
|
||||
players: seats.length,
|
||||
|
||||
@@ -9,14 +9,14 @@ describe('availableVariants', () => {
|
||||
});
|
||||
|
||||
it('offers only English for an en-only service', () => {
|
||||
expect(availableVariants(['en']).map((v) => v.id)).toEqual(['english']);
|
||||
expect(availableVariants(['en']).map((v) => v.id)).toEqual(['scrabble_en']);
|
||||
});
|
||||
|
||||
it('offers Russian and Эрудит for a ru-only service', () => {
|
||||
expect(availableVariants(['ru']).map((v) => v.id)).toEqual(['russian_scrabble', 'erudit']);
|
||||
expect(availableVariants(['ru']).map((v) => v.id)).toEqual(['scrabble_ru', 'erudit_ru']);
|
||||
});
|
||||
|
||||
it('offers every variant for a bilingual service', () => {
|
||||
expect(availableVariants(['en', 'ru']).map((v) => v.id)).toEqual(['english', 'russian_scrabble', 'erudit']);
|
||||
expect(availableVariants(['en', 'ru']).map((v) => v.id)).toEqual(['scrabble_en', 'scrabble_ru', 'erudit_ru']);
|
||||
});
|
||||
});
|
||||
|
||||
+10
-10
@@ -17,9 +17,9 @@ export interface VariantOption {
|
||||
// two never collide whatever the UI language); Erudit is localized "Erudite"/"Эрудит"
|
||||
// (Stage 17).
|
||||
export const ALL_VARIANTS: VariantOption[] = [
|
||||
{ id: 'english', label: 'new.english' },
|
||||
{ id: 'russian_scrabble', label: 'new.russian' },
|
||||
{ id: 'erudit', label: 'new.erudit' },
|
||||
{ id: 'scrabble_en', label: 'new.english' },
|
||||
{ id: 'scrabble_ru', label: 'new.russian' },
|
||||
{ id: 'erudit_ru', label: 'new.erudit' },
|
||||
];
|
||||
|
||||
// variantNameKey returns the i18n key for a variant's display name (used by the in-game
|
||||
@@ -31,22 +31,22 @@ export function variantNameKey(v: Variant): MessageKey {
|
||||
// VARIANT_RULES is the i18n key for each variant's one-line rules summary on the New Game
|
||||
// buttons (bag size, the ё rule, bonus differences), sourced from the engine rulesets.
|
||||
export const VARIANT_RULES: Record<Variant, MessageKey> = {
|
||||
english: 'new.rulesEnglish',
|
||||
russian_scrabble: 'new.rulesRussian',
|
||||
erudit: 'new.rulesErudit',
|
||||
scrabble_en: 'new.rulesEnglish',
|
||||
scrabble_ru: 'new.rulesRussian',
|
||||
erudit_ru: 'new.rulesErudit',
|
||||
};
|
||||
|
||||
// VARIANT_FLAG is the flag shown on a variant button: an emoji for the Scrabble variants;
|
||||
// Erudit uses the bundled USSR flag SVG (public/flag-ussr.svg), so its entry is empty.
|
||||
export const VARIANT_FLAG: Record<Variant, string> = {
|
||||
english: '🇺🇸',
|
||||
russian_scrabble: '🇷🇺',
|
||||
erudit: '',
|
||||
scrabble_en: '🇺🇸',
|
||||
scrabble_ru: '🇷🇺',
|
||||
erudit_ru: '',
|
||||
};
|
||||
|
||||
// VARIANT_LANGUAGE maps each variant to its game language. en -> English;
|
||||
// ru -> Russian + Эрудит.
|
||||
export const VARIANT_LANGUAGE: Record<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
|
||||
// or absent set is ungated (a web/legacy session without a declared set), returning
|
||||
|
||||
@@ -144,9 +144,9 @@
|
||||
}
|
||||
|
||||
const variantKey: Record<string, MessageKey> = {
|
||||
english: 'new.english',
|
||||
russian_scrabble: 'new.russian',
|
||||
erudit: 'new.erudit',
|
||||
scrabble_en: 'new.english',
|
||||
scrabble_ru: 'new.russian',
|
||||
erudit_ru: 'new.erudit',
|
||||
};
|
||||
</script>
|
||||
|
||||
|
||||
Reference in New Issue
Block a user