diff --git a/PLAN.md b/PLAN.md index d122e05..f4b0b22 100644 --- a/PLAN.md +++ b/PLAN.md @@ -1437,6 +1437,30 @@ provided cert) at the contour caddy; prod VPN; rollback. connector. The backend resolves the mover's display name (the score line and result are built per recipient). Covered by notify round-trip, emit, connector-render (en/ru) and routing tests. + - **No-op drain of all bot updates (#1, investigated — no change):** confirmed the Telegram bot + already long-polls and the library advances the offset for every delivered update (the default + handler no-ops anything but `/start`), so the queue never piles up. Telegram withholds only + `message_reaction` / `message_reaction_count` / `chat_member` by default, and — being + unrequested — those never queue either. Owner chose to leave `allowed_updates` at the default + (zero risk) rather than hand-maintain a full whitelist (a wrong/stale type would break + `getUpdates` entirely); a specific type will be requested when a concrete handler needs it. + - **Reconnect UX — "Connecting…" + soft-disable (#2, shipped):** connectivity failures became + **state, not toasts**. A global `online` signal (`lib/connection.svelte.ts`) flips on a + transport `unavailable` / `rate_limited` (and on the live stream's drop), driving a pure-CSS + header **spinner + "Connecting…"** in place of the title and softly disabling the in-game + server actions (commit / exchange / pass / hint; local board/rack/reset stay live). The + transport (`exec`) **auto-retries with capped backoff** — every op on a rate-limit, **reads + only** on `unavailable` (mutations are not blindly re-sent; their buttons are disabled while + offline, so the player re-issues on reconnect — the idempotency caveat the owner accepted). A + reachability **watcher** (`profile.get` probe) and any successful traffic clear the signal; the + old red `error.unavailable` toast is gone (the indicator replaces it). A server-data screen + still **opens with the spinner** and fills on reconnect (global indicator + read auto-retry), + so navigation is never dead. Pure policy unit-tested (`retry.ts`); a mock-only `window.__conn` + hook drives a Chromium+WebKit e2e (indicator appears offline, the action disables, both clear + on reconnect). The visual soft-disable spans the server-action buttons across the app: the + game bar (commit/exchange/pass/hint/resign), chat send + nudge, profile save / link / merge, + friends (request/respond/unfriend/block/code), New Game (auto-match + invite) and the lobby + hide ❌; purely local controls (board/rack/reset, menu, navigation, settings) stay live. ## Deferred TODOs (cross-stage) diff --git a/backend/internal/account/profile.go b/backend/internal/account/profile.go index 8aa7985..ec87974 100644 --- a/backend/internal/account/profile.go +++ b/backend/internal/account/profile.go @@ -23,6 +23,11 @@ import ( // is unbounded; auto-provisioned platform names bypass this editor validation). const maxDisplayName = 32 +// maxDisplayNameSpecials caps the total special characters (the "." / "_" separators — +// every name rune that is neither a letter nor a space) an editable display name may +// carry, so a still-well-formed name cannot be made of mostly punctuation (Stage 17). +const maxDisplayNameSpecials = 5 + // maxAwayWindow bounds the daily away window's duration (midnight-wrap aware). const maxAwayWindow = 12 * time.Hour @@ -110,6 +115,15 @@ func ValidateDisplayName(raw string) (string, error) { if !displayNameRe.MatchString(name) { return "", fmt.Errorf("%w: display name has an invalid character or layout", ErrInvalidProfile) } + specials := 0 + for _, r := range name { + if r != ' ' && !unicode.IsLetter(r) { + specials++ + } + } + if specials > maxDisplayNameSpecials { + return "", fmt.Errorf("%w: display name has more than %d special characters", ErrInvalidProfile, maxDisplayNameSpecials) + } return name, nil } diff --git a/backend/internal/account/validate_test.go b/backend/internal/account/validate_test.go index 0bdee79..e0b72b9 100644 --- a/backend/internal/account/validate_test.go +++ b/backend/internal/account/validate_test.go @@ -27,6 +27,9 @@ func TestValidateDisplayName(t *testing.T) { "digit rejected": {"Name2", "", false}, "blank": {" ", "", false}, "too long": {strings.Repeat("a", 33), "", false}, + "five specials ok": {"a.a.a.a.a.a", "a.a.a.a.a.a", true}, // 5 dots + "six specials": {"a.a.a.a.a.a.a", "", false}, // 6 dots + "initials spaces ok": {"J. R. R. Tolkien", "J. R. R. Tolkien", true}, // 3 dots; spaces don't count } for name, tc := range cases { t.Run(name, func(t *testing.T) { diff --git a/docs/ARCHITECTURE.md b/docs/ARCHITECTURE.md index 7780502..d1a004a 100644 --- a/docs/ARCHITECTURE.md +++ b/docs/ARCHITECTURE.md @@ -89,7 +89,15 @@ dropped). Horizontal scaling is explicit future work. auth operations are unauthenticated and return the minted token. A unary operation's domain outcome rides back in `ExecuteResponse.result_code` (HTTP 200); only edge failures (rate limit, missing session, unknown type, internal) - surface as Connect error codes. + surface as Connect error codes. The client (Stage 17) treats a connectivity edge failure as + **state, not a per-call toast**: a transport `unavailable` or a `rate_limited` flips a global + `online` signal that drives a header **"Connecting…"** spinner and softly disables proactive + actions, and the transport **auto-retries with capped exponential backoff** — every op on a + rate-limit (the gateway rejected it before processing, so it is safe), but only **read-only** + ops on `unavailable` (a mutation is never blindly re-sent, to avoid double-applying one whose + response was lost — its button is disabled while offline and the player re-issues it on + reconnect). A reachability watcher (a lightweight `profile.get` probe) clears the signal when no + other traffic is in flight; the live `Subscribe` stream's drop/recovery feeds the same signal. - **Alphabet on the wire (Stage 13)**: live play exchanges **alphabet indices**, not concrete letters. The rack (`StateView.rack`), the `SubmitPlay`/`Evaluate` tiles, the `Exchange` tiles and the `CheckWord` word are `ubyte` indices into the variant's alphabet diff --git a/docs/FUNCTIONAL.md b/docs/FUNCTIONAL.md index 4d00a05..61ce34c 100644 --- a/docs/FUNCTIONAL.md +++ b/docs/FUNCTIONAL.md @@ -46,7 +46,12 @@ request) arrive as a **Telegram notification** instead — unless the player kee notifications in the app only (a profile setting, **on by default**). The "your turn" notification names the opponent and recaps their last move — the word and the running score for a scoring play, or that they swapped or passed — and a finished game sends a "game over" -notification with your result and the final score (scores read with yours first). +notification with your result and the final score (scores read with yours first). If the +connection drops or the server is rate-limiting, the app does not nag with errors: the header +shows a quiet **"Connecting…"** spinner while it reconnects, actions that send to the server +pause until it is back (a server-data screen still opens, with the spinner, and fills in on +reconnect), and pending reads resume on their own — the interface stays usable instead of +flashing a red banner each time. ### Accounts, linking & merge *(Stage 1 / 11)* First platform contact auto-provisions a durable account. From the profile a player @@ -136,7 +141,8 @@ new chat message raises an **unread badge** on the game's menu until the chat is ### Profile & settings *(Stage 4 / 8)* Edit the display name (letters joined by a single space / "." / "_" separator, with an -optional trailing ".", up to 32 characters), the timezone (chosen as a UTC offset), the +optional trailing ".", up to 32 characters and at most 5 special characters — the "." / "_" +punctuation, spaces aside), the timezone (chosen as a UTC offset), the daily away window (on a 10-minute grid, at most 12 hours, wrapping midnight) and the block toggles. The profile form is edited inline (no separate edit mode). Linking an email or Telegram and merging accounts are covered under "Accounts, linking & diff --git a/docs/FUNCTIONAL_ru.md b/docs/FUNCTIONAL_ru.md index 2472c6a..aac767b 100644 --- a/docs/FUNCTIONAL_ru.md +++ b/docs/FUNCTIONAL_ru.md @@ -48,7 +48,12 @@ Mini App** авторизует по подписанным `initData` плат Уведомление «ваш ход» называет соперника и пересказывает его последний ход — слово и текущий счёт для результативного хода либо что он поменял фишки или пропустил, — а по завершении партии приходит уведомление «конец партии» с твоим результатом и финальным -счётом (счёт читается, твой первым). +счётом (счёт читается, твой первым). Если связь пропадает или сервер ограничивает частоту +запросов, приложение не донимает ошибками: в шапке тихо крутится спиннер **«Подключение…»**, +пока идёт переподключение, действия, отправляющие данные на сервер, приостанавливаются до +восстановления связи (экран с серверными данными всё равно открывается — со спиннером — и +подгружается при реконнекте), а незавершённые чтения возобновляются сами — интерфейс остаётся +рабочим вместо красного баннера каждый раз. ### Аккаунты, привязка и слияние *(Stage 1 / 11)* Первый контакт с платформы заводит постоянный аккаунт. Из профиля игрок @@ -141,7 +146,8 @@ push доставляется через платформу. ### Профиль и настройки *(Stage 4 / 8)* Редактирование отображаемого имени (буквы, разделённые одиночным пробелом / «.» / -«_», с необязательной завершающей «.», до 32 символов), таймзоны (выбор смещения от +«_», с необязательной завершающей «.», до 32 символов и не более 5 спецсимволов — +пунктуации «.» / «_», пробелы не в счёт), таймзоны (выбор смещения от UTC), суточного окна отсутствия (away; сетка по 10 минут, не более 12 часов, с переходом через полночь) и переключателей блокировок. Форма профиля редактируется сразу (без отдельного режима редактирования). Привязка email и Telegram, а также diff --git a/ui/e2e/game.spec.ts b/ui/e2e/game.spec.ts index c82f8ac..d2c6383 100644 --- a/ui/e2e/game.spec.ts +++ b/ui/e2e/game.spec.ts @@ -16,6 +16,25 @@ async function openGame(page: Page): Promise { await expect(page.locator('.pane')).toHaveCount(1); } +test('offline shows the Connecting indicator and softly disables server actions (Stage 17)', async ({ page }) => { + await openGame(page); + // The exchange/draw tab is a server action; on my turn with tiles in the bag it is live. + const draw = page.locator('.tab').first(); + await expect(draw).toBeEnabled(); + await expect(page.getByText('Connecting…')).toHaveCount(0); + + // Drop the connection (mock-only hook): the header swaps the title for the spinner + + // "Connecting…", and the server action goes inert. + await page.evaluate(() => (window as unknown as { __conn: { offline(): void } }).__conn.offline()); + await expect(page.getByText('Connecting…')).toBeVisible(); + await expect(draw).toBeDisabled(); + + // Reconnect: the indicator clears and the action is live again. + await page.evaluate(() => (window as unknown as { __conn: { online(): void } }).__conn.online()); + await expect(page.getByText('Connecting…')).toHaveCount(0); + await expect(draw).toBeEnabled(); +}); + test('placing a tile and confirming via ✅ commits the move', async ({ page }) => { await openGame(page); await page.locator('.rack .tile').first().click(); diff --git a/ui/src/components/Header.svelte b/ui/src/components/Header.svelte index f5a5b01..844d263 100644 --- a/ui/src/components/Header.svelte +++ b/ui/src/components/Header.svelte @@ -2,6 +2,9 @@ import type { Snippet } from 'svelte'; import { navigate } from '../lib/router.svelte'; import { insideTelegram } from '../lib/telegram'; + import { connection } from '../lib/connection.svelte'; + import { t } from '../lib/i18n/index.svelte'; + import Spinner from './Spinner.svelte'; let { title, back, menu, grow = false }: { title: string; back?: string; menu?: Snippet; grow?: boolean } = $props(); @@ -20,7 +23,11 @@ {:else} {/if} -

{title}

+ {#if connection.online} +

{title}

+ {:else} +

{t('connection.connecting')}

+ {/if}
{#if menu}{@render menu()}{/if}
@@ -57,6 +64,16 @@ overflow: hidden; text-overflow: ellipsis; } + /* The "Connecting…" indicator replaces the title while offline: a spinner + muted label, + centred like the title so the bar does not shift. */ + h1.connecting { + display: inline-flex; + align-items: center; + justify-content: center; + gap: 8px; + color: var(--text-muted); + font-weight: 500; + } .icon, .spacer, .end { diff --git a/ui/src/components/Spinner.svelte b/ui/src/components/Spinner.svelte new file mode 100644 index 0000000..42ea1ba --- /dev/null +++ b/ui/src/components/Spinner.svelte @@ -0,0 +1,32 @@ + + + + + diff --git a/ui/src/game/Chat.svelte b/ui/src/game/Chat.svelte index f1a6dd0..10ec271 100644 --- a/ui/src/game/Chat.svelte +++ b/ui/src/game/Chat.svelte @@ -1,6 +1,7 @@