diff --git a/apple/PLAN.md b/apple/PLAN.md index f699325..866883a 100644 --- a/apple/PLAN.md +++ b/apple/PLAN.md @@ -501,7 +501,7 @@ emerge while implementing the vertical slice, step 3.)* toggle → **Start**. - *With friends:* rows **Invite a friend** / **Pass-and-play** → pushed sub-flows. - **Variant picker:** **emblem chips / segmented** (≤3 variants); **hidden entirely when a single variant - is enabled** (the common default — Erudite only). + is enabled** (a guest's default — Erudite only; a registered account starts with two). **Behaviour (per FUNCTIONAL, kept)** - **Quick game:** AI default (instant 🤖, no wait) / Random (auto-match 2p — drops you into the game to diff --git a/backend/internal/account/account.go b/backend/internal/account/account.go index 1dcb901..63f8cd7 100644 --- a/backend/internal/account/account.go +++ b/backend/internal/account/account.go @@ -18,6 +18,7 @@ import ( "github.com/go-jet/jet/v2/qrm" "github.com/google/uuid" "github.com/jackc/pgx/v5/pgconn" + "github.com/lib/pq" "scrabble/backend/internal/postgres/jet/backend/model" "scrabble/backend/internal/postgres/jet/backend/table" @@ -57,8 +58,9 @@ type Account struct { // VariantPreferences is the set of game variants (engine.Variant stable labels: // "scrabble_en", "scrabble_ru", "erudit_ru") the player is willing to be matched // into. It gates the New Game picker, the matchmaker and the friend-invite the - // player creates; an invited friend may still accept any variant. A new account - // defaults to Erudit only. Never empty — enforced on update and by a DB check. + // player creates; an invited friend may still accept any variant. A newly registered + // account defaults to DefaultVariantPreferences and a guest to + // GuestVariantPreferences. Never empty — enforced on update and by a DB check. VariantPreferences []string // IsGuest marks an ephemeral guest account: a durable row with no identity, // excluded from statistics, friends and history. @@ -562,9 +564,14 @@ func (s *Store) ProvisionGuest(ctx context.Context, browserTZ, language string) if lang == "" { lang = "en" } + // A guest is narrower than a registered player: Эрудит alone, where the column default + // gives a registered account both Russian-alphabet games (DefaultVariantPreferences). + // The set is written explicitly for exactly that reason, and ClearGuest widens it to the + // default when the guest later registers. stmt := table.Accounts. - INSERT(table.Accounts.AccountID, table.Accounts.DisplayName, table.Accounts.IsGuest, table.Accounts.TimeZone, table.Accounts.PreferredLanguage). - VALUES(accountID, guestDisplayName(), true, tz, lang). + INSERT(table.Accounts.AccountID, table.Accounts.DisplayName, table.Accounts.IsGuest, table.Accounts.TimeZone, table.Accounts.PreferredLanguage, table.Accounts.VariantPreferences). + VALUES(accountID, guestDisplayName(), true, tz, lang, + postgres.Raw("#guest_variants::text[]", map[string]interface{}{"#guest_variants": pq.StringArray(GuestVariantPreferences)})). RETURNING(table.Accounts.AllColumns) var row model.Accounts diff --git a/backend/internal/account/link.go b/backend/internal/account/link.go index 229c4cb..f711aad 100644 --- a/backend/internal/account/link.go +++ b/backend/internal/account/link.go @@ -9,6 +9,7 @@ import ( "github.com/go-jet/jet/v2/postgres" "github.com/google/uuid" + "github.com/lib/pq" "scrabble/backend/internal/postgres/jet/backend/table" ) @@ -282,9 +283,22 @@ func (s *Store) AttachIdentity(ctx context.Context, accountID uuid.UUID, kind, e // ClearGuest removes the is_guest flag from accountID, promoting an ephemeral guest // to a durable account once it gains its first identity. It is a no-op // for an already-durable account. +// +// The promotion also widens the guest's narrow variant set (GuestVariantPreferences, +// Эрудит alone) to the registered default, so a player who registers from the New Game +// hint really does gain Russian Scrabble. A guest who changed the set themselves is left +// alone: only the untouched guest default is replaced. func (s *Store) ClearGuest(ctx context.Context, accountID uuid.UUID) error { - upd := table.Accounts.UPDATE(table.Accounts.IsGuest, table.Accounts.UpdatedAt). - SET(postgres.Bool(false), postgres.TimestampzT(time.Now().UTC())). + upd := table.Accounts.UPDATE(table.Accounts.IsGuest, table.Accounts.VariantPreferences, table.Accounts.UpdatedAt). + SET(postgres.Bool(false), + postgres.Raw( + "CASE WHEN variant_preferences = #guest_variants::text[] THEN #default_variants::text[] ELSE variant_preferences END", + map[string]interface{}{ + "#guest_variants": pq.StringArray(GuestVariantPreferences), + "#default_variants": pq.StringArray(DefaultVariantPreferences), + }, + ), + postgres.TimestampzT(time.Now().UTC())). WHERE( table.Accounts.AccountID.EQ(postgres.UUID(accountID)). AND(table.Accounts.IsGuest.EQ(postgres.Bool(true))), diff --git a/backend/internal/account/profile.go b/backend/internal/account/profile.go index 476adac..b62e952 100644 --- a/backend/internal/account/profile.go +++ b/backend/internal/account/profile.go @@ -6,6 +6,7 @@ import ( "fmt" "math/rand/v2" "regexp" + "slices" "strings" "time" "unicode" @@ -76,6 +77,18 @@ var knownVariants = map[string]bool{"erudit_ru": true, "scrabble_ru": true, "scr // in (Erudit, Russian Scrabble, English), independent of the client's order. var canonicalVariantOrder = []string{"erudit_ru", "scrabble_ru", "scrabble_en"} +// DefaultVariantPreferences is the variant set a newly registered account starts with: +// both Russian-alphabet games. It mirrors the accounts.variant_preferences column +// default, which is what actually seeds a created row — the constant is here so the +// promotion of a guest (ClearGuest) and the tests name the same set as the schema. +var DefaultVariantPreferences = []string{"erudit_ru", "scrabble_ru"} + +// GuestVariantPreferences is the variant set an ephemeral guest starts with: Эрудит +// alone. A guest is deliberately narrower than a registered player — the New Game +// screen offers the single variant and invites the guest to register for the rest — +// so ProvisionGuest writes it explicitly instead of taking the column default. +var GuestVariantPreferences = []string{"erudit_ru"} + // validateVariantPreferences cleans a profile's variant-preference set: it drops // duplicates, rejects an unknown label or an empty set (ErrInvalidProfile) and // returns the preferences in canonicalVariantOrder so the stored value is @@ -129,6 +142,33 @@ func SeedVariantsFromStartParam(startParam string) []string { return prefs } +// MergeVariantSeed resolves the variant-preference set to store for an account that +// arrived on a promo deep link. The seed decoded from the link is *added* to current +// (the set the account already carries, normally the column default) rather than +// replacing it, so a campaign can only ever widen a player's choice. English Scrabble +// is the one variant the language gates: it is dropped from seed when languageCode is +// Russian, because a Russian-speaking arrival is served by the two Russian-alphabet +// games and the English one would only clutter New Game. It returns nil when there is +// nothing to write — an empty or invalid seed, or a merge that leaves current as it is +// — so the caller can skip the update. +func MergeVariantSeed(current, seed []string, languageCode string) []string { + if len(seed) == 0 { + return nil + } + extra := seed + if supportedLanguage(languageCode) == "ru" { + extra = slices.DeleteFunc(slices.Clone(seed), func(v string) bool { return v == "scrabble_en" }) + } + merged, err := validateVariantPreferences(append(slices.Clone(current), extra...)) + if err != nil { + return nil + } + if base, err := validateVariantPreferences(current); err == nil && slices.Equal(base, merged) { + return nil + } + return merged +} + // SetVariantPreferences overwrites only the variant-preference set of the account, // cleaning it to a deduplicated, canonically ordered subset of the known variants // (rejecting an empty or unknown set with ErrInvalidProfile) and bumping updated_at; it diff --git a/backend/internal/account/variant_seed_test.go b/backend/internal/account/variant_seed_test.go index 3977997..c1479b6 100644 --- a/backend/internal/account/variant_seed_test.go +++ b/backend/internal/account/variant_seed_test.go @@ -35,3 +35,37 @@ func TestSeedVariantsFromStartParam(t *testing.T) { }) } } + +// TestMergeVariantSeed covers resolving what a promo-onboarded account should end up with: +// the seed only ever widens the current set, English Scrabble is withheld from a +// Russian-speaking arrival, and a merge that changes nothing reports nil so the caller +// skips the write. +func TestMergeVariantSeed(t *testing.T) { + promo := []string{"erudit_ru", "scrabble_en"} // the campaign link "verudit_ru-scrabble_en" + tests := []struct { + name string + current []string + seed []string + language string + want []string + }{ + {"russian speaker keeps the default", DefaultVariantPreferences, promo, "ru", nil}, + {"russian locale variant is still russian", DefaultVariantPreferences, promo, "ru-RU", nil}, + {"english speaker gains english", DefaultVariantPreferences, promo, "en", []string{"erudit_ru", "scrabble_ru", "scrabble_en"}}, + {"unsupported language is not russian", DefaultVariantPreferences, promo, "de", []string{"erudit_ru", "scrabble_ru", "scrabble_en"}}, + {"absent language is not russian", DefaultVariantPreferences, promo, "", []string{"erudit_ru", "scrabble_ru", "scrabble_en"}}, + {"no seed writes nothing", DefaultVariantPreferences, nil, "en", nil}, + {"seed already covered writes nothing", DefaultVariantPreferences, []string{"scrabble_ru"}, "ru", nil}, + {"the russian filter spares the account's own english", []string{"erudit_ru", "scrabble_en"}, promo, "ru", nil}, + {"invalid seed writes nothing", DefaultVariantPreferences, []string{"chess"}, "en", nil}, + {"a guest set is widened by the seed", GuestVariantPreferences, []string{"scrabble_ru"}, "ru", DefaultVariantPreferences}, + } + for _, tc := range tests { + t.Run(tc.name, func(t *testing.T) { + got := MergeVariantSeed(tc.current, tc.seed, tc.language) + if !slices.Equal(got, tc.want) { + t.Errorf("MergeVariantSeed(%v, %v, %q) = %v, want %v", tc.current, tc.seed, tc.language, got, tc.want) + } + }) + } +} diff --git a/backend/internal/inttest/account_test.go b/backend/internal/inttest/account_test.go index fcbdb70..5cfde10 100644 --- a/backend/internal/inttest/account_test.go +++ b/backend/internal/inttest/account_test.go @@ -6,6 +6,7 @@ import ( "context" "errors" "regexp" + "slices" "testing" "time" @@ -266,6 +267,81 @@ func TestProvisionSeedsTimeZone(t *testing.T) { } } +// TestVariantPreferenceDefaults covers the variant set an account is born with and how +// registration widens it: a registered account starts on both Russian-alphabet games +// (the column default), a guest on Эрудит alone, and promoting the guest lifts it to the +// registered default — but only while the guest still carries that untouched set, and +// never for an account that is already durable. +func TestVariantPreferenceDefaults(t *testing.T) { + ctx := context.Background() + store := account.NewStore(testDB) + + equal := func(t *testing.T, what string, got, want []string) { + t.Helper() + if !slices.Equal(got, want) { + t.Errorf("%s = %v, want %v", what, got, want) + } + } + + // A registered account (any identity kind) takes the column default. + durable, _, err := store.ProvisionTelegram(ctx, "tg-"+uuid.NewString(), "ru", "", "Player", "") + if err != nil { + t.Fatalf("provision telegram: %v", err) + } + equal(t, "registered account variants", durable.VariantPreferences, account.DefaultVariantPreferences) + + // A guest is deliberately narrower. + guest, err := store.ProvisionGuest(ctx, "", "ru") + if err != nil { + t.Fatalf("provision guest: %v", err) + } + equal(t, "guest variants", guest.VariantPreferences, account.GuestVariantPreferences) + + // Registering the guest promotes it to a durable account on the registered default. + if err := store.ClearGuest(ctx, guest.ID); err != nil { + t.Fatalf("clear guest: %v", err) + } + promoted, err := store.GetByID(ctx, guest.ID) + if err != nil { + t.Fatalf("reload promoted guest: %v", err) + } + if promoted.IsGuest { + t.Error("promoted guest is still flagged is_guest") + } + equal(t, "promoted guest variants", promoted.VariantPreferences, account.DefaultVariantPreferences) + + // A guest who picked their own set keeps it through the promotion. + picky, err := store.ProvisionGuest(ctx, "", "en") + if err != nil { + t.Fatalf("provision picky guest: %v", err) + } + if _, err := store.SetVariantPreferences(ctx, picky.ID, []string{"scrabble_en"}); err != nil { + t.Fatalf("set picky guest variants: %v", err) + } + if err := store.ClearGuest(ctx, picky.ID); err != nil { + t.Fatalf("clear picky guest: %v", err) + } + reloaded, err := store.GetByID(ctx, picky.ID) + if err != nil { + t.Fatalf("reload picky guest: %v", err) + } + equal(t, "picky guest variants", reloaded.VariantPreferences, []string{"scrabble_en"}) + + // An account that is already durable is never rewritten — an established player who + // settled on Эрудит alone stays there. + if _, err := store.SetVariantPreferences(ctx, durable.ID, []string{"erudit_ru"}); err != nil { + t.Fatalf("narrow durable variants: %v", err) + } + if err := store.ClearGuest(ctx, durable.ID); err != nil { + t.Fatalf("clear guest on a durable account: %v", err) + } + established, err := store.GetByID(ctx, durable.ID) + if err != nil { + t.Fatalf("reload durable account: %v", err) + } + equal(t, "established account variants", established.VariantPreferences, []string{"erudit_ru"}) +} + // TestProvisionTelegramUnknownLanguageDefaults checks an unsupported Telegram // client language falls back to the account default rather than failing the // language CHECK. diff --git a/backend/internal/inttest/email_test.go b/backend/internal/inttest/email_test.go index 2dcd1b3..9a4f72e 100644 --- a/backend/internal/inttest/email_test.go +++ b/backend/internal/inttest/email_test.go @@ -135,10 +135,11 @@ func TestUpdateProfilePersists(t *testing.T) { store := account.NewStore(testDB) acc := provisionAccount(t) - // A fresh account defaults to Erudit only (the DB-level column default). + // A fresh registered account defaults to both Russian-alphabet games (the DB-level + // column default); the lifecycle around it is covered by TestVariantPreferenceDefaults. if def, err := store.GetByID(ctx, acc); err != nil { t.Fatalf("get default: %v", err) - } else if want := []string{"erudit_ru"}; !slices.Equal(def.VariantPreferences, want) { + } else if want := account.DefaultVariantPreferences; !slices.Equal(def.VariantPreferences, want) { t.Errorf("default variant preferences = %v, want %v", def.VariantPreferences, want) } diff --git a/backend/internal/inttest/promo_variant_test.go b/backend/internal/inttest/promo_variant_test.go index c25f475..40d5bcc 100644 --- a/backend/internal/inttest/promo_variant_test.go +++ b/backend/internal/inttest/promo_variant_test.go @@ -19,10 +19,11 @@ import ( ) // TestTelegramAuthSeedsPromoVariantForNewUserOnly drives the sessions/telegram endpoint -// to confirm a promo deep-link start-param seeds a brand-new account's variant -// preferences (English Scrabble alongside the default Erudit), that a new account with no -// such payload keeps the Erudit-only default, and that an existing account is never -// re-seeded on a later login (the new-user-only contract). +// to confirm a promo deep-link start-param widens a brand-new account's variant +// preferences — English Scrabble on top of the default pair, but only for a +// non-Russian-speaking arrival — that a new account with no such payload keeps the +// default, and that an existing account is never re-seeded on a later login (the +// new-user-only contract). func TestTelegramAuthSeedsPromoVariantForNewUserOnly(t *testing.T) { srv := server.New(":0", server.Deps{ Logger: zaptest.NewLogger(t), @@ -33,8 +34,8 @@ func TestTelegramAuthSeedsPromoVariantForNewUserOnly(t *testing.T) { }) h := srv.Handler() - post := func(ext, startParam string) { - body := `{"external_id":"` + ext + `","language_code":"en","first_name":"Promo"` + post := func(ext, language, startParam string) { + body := `{"external_id":"` + ext + `","language_code":"` + language + `","first_name":"Promo"` if startParam != "" { body += `,"start_param":"` + startParam + `"` } @@ -56,25 +57,33 @@ func TestTelegramAuthSeedsPromoVariantForNewUserOnly(t *testing.T) { return acc.VariantPreferences } - // A brand-new account reached through a promo deep-link is seeded with English - // Scrabble alongside the default Erudit. + // An English-speaking arrival on a promo deep-link gains English Scrabble on top of + // the default pair, so a native English player is not lost at the door. promoExt := "tg-" + uuid.NewString() - post(promoExt, "verudit_ru-scrabble_en") - if got, want := reload(promoExt), []string{"erudit_ru", "scrabble_en"}; !slices.Equal(got, want) { - t.Errorf("promo new account variants = %v, want %v", got, want) + post(promoExt, "en", "verudit_ru-scrabble_en") + if got, want := reload(promoExt), []string{"erudit_ru", "scrabble_ru", "scrabble_en"}; !slices.Equal(got, want) { + t.Errorf("english promo account variants = %v, want %v", got, want) } - // A brand-new account with no promo payload keeps the Erudit-only default. + // A Russian-speaking arrival on the same link stays on the default pair: the two + // Russian-alphabet games serve them, and English would only clutter New Game. + ruExt := "tg-" + uuid.NewString() + post(ruExt, "ru", "verudit_ru-scrabble_en") + if got, want := reload(ruExt), []string{"erudit_ru", "scrabble_ru"}; !slices.Equal(got, want) { + t.Errorf("russian promo account variants = %v, want %v", got, want) + } + + // A brand-new account with no promo payload keeps the default. plainExt := "tg-" + uuid.NewString() - post(plainExt, "") - if got, want := reload(plainExt), []string{"erudit_ru"}; !slices.Equal(got, want) { + post(plainExt, "en", "") + if got, want := reload(plainExt), []string{"erudit_ru", "scrabble_ru"}; !slices.Equal(got, want) { t.Errorf("plain new account variants = %v, want %v", got, want) } // A later login of the promo account, even via a different payload, must not re-seed: // the seed is first-contact only. - post(promoExt, "vscrabble_ru") - if got, want := reload(promoExt), []string{"erudit_ru", "scrabble_en"}; !slices.Equal(got, want) { + post(promoExt, "en", "vscrabble_ru") + if got, want := reload(promoExt), []string{"erudit_ru", "scrabble_ru", "scrabble_en"}; !slices.Equal(got, want) { t.Errorf("existing account re-seeded = %v, want unchanged %v", got, want) } } diff --git a/backend/internal/inttest/variant_preference_test.go b/backend/internal/inttest/variant_preference_test.go index 5e49a28..58be39d 100644 --- a/backend/internal/inttest/variant_preference_test.go +++ b/backend/internal/inttest/variant_preference_test.go @@ -16,10 +16,10 @@ import ( // TestVariantPreferenceGate covers the New Game gate: a player may start a quick game // or create a friend invitation only in a variant they have enabled in their profile -// (a fresh account defaults to Erudit only), and any other variant is refused with 400. -// The complementary case — an invited friend accepting an invitation in a variant they -// have NOT enabled — is exercised by TestGameLimitGate, where a default-Erudit human -// accepts an English invitation over HTTP. +// (a fresh account defaults to the two Russian-alphabet games), and any other variant is +// refused with 400. The complementary case — an invited friend accepting an invitation in +// a variant they have NOT enabled — is exercised by TestGameLimitGate, where a +// default-preference human accepts an English invitation over HTTP. func TestVariantPreferenceGate(t *testing.T) { clearOpenGames(t) srv := server.New(":0", server.Deps{ diff --git a/backend/internal/postgres/migrations/00018_default_variant_preferences.sql b/backend/internal/postgres/migrations/00018_default_variant_preferences.sql new file mode 100644 index 0000000..66d34dc --- /dev/null +++ b/backend/internal/postgres/migrations/00018_default_variant_preferences.sql @@ -0,0 +1,16 @@ +-- Registered players start with both Russian-alphabet games enabled: Эрудит and Russian Scrabble. +-- Only the column default moves — existing accounts keep the set they already carry, so a player +-- who deliberately settled on one variant is not overruled. Guests are the exception and stay on +-- Эрудит alone; account.ProvisionGuest therefore writes the column explicitly rather than relying +-- on this default, and account.ClearGuest widens a promoted guest to the default set. A default +-- change rewrites no rows (no contour wipe); an image rollback ignores it — the previous code +-- reads a two-element set unchanged. +-- +goose Up + +ALTER TABLE backend.accounts + ALTER COLUMN variant_preferences SET DEFAULT ARRAY['erudit_ru', 'scrabble_ru']::text[]; + +-- +goose Down + +ALTER TABLE backend.accounts + ALTER COLUMN variant_preferences SET DEFAULT ARRAY['erudit_ru']::text[]; diff --git a/backend/internal/server/handlers_auth.go b/backend/internal/server/handlers_auth.go index a24c6a6..e0e3713 100644 --- a/backend/internal/server/handlers_auth.go +++ b/backend/internal/server/handlers_auth.go @@ -23,10 +23,11 @@ import ( // validated initData payload. Username, FirstName and LanguageCode seed a // brand-new account's display name and language; BrowserTZ (the client's detected // "±HH:MM" UTC offset) seeds its time zone; StartParam is the validated launch -// deep-link payload, which may seed the new account's variant preferences (first -// contact only). Subtype is the client-reported device family (ios/android/web); -// Telegram's initData does not sign it, so it is recorded best-effort and the -// payments gate never relies on it. +// deep-link payload, which may widen the new account's variant preferences (first +// contact only) — LanguageCode decides whether English Scrabble is part of that. +// Subtype is the client-reported device family (ios/android/web); Telegram's initData +// does not sign it, so it is recorded best-effort and the payments gate never relies +// on it. type telegramAuthRequest struct { ExternalID string `json:"external_id"` Username string `json:"username"` @@ -56,12 +57,14 @@ func (s *Server) handleTelegramAuth(c *gin.Context) { // joined the chat before registering is granted on the spot (no chat_member // event fires on registration). s.publishChatAccessChange(acc.ID) - // A promo deep-link may seed this brand-new account's variant preferences (e.g. - // English Scrabble alongside the default Erudit). Best-effort: an absent or - // malformed payload leaves the account on its defaults, and a write failure must - // not block the session mint. - if seed := account.SeedVariantsFromStartParam(req.StartParam); len(seed) > 0 { - if _, err := s.accounts.SetVariantPreferences(c.Request.Context(), acc.ID, seed); err != nil { + // A promo deep-link may widen this brand-new account's variant preferences beyond + // the default (English Scrabble for a non-Russian-speaking arrival — see + // account.MergeVariantSeed, which gates that variant on req.LanguageCode). + // Best-effort: an absent or malformed payload leaves the account on its defaults, + // and a write failure must not block the session mint. + seed := account.SeedVariantsFromStartParam(req.StartParam) + if merged := account.MergeVariantSeed(acc.VariantPreferences, seed, req.LanguageCode); len(merged) > 0 { + if _, err := s.accounts.SetVariantPreferences(c.Request.Context(), acc.ID, merged); err != nil { s.log.Warn("telegram: seed variant preferences failed", zap.String("account", acc.ID.String()), zap.Error(err)) } diff --git a/docs/ARCHITECTURE.md b/docs/ARCHITECTURE.md index 99edbe5..72be347 100644 --- a/docs/ARCHITECTURE.md +++ b/docs/ARCHITECTURE.md @@ -245,9 +245,15 @@ arrive from a platform rather than completing a mandatory registration). than stranding a user who never opened Settings on the creation-time seed. - **Variant preferences (New Game gating).** Which variants a player may be matched into is a per-user **profile** setting — `variant_preferences`, a set of `engine.Variant` labels - (`scrabble_en`, `scrabble_ru`, `erudit_ru`) edited on the Settings/Profile screen. New - accounts default to **Erudit only** (a DB column default); at least one variant must stay - selected. The picker is ordered **Erudit-first** everywhere. The preference gates the New + (`scrabble_en`, `scrabble_ru`, `erudit_ru`) edited on the Settings/Profile screen. A newly + **registered** account defaults to the two Russian-alphabet games — **Erudit + Russian + Scrabble** (a DB column default); a **guest** is deliberately narrower and starts on + **Erudit only** (`account.ProvisionGuest` writes the set explicitly), with New Game inviting + the guest to register for the rest. Registering promotes the set to the registered default + (`account.ClearGuest`), but only while the guest still carries the untouched guest set — + a player who chose their own set is never overruled, and **existing accounts are not + backfilled** when the default moves. At least one variant must stay selected. The picker is + ordered **Erudit-first** everywhere. The preference gates the New Game picker on every create path the player **initiates** — auto-match, vs-AI and a friend invitation the player **creates** — and the backend **enforces** it on those paths (a chosen variant outside the caller's preferences is rejected with HTTP 400). An **invited** friend @@ -316,6 +322,18 @@ arrive from a platform rather than completing a mandatory registration). > `service_language`/`supported_languages` and the push `language` routing field, and > gained `variant_preferences` on Profile/UpdateProfile. +> **Decision (2026-07-27) — registered accounts start on both Russian games.** The +> Erudit-only default was too narrow a first impression on VK, where Russian Scrabble is the +> familiar game: a **registered** account is now created with `erudit_ru + scrabble_ru`, so +> New Game opens with a real choice (and therefore no pre-selected variant). A **guest** +> stays on Erudit alone and is invited to register for the rest, which keeps the offline +> device-local guest — who has no profile at all — matching the server. **Existing accounts +> are not backfilled**: the set a player already carries may be a deliberate choice, and the +> two are indistinguishable in the data. The Telegram promo `start_param` correspondingly +> became **additive** rather than a replacement (it can only widen a set), with English +> Scrabble withheld from a Russian-speaking arrival — otherwise the English campaign link +> would have quietly taken Russian Scrabble away from every account it onboarded. + ## 4. Accounts, identities, linking & merge - One internal account may carry several **platform identities** @@ -883,7 +901,8 @@ in either direction (the enqueue excludes the caller's `BlockedWith` set); - **Profile**: `preferred_language` (en/ru; tracks the interface language — §4), display name, email (confirm-code binding, see §4), **timezone**, the daily **away window**, the **variant preferences** (`variant_preferences`, the matchable-variant set that gates New - Game — §3, defaulting to Erudit only, at least one enforced) and the + Game — §3, defaulting to Erudit + Russian Scrabble for a registered account and Erudit + alone for a guest, at least one enforced) and the block toggles — all editable through `account.UpdateProfile`, which validates them: a display name is Unicode letters joined by single ` `/`.`/`_` separators (no leading/trailing/adjacent separators, ≤ 32 runes); the timezone is a diff --git a/docs/FUNCTIONAL.md b/docs/FUNCTIONAL.md index a95831d..896a171 100644 --- a/docs/FUNCTIONAL.md +++ b/docs/FUNCTIONAL.md @@ -174,7 +174,8 @@ resigning or by letting it lapse to the 7-day timeout — drops from your *finis automatically, with no swipe needed; a normally finished AI game stays until you remove it, and no other game type is auto-removed. The game types offered on **New Game** are limited to the player's chosen **variant preferences** (see *Profile & settings*) — -Erudite-first, defaulting to Erudite only. Variants are shown by their **display name** — both Scrabble variants read +Erudite-first, defaulting to Erudite + Russian Scrabble for a registered player and to Erudite +alone for a guest. Variants are shown by their **display name** — both Scrabble variants read "Scrabble"/"Скрэббл" and Erudit reads "Erudite"/"Эрудит" (by the interface language), and the same name titles the in-game screen. This gates only **starting** a new game you initiate — auto-match, a vs-AI game and a friend invitation **you create** — so a player still sees and plays @@ -451,8 +452,11 @@ per-device preference and is hidden on desktop, where the board already fits and **Preferences (which variants you can be matched into).** A profile setting picks the game variants — Erudite, Russian Scrabble and English Scrabble, shown **Erudite-first** — you allow -yourself to be matched into; a **new account starts with Erudite only** — unless it was created -through the **promo bot's deep link**, which also enables **English Scrabble** — and you must keep +yourself to be matched into; a **newly registered account starts with Erudite + Russian +Scrabble**, and arriving through the **promo bot's deep link** also enables **English Scrabble** +when your Telegram language is not Russian. A **guest** starts with **Erudite only** and New Game +invites them to register for the rest; registering widens the account to the registered default. +Changing the defaults never rewrites accounts that already exist. You must keep **at least one** selected. This list is exactly what **New Game** offers when you start a game (auto-match, an AI game, or a friend invitation you create) — a variant you have not enabled is not offered, and the server refuses it. It does not restrict games you are **invited** to: an diff --git a/docs/FUNCTIONAL_ru.md b/docs/FUNCTIONAL_ru.md index 3e9d291..15aae53 100644 --- a/docs/FUNCTIONAL_ru.md +++ b/docs/FUNCTIONAL_ru.md @@ -177,7 +177,8 @@ e-mail) либо ввод фразы. Активные игры форфейтя уберёшь её, и никакие другие типы партий автоматически не убираются. Типы партий на экране **Новая игра** ограничены выбранными игроком **предпочтениями вариантов** (см. «Профиль и -настройки») — сначала Эрудит, по умолчанию только Эрудит. Варианты показываются под **отображаемым именем** — оба варианта Scrabble +настройки») — сначала Эрудит, по умолчанию Эрудит и русский Scrabble у зарегистрированного игрока +и только Эрудит у гостя. Варианты показываются под **отображаемым именем** — оба варианта Scrabble читаются как «Scrabble»/«Скрэббл», а Erudit — «Erudite»/«Эрудит» (по языку интерфейса), и это же имя выносится в заголовок экрана игры. Это ограничивает только **старт** игры, которую ты инициируешь, — авто-подбор, игру с ИИ и приглашение друга, которое ты **создаёшь**, — поэтому игрок @@ -458,9 +459,12 @@ Telegram. Внутри VK Mini App те же настройки — и язык **Предпочтения (в какие варианты тебя можно подбирать).** Настройка профиля задаёт варианты игры — Эрудит, русский Scrabble и английский Scrabble, показанные **сначала Эрудит**, — в -которые ты разрешаешь себя подбирать; **новый аккаунт стартует только с Эрудитом** — если только -он не создан по **диплинку промо-бота**, который дополнительно включает **английский Scrabble**, — -и нужно оставить выбранным **хотя бы один**. Именно этот список предлагает **Новая игра**, когда ты +которые ты разрешаешь себя подбирать; **новый зарегистрированный аккаунт стартует с Эрудитом и +русским Scrabble**, а вход по **диплинку промо-бота** дополнительно включает **английский +Scrabble**, если язык Telegram не русский. **Гость** стартует **только с Эрудитом**, и «Новая игра» +предлагает ему зарегистрироваться ради остальных; регистрация расширяет аккаунт до набора +зарегистрированного. Смена набора по умолчанию никогда не переписывает уже существующие аккаунты. +Нужно оставить выбранным **хотя бы один**. Именно этот список предлагает **Новая игра**, когда ты запускаешь партию (авто-подбор, игра с ИИ или приглашение друга, которое ты создаёшь), — не включённый вариант не предлагается, и сервер его отклоняет. На партии, в которые тебя **приглашают**, это не влияет: приглашённый друг может принять приглашение в **любом** варианте, diff --git a/ui/src/lib/i18n/en.ts b/ui/src/lib/i18n/en.ts index 8725405..7f5125c 100644 --- a/ui/src/lib/i18n/en.ts +++ b/ui/src/lib/i18n/en.ts @@ -83,6 +83,11 @@ export const en = { 'new.moveLimit': 'Move time: {n} h 00 min', 'new.searchHint': 'Finding an opponent can sometimes take a while. If you do not want to wait, close the app after starting the game and come back in a couple of minutes.', + // The guest's invitation to register, shown under the single variant a guest is offered. It is + // two keys because the first word is a link to the profile screen; the tail carries its own + // leading space, since Svelte trims literal whitespace at the edges of markup. + 'new.guestVariantHintLink': 'Register', + 'new.guestVariantHint': ' to play the Russian or English Scrabble.', // First-run coachmark hints (components/Coachmark.svelte). The leading emoji in each comment // names the UI element the bubble's tail points at. diff --git a/ui/src/lib/i18n/ru.ts b/ui/src/lib/i18n/ru.ts index ad75f7f..d528c4d 100644 --- a/ui/src/lib/i18n/ru.ts +++ b/ui/src/lib/i18n/ru.ts @@ -84,6 +84,8 @@ export const ru: Record = { 'new.moveLimit': 'Время на ход: {n} ч. 00 мин.', 'new.searchHint': 'Иногда поиск соперника может занять некоторое время. Если не захотите ждать после начала игры, можете вернуться в приложение через несколько минут.', + 'new.guestVariantHintLink': 'Зарегистрируйтесь', + 'new.guestVariantHint': ', чтобы играть в русский или английский вариант Скрэббл.', // Подсказки первого запуска (components/Coachmark.svelte). 'onboarding.lobbySettings': 'Друзья, настройки игры и профиля, обратная связь', // ⚙️ diff --git a/ui/src/lib/variants.test.ts b/ui/src/lib/variants.test.ts index 9558a62..94af281 100644 --- a/ui/src/lib/variants.test.ts +++ b/ui/src/lib/variants.test.ts @@ -4,6 +4,7 @@ import { ALL_VARIANTS, DEFAULT_VARIANTS, availableVariants, + showRegisterHint, supportsMultipleWordsToggle, multipleWordsForRequest, usesStarBlank, @@ -38,6 +39,18 @@ describe('availableVariants', () => { }); }); +describe('showRegisterHint', () => { + it('invites only an online guest holding a single variant', () => { + expect(showRegisterHint(true, 1, false)).toBe(true); + }); + + it('stays hidden for a registered player, offline, or when several variants are offered', () => { + expect(showRegisterHint(false, 1, false)).toBe(false); + expect(showRegisterHint(true, 1, true)).toBe(false); + expect(showRegisterHint(true, 2, false)).toBe(false); + }); +}); + describe('supportsMultipleWordsToggle', () => { it('is true for Russian variants only', () => { expect(supportsMultipleWordsToggle('scrabble_ru')).toBe(true); diff --git a/ui/src/lib/variants.ts b/ui/src/lib/variants.ts index a1c79b1..475d37f 100644 --- a/ui/src/lib/variants.ts +++ b/ui/src/lib/variants.ts @@ -73,23 +73,32 @@ export function usesStarBlank(v: Variant): boolean { // DEFAULT_VARIANTS is the variant set a player sees before any preference is stored — a fresh // or offline native client whose profile has not been synced yet (the device-local guest boots -// with no profile at all). It mirrors the backend's new-account default (the account service -// seeds Erudit only), so the local guest and a later synced account agree on what is enabled. -// Every dictionary is still bundled; the other variants are simply off until the player turns -// them on in Settings. +// with no profile at all). Such a client is a guest, so it mirrors the backend's *guest* set +// (account.GuestVariantPreferences, Erudit alone) rather than the wider set a registered account +// is created with. Every dictionary is still bundled; the other variants are simply off until +// the player registers and turns them on in Settings. export const DEFAULT_VARIANTS: Variant[] = ['erudit_ru']; // availableVariants gates ALL_VARIANTS by the player's variant preferences (the set they // enabled in Settings). An empty or absent set falls back to DEFAULT_VARIANTS rather than every // variant: a real profile always carries at least one preference, and a profileless client (a -// fresh offline native launch) must match the server's Erudit-only default instead of exposing -// the English game before the player opts in. +// fresh offline native launch) must match the server's guest default instead of exposing +// the other games before the player opts in. export function availableVariants(preferences: Variant[] | undefined): VariantOption[] { const prefs = preferences ?? []; const effective = prefs.length === 0 ? DEFAULT_VARIANTS : prefs; return ALL_VARIANTS.filter((v) => effective.includes(v.id)); } +// showRegisterHint reports whether New Game invites the player to register in order to unlock the +// other games, given whether they are a guest (isGuest), how many variants they are offered +// (offered) and whether the app is in offline mode (offline). It is the guest's case alone: a +// guest is created with Erudit only, while registering widens the account to both Russian-alphabet +// games. Offline it is suppressed — registration needs the network, so the link would be dead. +export function showRegisterHint(isGuest: boolean, offered: number, offline: boolean): boolean { + return isGuest && !offline && offered === 1; +} + // supportsMultipleWordsToggle reports whether the New Game "multiple words per turn" toggle // applies to a variant. Only Russian games choose the rule; English is always standard, so // its toggle is not shown. diff --git a/ui/src/screens/NewGame.svelte b/ui/src/screens/NewGame.svelte index 5ffd6a0..e5c856f 100644 --- a/ui/src/screens/NewGame.svelte +++ b/ui/src/screens/NewGame.svelte @@ -22,6 +22,7 @@ import type { AccountRef, GameView, Variant } from '../lib/model'; import { availableVariants, + showRegisterHint, VARIANT_FLAG, VARIANT_RULES, supportsMultipleWordsToggle, @@ -395,6 +396,13 @@ {/each} + + {#if showRegisterHint(guest, variants.length, offlineMode.active)} +

+ {t('new.guestVariantHint')} +

+ {/if} {#if variants.some((v) => supportsMultipleWordsToggle(v.id))}