# Scrabble Game — Функциональная спецификация

Пользовательские сценарии по доменам: что делает каждая видимая пользователю
операция. Это зеркало [`FUNCTIONAL.md`](FUNCTIONAL.md) для владельца проекта;
**авторитетна английская версия**. Любую точечную правку переносим в том же
патче (переводим только изменённые абзацы).

## Домены

### Клиентское приложение
Веб/приложение-клиент (Svelte + Vite) воплощает эти истории. Он
покрывает вход (гость или email), лобби «мои игры», старт авто-подбора,
игру на доске (постановка фишек перетаскиванием или тапом, пас, обмен, сдача),
top-1 подсказку, безлимитную проверку слова с жалобой, чат и nudge в партии,
обновления в реальном времени, переключение языка интерфейса (en/ru) и темы и
профиль только для чтения. Он также включает управление друзьями (в т.ч.
одноразовые коды-приглашения) и блоками, дружеские приглашения в игру,
редактирование профиля и привязку email, экран статистики и просмотр истории
партии с экспортом в GCG / PNG-картинку. В настройках также выбирается стиль подписей бонус-клеток
(новичок / классика / без текста). Подсказка **выставляет предложенные фишки на
доску** — игрок сам решает сделать ход, и подсказка не тратится, если ходов нет.
Проверка слова принимает только алфавит варианта, запоминает ответы в рамках сессии
и ограничивает частоту повторов.
Публичная **посадочная страница** в корне сайта представляет игру, переключает язык и
тему и ведёт в Telegram-канал игры, в VK Mini App и в **веб-версию** (`/app/`) — каждая под
подписью (Telegram / VK / Веб-версия); сама игра живёт по адресам
`/app/` (веб), `/telegram/` (Telegram Mini App) и `/vk/` (VK Mini App). Тема на странице эфемерна (берётся из
системной настройки, а не из сохранённой), выбор языка сохраняется. Страница всегда
открывается на **русском** — язык браузера не определяется автоматически (роботы
рендерят страницу с произвольным языком, и автоопределение сделало бы
проиндексированный контент недетерминированным); сохранённый выбор 🌐 по-прежнему
главнее. Страница несёт статическую русскую SEO-«шапку» (title/description, карточка
Open Graph, которую используют превью ссылок в Telegram/VK, канонический адрес
продакшен-домена, JSON-LD, набор favicon и `robots.txt`), а оболочка SPA помечена
`noindex` — индексируется только посадочная страница. В подвале — ссылки на **публичную
оферту** (`/offer/`) и рядом на **обратную связь** (тот самый Telegram-бот, который оферта
указывает как контакт Продавца). Страница оферты (юридический документ, который принимается при
покупке) рендерится по запросу из `ui/legal/offer_ru.md`, а её **перечень стоимости** (§4.4)
формируется из живого каталога товаров, поэтому опубликованные цены всегда совпадают с тем, что
сейчас в продаже — без передеплоя.

В обычном вебе клиент — **устанавливаемое PWA**: незалогиненный игрок видит призыв к установке
под формой входа (и внизу «Настроек»), который в один тап ставит приложение на рабочий стол
(домашний экран) там, где браузер это умеет (Chromium), либо показывает ручные шаги
«На экран „Домой“» в Safari на iOS; он скрыт после установки и внутри Mini App.

### Первый запуск: онбординг
При первом открытии приложения игрока один раз проводят по интерфейсу лёгким
**coachmark-оверлеем**: затемнённый слой показывает по одной подсказке-«облачку» за раз, каждое
«хвостиком» указывает на реальный элемент управления, а **тап в любом месте** переходит к
следующей; после последней подсказки оверлей убирается навсегда. Серий две. Серия **лобби**
указывает на вкладки ⚙️ настроек, ✏️ статистики и 🎲 новой игры. Серия **игры**, на первой партии
игрока, указывает на шапку со счётом (история ходов / чат / проверка слова), кнопки 🔄 пас-и-обмен,
🛟 подсказок и 🔀 перемешивания, и на стойку с фишками. Серия считается просмотренной только
**после последней подсказки**, поэтому выход на середине в следующий раз покажет её с начала;
запоминается она по устройству. Триггер серии лобби — попадание в лобби, так что игрок, пришедший
по deeplink сразу в Настройки → Друзья, всё равно получит проводку по лобби при первом возврате.
Обе серии одинаково работают в portrait и landscape (облачка переустанавливаются туда, куда
переезжают элементы), тексты подсказок локализованы (en/ru). Рекламный баннер скрывается, пока
оверлей показан, и возвращается по закрытию.

