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
+5
View File
@@ -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.
+2
View File
@@ -84,6 +84,8 @@ export const ru: Record<MessageKey, string> = {
'new.moveLimit': 'Время на ход: {n} ч. 00 мин.',
'new.searchHint':
'Иногда поиск соперника может занять некоторое время. Если не захотите ждать после начала игры, можете вернуться в приложение через несколько минут.',
'new.guestVariantHintLink': 'Зарегистрируйтесь',
'new.guestVariantHint': ', чтобы играть в русский или английский вариант Скрэббл.',
// Подсказки первого запуска (components/Coachmark.svelte).
'onboarding.lobbySettings': 'Друзья, настройки игры и профиля, обратная связь', // ⚙️
+13
View File
@@ -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);
+15 -6
View File
@@ -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.
+26
View File
@@ -22,6 +22,7 @@
import type { AccountRef, GameView, Variant } from '../lib/model';
import {
availableVariants,
showRegisterHint,
VARIANT_FLAG,
VARIANT_RULES,
supportsMultipleWordsToggle,
@@ -395,6 +396,13 @@
</button>
{/each}
</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))}
<label class="toggle">
<span>{t('new.multipleWordsPerTurn')}</span>
@@ -771,6 +779,24 @@
font-size: 0.85rem;
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). */
.dictnote {
margin: 0;