diff --git a/docs/FUNCTIONAL.md b/docs/FUNCTIONAL.md index c6206c6..30a4875 100644 --- a/docs/FUNCTIONAL.md +++ b/docs/FUNCTIONAL.md @@ -31,7 +31,10 @@ ephemeral guest. The gateway validates the credential once and mints a thin session token; the backend resolves it to an internal `user_id`. A **Telegram Mini App** launch authenticates from the platform's signed `initData`, themes the UI to the Telegram colours, and — on first contact — seeds the new account's interface -language from the Telegram client. Telegram runs a **single bot**: every player uses +language from the Telegram client. If a launch cannot reach the backend (for example during a +deployment), the Mini App retries quietly and then shows a small "couldn't load" screen with a +**Retry** button, rather than dropping to the web sign-in, which has no place inside Telegram. +Telegram runs a **single bot**: every player uses the same bot, and all of its chat and out-of-app notifications are written in the player's own **interface language** (en/ru). A separate optional **promo bot** can run alongside the main one — its only job is to answer `/start` with a short message and a button that opens the @@ -56,6 +59,10 @@ reconnect), and pending reads resume on their own — the interface stays usable flashing a red banner each time. ### Accounts, linking & merge +_Sign-in is currently provider-only, so the in-profile linking UI is temporarily hidden; it +returns once the anonymous `/app/` guest (whose upgrade path this is) ships. The flow below +describes it for when it does._ + First platform contact auto-provisions a durable account. From the profile a player links an email (via a confirm code) or their Telegram (via the web sign-in); a guest who links their first identity becomes a durable account. The "already taken" status diff --git a/docs/FUNCTIONAL_ru.md b/docs/FUNCTIONAL_ru.md index b5058fc..43c47a3 100644 --- a/docs/FUNCTIONAL_ru.md +++ b/docs/FUNCTIONAL_ru.md @@ -32,7 +32,10 @@ top-1 подсказку, безлимитную проверку слова с session-токен; backend сопоставляет его с внутренним `user_id`. Запуск **Telegram Mini App** авторизует по подписанным `initData` платформы, перекрашивает интерфейс в цвета Telegram и — при первом контакте — задаёт язык интерфейса нового аккаунта по -языку Telegram-клиента. Telegram держит **единого бота**: все игроки пользуются одним +языку Telegram-клиента. Если запуск не может достучаться до бэкенда (например, во время +деплоя), Mini App тихо повторяет попытки, а затем показывает небольшой экран «не удалось +загрузить» с кнопкой **Повторить**, вместо того чтобы сбрасывать на веб-вход, которому внутри +Telegram не место. Telegram держит **единого бота**: все игроки пользуются одним и тем же ботом, а весь его чат и внеприложенческие уведомления пишутся на **языке интерфейса** самого игрока (en/ru). Рядом с основным может работать отдельный опциональный **промо-бот** — его единственная задача отвечать на `/start` коротким сообщением и кнопкой, @@ -57,6 +60,10 @@ Mini App** авторизует по подписанным `initData` плат рабочим вместо красного баннера каждый раз. ### Аккаунты, привязка и слияние +_Вход сейчас только через провайдера, поэтому UI привязки в профиле временно скрыт; он +вернётся, когда появится анонимный `/app/`-гость (для апгрейда которого он и нужен). Описание +ниже — на этот случай._ + Первый контакт с платформы заводит постоянный аккаунт. Из профиля игрок привязывает email (по confirm-коду) или свой Telegram (через веб-вход); гость, привязавший первую личность, становится постоянным аккаунтом. Факт «личность уже diff --git a/ui/e2e/social.spec.ts b/ui/e2e/social.spec.ts index d07d1ec..ae01d0f 100644 --- a/ui/e2e/social.spec.ts +++ b/ui/e2e/social.spec.ts @@ -238,7 +238,10 @@ test('profile edit disables Save and flags an invalid display name', async ({ pa await expect(save).toBeEnabled(); }); -test('link account: a taken email opens the irreversible merge confirmation', async ({ page }) => { +// Account linking is hidden in Profile.svelte while we target provider sign-in (the anonymous +// /app/ guest who upgrades by linking comes later). The flow is kept wired; re-enable these two +// specs together with the `.emailbox` section. +test.skip('link account: a taken email opens the irreversible merge confirmation', async ({ page }) => { await loginLobby(page); await openProfile(page); @@ -258,7 +261,7 @@ test('link account: a taken email opens the irreversible merge confirmation', as await expect(page.getByText('Merge accounts?')).toBeHidden(); }); -test('link account: the Telegram web sign-in control is offered in a browser', async ({ page }) => { +test.skip('link account: the Telegram web sign-in control is offered in a browser', async ({ page }) => { await loginLobby(page); await openProfile(page); await expect(page.getByRole('button', { name: 'Link Telegram' })).toBeVisible(); diff --git a/ui/e2e/telegram.spec.ts b/ui/e2e/telegram.spec.ts index dbc3e00..d402033 100644 --- a/ui/e2e/telegram.spec.ts +++ b/ui/e2e/telegram.spec.ts @@ -83,6 +83,30 @@ test('tg-fullscreen header keeps a constant native-nav gap as the font scales', expect(large.overflows).toBe(false); }); +test('inside Telegram, a failed launch shows the retry screen, not the web login', async ({ page }) => { + // initData carrying the mock's "bootfail" sentinel makes authTelegram reject, simulating a + // backend outage during launch (e.g. a deploy rolling). The Mini App must surface its own + // boot-error/retry screen and never fall back to the web (guest/email) login. + await page.addInitScript(() => { + Object.assign(window, { + Telegram: { + WebApp: { + initData: 'query_id=bootfail&user=%7B%22id%22%3A1%7D&auth_date=1&hash=deadbeef', + initDataUnsafe: {}, + ready() {}, + expand() {}, + }, + }, + }); + }); + await page.goto('/'); + + // After the silent retries, the boot-error screen with its Retry button shows… + await expect(page.getByRole('button', { name: 'Retry' })).toBeVisible(); + // …and the web login (guest) is never shown inside Telegram. + await expect(page.getByRole('button', { name: /guest/i })).toHaveCount(0); +}); + test('outside Telegram, the /telegram/ entry redirects to the site root', async ({ page }) => { await page.goto('/telegram/'); diff --git a/ui/src/App.svelte b/ui/src/App.svelte index b0920dc..2368666 100644 --- a/ui/src/App.svelte +++ b/ui/src/App.svelte @@ -18,6 +18,7 @@ import CommsHub from './game/CommsHub.svelte'; import Feedback from './screens/Feedback.svelte'; import Blocked from './screens/Blocked.svelte'; + import BootError from './screens/BootError.svelte'; onMount(() => { void bootstrap(); @@ -83,6 +84,10 @@ {#if !routeIsLobby}
{t('common.loading')}
{/if} +{:else if app.bootError} + + {:else if app.blocked} {:else} @@ -123,7 +128,7 @@ -{#if routeIsLobby && !app.splashDone && !app.blocked} +{#if routeIsLobby && !app.splashDone && !app.blocked && !app.bootError} {/if} diff --git a/ui/src/lib/app.svelte.ts b/ui/src/lib/app.svelte.ts index 3f1a818..f494291 100644 --- a/ui/src/lib/app.svelte.ts +++ b/ui/src/lib/app.svelte.ts @@ -18,6 +18,7 @@ import { telegramDisableVerticalSwipes, telegramHaptic, telegramLaunch, + type TelegramLaunch, telegramOnEvent, telegramRequestFullscreen, telegramSetChrome, @@ -41,6 +42,10 @@ export interface Toast { export const app = $state<{ ready: boolean; + /** Inside a Mini App, set when the launch failed to authenticate after its retries (e.g. the + * backend was down during a deploy). App.svelte then renders the boot-error retry screen + * instead of the web login — a Mini App has no manual sign-in to fall back to. */ + bootError: boolean; /** Whether the lobby's first cold load has settled (success or error). The loading splash * (components/Splash.svelte) watches it to know when to dismiss; set by screens/Lobby. */ lobbyReady: boolean; @@ -90,6 +95,7 @@ export const app = $state<{ resync: number; }>({ ready: false, + bootError: false, lobbyReady: false, splashDone: false, streamAlive: false, @@ -563,14 +569,7 @@ export async function bootstrap(): Promise { // listener above then re-syncs the safe-area insets. Desktop keeps the bot's full-size // window. No-op on clients predating Bot API 8.0. telegramRequestFullscreen(); - try { - await adoptSession(await gateway.authTelegram(launch.initData)); - // A blocked account skips deep-link routing — the blocked screen overlays every route. - if (!app.blocked) await routeStartParam(launch.startParam); - } catch (err) { - handleError(err); - navigate('/login'); - } + await bootTelegram(launch); app.ready = true; return; } @@ -585,6 +584,57 @@ export async function bootstrap(): Promise { app.ready = true; } +// Inside a Mini App the only identity is the Telegram session, so a failed launch must never fall +// back to the web login screen. A transient backend outage (a deploy rolling over) is retried a +// few times in silence; only then does the boot-error screen surface, from which Retry re-runs the +// same path (retryTelegramBoot). +const TELEGRAM_BOOT_RETRIES = 2; +const TELEGRAM_BOOT_RETRY_MS = 1200; + +function delay(ms: number): Promise { + return new Promise((resolve) => setTimeout(resolve, ms)); +} + +/** + * bootTelegram authenticates a Mini App launch from its initData and routes any deep-link start + * parameter, retrying a few times on a transient failure before raising the boot-error screen + * (app.bootError). A blocked account is terminal — it switches straight to the blocked screen + * without retrying. + */ +async function bootTelegram(launch: TelegramLaunch): Promise { + for (let attempt = 0; ; attempt++) { + try { + await adoptSession(await gateway.authTelegram(launch.initData)); + // A blocked account skips deep-link routing — the blocked screen overlays every route. + if (!app.blocked) await routeStartParam(launch.startParam); + app.bootError = false; + return; + } catch (err) { + if (err instanceof GatewayError && err.code === 'account_blocked') { + await enterBlocked(); + return; + } + if (attempt >= TELEGRAM_BOOT_RETRIES) { + app.bootError = true; + return; + } + await delay(TELEGRAM_BOOT_RETRY_MS); + } + } +} + +/** + * retryTelegramBoot re-attempts the Mini App launch from the boot-error screen's Retry button. It + * clears the error and shows the loading state again, then runs the same retrying boot; on success + * the app renders normally, otherwise the boot-error screen returns. + */ +export async function retryTelegramBoot(): Promise { + app.bootError = false; + app.ready = false; + await bootTelegram(telegramLaunch()); + app.ready = true; +} + /** * routeStartParam navigates a Telegram deep-link start parameter to its target: a * specific game, the friends screen with a friend-code redemption, or the lobby diff --git a/ui/src/lib/i18n/en.ts b/ui/src/lib/i18n/en.ts index 132915d..71e07b4 100644 --- a/ui/src/lib/i18n/en.ts +++ b/ui/src/lib/i18n/en.ts @@ -11,6 +11,9 @@ export const en = { 'blocked.temporary': 'Your account is blocked until {until}.', 'blocked.reason': 'Reason:', + 'boot.errorTitle': "Couldn't load the game", + 'boot.errorBody': 'Please try again in a moment.', + 'common.back': 'Back', 'common.cancel': 'Cancel', 'common.ok': 'OK', diff --git a/ui/src/lib/i18n/ru.ts b/ui/src/lib/i18n/ru.ts index a94aaac..122260f 100644 --- a/ui/src/lib/i18n/ru.ts +++ b/ui/src/lib/i18n/ru.ts @@ -12,6 +12,9 @@ export const ru: Record = { 'blocked.temporary': 'Ваша учётная запись заблокирована до {until}.', 'blocked.reason': 'Причина:', + 'boot.errorTitle': 'Не удалось загрузить игру', + 'boot.errorBody': 'Попробуйте ещё раз или зайдите позже.', + 'common.back': 'Назад', 'common.cancel': 'Отмена', 'common.ok': 'ОК', diff --git a/ui/src/lib/mock/client.ts b/ui/src/lib/mock/client.ts index 941ea33..61a5224 100644 --- a/ui/src/lib/mock/client.ts +++ b/ui/src/lib/mock/client.ts @@ -136,7 +136,10 @@ export class MockGateway implements GatewayClient { } // --- auth --- - async authTelegram(): Promise { + async authTelegram(initData: string): Promise { + // e2e hook: an initData carrying this sentinel simulates a backend that rejects the launch, + // so the Mini App boot-failure path (silent retries → boot-error screen) can be exercised. + if (initData.includes('bootfail')) throw new GatewayError('unavailable'); return { ...SESSION, isGuest: false }; } async authGuest(): Promise { diff --git a/ui/src/screens/BootError.svelte b/ui/src/screens/BootError.svelte new file mode 100644 index 0000000..92ddee5 --- /dev/null +++ b/ui/src/screens/BootError.svelte @@ -0,0 +1,63 @@ + + +
+
+

{t('boot.errorTitle')}

+

{t('boot.errorBody')}

+ +
+
+ + diff --git a/ui/src/screens/Profile.svelte b/ui/src/screens/Profile.svelte index 12561fa..f12a500 100644 --- a/ui/src/screens/Profile.svelte +++ b/ui/src/screens/Profile.svelte @@ -244,9 +244,10 @@ {/if} - -
+ +