### Личность и сессии
Игрок приходит с платформы (сначала Telegram), через email-вход или как
эфемерный гость. Gateway один раз валидирует доступ и выдаёт тонкий
session-токен; backend сопоставляет его с внутренним `user_id`. Запуск **Telegram
Mini App** авторизует по подписанным `initData` платформы, перекрашивает интерфейс
в цвета Telegram (перекрашиваясь вживую при смене светлой/тёмной темы Telegram) и
вписывается в безопасные зоны экрана (safe-area), а — при первом контакте — задаёт язык
интерфейса нового аккаунта по
языку Telegram-клиента. Если запуск не может достучаться до бэкенда (например, во время
деплоя), Mini App тихо повторяет попытки, а затем показывает небольшой экран «не удалось
загрузить» с кнопкой **Повторить**, вместо того чтобы сбрасывать на веб-вход, которому внутри
Telegram не место. Запуск **VK Mini App** работает так же: авторизует по подписанным
launch-параметрам VK (их проверяет gateway), и при первом контакте задаёт язык интерфейса
нового аккаунта по `vk_language`, а отображаемое имя — по профилю VK (читается на клиенте,
так как VK не кладёт имя в подписанный запуск). Пока тема в режиме «авто», приложение следует
светлой/тёмной схеме VK-клиента, а раскладка обходит нижнюю home-bar VK на мобильных. Тот же экран
тихого повтора «не удалось
загрузить» действует и внутри VK.
**Email**-вход (в вебе) отправляет на адрес брендированный шестизначный код подтверждения —
локализованный (ru/en), без картинок; после ввода игрок входит. Новый адрес заводится при первом
запросе, но становится постоянным аккаунтом только после подтверждения кода — так брошенная,
неподтверждённая попытка вычищается, а адрес освобождается. Отправки ограничены по частоте (короткая
пауза между кодами и небольшой часовой лимит на адрес), чтобы опечатка в адресе или нетерпеливый тап
не завалили почтовый ящик. В письме также есть **ссылка одного нажатия**, которая подтверждает
адрес — или сразу выполняет вход — в один тап; открытие её в другом браузере тут же отражается в
приложении, а почтовый сканер, который просто загружает ссылку, до токена не дотянется —
он едет в URL-фрагменте, который не уходит на сервер. Вход, запрошенный из **установленного
PWA**, эту ссылку не содержит: в письме только код, который вводится в том же окне (ссылка
открылась бы в отдельном браузере, недоступном приложению).
Telegram держит **единого бота**: все игроки пользуются одним
и тем же ботом, а весь его чат и внеприложенческие уведомления пишутся на **языке
интерфейса** самого игрока (en/ru). Рядом с основным может работать отдельный опциональный
**промо-бот** — его единственная задача отвечать на `/start` коротким сообщением и кнопкой,
открывающей приложение **основного** бота, где игрок выбирает нужный вариант игры; это точка входа
для онбординга, не затрагивающая больше ничего. Гость — только сессия, с урезанными функциями (только
авто-подбор; без друзей, статистики и истории); заброшенный гость, не вошедший ни
в одну игру и простаивавший дольше окна удержания, удаляется сборщиком. Пока приложение открыто, клиент
держит живой стрим и получает обновления в реальном времени — ход соперника, ваш ход,
чат, nudge и подключение соперника к игре, в которой вы ждёте. Каждое обновление приходит самим событием и применяется на месте без
перезагрузки — доска обновляется бесшовно, а приглашённая игра открывается мгновенно. Когда приложение **закрыто**, выбранные внеприложенческие
события (ваш ход, конец партии, nudge, приглашение или заявка в друзья)
приходят вместо этого **уведомлением в Telegram** — если только игрок не оставил
уведомления только в приложении (настройка профиля, **включена по умолчанию**).
Уведомление «ваш ход» называет соперника и пересказывает его последний ход — слово и
текущий счёт для результативного хода либо что он поменял фишки или пропустил, — а по
завершении партии приходит уведомление «конец партии» с твоим результатом и финальным
счётом (счёт читается, твой первым). Если связь пропадает или сервер ограничивает частоту
запросов, приложение не донимает ошибками: в шапке тихо крутится спиннер **«Подключение…»**,
пока идёт переподключение, действия, отправляющие данные на сервер, приостанавливаются до
восстановления связи (экран с серверными данными всё равно открывается — со спиннером — и
подгружается при реконнекте), а незавершённые чтения возобновляются сами — интерфейс остаётся
рабочим вместо красного баннера каждый раз.

### Аккаунты, привязка и слияние
Первый контакт с платформы заводит постоянный аккаунт. Из профиля игрок
привязывает email (по confirm-коду) или свой Telegram (через веб-вход); гость,
привязавший первую личность, становится постоянным аккаунтом. Факт «личность уже
занята» не раскрывается до проверки кода/входа. Если привязываемая личность уже
принадлежит другому аккаунту, игроку показывают явное **необратимое**
подтверждение, и два аккаунта сливаются в тот, под которым он сейчас работает
(статистика суммируется, игры и друзья переносятся, дубликаты убираются), — кроме
случая, когда гость привязывает личность с уже существующим постоянным аккаунтом:
тогда сохраняется постоянный аккаунт, а игры гостя переходят в него. Слияние
запрещено, только пока у аккаунтов есть общая незавершённая игра.

В профиле перечислены **способы входа** аккаунта. В вебе игрок может добавить
Telegram (попап логин-виджета) или VK (веб-вход VK ID — редирект на страницу входа VK и
обратно); внутри Mini App платформа-хозяин уже привязана, и её собственная плашка способа
входа скрыта — платформу, через которую игрок сейчас вошёл, отсюда отвязать нельзя,
показывается только плашка другого провайдера. Привязка провайдера, уже
принадлежащего другому аккаунту, предлагает то же необратимое **слияние**, что и привязка
email. Привязанного провайдера можно **отвязать** — кроме последнего
оставшегося способа входа: он не отвязывается, чтобы аккаунт оставался достижимым.
**Email не отвязывают — его меняют**: игрок вводит новый адрес, подтверждает код,
отправленный на него, и аккаунт атомарно переключается на новый адрес, освобождая
старый. Новый адрес, уже принадлежащий другому аккаунту, отклоняется нейтральным
«проверьте правильность e-mail или обратитесь в поддержку» — смена никогда не сливает
аккаунты и не раскрывает чужой.

Игрок может **удалить аккаунт**. Это удаление с юридическим удержанием, а не стирание:
аккаунт деактивируется, живые поверхности обезличиваются (соперники видят «[Deleted]»),
способы входа освобождаются под повторную регистрацию, сессии отзываются — но досье
(какие креды были привязаны, последний вход, сообщения игрока) сохраняется для оператора и
чистится через два года. Подтверждение удаления — код на почту (если у аккаунта есть
e-mail) либо ввод фразы. Активные игры форфейтятся, чтобы не бросать соперников; одиночные
игры против ИИ удаляются, а игры с людьми сохраняются под обезличенным именем, чат игрока
остаётся. Если снова открыть приложение после удаления — создаётся новый аккаунт.

### Лобби и подбор
При холодном запуске лобби встречает игрока короткой **заставкой загрузки** — фишки Scrabble
складывают небольшой кроссворд из слов **ЭРУДИТ / ЗАГРУЗКА / ОЖИДАНИЕ** — и она исчезает, как
только список игр готов, поэтому на медленном соединении список не мигает «пустым» состоянием.

