feat(variants): default a registered account to Erudit + Russian Scrabble
CI / changes (pull_request) Successful in 3s
CI / unit (pull_request) Successful in 11s
CI / integration (pull_request) Successful in 22s
CI / ui (pull_request) Successful in 1m16s
CI / conformance (pull_request) Successful in 10s
CI / gate (pull_request) Successful in 0s
CI / deploy (pull_request) Successful in 1m53s
CI / changes (pull_request) Successful in 3s
CI / unit (pull_request) Successful in 11s
CI / integration (pull_request) Successful in 22s
CI / ui (pull_request) Successful in 1m16s
CI / conformance (pull_request) Successful in 10s
CI / gate (pull_request) Successful in 0s
CI / deploy (pull_request) Successful in 1m53s
New Game opened on a single variant for everyone, which reads poorly on VK where Russian Scrabble is the familiar game. A registered account now starts with both Russian-alphabet games, so the picker offers a real choice with no pre-selection. A guest stays on Erudit alone and is invited to register for the rest: the device-local guest has no profile at all, so the client-side fallback has to keep matching the server. Registering promotes the set, but only while the guest still carries the untouched guest default. Existing accounts are not backfilled — the set they carry may be a deliberate choice. The Telegram promo start-param becomes additive rather than a replacement, with English Scrabble withheld from a Russian-speaking arrival; otherwise the English campaign link would have taken Russian Scrabble away from every account it onboarded.
This commit is contained in:
+1
-1
@@ -501,7 +501,7 @@ emerge while implementing the vertical slice, step 3.)*
|
|||||||
toggle → **Start**.
|
toggle → **Start**.
|
||||||
- *With friends:* rows **Invite a friend** / **Pass-and-play** → pushed sub-flows.
|
- *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
|
- **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)**
|
**Behaviour (per FUNCTIONAL, kept)**
|
||||||
- **Quick game:** AI default (instant 🤖, no wait) / Random (auto-match 2p — drops you into the game to
|
- **Quick game:** AI default (instant 🤖, no wait) / Random (auto-match 2p — drops you into the game to
|
||||||
|
|||||||
@@ -18,6 +18,7 @@ import (
|
|||||||
"github.com/go-jet/jet/v2/qrm"
|
"github.com/go-jet/jet/v2/qrm"
|
||||||
"github.com/google/uuid"
|
"github.com/google/uuid"
|
||||||
"github.com/jackc/pgx/v5/pgconn"
|
"github.com/jackc/pgx/v5/pgconn"
|
||||||
|
"github.com/lib/pq"
|
||||||
|
|
||||||
"scrabble/backend/internal/postgres/jet/backend/model"
|
"scrabble/backend/internal/postgres/jet/backend/model"
|
||||||
"scrabble/backend/internal/postgres/jet/backend/table"
|
"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:
|
// VariantPreferences is the set of game variants (engine.Variant stable labels:
|
||||||
// "scrabble_en", "scrabble_ru", "erudit_ru") the player is willing to be matched
|
// "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
|
// 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
|
// player creates; an invited friend may still accept any variant. A newly registered
|
||||||
// defaults to Erudit only. Never empty — enforced on update and by a DB check.
|
// account defaults to DefaultVariantPreferences and a guest to
|
||||||
|
// GuestVariantPreferences. Never empty — enforced on update and by a DB check.
|
||||||
VariantPreferences []string
|
VariantPreferences []string
|
||||||
// IsGuest marks an ephemeral guest account: a durable row with no identity,
|
// IsGuest marks an ephemeral guest account: a durable row with no identity,
|
||||||
// excluded from statistics, friends and history.
|
// excluded from statistics, friends and history.
|
||||||
@@ -562,9 +564,14 @@ func (s *Store) ProvisionGuest(ctx context.Context, browserTZ, language string)
|
|||||||
if lang == "" {
|
if lang == "" {
|
||||||
lang = "en"
|
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.
|
stmt := table.Accounts.
|
||||||
INSERT(table.Accounts.AccountID, table.Accounts.DisplayName, table.Accounts.IsGuest, table.Accounts.TimeZone, table.Accounts.PreferredLanguage).
|
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).
|
VALUES(accountID, guestDisplayName(), true, tz, lang,
|
||||||
|
postgres.Raw("#guest_variants::text[]", map[string]interface{}{"#guest_variants": pq.StringArray(GuestVariantPreferences)})).
|
||||||
RETURNING(table.Accounts.AllColumns)
|
RETURNING(table.Accounts.AllColumns)
|
||||||
|
|
||||||
var row model.Accounts
|
var row model.Accounts
|
||||||
|
|||||||
@@ -9,6 +9,7 @@ import (
|
|||||||
|
|
||||||
"github.com/go-jet/jet/v2/postgres"
|
"github.com/go-jet/jet/v2/postgres"
|
||||||
"github.com/google/uuid"
|
"github.com/google/uuid"
|
||||||
|
"github.com/lib/pq"
|
||||||
|
|
||||||
"scrabble/backend/internal/postgres/jet/backend/table"
|
"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
|
// 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
|
// to a durable account once it gains its first identity. It is a no-op
|
||||||
// for an already-durable account.
|
// 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 {
|
func (s *Store) ClearGuest(ctx context.Context, accountID uuid.UUID) error {
|
||||||
upd := table.Accounts.UPDATE(table.Accounts.IsGuest, table.Accounts.UpdatedAt).
|
upd := table.Accounts.UPDATE(table.Accounts.IsGuest, table.Accounts.VariantPreferences, table.Accounts.UpdatedAt).
|
||||||
SET(postgres.Bool(false), postgres.TimestampzT(time.Now().UTC())).
|
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(
|
WHERE(
|
||||||
table.Accounts.AccountID.EQ(postgres.UUID(accountID)).
|
table.Accounts.AccountID.EQ(postgres.UUID(accountID)).
|
||||||
AND(table.Accounts.IsGuest.EQ(postgres.Bool(true))),
|
AND(table.Accounts.IsGuest.EQ(postgres.Bool(true))),
|
||||||
|
|||||||
@@ -6,6 +6,7 @@ import (
|
|||||||
"fmt"
|
"fmt"
|
||||||
"math/rand/v2"
|
"math/rand/v2"
|
||||||
"regexp"
|
"regexp"
|
||||||
|
"slices"
|
||||||
"strings"
|
"strings"
|
||||||
"time"
|
"time"
|
||||||
"unicode"
|
"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.
|
// in (Erudit, Russian Scrabble, English), independent of the client's order.
|
||||||
var canonicalVariantOrder = []string{"erudit_ru", "scrabble_ru", "scrabble_en"}
|
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
|
// validateVariantPreferences cleans a profile's variant-preference set: it drops
|
||||||
// duplicates, rejects an unknown label or an empty set (ErrInvalidProfile) and
|
// duplicates, rejects an unknown label or an empty set (ErrInvalidProfile) and
|
||||||
// returns the preferences in canonicalVariantOrder so the stored value is
|
// returns the preferences in canonicalVariantOrder so the stored value is
|
||||||
@@ -129,6 +142,33 @@ func SeedVariantsFromStartParam(startParam string) []string {
|
|||||||
return prefs
|
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,
|
// SetVariantPreferences overwrites only the variant-preference set of the account,
|
||||||
// cleaning it to a deduplicated, canonically ordered subset of the known variants
|
// 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
|
// (rejecting an empty or unknown set with ErrInvalidProfile) and bumping updated_at; it
|
||||||
|
|||||||
@@ -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)
|
||||||
|
}
|
||||||
|
})
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|||||||
@@ -6,6 +6,7 @@ import (
|
|||||||
"context"
|
"context"
|
||||||
"errors"
|
"errors"
|
||||||
"regexp"
|
"regexp"
|
||||||
|
"slices"
|
||||||
"testing"
|
"testing"
|
||||||
"time"
|
"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
|
// TestProvisionTelegramUnknownLanguageDefaults checks an unsupported Telegram
|
||||||
// client language falls back to the account default rather than failing the
|
// client language falls back to the account default rather than failing the
|
||||||
// language CHECK.
|
// language CHECK.
|
||||||
|
|||||||
@@ -135,10 +135,11 @@ func TestUpdateProfilePersists(t *testing.T) {
|
|||||||
store := account.NewStore(testDB)
|
store := account.NewStore(testDB)
|
||||||
acc := provisionAccount(t)
|
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 {
|
if def, err := store.GetByID(ctx, acc); err != nil {
|
||||||
t.Fatalf("get default: %v", err)
|
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)
|
t.Errorf("default variant preferences = %v, want %v", def.VariantPreferences, want)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -19,10 +19,11 @@ import (
|
|||||||
)
|
)
|
||||||
|
|
||||||
// TestTelegramAuthSeedsPromoVariantForNewUserOnly drives the sessions/telegram endpoint
|
// TestTelegramAuthSeedsPromoVariantForNewUserOnly drives the sessions/telegram endpoint
|
||||||
// to confirm a promo deep-link start-param seeds a brand-new account's variant
|
// to confirm a promo deep-link start-param widens a brand-new account's variant
|
||||||
// preferences (English Scrabble alongside the default Erudit), that a new account with no
|
// preferences — English Scrabble on top of the default pair, but only for a
|
||||||
// such payload keeps the Erudit-only default, and that an existing account is never
|
// non-Russian-speaking arrival — that a new account with no such payload keeps the
|
||||||
// re-seeded on a later login (the new-user-only contract).
|
// default, and that an existing account is never re-seeded on a later login (the
|
||||||
|
// new-user-only contract).
|
||||||
func TestTelegramAuthSeedsPromoVariantForNewUserOnly(t *testing.T) {
|
func TestTelegramAuthSeedsPromoVariantForNewUserOnly(t *testing.T) {
|
||||||
srv := server.New(":0", server.Deps{
|
srv := server.New(":0", server.Deps{
|
||||||
Logger: zaptest.NewLogger(t),
|
Logger: zaptest.NewLogger(t),
|
||||||
@@ -33,8 +34,8 @@ func TestTelegramAuthSeedsPromoVariantForNewUserOnly(t *testing.T) {
|
|||||||
})
|
})
|
||||||
h := srv.Handler()
|
h := srv.Handler()
|
||||||
|
|
||||||
post := func(ext, startParam string) {
|
post := func(ext, language, startParam string) {
|
||||||
body := `{"external_id":"` + ext + `","language_code":"en","first_name":"Promo"`
|
body := `{"external_id":"` + ext + `","language_code":"` + language + `","first_name":"Promo"`
|
||||||
if startParam != "" {
|
if startParam != "" {
|
||||||
body += `,"start_param":"` + startParam + `"`
|
body += `,"start_param":"` + startParam + `"`
|
||||||
}
|
}
|
||||||
@@ -56,25 +57,33 @@ func TestTelegramAuthSeedsPromoVariantForNewUserOnly(t *testing.T) {
|
|||||||
return acc.VariantPreferences
|
return acc.VariantPreferences
|
||||||
}
|
}
|
||||||
|
|
||||||
// A brand-new account reached through a promo deep-link is seeded with English
|
// An English-speaking arrival on a promo deep-link gains English Scrabble on top of
|
||||||
// Scrabble alongside the default Erudit.
|
// the default pair, so a native English player is not lost at the door.
|
||||||
promoExt := "tg-" + uuid.NewString()
|
promoExt := "tg-" + uuid.NewString()
|
||||||
post(promoExt, "verudit_ru-scrabble_en")
|
post(promoExt, "en", "verudit_ru-scrabble_en")
|
||||||
if got, want := reload(promoExt), []string{"erudit_ru", "scrabble_en"}; !slices.Equal(got, want) {
|
if got, want := reload(promoExt), []string{"erudit_ru", "scrabble_ru", "scrabble_en"}; !slices.Equal(got, want) {
|
||||||
t.Errorf("promo new account variants = %v, want %v", 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()
|
plainExt := "tg-" + uuid.NewString()
|
||||||
post(plainExt, "")
|
post(plainExt, "en", "")
|
||||||
if got, want := reload(plainExt), []string{"erudit_ru"}; !slices.Equal(got, want) {
|
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)
|
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:
|
// A later login of the promo account, even via a different payload, must not re-seed:
|
||||||
// the seed is first-contact only.
|
// the seed is first-contact only.
|
||||||
post(promoExt, "vscrabble_ru")
|
post(promoExt, "en", "vscrabble_ru")
|
||||||
if got, want := reload(promoExt), []string{"erudit_ru", "scrabble_en"}; !slices.Equal(got, want) {
|
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)
|
t.Errorf("existing account re-seeded = %v, want unchanged %v", got, want)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -16,10 +16,10 @@ import (
|
|||||||
|
|
||||||
// TestVariantPreferenceGate covers the New Game gate: a player may start a quick game
|
// 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
|
// 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.
|
// (a fresh account defaults to the two Russian-alphabet games), and any other variant is
|
||||||
// The complementary case — an invited friend accepting an invitation in a variant they
|
// refused with 400. The complementary case — an invited friend accepting an invitation in
|
||||||
// have NOT enabled — is exercised by TestGameLimitGate, where a default-Erudit human
|
// a variant they have NOT enabled — is exercised by TestGameLimitGate, where a
|
||||||
// accepts an English invitation over HTTP.
|
// default-preference human accepts an English invitation over HTTP.
|
||||||
func TestVariantPreferenceGate(t *testing.T) {
|
func TestVariantPreferenceGate(t *testing.T) {
|
||||||
clearOpenGames(t)
|
clearOpenGames(t)
|
||||||
srv := server.New(":0", server.Deps{
|
srv := server.New(":0", server.Deps{
|
||||||
|
|||||||
@@ -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[];
|
||||||
@@ -23,10 +23,11 @@ import (
|
|||||||
// validated initData payload. Username, FirstName and LanguageCode seed a
|
// validated initData payload. Username, FirstName and LanguageCode seed a
|
||||||
// brand-new account's display name and language; BrowserTZ (the client's detected
|
// 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
|
// "±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
|
// deep-link payload, which may widen the new account's variant preferences (first
|
||||||
// contact only). Subtype is the client-reported device family (ios/android/web);
|
// contact only) — LanguageCode decides whether English Scrabble is part of that.
|
||||||
// Telegram's initData does not sign it, so it is recorded best-effort and the
|
// Subtype is the client-reported device family (ios/android/web); Telegram's initData
|
||||||
// payments gate never relies on it.
|
// does not sign it, so it is recorded best-effort and the payments gate never relies
|
||||||
|
// on it.
|
||||||
type telegramAuthRequest struct {
|
type telegramAuthRequest struct {
|
||||||
ExternalID string `json:"external_id"`
|
ExternalID string `json:"external_id"`
|
||||||
Username string `json:"username"`
|
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
|
// joined the chat before registering is granted on the spot (no chat_member
|
||||||
// event fires on registration).
|
// event fires on registration).
|
||||||
s.publishChatAccessChange(acc.ID)
|
s.publishChatAccessChange(acc.ID)
|
||||||
// A promo deep-link may seed this brand-new account's variant preferences (e.g.
|
// A promo deep-link may widen this brand-new account's variant preferences beyond
|
||||||
// English Scrabble alongside the default Erudit). Best-effort: an absent or
|
// the default (English Scrabble for a non-Russian-speaking arrival — see
|
||||||
// malformed payload leaves the account on its defaults, and a write failure must
|
// account.MergeVariantSeed, which gates that variant on req.LanguageCode).
|
||||||
// not block the session mint.
|
// Best-effort: an absent or malformed payload leaves the account on its defaults,
|
||||||
if seed := account.SeedVariantsFromStartParam(req.StartParam); len(seed) > 0 {
|
// and a write failure must not block the session mint.
|
||||||
if _, err := s.accounts.SetVariantPreferences(c.Request.Context(), acc.ID, seed); err != nil {
|
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",
|
s.log.Warn("telegram: seed variant preferences failed",
|
||||||
zap.String("account", acc.ID.String()), zap.Error(err))
|
zap.String("account", acc.ID.String()), zap.Error(err))
|
||||||
}
|
}
|
||||||
|
|||||||
+23
-4
@@ -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.
|
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
|
- **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
|
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
|
(`scrabble_en`, `scrabble_ru`, `erudit_ru`) edited on the Settings/Profile screen. A newly
|
||||||
accounts default to **Erudit only** (a DB column default); at least one variant must stay
|
**registered** account defaults to the two Russian-alphabet games — **Erudit + Russian
|
||||||
selected. The picker is ordered **Erudit-first** everywhere. The preference gates the New
|
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
|
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
|
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
|
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
|
> `service_language`/`supported_languages` and the push `language` routing field, and
|
||||||
> gained `variant_preferences` on Profile/UpdateProfile.
|
> 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
|
## 4. Accounts, identities, linking & merge
|
||||||
|
|
||||||
- One internal account may carry several **platform identities**
|
- 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
|
- **Profile**: `preferred_language` (en/ru; tracks the interface language — §4), display name, email
|
||||||
(confirm-code binding, see §4), **timezone**, the daily **away window**, the
|
(confirm-code binding, see §4), **timezone**, the daily **away window**, the
|
||||||
**variant preferences** (`variant_preferences`, the matchable-variant set that gates New
|
**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:
|
block toggles — all editable through `account.UpdateProfile`, which validates them:
|
||||||
a display name is Unicode letters joined by single ` `/`.`/`_`
|
a display name is Unicode letters joined by single ` `/`.`/`_`
|
||||||
separators (no leading/trailing/adjacent separators, ≤ 32 runes); the timezone is a
|
separators (no leading/trailing/adjacent separators, ≤ 32 runes); the timezone is a
|
||||||
|
|||||||
+7
-3
@@ -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
|
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
|
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*) —
|
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
|
"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 —
|
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
|
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
|
**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
|
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
|
yourself to be matched into; a **newly registered account starts with Erudite + Russian
|
||||||
through the **promo bot's deep link**, which also enables **English Scrabble** — and you must keep
|
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
|
**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
|
(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
|
not offered, and the server refuses it. It does not restrict games you are **invited** to: an
|
||||||
|
|||||||
@@ -177,7 +177,8 @@ e-mail) либо ввод фразы. Активные игры форфейтя
|
|||||||
уберёшь её, и никакие другие типы партий автоматически не убираются. Типы партий
|
уберёшь её, и никакие другие типы партий автоматически не убираются. Типы партий
|
||||||
на экране **Новая игра**
|
на экране **Новая игра**
|
||||||
ограничены выбранными игроком **предпочтениями вариантов** (см. «Профиль и
|
ограничены выбранными игроком **предпочтениями вариантов** (см. «Профиль и
|
||||||
настройки») — сначала Эрудит, по умолчанию только Эрудит. Варианты показываются под **отображаемым именем** — оба варианта Scrabble
|
настройки») — сначала Эрудит, по умолчанию Эрудит и русский Scrabble у зарегистрированного игрока
|
||||||
|
и только Эрудит у гостя. Варианты показываются под **отображаемым именем** — оба варианта Scrabble
|
||||||
читаются как «Scrabble»/«Скрэббл», а Erudit — «Erudite»/«Эрудит» (по языку интерфейса),
|
читаются как «Scrabble»/«Скрэббл», а Erudit — «Erudite»/«Эрудит» (по языку интерфейса),
|
||||||
и это же имя выносится в заголовок экрана игры. Это ограничивает только **старт** игры, которую ты
|
и это же имя выносится в заголовок экрана игры. Это ограничивает только **старт** игры, которую ты
|
||||||
инициируешь, — авто-подбор, игру с ИИ и приглашение друга, которое ты **создаёшь**, — поэтому игрок
|
инициируешь, — авто-подбор, игру с ИИ и приглашение друга, которое ты **создаёшь**, — поэтому игрок
|
||||||
@@ -458,9 +459,12 @@ Telegram. Внутри VK Mini App те же настройки — и язык
|
|||||||
|
|
||||||
**Предпочтения (в какие варианты тебя можно подбирать).** Настройка профиля задаёт варианты
|
**Предпочтения (в какие варианты тебя можно подбирать).** Настройка профиля задаёт варианты
|
||||||
игры — Эрудит, русский Scrabble и английский Scrabble, показанные **сначала Эрудит**, — в
|
игры — Эрудит, русский Scrabble и английский Scrabble, показанные **сначала Эрудит**, — в
|
||||||
которые ты разрешаешь себя подбирать; **новый аккаунт стартует только с Эрудитом** — если только
|
которые ты разрешаешь себя подбирать; **новый зарегистрированный аккаунт стартует с Эрудитом и
|
||||||
он не создан по **диплинку промо-бота**, который дополнительно включает **английский Scrabble**, —
|
русским Scrabble**, а вход по **диплинку промо-бота** дополнительно включает **английский
|
||||||
и нужно оставить выбранным **хотя бы один**. Именно этот список предлагает **Новая игра**, когда ты
|
Scrabble**, если язык Telegram не русский. **Гость** стартует **только с Эрудитом**, и «Новая игра»
|
||||||
|
предлагает ему зарегистрироваться ради остальных; регистрация расширяет аккаунт до набора
|
||||||
|
зарегистрированного. Смена набора по умолчанию никогда не переписывает уже существующие аккаунты.
|
||||||
|
Нужно оставить выбранным **хотя бы один**. Именно этот список предлагает **Новая игра**, когда ты
|
||||||
запускаешь партию (авто-подбор, игра с ИИ или приглашение друга, которое ты создаёшь), — не
|
запускаешь партию (авто-подбор, игра с ИИ или приглашение друга, которое ты создаёшь), — не
|
||||||
включённый вариант не предлагается, и сервер его отклоняет. На партии, в которые тебя
|
включённый вариант не предлагается, и сервер его отклоняет. На партии, в которые тебя
|
||||||
**приглашают**, это не влияет: приглашённый друг может принять приглашение в **любом** варианте,
|
**приглашают**, это не влияет: приглашённый друг может принять приглашение в **любом** варианте,
|
||||||
|
|||||||
@@ -83,6 +83,11 @@ export const en = {
|
|||||||
'new.moveLimit': 'Move time: {n} h 00 min',
|
'new.moveLimit': 'Move time: {n} h 00 min',
|
||||||
'new.searchHint':
|
'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.',
|
'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
|
// First-run coachmark hints (components/Coachmark.svelte). The leading emoji in each comment
|
||||||
// names the UI element the bubble's tail points at.
|
// names the UI element the bubble's tail points at.
|
||||||
|
|||||||
@@ -84,6 +84,8 @@ export const ru: Record<MessageKey, string> = {
|
|||||||
'new.moveLimit': 'Время на ход: {n} ч. 00 мин.',
|
'new.moveLimit': 'Время на ход: {n} ч. 00 мин.',
|
||||||
'new.searchHint':
|
'new.searchHint':
|
||||||
'Иногда поиск соперника может занять некоторое время. Если не захотите ждать после начала игры, можете вернуться в приложение через несколько минут.',
|
'Иногда поиск соперника может занять некоторое время. Если не захотите ждать после начала игры, можете вернуться в приложение через несколько минут.',
|
||||||
|
'new.guestVariantHintLink': 'Зарегистрируйтесь',
|
||||||
|
'new.guestVariantHint': ', чтобы играть в русский или английский вариант Скрэббл.',
|
||||||
|
|
||||||
// Подсказки первого запуска (components/Coachmark.svelte).
|
// Подсказки первого запуска (components/Coachmark.svelte).
|
||||||
'onboarding.lobbySettings': 'Друзья, настройки игры и профиля, обратная связь', // ⚙️
|
'onboarding.lobbySettings': 'Друзья, настройки игры и профиля, обратная связь', // ⚙️
|
||||||
|
|||||||
@@ -4,6 +4,7 @@ import {
|
|||||||
ALL_VARIANTS,
|
ALL_VARIANTS,
|
||||||
DEFAULT_VARIANTS,
|
DEFAULT_VARIANTS,
|
||||||
availableVariants,
|
availableVariants,
|
||||||
|
showRegisterHint,
|
||||||
supportsMultipleWordsToggle,
|
supportsMultipleWordsToggle,
|
||||||
multipleWordsForRequest,
|
multipleWordsForRequest,
|
||||||
usesStarBlank,
|
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', () => {
|
describe('supportsMultipleWordsToggle', () => {
|
||||||
it('is true for Russian variants only', () => {
|
it('is true for Russian variants only', () => {
|
||||||
expect(supportsMultipleWordsToggle('scrabble_ru')).toBe(true);
|
expect(supportsMultipleWordsToggle('scrabble_ru')).toBe(true);
|
||||||
|
|||||||
+15
-6
@@ -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
|
// 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
|
// 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
|
// with no profile at all). Such a client is a guest, so it mirrors the backend's *guest* set
|
||||||
// seeds Erudit only), so the local guest and a later synced account agree on what is enabled.
|
// (account.GuestVariantPreferences, Erudit alone) rather than the wider set a registered account
|
||||||
// Every dictionary is still bundled; the other variants are simply off until the player turns
|
// is created with. Every dictionary is still bundled; the other variants are simply off until
|
||||||
// them on in Settings.
|
// the player registers and turns them on in Settings.
|
||||||
export const DEFAULT_VARIANTS: Variant[] = ['erudit_ru'];
|
export const DEFAULT_VARIANTS: Variant[] = ['erudit_ru'];
|
||||||
|
|
||||||
// availableVariants gates ALL_VARIANTS by the player's variant preferences (the set they
|
// 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
|
// 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
|
// 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
|
// fresh offline native launch) must match the server's guest default instead of exposing
|
||||||
// the English game before the player opts in.
|
// the other games before the player opts in.
|
||||||
export function availableVariants(preferences: Variant[] | undefined): VariantOption[] {
|
export function availableVariants(preferences: Variant[] | undefined): VariantOption[] {
|
||||||
const prefs = preferences ?? [];
|
const prefs = preferences ?? [];
|
||||||
const effective = prefs.length === 0 ? DEFAULT_VARIANTS : prefs;
|
const effective = prefs.length === 0 ? DEFAULT_VARIANTS : prefs;
|
||||||
return ALL_VARIANTS.filter((v) => effective.includes(v.id));
|
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
|
// 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
|
// applies to a variant. Only Russian games choose the rule; English is always standard, so
|
||||||
// its toggle is not shown.
|
// its toggle is not shown.
|
||||||
|
|||||||
@@ -22,6 +22,7 @@
|
|||||||
import type { AccountRef, GameView, Variant } from '../lib/model';
|
import type { AccountRef, GameView, Variant } from '../lib/model';
|
||||||
import {
|
import {
|
||||||
availableVariants,
|
availableVariants,
|
||||||
|
showRegisterHint,
|
||||||
VARIANT_FLAG,
|
VARIANT_FLAG,
|
||||||
VARIANT_RULES,
|
VARIANT_RULES,
|
||||||
supportsMultipleWordsToggle,
|
supportsMultipleWordsToggle,
|
||||||
@@ -395,6 +396,13 @@
|
|||||||
</button>
|
</button>
|
||||||
{/each}
|
{/each}
|
||||||
</div>
|
</div>
|
||||||
|
<!-- A guest is offered Erudit alone; registering widens the account to both Russian games
|
||||||
|
(and Settings then offers English), so point them at the profile screen. -->
|
||||||
|
{#if showRegisterHint(guest, variants.length, offlineMode.active)}
|
||||||
|
<p class="guesthint">
|
||||||
|
<button class="hintlink" onclick={() => navigate('/profile')}>{t('new.guestVariantHintLink')}</button>{t('new.guestVariantHint')}
|
||||||
|
</p>
|
||||||
|
{/if}
|
||||||
{#if variants.some((v) => supportsMultipleWordsToggle(v.id))}
|
{#if variants.some((v) => supportsMultipleWordsToggle(v.id))}
|
||||||
<label class="toggle">
|
<label class="toggle">
|
||||||
<span>{t('new.multipleWordsPerTurn')}</span>
|
<span>{t('new.multipleWordsPerTurn')}</span>
|
||||||
@@ -771,6 +779,24 @@
|
|||||||
font-size: 0.85rem;
|
font-size: 0.85rem;
|
||||||
line-height: 1.4;
|
line-height: 1.4;
|
||||||
}
|
}
|
||||||
|
/* The guest's register invitation: a muted line opening with an inline link-styled button
|
||||||
|
that routes to the profile screen (mirrors Profile's .linklike). */
|
||||||
|
.guesthint {
|
||||||
|
margin: 0;
|
||||||
|
text-align: center;
|
||||||
|
color: var(--text-muted);
|
||||||
|
font-size: 0.85rem;
|
||||||
|
line-height: 1.4;
|
||||||
|
}
|
||||||
|
.hintlink {
|
||||||
|
background: none;
|
||||||
|
border: none;
|
||||||
|
padding: 0;
|
||||||
|
color: var(--accent);
|
||||||
|
font: inherit;
|
||||||
|
text-decoration: underline;
|
||||||
|
cursor: pointer;
|
||||||
|
}
|
||||||
/* The offline-blocker reason (a missing dictionary, or the online segment needing a connection). */
|
/* The offline-blocker reason (a missing dictionary, or the online segment needing a connection). */
|
||||||
.dictnote {
|
.dictnote {
|
||||||
margin: 0;
|
margin: 0;
|
||||||
|
|||||||
Reference in New Issue
Block a user