feat(telegram,game): single bot + per-user variant preferences
CI / changes (pull_request) Successful in 2s
CI / unit (pull_request) Successful in 10s
CI / integration (pull_request) Successful in 13s
CI / ui (pull_request) Successful in 54s
CI / gate (pull_request) Successful in 0s
CI / deploy (pull_request) Successful in 57s

Collapse the two per-language Telegram bots into one unified bot and
replace language-based variant gating with explicit per-user variant
preferences.

- Telegram: one bot; drop service_language and the supported_languages
  set everywhere (DB, account, auth, FlatBuffers Session wire, gateway,
  connector proto). The single bot renders chat and out-of-app push in
  the recipient's preferred_language; remove the game-language push
  routing override (notify Intent.Language / push Event.language).
- Preferences: new accounts.variant_preferences (text[], DB default
  {erudit_ru}, CHECK non-empty + subset of the three variants). Gates
  the New Game picker, vs-AI and the friend invitation the player
  creates, enforced server-side (HTTP 400 otherwise); an invited friend
  may still accept any variant. Edited on the Settings screen; variants
  are Erudit-first everywhere.
- Admin: drop the per-bot language selectors (broadcast / send-to-user)
  and the feedback channel_lang column/field.
- Env/CI: collapse TELEGRAM_BOT_TOKEN_{EN,RU}, TELEGRAM_GAME_CHANNEL_ID_{EN,RU},
  VITE_TELEGRAM_LINK{,_EN,_RU} and VITE_TELEGRAM_GAME_CHANNEL_NAME_{EN,RU}
  to single unsuffixed names; drop GATEWAY_DEFAULT_SUPPORTED_LANGUAGES.
- Docs updated (ARCHITECTURE, FUNCTIONAL + _ru, platform/telegram, gateway,
  backend, ui, UI_DESIGN, PRERELEASE).