В лобби — список **мои игры** и нижний tab-bar (новая игра, статистика и вкладка
**⚙️ настройки**, открывающая хаб настроек — настройки, профиль, друзья, о программе).
Список **мои игры** разбит на три секции —
*твой ход*, *ход соперника* и *завершённые* (пустые секции скрыты) — и упорядочен так,
что игры, ждущие твоего хода, идут первыми, дольше всего ждущие сверху, а игры на ходу
соперника и завершённые — самые свежие сверху; внутри секций «твой ход» и «ход соперника»
игра с любым непрочитанным уведомлением всплывает наверх (секция «завершённые» сохраняет
свой порядок). Отображается компактным списком с линиями-разделителями. Пока лобби открыто и партия из списка **переходит на твой ход или
завершается**, её значок статуса **дважды моргает**, привлекая внимание (переход на ход
соперника — без моргания, применяется на месте). Завершённую партию можно **убрать из своего списка**:
проведи по строке завершённой партии влево (или, на десктопе, нажми её **⋮**), чтобы открыть
**❌**, и нажми её. Удаление действует только для твоего аккаунта и необратимо — партия
исчезает лишь из твоего списка и остаётся в списках других игроков, отмены нет. Завершённая
**игра с ИИ (🤖), покинутая тобой** — через сдачу или истечение 7-дневного таймаута — убирается
из *завершённых* автоматически, без свайпа; нормально доигранная игра с ИИ остаётся, пока ты не
уберёшь её, и никакие другие типы партий автоматически не убираются. Типы партий
на экране **Новая игра**
ограничены выбранными игроком **предпочтениями вариантов** (см. «Профиль и
настройки») — сначала Эрудит, по умолчанию только Эрудит. Варианты показываются под **отображаемым именем** — оба варианта Scrabble
читаются как «Scrabble»/«Скрэббл», а Erudit — «Erudite»/«Эрудит» (по языку интерфейса),
и это же имя выносится в заголовок экрана игры. Это ограничивает только **старт** игры, которую ты
инициируешь, — авто-подбор, игру с ИИ и приглашение друга, которое ты **создаёшь**, — поэтому игрок
по-прежнему видит и играет существующие игры любого варианта, а при **приглашении** в партию её
можно принять в любом варианте.

**Быстрая игра** даёт выбрать соперника — **ИИ** (по умолчанию) или **случайного игрока**. С **ИИ**
вы сразу начинаете против 🤖, который присоединяется и отвечает мгновенно: ожидания нет, чат и nudge
нет — только проверка слова, — «добавить в друзья» не показывается; вместо лимита на ход вы
проигрываете только после **7 дней без хода** (так что ИИ-партию можно спокойно забросить, а сам ИИ
никогда не «просрочит» ход). Выбор **случайного игрока** — это авто-подбор (всегда 2 игрока), который
сразу **помещает вас в игру, и вы ждёте соперника прямо
в ней**: если ваш ход — вы уже можете ходить, иначе просто рассматриваете свои фишки. Пока соперник не
присоединился, на карточке соперника (и в строке игры в лобби) написано **«Поиск соперника...»**, а
сдача, чат и nudge недоступны. Другой игрок, ищущий тот же вариант и правило, присоединяется к вашей
игре; если такого нет — через **1,5–3 минуты** свободное место занимает робот, так что игра всегда
стартует, и экран новой игры подсказывает, что можно закрыть приложение на время ожидания и вернуться
позже. Для русских игр (авто-подбор или приглашение) на экране
новой игры есть опция **«Несколько слов за ход»** (по умолчанию **выключена**): выключена —
упрощённое **правило одного слова**: настоящим словом должно быть только слово, выложенное
вдоль линии хода (и оно должно пересекать или продолжать уже стоящие на доске буквы вдоль
этой линии), а случайные перпендикулярные слова игнорируются и не засчитываются;
включена — обычный скрэббл. Английские игры всегда по стандартным правилам и тоггл не
показывают. В авто-подборе выбор входит в ключ подбора, поэтому игрок сводится только с теми,
кто выбрал то же правило. Игры с друзьями (2–4)
формируются приглашением игроков из списка друзей (приглашение, как и код друга,
можно отправить deep-link'ом в Telegram, который откроет его сразу — на VK, который не передаёт Mini App
никакой нагрузки, ссылка лишь открывает приложение, а скопированный код получатель вводит вручную): инициатор
выбирает настройки, и партия стартует, когда приняли все приглашённые — любой отказ отменяет приглашение, а без
ответа приглашение протухает через семь дней.

**Лимит одновременных партий (по видам).** Число одновременных незавершённых партий игрока
ограничено **по видам** — против AI, в случайном подборе и по приглашению друзей — с раздельными
лимитами для **гостя** и для вошедшего (постоянного) аккаунта; оператор настраивает их без релиза.
**Гость** может держать **1** партию против AI и **1** случайную одновременно и вовсе не может
создавать игры с друзьями; вошедший аккаунт — до **10** каждого вида. Считаются только идущие и ещё
ищущие соперника партии; завершённая освобождает слот, а уже идущие партии никогда не прерываются.
На экране **«Новая игра»** старт того вида, что достиг лимита, показывает **🔒** и по нажатию
открывает короткое сообщение вместо партии: **гостю** предлагают войти или создать учётную запись
(чтобы играть несколько партий сразу), вошедшему — сначала завершить текущую. **Принимать входящее
приглашение никогда не запрещено** — игры с друзьями ограничиваются только при создании. Сервер
применяет все лимиты независимо от клиента и напрямую отклоняет заявку в друзья, код друга или
приглашение от гостя.

