R1: schema & naming reset — squash migrations, rename variants
CI / changes (pull_request) Successful in 2s
CI / unit (pull_request) Successful in 9s
CI / integration (pull_request) Successful in 11s
CI / ui (pull_request) Successful in 37s
CI / gate (pull_request) Successful in 0s
CI / deploy (pull_request) Successful in 1m8s
CI / changes (pull_request) Successful in 2s
CI / unit (pull_request) Successful in 9s
CI / integration (pull_request) Successful in 11s
CI / ui (pull_request) Successful in 37s
CI / gate (pull_request) Successful in 0s
CI / deploy (pull_request) Successful in 1m8s
Squash the 12 goose migrations into one 00001_baseline.sql (there is no prod data; verified schema-identical to the chain via a pg_dump diff + the green integration suite) and rename the game-variant labels english/russian_scrabble/erudit -> scrabble_en/scrabble_ru/erudit_ru across the backend, the FlatBuffers wire values and the UI. dawg filenames and the Go enum identifiers are unchanged; the i18n display keys are kept. Adds PRERELEASE.md (the R1-R7 pre-release tracker), linked from CLAUDE.md. Contour DB wipe and the scrabble-dictionary tidy are follow-ups.
This commit is contained in:
@@ -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 {
|
||||
|
||||
Reference in New Issue
Block a user