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

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:
Ilia Denisov
2026-07-27 20:38:09 +02:00
parent 69b1a50644
commit b6d88da78c
19 changed files with 338 additions and 56 deletions
+11 -4
View File
@@ -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
+16 -2
View File
@@ -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))),
+40
View File
@@ -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
@@ -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)
}
})
}
}