### Игровой процесс
Выкладывание фишек, пас, обмен или сдача. Пас и обмен — один элемент управления:
без выбранных фишек — пас, с выбранными — обмен. Число оставшихся в мешке фишек
показано счётчиком на этом элементе и внизу таблицы ходов. Фишки кладутся без выбора
направления — игра сама определяет ориентацию хода, поэтому одна фишка, продолжающая
уже лежащее
слово (по столбцу или по строке), принимается. Ход проверяется по словарю партии при
сдаче и считается; безлимитный предпросмотр показывает **прямо на доске**, допустим ли
предполагаемый ход: выложенные фишки становятся светло-зелёными, когда образуют слово
(и чуть темнее — фишки доски, через которые проходит слово; в партии «одно слово за ход»
подсвечивается только основное слово), или спокойно-розовыми, когда слова нет, — а
**оранжевый бейдж** на основном слове несёт очки хода. Кнопка
подтверждения появляется только когда ход допустим. Предпросмотр считается **на устройстве**
и отвечает мгновенно, как только словарь партии загружен — иначе при первом открытии показывается
короткий прогрев, — и уходит на сервер, если локальный словарь недоступен. Инструмент проверки слова безлимитный и
предлагает пожаловаться на любой результат; для найденного слова он также даёт ссылку на
внешний справочный словарь (gramota.ru для русских игр, scrabblewordfinder.org для английских),
чтобы его посмотреть. Подсказки управляются настройками
партии — разрешены ли они и сколько их у каждого игрока на старте — и расходуют
личный кошелёк подсказок после исчерпания внутриигрового лимита. Против робота
(**vs_ai**) подсказки, наоборот, **безлимитны и без кошелька**, но с idle-гейтом как
анти-фрустрация: подсказка открывается, только когда игрок **застрял на ходу ~30 минут**
(отсчёт от последнего хода робота; самый первый ход, до хода робота, исключён). Пока гейт
закрыт, кнопка подсказки несёт маленький **🔒 замок**, а тап показывает, сколько осталось;
замок снимается вживую в нужный момент. Один и тот же гейт работает **онлайн и офлайн**: отсчёт
идёт по **ровному внутриигровому таймеру**, а не по системным часам, поэтому их перевод его не
собьёт, а повторный вход перечитывает остаток — застрявший ход не забывается. Онлайн гейт
**принуждает сервер** по своим часам (их игрок не тронет); vs_ai-подсказка не учитывается ни в
одной статистике подсказок. Партия
завершается, когда мешок пуст и игрок выложил стойку, после 6 подряд бесплодных
ходов, по сдаче, либо по таймауту хода (от 5 минут до 24 часов, дефолт 24 часа):
пропущенный ход означает авто-сдачу, кроме как когда игрок внутри своего
суточного окна отсутствия (away). В партии на двоих сдача или таймаут отдают
победу другому игроку, а вышедший сохраняет свои очки. В партии на троих-четверых
место вышедшего убирается, остальные играют дальше, и партия завершается, когда
остаётся один активный игрок; что делать с фишками вышедшего (вернуть в мешок или
убрать из игры) выбирается при создании партии, а его стойка никогда не
показывается остальным. **Композиция на доске сохраняется по партии**: расположение
фишек на стойке и выложенные, но не отправленные фишки сохраняются по мере составления
хода и восстанавливаются при возврате (в том числе на другом устройстве); игрок может
**раскладывать фишки и в ход соперника**, но такой черновик только позиционный —
предпросмотр счёта и отправка доступны лишь в собственный ход.

### Робот-соперник
Если авто-подбор не находит человека за время ожидания (1,5–3 минуты), свободное место в игре,
в которой вы уже ждёте, занимает робот-соперник. Он задуман неотличимым от человека:
один раз за партию решает, играть ли на победу (примерно в 40% случаев, так что
человек выигрывает большинство партий), целится в близкий счёт, а не в разгром или
поддавки, время от времени делает один ход вопреки этому плану — неожиданный отрыв или
слабый ход, — но к концу партии (когда мешок пуст) держится плана, и ходит с
человеческим темпом — чаще короткие раздумья, изредка долгие, и ночная пауза,
подстроенная под день игрока; когда партия уже явно решена и обе стороны только пасуют,
он перестаёт её затягивать — отвечает на вынужденные пасы примерно в темпе самого игрока,
а не после долгого раздумья. На nudge отвечает за несколько минут и, когда игрок
держит его в ожидании, сам шлёт nudge редко — примерно раз в 9-12 часов и никогда во время
своей ночной паузы. Носит человекоподобное имя, подходящее
языку партии — каждую партию новое, из широкого международного пула реальных имён и
никнеймов, так что арена кажется полной разных игроков (в русской партии — в основном
русские имена, без восточноазиатских письменностей; в международной — весь спектр); не общается в чате и
**молча игнорирует заявки в друзья** — заявка роботу остаётся в ожидании и истекает,
ровно как у человека, который не отвечает.

Тот же робот стоит и за **честной игрой против ИИ**, которую игрок выбирает напрямую (выше). Там он
не притворяется: везде показан как **🤖**, присоединяется и ходит сразу (без раздумий и ночной
паузы), сохраняет ту же силу (по-прежнему играет на победу лишь примерно в 40% партий, с теми же
редкими ходами вопреки плану, затухающими к эндшпилю), а чат, nudge и «добавить в друзья» выключены. Партии с ИИ — это **тренировка**: они не идут в статистику игрока.

### Офлайн-режим
Установленный веб-PWA со входом по подтверждённой почте может переключиться в осознанный
**офлайн-режим** (Настройки → Режим игры). В офлайне приложение не обращается к сети: шапка синеет с
меткой *Офлайн*, лобби показывает только сохранённые на устройстве игры, а сетевые поверхности
отключены или скрыты (вкладка Статистика; вариант «случайный соперник» в Новой игре).
Новая игра против робота создаёт локальную **vs_ai**-игру — он ходит
целиком в браузере без бэкенда. Локальные игры хранятся на устройстве, видны только в офлайн-режиме и
никогда не синхронизируются с аккаунтом. Чтобы игру можно было создать и сыграть без связи, приложение
заранее, пока ещё онлайн, подгружает словари включённых игроком вариантов и (после установки)
запускается из прекешированного шелла даже без сети. Переключение **в** офлайн сначала проверяет, что
словари включённых вариантов есть на устройстве — если каких-то не хватает, оно их подгружает и недолго
ждёт, а если подготовить их не удаётся, остаётся онлайн с короткой заметкой *нужен интернет* (загрузка
продолжается в фоне, так что следующее переключение мгновенно). Режим привязан к устройству и
сохраняется между запусками.

В офлайне «с друзьями» в Новой игре запускает **локальную игру по очереди (hotseat)** на 2–4 человек
за одним устройством. Сначала задаётся **пароль ведущего** — 4-значный PIN на клавиатуре в стиле
экрана блокировки; пока он не задан, строки игроков заблокированы. Затем приложение спрашивает,
играете ли вы сами — если да, вы занимаете первое место под именем из профиля. Вы называете каждого
игрока (от 2 до 4; строки можно добавлять и удалять, удаление спрашивает пароль ведущего) и можете
дать любому месту **свой необязательный PIN**. Во время игры доска видна всегда, но место с PIN
вместо стойки показывает кнопку **«Разблокировать»**, пока владелец не введёт PIN, — так устройство
передаётся из рук в руки, и только защищённое место прячет свои буквы; на каждом ходу замок
возвращается. **Ведущий** (кнопка ведущего в игре) может по паролю ведущего **пропустить** ход
текущего игрока, **исключить** игрока или **завершить игру досрочно** — досрочное завершение стирает
партию без победителей и проигравших. Подсказки и чат в игре по очереди отключены. Нормально
завершённая партия сохраняется в офлайн-лобби как любая другая; удаление любой игры по очереди —
идущей или завершённой — из лобби спрашивает пароль ведущего, чтобы никто не стёр партию сразу после
своего хода.