The migration squash is deferred to a follow-up PR.
This commit is contained in:
Ilia Denisov
2026-06-20 14:08:27 +02:00
parent 1933849dba
commit 57c778f9b2
99 changed files with 1006 additions and 1256 deletions
+1 -1
View File
@@ -16,7 +16,7 @@
let prefs: Partial<Prefs> = {};
const about = $derived(aboutContent(i18n.locale, 24)); // 24h = the auto-match move clock
const tgLink = $derived(telegramChannelLink(i18n.locale));
const tgLink = $derived(telegramChannelLink());
const locales: { code: Locale; label: string }[] = [
{ code: 'en', label: '🇬🇧 English' },
{ code: 'ru', label: '🇷🇺 Русский' },
+2 -3
View File
@@ -5,9 +5,8 @@
import { telegramOpenLink } from '../lib/telegram';
import Modal from './Modal.svelte';
// Point at the bot the player signed in through (its service language), falling back to
// the interface locale, so an ru player is sent to the ru bot and an en player to the en one.
const username = $derived(botUsername(app.session?.serviceLanguage || app.locale));
// The single bot's @username, for the deep link.
const username = $derived(botUsername());
// Split the message around the {bot} token so the bot handle renders as an inline link.
const parts = $derived(t('friends.staleInvite').split('{bot}'));
+2 -3
View File
@@ -5,9 +5,8 @@
import { telegramOpenLink } from '../lib/telegram';
import Modal from './Modal.svelte';
// Point at the bot the player signed in through (its service language), falling back to the
// interface locale, so an ru player is sent to the ru bot and an en player to the en one.
const username = $derived(botUsername(app.session?.serviceLanguage || app.locale));
// The single bot's @username, for the deep link.
const username = $derived(botUsername());
// Greet the arriving player by their own display name.
const name = $derived(app.profile?.displayName || app.session?.displayName || '');
// Interpolate the name, then split the rest around the {bot} token so the bot handle renders
+29 -1
View File
@@ -95,8 +95,20 @@ banner(obj?:BannerInfo):BannerInfo|null {
return offset ? (obj || new BannerInfo()).__init(this.bb!.__indirect(this.bb_pos + offset), this.bb!) : null;
}
variantPreferences(index: number):string
variantPreferences(index: number,optionalEncoding:flatbuffers.Encoding):string|Uint8Array
variantPreferences(index: number,optionalEncoding?:any):string|Uint8Array|null {
const offset = this.bb!.__offset(this.bb_pos, 28);
return offset ? this.bb!.__string(this.bb!.__vector(this.bb_pos + offset) + index * 4, optionalEncoding) : null;
}
variantPreferencesLength():number {
const offset = this.bb!.__offset(this.bb_pos, 28);
return offset ? this.bb!.__vector_len(this.bb_pos + offset) : 0;
}
static startProfile(builder:flatbuffers.Builder) {
builder.startObject(12);
builder.startObject(13);
}
static addUserId(builder:flatbuffers.Builder, userIdOffset:flatbuffers.Offset) {
@@ -147,6 +159,22 @@ static addBanner(builder:flatbuffers.Builder, bannerOffset:flatbuffers.Offset) {
builder.addFieldOffset(11, bannerOffset, 0);
}
static addVariantPreferences(builder:flatbuffers.Builder, variantPreferencesOffset:flatbuffers.Offset) {
builder.addFieldOffset(12, variantPreferencesOffset, 0);
}
static createVariantPreferencesVector(builder:flatbuffers.Builder, data:flatbuffers.Offset[]):flatbuffers.Offset {
builder.startVector(4, data.length, 4);
for (let i = data.length - 1; i >= 0; i--) {
builder.addOffset(data[i]!);
}
return builder.endVector();
}
static startVariantPreferencesVector(builder:flatbuffers.Builder, numElems:number) {
builder.startVector(4, numElems, 4);
}
static endProfile(builder:flatbuffers.Builder):flatbuffers.Offset {
const offset = builder.endObject();
return offset;
+2 -43
View File
@@ -46,27 +46,8 @@ displayName(optionalEncoding?:any):string|Uint8Array|null {
return offset ? this.bb!.__string(this.bb_pos + offset, optionalEncoding) : null;
}
supportedLanguages(index: number):string
supportedLanguages(index: number,optionalEncoding:flatbuffers.Encoding):string|Uint8Array
supportedLanguages(index: number,optionalEncoding?:any):string|Uint8Array|null {
const offset = this.bb!.__offset(this.bb_pos, 12);
return offset ? this.bb!.__string(this.bb!.__vector(this.bb_pos + offset) + index * 4, optionalEncoding) : null;
}
supportedLanguagesLength():number {
const offset = this.bb!.__offset(this.bb_pos, 12);
return offset ? this.bb!.__vector_len(this.bb_pos + offset) : 0;
}
serviceLanguage():string|null
serviceLanguage(optionalEncoding:flatbuffers.Encoding):string|Uint8Array|null
serviceLanguage(optionalEncoding?:any):string|Uint8Array|null {
const offset = this.bb!.__offset(this.bb_pos, 14);
return offset ? this.bb!.__string(this.bb_pos + offset, optionalEncoding) : null;
}
static startSession(builder:flatbuffers.Builder) {
builder.startObject(6);
builder.startObject(4);
}
static addToken(builder:flatbuffers.Builder, tokenOffset:flatbuffers.Offset) {
@@ -85,39 +66,17 @@ static addDisplayName(builder:flatbuffers.Builder, displayNameOffset:flatbuffers
builder.addFieldOffset(3, displayNameOffset, 0);
}
static addSupportedLanguages(builder:flatbuffers.Builder, supportedLanguagesOffset:flatbuffers.Offset) {
builder.addFieldOffset(4, supportedLanguagesOffset, 0);
}
static createSupportedLanguagesVector(builder:flatbuffers.Builder, data:flatbuffers.Offset[]):flatbuffers.Offset {
builder.startVector(4, data.length, 4);
for (let i = data.length - 1; i >= 0; i--) {
builder.addOffset(data[i]!);
}
return builder.endVector();
}
static startSupportedLanguagesVector(builder:flatbuffers.Builder, numElems:number) {
builder.startVector(4, numElems, 4);
}
static addServiceLanguage(builder:flatbuffers.Builder, serviceLanguageOffset:flatbuffers.Offset) {
builder.addFieldOffset(5, serviceLanguageOffset, 0);
}
static endSession(builder:flatbuffers.Builder):flatbuffers.Offset {
const offset = builder.endObject();
return offset;
}
static createSession(builder:flatbuffers.Builder, tokenOffset:flatbuffers.Offset, userIdOffset:flatbuffers.Offset, isGuest:boolean, displayNameOffset:flatbuffers.Offset, supportedLanguagesOffset:flatbuffers.Offset, serviceLanguageOffset:flatbuffers.Offset):flatbuffers.Offset {
static createSession(builder:flatbuffers.Builder, tokenOffset:flatbuffers.Offset, userIdOffset:flatbuffers.Offset, isGuest:boolean, displayNameOffset:flatbuffers.Offset):flatbuffers.Offset {
Session.startSession(builder);
Session.addToken(builder, tokenOffset);
Session.addUserId(builder, userIdOffset);
Session.addIsGuest(builder, isGuest);
Session.addDisplayName(builder, displayNameOffset);
Session.addSupportedLanguages(builder, supportedLanguagesOffset);
Session.addServiceLanguage(builder, serviceLanguageOffset);
return Session.endSession(builder);
}
}
@@ -70,8 +70,20 @@ notificationsInAppOnly():boolean {
return offset ? !!this.bb!.readInt8(this.bb_pos + offset) : true;
}
variantPreferences(index: number):string
variantPreferences(index: number,optionalEncoding:flatbuffers.Encoding):string|Uint8Array
variantPreferences(index: number,optionalEncoding?:any):string|Uint8Array|null {
const offset = this.bb!.__offset(this.bb_pos, 20);
return offset ? this.bb!.__string(this.bb!.__vector(this.bb_pos + offset) + index * 4, optionalEncoding) : null;
}
variantPreferencesLength():number {
const offset = this.bb!.__offset(this.bb_pos, 20);
return offset ? this.bb!.__vector_len(this.bb_pos + offset) : 0;
}
static startUpdateProfileRequest(builder:flatbuffers.Builder) {
builder.startObject(8);
builder.startObject(9);
}
static addDisplayName(builder:flatbuffers.Builder, displayNameOffset:flatbuffers.Offset) {
@@ -106,12 +118,28 @@ static addNotificationsInAppOnly(builder:flatbuffers.Builder, notificationsInApp
builder.addFieldInt8(7, +notificationsInAppOnly, +true);
}
static addVariantPreferences(builder:flatbuffers.Builder, variantPreferencesOffset:flatbuffers.Offset) {
builder.addFieldOffset(8, variantPreferencesOffset, 0);
}
static createVariantPreferencesVector(builder:flatbuffers.Builder, data:flatbuffers.Offset[]):flatbuffers.Offset {
builder.startVector(4, data.length, 4);
for (let i = data.length - 1; i >= 0; i--) {
builder.addOffset(data[i]!);
}
return builder.endVector();
}
static startVariantPreferencesVector(builder:flatbuffers.Builder, numElems:number) {
builder.startVector(4, numElems, 4);
}
static endUpdateProfileRequest(builder:flatbuffers.Builder):flatbuffers.Offset {
const offset = builder.endObject();
return offset;
}
static createUpdateProfileRequest(builder:flatbuffers.Builder, displayNameOffset:flatbuffers.Offset, preferredLanguageOffset:flatbuffers.Offset, timeZoneOffset:flatbuffers.Offset, awayStartOffset:flatbuffers.Offset, awayEndOffset:flatbuffers.Offset, blockChat:boolean, blockFriendRequests:boolean, notificationsInAppOnly:boolean):flatbuffers.Offset {
static createUpdateProfileRequest(builder:flatbuffers.Builder, displayNameOffset:flatbuffers.Offset, preferredLanguageOffset:flatbuffers.Offset, timeZoneOffset:flatbuffers.Offset, awayStartOffset:flatbuffers.Offset, awayEndOffset:flatbuffers.Offset, blockChat:boolean, blockFriendRequests:boolean, notificationsInAppOnly:boolean, variantPreferencesOffset:flatbuffers.Offset):flatbuffers.Offset {
UpdateProfileRequest.startUpdateProfileRequest(builder);
UpdateProfileRequest.addDisplayName(builder, displayNameOffset);
UpdateProfileRequest.addPreferredLanguage(builder, preferredLanguageOffset);
@@ -121,6 +149,7 @@ static createUpdateProfileRequest(builder:flatbuffers.Builder, displayNameOffset
UpdateProfileRequest.addBlockChat(builder, blockChat);
UpdateProfileRequest.addBlockFriendRequests(builder, blockFriendRequests);
UpdateProfileRequest.addNotificationsInAppOnly(builder, notificationsInAppOnly);
UpdateProfileRequest.addVariantPreferences(builder, variantPreferencesOffset);
return UpdateProfileRequest.endUpdateProfileRequest(builder);
}
}
+1
View File
@@ -706,6 +706,7 @@ async function persistLanguageToServer(locale: Locale): Promise<void> {
blockChat: p.blockChat,
blockFriendRequests: p.blockFriendRequests,
notificationsInAppOnly: p.notificationsInAppOnly,
variantPreferences: p.variantPreferences,
});
} catch {
// The client locale already changed; the server sync is best-effort.
+31 -5
View File
@@ -26,6 +26,7 @@ import {
encodeStateRequest,
encodeSubmitPlay,
encodeTarget,
encodeUpdateProfile,
} from './codec';
describe('codec', () => {
@@ -188,21 +189,17 @@ describe('codec', () => {
const token = b.createString('tok');
const uid = b.createString('u1');
const name = b.createString('Me');
const langs = fb.Session.createSupportedLanguagesVector(b, [b.createString('en'), b.createString('ru')]);
fb.Session.startSession(b);
fb.Session.addToken(b, token);
fb.Session.addUserId(b, uid);
fb.Session.addIsGuest(b, true);
fb.Session.addDisplayName(b, name);
fb.Session.addSupportedLanguages(b, langs);
b.finish(fb.Session.endSession(b));
expect(decodeSession(b.asUint8Array())).toEqual({
token: 'tok',
userId: 'u1',
isGuest: true,
displayName: 'Me',
supportedLanguages: ['en', 'ru'],
serviceLanguage: '',
});
});
@@ -411,7 +408,7 @@ describe('codec', () => {
b.finish(fb.LinkResult.endLinkResult(b));
const r = decodeLinkResult(b.asUint8Array());
expect(r.status).toBe('merged');
expect(r.session).toEqual({ token: 'tok-9', userId: 'a-1', isGuest: false, displayName: 'Kaya', supportedLanguages: [], serviceLanguage: '' });
expect(r.session).toEqual({ token: 'tok-9', userId: 'a-1', isGuest: false, displayName: 'Kaya' });
});
it('decodes an Invitation with inviter and invitees', () => {
@@ -568,6 +565,35 @@ describe('codec', () => {
b.finish(fb.Profile.endProfile(b));
expect(decodeProfile(b.asUint8Array()).banner).toBeUndefined();
});
it('decodes the profile variant preferences', () => {
const b = new Builder(64);
const uid = b.createString('u-1');
const prefs = fb.Profile.createVariantPreferencesVector(b, [b.createString('erudit_ru'), b.createString('scrabble_en')]);
fb.Profile.startProfile(b);
fb.Profile.addUserId(b, uid);
fb.Profile.addVariantPreferences(b, prefs);
b.finish(fb.Profile.endProfile(b));
expect(decodeProfile(b.asUint8Array()).variantPreferences).toEqual(['erudit_ru', 'scrabble_en']);
});
it('encodes the update-profile variant preferences', () => {
const buf = encodeUpdateProfile({
displayName: 'Kaya',
preferredLanguage: 'ru',
timeZone: 'UTC',
awayStart: '00:00',
awayEnd: '07:00',
blockChat: false,
blockFriendRequests: false,
notificationsInAppOnly: true,
variantPreferences: ['erudit_ru', 'scrabble_ru'],
});
const t = fb.UpdateProfileRequest.getRootAsUpdateProfileRequest(new ByteBuffer(buf));
const out: string[] = [];
for (let i = 0; i < t.variantPreferencesLength(); i++) out.push(t.variantPreferences(i));
expect(out).toEqual(['erudit_ru', 'scrabble_ru']);
});
});
// The live play loop exchanges alphabet indices, mapped through the per-variant
+15 -5
View File
@@ -294,17 +294,13 @@ function decodeChatMsg(m: fb.ChatMessage): ChatMessage {
}
// sessionFromTable projects a FlatBuffers Session table (a root or a nested one) to
// the Session model, including the supported-languages set the UI gates variants by.
// the Session model.
function sessionFromTable(t: fb.Session): Session {
const supportedLanguages: string[] = [];
for (let i = 0; i < t.supportedLanguagesLength(); i++) supportedLanguages.push(s(t.supportedLanguages(i)));
return {
token: s(t.token()),
userId: s(t.userId()),
isGuest: t.isGuest(),
displayName: s(t.displayName()),
supportedLanguages,
serviceLanguage: s(t.serviceLanguage()),
};
}
@@ -326,10 +322,19 @@ export function decodeProfile(buf: Uint8Array): Profile {
blockFriendRequests: p.blockFriendRequests(),
isGuest: p.isGuest(),
notificationsInAppOnly: p.notificationsInAppOnly(),
variantPreferences: decodeVariantPreferences(p),
banner: decodeBanner(p),
};
}
// decodeVariantPreferences reads the Profile.variant_preferences vector (the variants
// the player enabled in Settings).
function decodeVariantPreferences(p: fb.Profile): Variant[] {
const out: Variant[] = [];
for (let i = 0; i < p.variantPreferencesLength(); i++) out.push(s(p.variantPreferences(i)) as Variant);
return out;
}
// decodeBanner projects the optional advertising-banner block of a Profile, or
// undefined when the viewer is not eligible (the field is absent).
function decodeBanner(p: fb.Profile): Banner | undefined {
@@ -642,6 +647,10 @@ export function encodeUpdateProfile(p: ProfileUpdate): Uint8Array {
const tz = b.createString(p.timeZone);
const as = b.createString(p.awayStart);
const ae = b.createString(p.awayEnd);
const prefs = fb.UpdateProfileRequest.createVariantPreferencesVector(
b,
p.variantPreferences.map((v) => b.createString(v)),
);
fb.UpdateProfileRequest.startUpdateProfileRequest(b);
fb.UpdateProfileRequest.addDisplayName(b, name);
fb.UpdateProfileRequest.addPreferredLanguage(b, lang);
@@ -651,6 +660,7 @@ export function encodeUpdateProfile(p: ProfileUpdate): Uint8Array {
fb.UpdateProfileRequest.addBlockChat(b, p.blockChat);
fb.UpdateProfileRequest.addBlockFriendRequests(b, p.blockFriendRequests);
fb.UpdateProfileRequest.addNotificationsInAppOnly(b, p.notificationsInAppOnly);
fb.UpdateProfileRequest.addVariantPreferences(b, prefs);
return finish(b, fb.UpdateProfileRequest.endUpdateProfileRequest(b));
}
-20
View File
@@ -35,19 +35,6 @@ describe('shareLink', () => {
vi.stubEnv('VITE_TELEGRAM_LINK', 'https://t.me/bot/app');
expect(shareLink('f123456')).toBe('https://t.me/bot/app?startapp=f123456');
});
it('picks the per-bot base by language', () => {
vi.stubEnv('VITE_TELEGRAM_LINK_EN', 'https://t.me/enbot/app');
vi.stubEnv('VITE_TELEGRAM_LINK_RU', 'https://t.me/rubot/app');
expect(shareLink('f1', 'en')).toBe('https://t.me/enbot/app?startapp=f1');
expect(shareLink('f1', 'ru')).toBe('https://t.me/rubot/app?startapp=f1');
});
it('falls back to the language-agnostic base when the per-bot one is unset', () => {
vi.stubEnv('VITE_TELEGRAM_LINK', 'https://t.me/fallback/app');
vi.stubEnv('VITE_TELEGRAM_LINK_RU', '');
expect(shareLink('f1', 'ru')).toBe('https://t.me/fallback/app?startapp=f1');
});
});
describe('botUsername', () => {
@@ -62,11 +49,4 @@ describe('botUsername', () => {
vi.stubEnv('VITE_TELEGRAM_LINK', 'https://t.me/bot/app');
expect(botUsername()).toBe('bot');
});
it('picks the per-bot handle by language', () => {
vi.stubEnv('VITE_TELEGRAM_LINK_EN', 'https://t.me/enbot/app');
vi.stubEnv('VITE_TELEGRAM_LINK_RU', 'https://t.me/rubot/app');
expect(botUsername('en')).toBe('enbot');
expect(botUsername('ru')).toBe('rubot');
});
});
+12 -18
View File
@@ -41,25 +41,20 @@ function envVar(name: string): string | undefined {
}
/**
* telegramBase returns the Mini App link base (e.g. https://t.me/<bot>/<app>) for a
* bot language: VITE_TELEGRAM_LINK_EN / _RU when lang is en/ru, else the
* language-agnostic VITE_TELEGRAM_LINK. Returns null when none is configured, so a
* shared link points at the same bot the player signed in through.
* telegramBase returns the single bot's Mini App link base (e.g. https://t.me/<bot>/<app>)
* from VITE_TELEGRAM_LINK, or null when it is not configured.
*/
function telegramBase(lang: string): string | null {
const byLang =
lang === 'ru' ? envVar('VITE_TELEGRAM_LINK_RU') : lang === 'en' ? envVar('VITE_TELEGRAM_LINK_EN') : undefined;
return byLang || envVar('VITE_TELEGRAM_LINK') || null;
function telegramBase(): string | null {
return envVar('VITE_TELEGRAM_LINK') || null;
}
/**
* botUsername extracts the bot's @username (without the @) from the configured Mini App
* link for a bot language: the first path segment of https://t.me/<bot>/<app>. Returns
* null when no base is configured or the link carries no path, so callers can fall back
* when they cannot point at a specific bot.
* link: the first path segment of https://t.me/<bot>/<app>. Returns null when no base is
* configured or the link carries no path.
*/
export function botUsername(lang = ''): string | null {
const base = telegramBase(lang);
export function botUsername(): string | null {
const base = telegramBase();
if (!base) return null;
try {
const seg = new URL(base).pathname.split('/').filter(Boolean)[0];
@@ -70,12 +65,11 @@ export function botUsername(lang = ''): string | null {
}
/**
* shareLink wraps a deep-link start parameter in a t.me Mini App link for the given
* bot language (the session's service language). Returns null when no base is
* configured, so callers can hide the share affordance.
* shareLink wraps a deep-link start parameter in a t.me Mini App link for the single
* bot. Returns null when no base is configured, so callers can hide the share affordance.
*/
export function shareLink(param: string, lang = ''): string | null {
const base = telegramBase(lang);
export function shareLink(param: string): string | null {
const base = telegramBase();
if (!base) return null;
const sep = base.includes('?') ? '&' : '?';
return `${base}${sep}startapp=${encodeURIComponent(param)}`;
+2
View File
@@ -136,6 +136,8 @@ export const en = {
'profile.blockChat': 'Disable chat',
'profile.blockFriendRequests': 'Disable friend requests',
'profile.notificationsInAppOnly': 'Notifications in the app only',
'profile.preferences': 'Preferences',
'profile.preferencesHint': 'The game variants you can be matched into. Pick at least one.',
'profile.email': 'Email',
'profile.bindEmail': 'Bind email',
'profile.emailCode': 'Confirmation code',
+2
View File
@@ -137,6 +137,8 @@ export const ru: Record<MessageKey, string> = {
'profile.blockChat': 'Отключить чат',
'profile.blockFriendRequests': 'Отключить заявки в друзья',
'profile.notificationsInAppOnly': 'Уведомления только в приложении',
'profile.preferences': 'Предпочтения',
'profile.preferencesHint': 'Варианты игр, в которые вас могут подобрать. Выберите хотя бы один.',
'profile.email': 'Эл. почта',
'profile.bindEmail': 'Привязать почту',
'profile.emailCode': 'Код подтверждения',
+6 -10
View File
@@ -4,17 +4,13 @@ import { telegramChannelLink } from './landing';
describe('telegramChannelLink', () => {
afterEach(() => vi.unstubAllEnvs());
it('builds the per-language t.me link from the channel name', () => {
vi.stubEnv('VITE_TELEGRAM_GAME_CHANNEL_NAME_EN', 'Scrabble_Game');
vi.stubEnv('VITE_TELEGRAM_GAME_CHANNEL_NAME_RU', '@Erudit_Game'); // a leading @ is tolerated
expect(telegramChannelLink('en')).toBe('https://t.me/Scrabble_Game');
expect(telegramChannelLink('ru')).toBe('https://t.me/Erudit_Game');
it('builds the t.me link from the channel name', () => {
vi.stubEnv('VITE_TELEGRAM_GAME_CHANNEL_NAME', '@Erudit_Game'); // a leading @ is tolerated
expect(telegramChannelLink()).toBe('https://t.me/Erudit_Game');
});
it('returns null when the locale channel is unset or blank', () => {
vi.stubEnv('VITE_TELEGRAM_GAME_CHANNEL_NAME_EN', '');
vi.stubEnv('VITE_TELEGRAM_GAME_CHANNEL_NAME_RU', ' ');
expect(telegramChannelLink('en')).toBeNull();
expect(telegramChannelLink('ru')).toBeNull();
it('returns null when the channel name is unset or blank', () => {
vi.stubEnv('VITE_TELEGRAM_GAME_CHANNEL_NAME', ' ');
expect(telegramChannelLink()).toBeNull();
});
});
+8 -13
View File
@@ -1,20 +1,15 @@
// Pure helpers for the public landing page, kept out of the Svelte component so
// the per-language Telegram-channel link selection is unit-testable.
import type { Locale } from './i18n/index.svelte';
// Pure helpers for the public landing page, kept out of the Svelte component so the
// Telegram-channel link selection is unit-testable.
/**
* telegramChannelLink returns the t.me link for the locale's game channel, or null when it is
* not configured. The channel usernames are build-time vars (VITE_TELEGRAM_GAME_CHANNEL_NAME_EN
* / VITE_TELEGRAM_GAME_CHANNEL_NAME_RU) because the test and prod contours run different
* channels; they are the same channels the connector posts to via TELEGRAM_GAME_CHANNEL_ID_*
* telegramChannelLink returns the t.me link for the single bot's game channel, or null
* when it is not configured. The channel username is a build-time var
* (VITE_TELEGRAM_GAME_CHANNEL_NAME) because the test and prod contours run different
* channels; it is the same channel the connector posts to via TELEGRAM_GAME_CHANNEL_ID
* (the id to post, the name to link). A leading "@" is tolerated.
*/
export function telegramChannelLink(locale: Locale): string | null {
const raw =
locale === 'ru'
? import.meta.env.VITE_TELEGRAM_GAME_CHANNEL_NAME_RU
: import.meta.env.VITE_TELEGRAM_GAME_CHANNEL_NAME_EN;
export function telegramChannelLink(): string | null {
const raw = import.meta.env.VITE_TELEGRAM_GAME_CHANNEL_NAME;
const name = (raw as string | undefined)?.trim().replace(/^@/, '');
return name ? `https://t.me/${name}` : null;
}
+2 -3
View File
@@ -23,9 +23,6 @@ export const SESSION: Session = {
userId: ME,
isGuest: true,
displayName: 'You',
// Both languages by default, so the mock-driven UI offers every variant.
supportedLanguages: ['en', 'ru'],
serviceLanguage: '',
};
export const PROFILE: Profile = {
@@ -40,6 +37,8 @@ export const PROFILE: Profile = {
blockFriendRequests: false,
isGuest: false,
notificationsInAppOnly: true,
// Every variant enabled, so the mock-driven UI offers the full New Game picker.
variantPreferences: ['erudit_ru', 'scrabble_ru', 'scrabble_en'],
};
// Seed social/account data for the mock (pnpm start + Playwright). The mock profile
+4 -8
View File
@@ -151,6 +151,9 @@ export interface Profile {
isGuest: boolean;
/** Confine notifications to the in-app stream (no out-of-app platform push). */
notificationsInAppOnly: boolean;
/** Variants the player allows themselves to be matched into (Erudit-first). The New
* Game picker is gated by this set, which is never empty. */
variantPreferences: Variant[];
/** The advertising-banner block, present only for a viewer eligible to see it. */
banner?: Banner;
}
@@ -201,6 +204,7 @@ export interface ProfileUpdate {
blockChat: boolean;
blockFriendRequests: boolean;
notificationsInAppOnly: boolean;
variantPreferences: Variant[];
}
/** A referenced account with its display name (friend, blocked user, invitee). */
@@ -327,14 +331,6 @@ export interface Session {
userId: string;
isGuest: boolean;
displayName: string;
// supportedLanguages is the set of game languages the service the user signed in
// through offers (subset of {en, ru}, at least one). New Game offers only the
// variants these languages support (en -> English; ru -> Russian + Эрудит). Empty
// means ungated (all variants).
supportedLanguages: string[];
// serviceLanguage is the en/ru tag of the Telegram bot this session was minted
// through (empty for a non-Telegram login); used to share a link to the same bot.
serviceLanguage: string;
}
// LinkResult is the outcome of an account link/merge step. status is
+16 -9
View File
@@ -7,22 +7,29 @@ import {
multipleWordsForRequest,
} from './variants';
describe('ALL_VARIANTS', () => {
it('lists variants Erudit-first', () => {
expect(ALL_VARIANTS.map((v) => v.id)).toEqual(['erudit_ru', 'scrabble_ru', 'scrabble_en']);
});
});
describe('availableVariants', () => {
it('is ungated (all variants) for an empty or absent set', () => {
it('is ungated (all variants) for an empty or absent preference set', () => {
expect(availableVariants([])).toEqual(ALL_VARIANTS);
expect(availableVariants(undefined)).toEqual(ALL_VARIANTS);
});
it('offers only English for an en-only service', () => {
expect(availableVariants(['en']).map((v) => v.id)).toEqual(['scrabble_en']);
it('offers only the preferred variants', () => {
expect(availableVariants(['scrabble_en']).map((v) => v.id)).toEqual(['scrabble_en']);
expect(availableVariants(['erudit_ru', 'scrabble_en']).map((v) => v.id)).toEqual(['erudit_ru', 'scrabble_en']);
});
it('offers Russian and Эрудит for a ru-only service', () => {
expect(availableVariants(['ru']).map((v) => v.id)).toEqual(['scrabble_ru', 'erudit_ru']);
});
it('offers every variant for a bilingual service', () => {
expect(availableVariants(['en', 'ru']).map((v) => v.id)).toEqual(['scrabble_en', 'scrabble_ru', 'erudit_ru']);
it('keeps the Erudit-first catalogue order regardless of preference order', () => {
expect(availableVariants(['scrabble_en', 'erudit_ru', 'scrabble_ru']).map((v) => v.id)).toEqual([
'erudit_ru',
'scrabble_ru',
'scrabble_en',
]);
});
});
+11 -10
View File
@@ -11,14 +11,15 @@ export interface VariantOption {
label: MessageKey;
}
// ALL_VARIANTS lists every variant in display order. The labels are display names keyed by
// ALL_VARIANTS lists every variant in display order (Erudit first — the default
// preference and the product's primary variant). The labels are display names keyed by
// the game's alphabet, not the interface language: the English-alphabet game is always
// "Scrabble" and the Russian-alphabet Scrabble always "Скрэббл" (both unlocalized, so the
// two never collide whatever the UI language); Erudit is localized "Erudite"/"Эрудит".
export const ALL_VARIANTS: VariantOption[] = [
{ id: 'scrabble_en', label: 'new.english' },
{ id: 'scrabble_ru', label: 'new.russian' },
{ id: 'erudit_ru', label: 'new.erudit' },
{ id: 'scrabble_ru', label: 'new.russian' },
{ id: 'scrabble_en', label: 'new.english' },
];
// variantNameKey returns the i18n key for a variant's display name (used by the in-game
@@ -47,13 +48,13 @@ export const VARIANT_FLAG: Record<Variant, string> = {
// ru -> Russian + Эрудит.
export const VARIANT_LANGUAGE: Record<Variant, 'en' | 'ru'> = { scrabble_en: 'en', scrabble_ru: 'ru', erudit_ru: 'ru' };
// availableVariants gates ALL_VARIANTS by the session's supported languages. An empty
// or absent set is ungated (a web/legacy session without a declared set), returning
// every variant.
export function availableVariants(supportedLanguages: string[] | undefined): VariantOption[] {
const langs = supportedLanguages ?? [];
if (langs.length === 0) return ALL_VARIANTS;
return ALL_VARIANTS.filter((v) => langs.includes(VARIANT_LANGUAGE[v.id]));
// availableVariants gates ALL_VARIANTS by the player's variant preferences (the set
// they enabled in Settings). An empty or absent set is ungated (returns every variant)
// — a safety fallback; a real profile always carries at least one preference.
export function availableVariants(preferences: Variant[] | undefined): VariantOption[] {
const prefs = preferences ?? [];
if (prefs.length === 0) return ALL_VARIANTS;
return ALL_VARIANTS.filter((v) => prefs.includes(v.id));
}
// supportsMultipleWordsToggle reports whether the New Game "multiple words per turn" toggle
+5 -9
View File
@@ -4,8 +4,7 @@
import { connection } from '../lib/connection.svelte';
import { gateway } from '../lib/gateway';
import { GatewayError } from '../lib/client';
import { localeFrom, t } from '../lib/i18n/index.svelte';
import { translate } from '../lib/i18n/catalog';
import { t } from '../lib/i18n/index.svelte';
import { friendCodeParam, shareLink } from '../lib/deeplink';
import { shareTelegramLink } from '../lib/telegram';
import type { AccountRef, FriendCode, RobotBlockEntry } from '../lib/model';
@@ -95,10 +94,8 @@
// shareInvite shares the friend-code deep link: inside Telegram via the native
// "share to chat" picker; on the web via the system share sheet; failing both, it
// copies the link to the clipboard.
async function shareInvite(url: string, lang: string) {
// The caption is in the bot's language (Эрудит for ru, Scrabble for en), not the
// interface language — the recipient lands in that bot.
const text = translate(localeFrom(lang), 'friends.inviteText');
async function shareInvite(url: string) {
const text = t('friends.inviteText');
if (shareTelegramLink(url, text)) return;
if (typeof navigator !== 'undefined' && navigator.share) {
try {
@@ -134,8 +131,7 @@
<button class="btn" onclick={redeem} disabled={!connection.online}>{t('friends.redeem')}</button>
</div>
{#if code}
{@const lang = app.session?.serviceLanguage || app.locale}
{@const tg = shareLink(friendCodeParam(code.code), lang)}
{@const tg = shareLink(friendCodeParam(code.code))}
<div class="code" data-testid="friend-code">
<div class="coderow">
<button class="codeval" onclick={copyCode}>{code.code}</button>
@@ -145,7 +141,7 @@
{t('friends.codeHint')} · {t('friends.codeExpires', { time: codeTime(code.expiresAtUnix) })}
</span>
{#if tg}
<button type="button" class="link tgshare" onclick={() => shareInvite(tg, lang)}>{t('friends.shareTelegram')}</button>
<button type="button" class="link tgshare" onclick={() => shareInvite(tg)}>{t('friends.shareTelegram')}</button>
{/if}
</div>
{:else}
+3 -3
View File
@@ -18,9 +18,9 @@
// The auto-match move clock (mirrors backend game.DefaultTurnTimeout = 24h).
const AUTO_MATCH_HOURS = 24;
// The offered variants are gated by the languages the sign-in service supports;
// the auto-match list and the friend-invite picker both use this.
const variants = $derived(availableVariants(app.session?.supportedLanguages));
// The offered variants are gated by the player's profile preferences (the variants
// they enabled in Settings); the auto-match list and the friend-invite picker both use this.
const variants = $derived(availableVariants(app.profile?.variantPreferences));
// "Multiple words per turn" off is the single-word rule; it is offered for Russian games
// only (English is always standard and shows no toggle). Shared by both flows.
let multipleWords = $state(false);
+56 -1
View File
@@ -16,6 +16,8 @@
validDisplayName,
validEmail,
} from '../lib/profileValidation';
import { ALL_VARIANTS, VARIANT_RULES } from '../lib/variants';
import type { Variant } from '../lib/model';
let dn = $state('');
let tz = $state('+00:00');
@@ -28,6 +30,7 @@
let blockChat = $state(false);
let blockFriendRequests = $state(false);
let notificationsInAppOnly = $state(true);
let variantPrefs = $state<Variant[]>([]);
let emailInput = $state('');
let codeInput = $state('');
let emailSent = $state(false);
@@ -60,6 +63,7 @@
blockChat = p.blockChat;
blockFriendRequests = p.blockFriendRequests;
notificationsInAppOnly = p.notificationsInAppOnly;
variantPrefs = [...p.variantPreferences];
}
onMount(populate);
@@ -67,9 +71,19 @@
const awayEnd = $derived(`${endH}:${endM}`);
const nameOk = $derived(validDisplayName(dn));
const awayOk = $derived(awayDurationOk(awayStart, awayEnd));
const formValid = $derived(nameOk && awayOk);
const formValid = $derived(nameOk && awayOk && variantPrefs.length >= 1);
const emailOk = $derived(validEmail(emailInput));
// toggleVariant flips a variant in the preference set, refusing to remove the last one —
// the player must allow themselves at least one variant.
function toggleVariant(id: Variant) {
if (variantPrefs.includes(id)) {
if (variantPrefs.length > 1) variantPrefs = variantPrefs.filter((v) => v !== id);
} else {
variantPrefs = [...variantPrefs, id];
}
}
async function save() {
if (!formValid) return;
try {
@@ -82,6 +96,7 @@
blockChat,
blockFriendRequests,
notificationsInAppOnly,
variantPreferences: variantPrefs,
});
showToast(t('profile.saved'));
} catch (e) {
@@ -207,6 +222,22 @@
<input type="checkbox" bind:checked={notificationsInAppOnly} />
<span>{t('profile.notificationsInAppOnly')}</span>
</label>
<fieldset class="prefs">
<legend>{t('profile.preferences')}</legend>
<p class="muted">{t('profile.preferencesHint')}</p>
{#each ALL_VARIANTS as v (v.id)}
<label class="check pref">
<input
type="checkbox"
checked={variantPrefs.includes(v.id)}
disabled={variantPrefs.length === 1 && variantPrefs.includes(v.id)}
onchange={() => toggleVariant(v.id)}
/>
<span class="pname">{t(v.label)}</span>
<span class="prule">{t(VARIANT_RULES[v.id])}</span>
</label>
{/each}
</fieldset>
<div class="formacts">
<button type="submit" class="btn" disabled={!formValid || !connection.online}>{t('common.save')}</button>
</div>
@@ -342,6 +373,30 @@
gap: 10px !important;
color: var(--text) !important;
}
.prefs {
border: 1px solid var(--border);
border-radius: var(--radius-sm);
padding: 10px 12px;
margin: 0;
display: flex;
flex-direction: column;
gap: 8px;
}
.prefs legend {
color: var(--text-muted);
font-size: 0.9rem;
padding: 0 4px;
}
.pref {
align-items: baseline;
}
.pname {
font-weight: 600;
}
.prule {
color: var(--text-muted);
font-size: 0.8rem;
}
.formacts {
display: flex;
gap: 10px;