Приложение также **само включает офлайн-режим**, когда не может достучаться до сети. При холодном
запуске без связи оно переходит в офлайн на текущую сессию; когда устройство онлайн, но шлюз молчит
несколько секунд, оно сперва спрашивает — *Нет связи. Включить офлайн-режим?*, с выбором **Включить**
(уйти в офлайн) или **Ждать сеть** (продолжить попытки онлайн). Пока приложение открыто, потеря сети —
режим полёта — переключает в офлайн автоматически, а её восстановление само возвращает онлайн.
Самостоятельно включённый офлайн временный: он возвращается в онлайн, как только сеть появилась, и
следующий запуск проверяет заново. **Осознанный** офлайн — тумблер в Настройках или **Включить** в том
диалоге — это выбор игрока: он сохраняется и никогда не отменяется автоматически.

### Нативное приложение (Android)
Игра также выходит как отдельное **приложение для Android** (сначала в **RuStore**) — упакованная сборка
того же клиента. Всё необходимое встроено, поэтому оно открывается **без сети**: первый запуск офлайн сразу
открывает лобби как **гость** (без экрана входа), и можно играть немедленно — против робота или в игру **по
очереди на одном устройстве** (pass-and-play) с друзьями — используя словари, вшитые в приложение. Когда сеть
доступна, приложение тихо заводит гостя в фоне, и онлайн-возможности (случайные соперники, друзья,
статистика) включаются, не прерывая игру; локальные игры, начатые офлайн, остаются на устройстве. Чтобы
сохранить постоянный аккаунт, вход выполняется по **email** из Профиля (гость становится вашим аккаунтом);
вход через Telegram и VK в нативном приложении не предлагается. Встроенные покупки в этом первом релизе
скрыты. Поскольку установленное приложение может быть намного старше сервера, сборка, которая **слишком
стара** для общения с текущим сервером, показывает единственный несбрасываемый экран *обновления*, кнопка
которого открывает страницу в магазине — он появляется только при онлайн-действии, никогда во время
офлайн-игры.

### Социальное: друзья, блок, чат, nudge
Подружиться можно двумя способами: погасить **одноразовый код**, который выпускает
другой игрок (шесть цифр, действует двенадцать часов), либо отправить **заявку
тому, с кем вы играли** — он принимает, игнорирует (заявка истекает через тридцать
дней, после чего её можно отправить снова) или отклоняет (отказ блокирует ваши
повторные заявки, пока он сам не передаст вам код). Отмена своей висящей заявки
снимает её; удаление расторгает дружбу. В партии карточка счёта каждого соперника (при открытой
истории ходов) несёт два контрола: **в друзья 🤝** справа и **блокировка ✖️** слева. Каждый
подтверждается тапом по затухающей ✅ — на месте счёта карточка показывает *В друзья?* (или
*Блокируем?* красным) во время подтверждения, — и пока подтверждается один, второй скрыт, чтобы
они не пересекались. 🤝 становится **неактивным**, пока заявка висит или была отклонена, и
**исчезает** после принятия; **оба контрола исчезают, а имя соперника становится зачёркнутым**
после блокировки. Они обновляются на месте в момент изменения отношения и остаются верны после
перезагрузки. Блокировка (как и заявка в друзья) применяется немедленно и подтверждается живым
событием; при сбое транспорта контрол возвращается в прежнее состояние.

Глобальная блокировка — отключить входящие чат и/или заявки — либо блокировка **конкретного
игрока**. Пер-юзер блок **односторонний и незаметный**: вы перестаёте получать от этого человека
всё — чат, nudge, заявки в друзья и приглашения в игру, — и матчмейкер никогда не сводит вас с
ним, а он **ничего не замечает**. Его сообщения и nudge по-прежнему отправляются по обычным
правилам, но не доходят до вас (всё, что было бы для вас непрочитанным, сразу помечается
прочитанным), а присланные им заявка или приглашение сохраняются, но никогда не всплывают. Блок
**перекрывает, но не удаляет** имеющуюся дружбу (поэтому можно заблокировать друга, а он
продолжает видеть вас в друзьях); активные игры не прерываются — их можно доигрывать, при этом у
заблокированного соперника «подвал» чата скрыт (остаётся только лог). Блокировка с карточки в
партии повторяет блокировку в **Настройках → Друзья**; **разблокировка** и **удаление из друзей**
есть только там.
В **Настройках → Друзья** каждый друг — однострочник, чей правый кебаб (⋮) выдвигает
иконки-действия **заблокировать 🚫** и **удалить ✖️**, и каждое действие подтверждается
диалогом с именем друга (*Заблокировать?* / *Удалить из друзей?*).
Блокировка **авто-матч соперника, который втайне робот**, в этой партии ведёт себя так же
(зачёркнутое имя, скрытый «подвал») и в списке заблокированных показывается под тем именем,
которое ты видел, но записывается только для этой партии — маскировка сохраняется, общий
аккаунт робота глобально не блокируется, и матчмейкер продолжает давать роботов (так что
заблокировать себя без соперников нельзя). Отправка 🤝 такому **замаскированному роботу** так же
записывает заявку только для этой партии (привязанной к месту, поэтому она читается как
*отправленная* здесь, не затрагивая этого робота в твоих других играх); робот её **игнорирует**,
заявка не показывается в **Настройки → Друзья** и автоматически удаляется примерно через неделю
после завершения партии. Чат
партии — для быстрых реакций: сообщения короткие (до 60 символов) и не должны
содержать ссылок, email и телефонов, даже завуалированных. В свой ход можно отправить
**одно сообщение за ход**; после отправки поле сменяется короткой подписью до следующего
хода. Nudge ожидаемого
соперника — не чаще раза в час (nudge — часть игрового чата); и всплывашка в приложении,
и внеприложенческий push (доставляется через платформу) **называют отправителя**
(«<соперник>: жду вашего хода»).
Чат и инструмент проверки слова — один **экран связи** со вкладками **💬 чат** / **🔎 словарь**,
открываемый по 💬 в шапке истории ходов (с кнопкой «назад» в партию). В ИИ-партии чата нет, поэтому её
экран связи — только проверка слова; а после завершения ИИ-партии (чата нет, словарь закрыт) сам вход
💬 исчезает. Непрочитанная запись чата —
сообщение **или nudge** от соперника — зажигает маленькую **точку** рядом с партией в
**лобби** и на **строке счёта** партии, **красную при непрочитанном сообщении и мягкую жёлтую,
когда непрочитаны только nudge'и**. Открытие **истории ходов** считается прочтением чата, даже
без захода в него: точка гаснет, а значок 💬 **дважды мигает**. Nudge также гаснет в момент, когда
его получатель **делает ход**, и **все nudge'и партии гаснут по её завершении** (после конца
партии бейдж неактуален) — но непрочитанные сообщения чата остаются непрочитанными.

### Профиль и настройки
Редактирование отображаемого имени (буквы, разделённые одиночным пробелом / «.» /
«_», с необязательной завершающей «.» или хвостом до пяти цифр, до 32 символов и не
более 5 спецсимволов — пунктуации «.» / «_», пробелы и цифры не в счёт), таймзоны (выбор смещения от
UTC; при создании аккаунта она подставляется из определённого смещения устройства — чтобы
игры с роботом таймились правильно ещё до открытия этой формы), суточного окна отсутствия
(away; сетка по 10 минут, не более 12 часов, с переходом через полночь) и переключателей блокировок. Форма профиля редактируется
сразу (без отдельного режима редактирования). Привязка email и Telegram, а также
слияние аккаунтов вынесены в раздел «Аккаунты, привязка и слияние». Внутри Telegram
Mini App пункт **Settings** в системном меню «⋮» Telegram также открывает этот экран, а
ваши настройки отображения (тема, стиль подписей клеток и reduce-motion — кроме языка
интерфейса, который следует за аккаунтом) синхронизируются между вашими устройствами в
Telegram. На сенсорном устройстве в настройках также есть переключатель **«Приближать
доску»** (по умолчанию включён): если выключить, при кидании фишки доска больше не
приближается к ней автоматически. Это настройка на устройство, и она скрыта на десктопе,
где доска и так помещается целиком и авто-зума нет.

**Предпочтения (в какие варианты тебя можно подбирать).** Настройка профиля задаёт варианты
игры — Эрудит, русский Scrabble и английский Scrabble, показанные **сначала Эрудит**, — в
которые ты разрешаешь себя подбирать; **новый аккаунт стартует только с Эрудитом** — если только
он не создан по **диплинку промо-бота**, который дополнительно включает **английский Scrabble**, —
и нужно оставить выбранным **хотя бы один**. Именно этот список предлагает **Новая игра**, когда ты
запускаешь партию (авто-подбор, игра с ИИ или приглашение друга, которое ты создаёшь), — не
включённый вариант не предлагается, и сервер его отклоняет. На партии, в которые тебя
**приглашают**, это не влияет: приглашённый друг может принять приглашение в **любом** варианте,
а открывать и играть существующие игры любого варианта можно всегда.

### Обратная связь
Зарегистрированный игрок обращается к операторам из Settings → Info: экран **«Обратная связь»** с
сообщением (до 1024 символов) и необязательным вложением (один файл, до ~1 МБ — изображения, PDF,
текст/лог, офисные документы, RTF или архивы; неподходящий файл отклоняется на форме без перечисления
допустимых типов). После отправки форма очищается и подтверждает «Ваше сообщение отправлено», а
повторная отправка блокируется, пока оператор не разобрал обращение — при повторном входе на экране
«Ожидаем рассмотрения вашего последнего обращения». Ответ оператора показывается под формой как
«Ответ на ваше последнее сообщение» (ссылки в нём открываются в новой вкладке); он помечается
прочитанным, как только экран его показал, и исчезает через неделю. Бейдж на вкладке Settings (и на Info внутри неё) сигналит о непрочитанном
ответе. Гость отправлять обратную связь не может (пункт скрыт). Игрок, которому оператор запретил
обратную связь (роль, а не полная блокировка аккаунта), видит кнопку отправки недоступной.

### Чат поддержки в Telegram
С операторами можно поговорить и прямо из Telegram-бота: всё, что пользователь присылает боту,
кроме `/start` — текст, фото, голосовое, файл, — пересылается в закрытую группу поддержки операторов
и собирается в отдельную ветку на пользователя. Пользователь не получает автоответа; оператор
отвечает из этой ветки, и бот доставляет ответ обратно обычным сообщением — для пользователя это
тихий диалог один на один. Операторы могут заблокировать пользователя (тогда бот молча игнорирует
его сообщения) или очистить сообщения ветки. Это независимо от встроенной «Обратной связи» выше —
канал для тех, кто пишет боту напрямую, а не через приложение.

### История и статистика
Завершённые партии архивируются в независимом от словаря виде и экспортируются в
**двух форматах за одной кнопкой 📤** — файл GCG и отрисованная **PNG-картинка**
финальной позиции; экспорт доступен **только после завершения партии** и никогда —
для тренировочной партии с ИИ (экспорт идущей партии раскрыл бы журнал ходов, а
партия с ИИ одноразовая). Выбор формата — **нативный попап Telegram** на
Telegram Android/desktop (там это безопасно: цепочка целиком из bridge-вызовов,
которым user activation не нужна) и собственный модал приложения в остальных
случаях — на Telegram iOS доставка идёт через системную share-шторку, которую из
коллбека нативного попапа не открыть, а у VK нативного чузера нет.

**Картинка** рендерится на сервере (внутренний рендер-сайдкар исполняет тот же
модуль отрисовки, что тестирует веб-клиент; всегда светлая тема): финальная
доска с классическими осями координат A..O / 1..15 слева — бонус-клетки чистой
цветовой заливкой, без текстовых подписей — и компактная таблица ходов по местам
справа: в шапке имя и финальный счёт каждого места (🏆 у победителя), далее по
строке на ход: классическая координата главного слова (горизонтальный ход —
цифра первой, `8G`; вертикальный — буква первой, `H8` — конвенция GCG), слово и
его очки; дополнительные слова мультисловного хода — второй мелкой строкой,
не-игровые ходы — локализованными пометками (пас/обмен/сдался/таймаут), а
замыкающая строка ± показывает концовочную поправку за рэк, когда она была
(финальные счета авторитетны — суммы ходов её не содержат). В подвале — хост сайта
и дата завершения в локали устройства. Типографика таблицы фиксирована; длинная
партия растягивает доску (не ниже минимума), так что пустот на картинке нет.

Доставка — **одна подписанная короткоживущая ссылка для обоих форматов на любой
платформе**, отданная лучшему механизму, который у платформы реально есть (каждая
ветка проверена на устройстве): Telegram Android/desktop — диалог загрузки Telegram
(из его превью файл шерится дальше); **Telegram iOS — системная share-шторка** со
скачанным файлом; VK iOS ведёт оба формата через загрузку VK в её нативный
share-поток, а на **VK Android** — чей загрузчик виснет — картинка открывается в
нативном просмотрщике фото VK (сохранение его кнопками), GCG копируется в буфер
(десктопный iframe VK — обычный браузер — скачивает оба); **мобильный браузер
получает системную share-шторку** (ничего не оседает в Загрузках); десктопный
браузер скачивает файл. Ссылка не требует логина при
скачивании (родные загрузчики платформ его не несут) и живёт минуты. Единственное
исключение — устаревший клиент Telegram без диалога загрузки (до Bot API 8.0): там
GCG откатывается на прежнее копирование в буфер (с подтверждающим тостом), а пункт
«картинка» не предлагается. Статистика (только у постоянных аккаунтов):
победы, поражения, ничьи, макс. очков за партию и макс. очков за один ход (лучший
ход, уже включающий все образованные им слова и бонус за все фишки). Также
показываются **число ходов** игрока (его выкладки — пасы и обмены не считаются) и
**доля подсказок** (процент таких ходов, на которых бралась подсказка).
Лучший ход также даётся **с разбивкой по вариантам игры** — **само слово**,
нарисованное игровыми фишками (wildcard показывает свою букву без очков), по одной
строке на каждый сыгранный вариант; варианты без ходов не выводятся.
Партия, которую больше нельзя продолжить — из-за изменения правил более ранний ход
стал бы недопустимым, — закрывается **ничьёй** в момент открытия её игроком, а не
остаётся висеть с ошибкой: в конце истории ходов показывается обезличенная заметка
организатора (партию не удалось продолжить), и та же заметка попадает в экспорт GCG.

### Администрирование
Оператор открывает серверную админ-консоль по адресу `${DOMAIN}/_gm` — её рендерит
backend; gateway закрывает её HTTP Basic Auth на публичном порту и проксирует
один-в-один. В консоли можно смотреть **пользователей** (профиль, статистика,
identity, их игры) и **игры** — сводка, места, записанная **жеребьёвка первого хода** (кто что
вытащил и кто ходит первым) и простой **пошаговый реплей доски** всей партии, — разбирать
**очередь жалоб на слова** —
закрывая каждую как reject / accept-add / accept-remove — и управлять **словарём**:
активная версия, на которую опираются новые партии, резидентные версии по вариантам,
онлайн-**обновление словаря** (загрузить релизный архив `scrabble-dawg-vX.Y.Z.tar.gz`,
посмотреть предпросмотр добавленных и удалённых слов по каждому варианту относительно
активного словаря, затем установить — версия записывается, загружается и становится
активной; версии неизменяемы, а идущие партии остаются на своей) и **список ожидающих
правок**, выведенный из принятых жалоб (он питает офлайн-пересборку и отмечается
применённым после обновления). Если
подключён Telegram-бот, оператор также может **написать пользователю** (по его
Telegram-identity) или **отправить пост в игровой канал**. Изменяющие действия
защищены проверкой same-origin; личность оператора не отслеживается.

Консоль также показывает **злоупотребление лимитами**: страница **Throttled**
перечисляет недавно затроттленных пользователей/IP по отчётам gateway (окно в памяти —
сбрасывается при рестарте backend) и аккаунты с действующим мягким **high-rate
флагом**. Аккаунт, устойчиво превышающий настраиваемый порог отказов, помечается
автоматически — маркер обратим, виден бейджем в списке пользователей и на карточке
аккаунта и **никогда не блокирует игру**; оператор рассматривает и снимает его с
карточки пользователя. Сам флаг аккаунта баном не является. В **проде** та же
страница дополнительно перечисляет **активные баны по IP**, которые применяет gateway:
временную блокировку IP клиента, превысившего порог наплыва, либо задевшего скрытую
**honeypot**-ловушку или подброшенный **honeytoken** — высокодостоверный признак
сканера или враждебного бота, но не нормального игрока. У каждого бана показаны причина
и срок, рядом действие **Unban**; баны истекают сами, а оператор может снять бан раньше.
Баны по IP — защита только для прода: общий тестовый контур не различает своих клиентов,
поэтому там не применяется.

Консоль также позволяет оператору **вручную заблокировать** аккаунт — жёсткий аналог мягкого
high-rate флага. С карточки пользователя оператор блокирует аккаунт **навсегда** или **до даты**
(пресеты: 1 день / 3 дня / 1 неделя / 1 месяц либо произвольная дата в UTC), при желании указав
**причину** из редактируемого **списка en+ru**, который ведётся на странице **Reasons**;
заблокированный игрок видит причину на своём языке. Блокировка мгновенно засчитывает **поражение**
во всех активных партиях игрока (каждый соперник побеждает, как при сдаче) и убирает его из
матчмейкинга. Заблокированный игрок — при открытии приложения или любом действии — видит
терминальный экран: *«Ваша учётная запись заблокирована»* (постоянная) или *«…заблокирована до
&lt;дата, время&gt;»* (временная, в его часовом поясе), плюс причину, если она была указана, и
приложение прекращает любое фоновое общение с сервером. Временная блокировка снимается сама по
истечении срока; оператор также может **разблокировать** с карточки пользователя в любой момент
(уже проигранные партии не возвращаются).

Там, где бот ведёт **привязанный к каналу чат-обсуждение**, по умолчанию писать может каждый, а бот
**глушит** игрока, который **не зарегистрирован** или **заблокирован**, и снимает мьют, как только тот
зарегистрируется или будет разблокирован. То есть незарегистрированного новичка, написавшего в чат,
бот глушит (промо-бот направляет его в игру зарегистрироваться, после чего бот возвращает голос), а
зарегистрированный незаблокированный игрок просто пишет. Оператор также может **замьютить игрока только
в чате** — роль `chat_muted` на карточке пользователя — без полной блокировки аккаунта; блокировка
аккаунта всё равно мьютит его в чате. Мьют и размьют срабатывают для игрока, уже находящегося в чате;
того, кого в чате нет, это не затрагивает до его следующего входа.

С карточки пользователя оператор также может **пополнить кошелёк подсказок** игрока: аддитивное
начисление (1–100 подсказок за раз), которое **только увеличивает** баланс на карточке. Начисления
**только в плюс** — понизить кошелёк из консоли нельзя (игрок теряет подсказки только тратя их в
партии), поэтому ошибочно начисленное через консоль там не отнять.

Консоль ведёт и очередь **обратной связи** (`/_gm/feedback`): присланные игроками сообщения с фильтром
**непрочитанные / прочитанные / архив** и поиском по пользователю, каждое — с отправителем, источником,
каналом (и языком бота — en/ru — для сообщения из Telegram), языком интерфейса отправителя,
**версией приложения**, с которой отправлено, IP, временем подачи (в трёх зонах — UTC, зоне браузера
на момент отправки и сохранённой зоне отправителя, каждая — «N/A», если неизвестна) и вложением. Оператор может пометить сообщение прочитанным, **ответить** игроку (доставка
в приложение), отправить в архив, удалить или удалить все сообщения этого игрока — и вместе с удалением
**запретить игроку обратную связь** (роль `feedback_banned`, отличная от полной блокировки аккаунта:
останавливает только отправку обратной связи). Роли перечислены и выдаются/снимаются на карточке
пользователя. Открытие сообщения не помечает его прочитанным — это делают только явные действия; тело
сообщения и вложения показываются защищённо (текст экранируется, вложения отдаются на скачивание, а не
рендерятся).

### Кошелёк

У постоянных (не гостевых) игроков есть **Кошелёк** — вкладка в разделе настроек, между «Друзьями» и
«О программе»; у гостей его нет. Экран открывает компактный **баланс**: сначала собственные **фишки**
(внутриигровая валюта) текущего контекста, затем привязанные фишки других платформ за их логотипом —
VK, Telegram или веб, разделённые по тому, где они были пополнены. Что видно и что можно потратить,
зависит от того, **где запущено приложение**, по правилам соответствия магазинам из
[`PAYMENTS.md`](PAYMENTS.md): внутри магазина доступны только его фишки; в открытом вебе — все
привязанные, списываются в порядке веб → VK → Telegram; на VK-iOS баланс показан, но трата заморожена.

Под балансом строка **«Активные»** перечисляет, что у игрока есть сейчас — остаток подсказок и, если
куплено, дату окончания «без рекламы» (или «навсегда»); она скрыта, когда активного ничего нет. Далее
**магазин** двухпозиционным переключателем делится на **«Купить фишки»** и **«Потратить фишки»**:

- **Купить фишки** — **наборы фишек** за настоящие деньги, в валюте текущего контекста (голоса VK,
  звёзды Telegram или рубли в вебе), а сверху — вариант **«ролик за фишки»**, где он предлагается.
  Оплата открывает страницу провайдера (с принятием публичной оферты).
- **Потратить фишки** — **ценности** за фишки (дополнительные подсказки, дни без рекламы) по
  фиксированной цене в фишках. Их действие называется **«Обмен»**, а ценность, на которую не хватает
  баланса, недоступна. Тап по «Обмену» просит **подтверждения** (трата мгновенна, окна провайдера,
  которое сошло бы за подтверждение, нет); это же окно несёт и **предупреждение** соответствия
  магазинам, когда трата в вебе списала бы фишки VK/Telegram — благо тогда работает только в вебе и в
  приложении.

В сборке для **Google Play** покупки за деньги скрыты за подсказкой установить сборку из RuStore
(правило Google о внутриигровой валюте); трата уже заработанных фишек по-прежнему работает. Кошелёк
**не хранит историю покупок** — только текущие балансы, активные блага и магазин.

### Рекламный баннер

Однострочный баннер под навбаром показывает короткие рекламные или служебные сообщения **бесплатным
игрокам**: всем, кто **не** оплатил пожизненный аккаунт, **не** имеет купленных подсказок и **не**
несёт роль **`no_banner`** (она отключает баннер безусловно). Покупка платного аккаунта или подсказок —
либо выдача `no_banner` — убирает его; гости как самые «бесплатные» пользователи баннер видят. Когда
оператор меняет одно из этих свойств, баннер появляется или исчезает **на месте**, без перезагрузки.

Сообщения берутся из управляемых оператором **кампаний**, каждая из которых — заказ на размещение со
**своим весом показа** (процент) и необязательным **окном** действия (начало/конец). Одновременно
идущие кампании **конкурируют** за баннер пропорционально весам; постоянная **дефолтная (домашняя)**
кампания добивает остаток, не выкупленный платными, и полностью уходит из ротации, когда те выкупают
все 100%. Каждое сообщение написано на **двух языках** (английский + русский); игрок видит вариант для
**бота, через которого играет**, независимо от языка интерфейса. Клиент ротирует подходящие кампании
**честно** — каждая получает свою долю по весу за цикл, равномерно размазанную, а не случайно — а
кампания с несколькими сообщениями показывает их по очереди.

Недефолтная кампания может нести собственные **цвета** — фон, текст и ссылку — чтобы рекламное или
служебное сообщение выделялось на фоне нейтральной ленты. Цвета задаются двумя наборами: один
применяется на **любой теме**, а необязательный второй переопределяет только **тёмную тему**, чтобы
кампания хорошо читалась на обеих; рамка ленты выводится из фона автоматически. Кампанию можно также
пометить как **срочную (urgent)**: срочная кампания показывается **всем** — даже платным игрокам,
владельцам подсказок и пользователям с ролью `no_banner` — и, пока она активна, лента показывает
**только её** (обычные кампании и домашняя дефолтная уступают место). Срочность — для настоящих
системных уведомлений (тех.работы, сбои), а не для рекламы.

Всем этим оператор управляет в админ-консоли по адресу **`/_gm/banners`**: создаёт, редактирует,
включает/выключает и планирует кампании, пишет двуязычные сообщения кампании (переставляет и удаляет
их), выбирает необязательные цвета недефолтной кампании (нативный color-picker с живым превью ленты
на светлой и тёмной темах) и помечает её срочной, а также задаёт общие **тайминги** показа (сколько
держится сообщение, прокрутка слишком длинного, и переход fade-out → пауза → fade-in между
сообщениями). Дефолтную кампанию нельзя удалить, в ней всегда остаётся хотя бы одно сообщение, и она
остаётся простой (без цветов, никогда не срочная).
