Merge pull request 'feat(ui): explicit offline state inside an online game (+ offline word check)' (#260) from feature/offline-ingame-ux into development
CI / changes (push) Successful in 2s
CI / unit (push) Successful in 12s
CI / integration (push) Successful in 25s
CI / ui (push) Successful in 1m16s
CI / conformance (push) Successful in 10s
CI / gate (push) Successful in 0s
CI / deploy (push) Successful in 1m57s

This commit was merged in pull request #260.
This commit is contained in:
2026-07-14 07:51:08 +00:00
35 changed files with 323 additions and 65 deletions
+1 -1
View File
@@ -86,7 +86,7 @@ GM_BASICAUTH_HASH= # required; `caddy hash-password` bcrypt
# --- UI build args (baked into the gateway image) --------------------------- # --- UI build args (baked into the gateway image) ---------------------------
VITE_TELEGRAM_BOT_ID= VITE_TELEGRAM_BOT_ID=
VITE_TELEGRAM_LINK= # friend-invite Mini App link (full URL, e.g. https://t.me/<bot>/<app>) VITE_TELEGRAM_LINK= # friend-invite Mini App link (full URL, e.g. https://telegram.me/<bot>/<app>)
VITE_TELEGRAM_GAME_CHANNEL_NAME= # landing "Play in Telegram" link, the bot's game channel VITE_TELEGRAM_GAME_CHANNEL_NAME= # landing "Play in Telegram" link, the bot's game channel
VITE_VK_APP_LINK= # landing "Play on VK" link (full URL, https://vk.com/app<id>) VITE_VK_APP_LINK= # landing "Play on VK" link (full URL, https://vk.com/app<id>)
VITE_VK_APP_ID= # VK ID "Web" app id (client_id) for VK web-login linking; also the gateway's GATEWAY_VK_ID_APP_ID VITE_VK_APP_ID= # VK ID "Web" app id (client_id) for VK web-login linking; also the gateway's GATEWAY_VK_ID_APP_ID
+2 -2
View File
@@ -107,8 +107,8 @@ without it Docker's resolver handles `otelcol`, `gateway` and `api.telegram.org`
| `TELEGRAM_TEST_ENV` | _pinned_ | `false` | `true` routes the bot through Telegram's test environment (`.../bot<token>/test/METHOD`). **The CI test contour pins this to `true` in `ci.yaml`** (the contour is the test environment) — it is not a Gitea variable. Set it in `.env` for a local run; prod leaves it `false`. | | `TELEGRAM_TEST_ENV` | _pinned_ | `false` | `true` routes the bot through Telegram's test environment (`.../bot<token>/test/METHOD`). **The CI test contour pins this to `true` in `ci.yaml`** (the contour is the test environment) — it is not a Gitea variable. Set it in `.env` for a local run; prod leaves it `false`. |
| `TELEGRAM_API_BASE_URL` | variable | _(empty)_ | Override the Bot API host (a mock/self-hosted server); empty = `https://api.telegram.org`. | | `TELEGRAM_API_BASE_URL` | variable | _(empty)_ | Override the Bot API host (a mock/self-hosted server); empty = `https://api.telegram.org`. |
| `VITE_TELEGRAM_BOT_ID` | variable | _(empty)_ | UI build-arg: numeric bot id for the web Login Widget. | | `VITE_TELEGRAM_BOT_ID` | variable | _(empty)_ | UI build-arg: numeric bot id for the web Login Widget. |
| `VITE_TELEGRAM_LINK` | variable | _(empty)_ | UI build-arg: friend-invite Mini App link (full URL, `https://t.me/<bot>/<app>``<app>` is the Mini App short name from BotFather). | | `VITE_TELEGRAM_LINK` | variable | _(empty)_ | UI build-arg: friend-invite Mini App link (full URL, `https://telegram.me/<bot>/<app>``<app>` is the Mini App short name from BotFather). |
| `VITE_TELEGRAM_GAME_CHANNEL_NAME` | variable | _(empty)_ | UI build-arg: the landing "Play in Telegram" link, the bot's game channel (e.g. `https://t.me/Erudit_Game`). | | `VITE_TELEGRAM_GAME_CHANNEL_NAME` | variable | _(empty)_ | UI build-arg: the landing "Play in Telegram" link, the bot's game channel (e.g. `https://telegram.me/Erudit_Game`). |
| `VITE_VK_APP_LINK` | variable (shared) | _(empty)_ | UI build-arg: the landing "Play on VK" link, the VK Mini App (full URL, `https://vk.com/app<id>`). One VK Mini App for all contours. | | `VITE_VK_APP_LINK` | variable (shared) | _(empty)_ | UI build-arg: the landing "Play on VK" link, the VK Mini App (full URL, `https://vk.com/app<id>`). One VK Mini App for all contours. |
| `VITE_VK_APP_ID` | variable (shared) | _(empty)_ | VK ID "Web" app id (`client_id`) for VK web-login linking. Baked into the SPA authorize URL and reused as the gateway's `GATEWAY_VK_ID_APP_ID`. Empty disables the `link.vk.*` ops. One VK ID "Web" app for all contours. | | `VITE_VK_APP_ID` | variable (shared) | _(empty)_ | VK ID "Web" app id (`client_id`) for VK web-login linking. Baked into the SPA authorize URL and reused as the gateway's `GATEWAY_VK_ID_APP_ID`. Empty disables the `link.vk.*` ops. One VK ID "Web" app for all contours. |
| `VITE_VK_ID_REDIRECT_URL` | derived | _(empty)_ | VK ID trusted redirect URL — must match the app's registered redirect exactly. The deploy derives `PUBLIC_BASE_URL + /app/` (used by the SPA and as the gateway's exchange `redirect_uri`); set it only for a local run. | | `VITE_VK_ID_REDIRECT_URL` | derived | _(empty)_ | VK ID trusted redirect URL — must match the app's registered redirect exactly. The deploy derives `PUBLIC_BASE_URL + /app/` (used by the SPA and as the gateway's exchange `redirect_uri`); set it only for a local run. |
+11 -4
View File
@@ -118,10 +118,17 @@ dropped). Horizontal scaling is explicit future work.
one whose response was lost — its button is disabled while offline and re-issued on reconnect). A one whose response was lost — its button is disabled while offline and re-issued on reconnect). A
reachability watcher (a lightweight `profile.get` probe; a session-less native guest reconciles a reachability watcher (a lightweight `profile.get` probe; a session-less native guest reconciles a
server guest instead — that reconcile IS the probe) drives recovery, and the live `Subscribe` stream's server guest instead — that reconcile IS the probe) drives recovery, and the live `Subscribe` stream's
drop/recovery feeds the same machine. **Telegram/VK are exempt** — always online: they are never fed an drop/recovery feeds the same machine. **Telegram/VK are exempt from the offline-*play* model**
offline signal, and `offlineMode.active` is additionally hard-gated on `offlineCapable()` (false in `offlineMode.active` is hard-gated on `offlineCapable()` (false in those mini-apps), so nothing — not
those mini-apps), so nothing — not even a version lock — puts them in offline mode: no blue chrome, no even a version lock — puts them in offline mode: no blue chrome, no local lobby, no transport kill
local lobby, no transport kill switch and no device-local create paths. switch and no device-local create paths. The machine still tracks reachability there, though: a
connection lost **inside an online game** is surfaced on **every platform**, driven off the machine's
confirmed-offline state (`netState.offline`, not `offlineMode.active`) — the game screen shows a
*connection lost* banner, **freezes** the tray and move controls, and hides the resign, add-friend/block
and chat/dictionary controls (a started move stays a draft, re-committed by the player on reconnect), and
the word-check tool falls back to the game's pinned on-device dictionary (`ui/src/lib/dict/check.ts`,
exact when that dawg is cached, else *unavailable*) with its network-only complaint and external
look-up hidden.
**Edge hardening:** every request body on the public listener is capped at **Edge hardening:** every request body on the public listener is capped at
`GATEWAY_MAX_BODY_BYTES` (default 1 MiB — far above any legitimate payload), both at the HTTP `GATEWAY_MAX_BODY_BYTES` (default 1 MiB — far above any legitimate payload), both at the HTTP
layer (`http.MaxBytesReader`) and as the Connect per-message read limit, so an oversized layer (`http.MaxBytesReader`) and as the Connect per-message read limit, so an oversized
+15 -1
View File
@@ -229,7 +229,10 @@ brief warm-up shows on the first open otherwise — and falls back to the server
local dictionary is unavailable. The dictionary check tool is local dictionary is unavailable. The dictionary check tool is
unlimited and offers a complaint on any result; for a word it finds, it also links out to an unlimited and offers a complaint on any result; for a word it finds, it also links out to an
external reference dictionary (gramota.ru for Russian games, scrabblewordfinder.org for external reference dictionary (gramota.ru for Russian games, scrabblewordfinder.org for
English) to look it up. Hints are governed per game — English) to look it up. While offline in an online game the check runs against the game's own
dictionary on the device — the same verdict as the server when that dictionary is available,
otherwise it reads *unavailable offline* — and the complaint and the external look-up, which both
need the network, are hidden. Hints are governed per game —
whether they are allowed and how many each player starts with — and draw on a whether they are allowed and how many each player starts with — and draw on a
personal hint wallet once the per-game allowance is spent. Against the robot personal hint wallet once the per-game allowance is spent. Against the robot
(**vs_ai**) hints are instead **unlimited and wallet-free**, but idle-gated as an (**vs_ai**) hints are instead **unlimited and wallet-free**, but idle-gated as an
@@ -322,6 +325,17 @@ offline on its own, and restoring the network returns it online by itself. There
switch to remember: a brief drop is ridden out quietly and only a sustained loss turns the chrome switch to remember: a brief drop is ridden out quietly and only a sustained loss turns the chrome
offline, so you are never interrupted for a hiccup. offline, so you are never interrupted for a hiccup.
**Losing the connection while inside an online game** is surfaced on **every platform, including the
Telegram/VK mini-apps** — unlike the offline-play mode above, which is web/native only — because an
online game needs the server for every move. A slim *connection lost* banner appears at the top of the
board and the play area **freezes**: the rack and the move controls disable, and the resign, the
add-friend/block and the chat/dictionary controls are hidden, while a move you had started stays on the
board as a draft. Nothing
is sent; when the connection returns the banner clears, the controls come back and you commit the move
yourself. If you were already in the chat or the dictionary when the drop happened you stay there — chat
becomes read-only (send and nudge disable) and the dictionary keeps checking words against the game's
on-device dictionary.
### Staying up to date ### Staying up to date
When a new client version is required, the app does not lock you out. On the **native** app a When a new client version is required, the app does not lock you out. On the **native** app a
too-old build shows a notice with **Update** (opens the store) and **Play offline** (dismiss and keep too-old build shows a notice with **Update** (opens the store) and **Play offline** (dismiss and keep
+13 -1
View File
@@ -236,7 +236,9 @@ e-mail) либо ввод фразы. Активные игры форфейтя
короткий прогрев, — и уходит на сервер, если локальный словарь недоступен. Инструмент проверки слова безлимитный и короткий прогрев, — и уходит на сервер, если локальный словарь недоступен. Инструмент проверки слова безлимитный и
предлагает пожаловаться на любой результат; для найденного слова он также даёт ссылку на предлагает пожаловаться на любой результат; для найденного слова он также даёт ссылку на
внешний справочный словарь (gramota.ru для русских игр, scrabblewordfinder.org для английских), внешний справочный словарь (gramota.ru для русских игр, scrabblewordfinder.org для английских),
чтобы его посмотреть. Подсказки управляются настройками чтобы его посмотреть. В офлайне в онлайн-партии проверка идёт по собственному словарю партии на
устройстве — тот же вердикт, что и на сервере, если этот словарь доступен, иначе показывается
*недоступно офлайн*, — а жалоба и внешняя ссылка, которым нужна сеть, скрываются. Подсказки управляются настройками
партии — разрешены ли они и сколько их у каждого игрока на старте — и расходуют партии — разрешены ли они и сколько их у каждого игрока на старте — и расходуют
личный кошелёк подсказок после исчерпания внутриигрового лимита. Против робота личный кошелёк подсказок после исчерпания внутриигрового лимита. Против робота
(**vs_ai**) подсказки, наоборот, **безлимитны и без кошелька**, но с idle-гейтом как (**vs_ai**) подсказки, наоборот, **безлимитны и без кошелька**, но с idle-гейтом как
@@ -327,6 +329,16 @@ e-mail) либо ввод фразы. Активные игры форфейтя
кратковременный сбой пережидается тихо, и только устойчивая потеря переводит интерфейс в офлайн, так что кратковременный сбой пережидается тихо, и только устойчивая потеря переводит интерфейс в офлайн, так что
вас не прерывают из-за короткой икоты. вас не прерывают из-за короткой икоты.
**Потеря связи внутри онлайн-партии** показывается на **всех платформах, включая мини-приложения
Telegram/VK** — в отличие от офлайн-режима игры выше, который только для веба и нативного приложения, —
потому что онлайн-партии сервер нужен на каждый ход. Сверху доски появляется тонкий баннер *связь
потеряна*, и игровая область **замораживается**: рэк и ходовые кнопки отключаются, а «сдаться»,
«в друзья»/«заблокировать» и вход в чат/словарь скрываются; начатый ход остаётся на доске черновиком.
Ничего не отправляется;
когда связь возвращается, баннер исчезает, кнопки оживают, и ход отправляешь ты сам. Если связь упала,
когда ты уже был в чате или словаре, ты там и остаёшься — чат становится «только для чтения» (отправка
и nudge отключаются), а словарь продолжает проверять слова по словарю партии на устройстве.
### Актуальная версия ### Актуальная версия
Когда требуется новая версия клиента, приложение не блокирует вас наглухо. В **нативном** приложении Когда требуется новая версия клиента, приложение не блокирует вас наглухо. В **нативном** приложении
слишком старая сборка показывает уведомление с **Обновить** (открывает магазин) и **Играть офлайн** слишком старая сборка показывает уведомление с **Обновить** (открывает магазин) и **Играть офлайн**
@@ -169,7 +169,7 @@ func TestExecutorChatGateInvalidExternalID(t *testing.T) {
} }
func TestExecutorCreateInvoice(t *testing.T) { func TestExecutorCreateInvoice(t *testing.T) {
sender := &fakeSender{invoiceLink: "https://t.me/$abc"} sender := &fakeSender{invoiceLink: "https://telegram.me/$abc"}
exec := NewExecutor(sender, 0, nil) exec := NewExecutor(sender, 0, nil)
cmd := &botlinkv1.Command{Payload: &botlinkv1.Command_CreateInvoice{CreateInvoice: &botlinkv1.CreateInvoiceCommand{ cmd := &botlinkv1.Command{Payload: &botlinkv1.Command_CreateInvoice{CreateInvoice: &botlinkv1.CreateInvoiceCommand{
Title: "50 chips", Description: "50 chips", Payload: "order-1", Amount: 40, Title: "50 chips", Description: "50 chips", Payload: "order-1", Amount: 40,
@@ -178,7 +178,7 @@ func TestExecutorCreateInvoice(t *testing.T) {
if err != nil { if err != nil {
t.Fatalf("handle: %v", err) t.Fatalf("handle: %v", err)
} }
if !delivered || result != "https://t.me/$abc" { if !delivered || result != "https://telegram.me/$abc" {
t.Errorf("create invoice = %v / %q, want true / the link", delivered, result) t.Errorf("create invoice = %v / %q, want true / the link", delivered, result)
} }
if len(sender.invoice) != 1 || sender.invoice[0].payload != "order-1" || sender.invoice[0].amount != 40 { if len(sender.invoice) != 1 || sender.invoice[0].payload != "order-1" || sender.invoice[0].amount != 40 {
+1 -1
View File
@@ -66,7 +66,7 @@ type BotConfig struct {
// bot's message text (TELEGRAM_BOT_USERNAME; required when the promo bot runs). // bot's message text (TELEGRAM_BOT_USERNAME; required when the promo bot runs).
BotUsername string BotUsername string
// BotLinkURL is the main bot's Mini App direct link — the same value the UI builds // BotLinkURL is the main bot's Mini App direct link — the same value the UI builds
// share links from (VITE_TELEGRAM_LINK), e.g. https://t.me/<bot>/<app>. The promo // share links from (VITE_TELEGRAM_LINK), e.g. https://telegram.me/<bot>/<app>. The promo
// button appends ?startapp=<payload> to it (TELEGRAM_BOT_LINK; required when the // button appends ?startapp=<payload> to it (TELEGRAM_BOT_LINK; required when the
// promo bot runs). It is distinct from the BotLink mTLS dial config below. // promo bot runs). It is distinct from the BotLink mTLS dial config below.
BotLinkURL string BotLinkURL string
@@ -112,7 +112,7 @@ func TestLoadBotChatAndPromo(t *testing.T) {
t.Setenv("TELEGRAM_CHAT_ID", "-100222") t.Setenv("TELEGRAM_CHAT_ID", "-100222")
t.Setenv("TELEGRAM_PROMO_BOT_TOKEN", "promo-token") t.Setenv("TELEGRAM_PROMO_BOT_TOKEN", "promo-token")
t.Setenv("TELEGRAM_BOT_USERNAME", "@ScrabbleBot") t.Setenv("TELEGRAM_BOT_USERNAME", "@ScrabbleBot")
t.Setenv("TELEGRAM_BOT_LINK", "https://t.me/ScrabbleBot/app") t.Setenv("TELEGRAM_BOT_LINK", "https://telegram.me/ScrabbleBot/app")
c, err := LoadBot() c, err := LoadBot()
if err != nil { if err != nil {
t.Fatalf("LoadBot: %v", err) t.Fatalf("LoadBot: %v", err)
@@ -126,7 +126,7 @@ func TestLoadBotChatAndPromo(t *testing.T) {
if c.BotUsername != "ScrabbleBot" { if c.BotUsername != "ScrabbleBot" {
t.Errorf("BotUsername = %q, want the leading @ stripped", c.BotUsername) t.Errorf("BotUsername = %q, want the leading @ stripped", c.BotUsername)
} }
if c.BotLinkURL != "https://t.me/ScrabbleBot/app" { if c.BotLinkURL != "https://telegram.me/ScrabbleBot/app" {
t.Errorf("BotLinkURL = %q", c.BotLinkURL) t.Errorf("BotLinkURL = %q", c.BotLinkURL)
} }
}) })
@@ -13,7 +13,7 @@ import (
) )
func TestPromoTextLocalization(t *testing.T) { func TestPromoTextLocalization(t *testing.T) {
const url = "https://t.me/bot/app?startapp=verudit_ru-scrabble_en" const url = "https://telegram.me/bot/app?startapp=verudit_ru-scrabble_en"
// The @username is rendered as a clickable deep link (the same target as the button), // The @username is rendered as a clickable deep link (the same target as the button),
// not a plain mention, so tapping it opens the seeded Mini App. // not a plain mention, so tapping it opens the seeded Mini App.
wantLink := `<a href="` + url + `">@ScrabbleBot</a>` wantLink := `<a href="` + url + `">@ScrabbleBot</a>`
@@ -41,17 +41,17 @@ func TestPromoTextLocalization(t *testing.T) {
} }
func TestLaunchURLAppendsStartapp(t *testing.T) { func TestLaunchURLAppendsStartapp(t *testing.T) {
b := &Bot{linkURL: "https://t.me/bot/app"} b := &Bot{linkURL: "https://telegram.me/bot/app"}
if got := b.launchURL(""); got != "https://t.me/bot/app" { if got := b.launchURL(""); got != "https://telegram.me/bot/app" {
t.Errorf("empty payload = %q, want the base link unchanged", got) t.Errorf("empty payload = %q, want the base link unchanged", got)
} }
if got := b.launchURL("g123"); got != "https://t.me/bot/app?startapp=g123" { if got := b.launchURL("g123"); got != "https://telegram.me/bot/app?startapp=g123" {
t.Errorf("launchURL = %q, want startapp=g123 appended", got) t.Errorf("launchURL = %q, want startapp=g123 appended", got)
} }
} }
func TestLaunchMarkupIsURLButton(t *testing.T) { func TestLaunchMarkupIsURLButton(t *testing.T) {
b := &Bot{linkURL: "https://t.me/bot/app"} b := &Bot{linkURL: "https://telegram.me/bot/app"}
btn := b.launchMarkup("Play", "f99").InlineKeyboard[0][0] btn := b.launchMarkup("Play", "f99").InlineKeyboard[0][0]
if btn.WebApp != nil { if btn.WebApp != nil {
t.Error("the promo button must not be a web_app button (it would sign initData with the promo token, which the main bot rejects)") t.Error("the promo button must not be a web_app button (it would sign initData with the promo token, which the main bot rejects)")
@@ -84,7 +84,7 @@ func TestHandleStartReplies(t *testing.T) {
api := &fakeAPI{} api := &fakeAPI{}
srv := httptest.NewServer(api) srv := httptest.NewServer(api)
t.Cleanup(srv.Close) t.Cleanup(srv.Close)
b, err := New(Config{Token: "1:2", APIBaseURL: srv.URL, BotUsername: "ScrabbleBot", BotLinkURL: "https://t.me/bot/app"}, zap.NewNop()) b, err := New(Config{Token: "1:2", APIBaseURL: srv.URL, BotUsername: "ScrabbleBot", BotLinkURL: "https://telegram.me/bot/app"}, zap.NewNop())
if err != nil { if err != nil {
t.Fatalf("new: %v", err) t.Fatalf("new: %v", err)
} }
@@ -98,7 +98,7 @@ func TestHandleStartReplies(t *testing.T) {
if api.chatID != "42" { if api.chatID != "42" {
t.Errorf("chat_id = %q, want 42", api.chatID) t.Errorf("chat_id = %q, want 42", api.chatID)
} }
if !strings.Contains(api.text, `<a href="https://t.me/bot/app?startapp=f99">@ScrabbleBot</a>`) { if !strings.Contains(api.text, `<a href="https://telegram.me/bot/app?startapp=f99">@ScrabbleBot</a>`) {
t.Errorf("text = %q, want the @mention linked to the startapp deep link", api.text) t.Errorf("text = %q, want the @mention linked to the startapp deep link", api.text)
} }
if strings.Contains(api.replyMarkup, "web_app") { if strings.Contains(api.replyMarkup, "web_app") {
@@ -113,7 +113,7 @@ func TestHandleStartIgnoresGroup(t *testing.T) {
api := &fakeAPI{} api := &fakeAPI{}
srv := httptest.NewServer(api) srv := httptest.NewServer(api)
t.Cleanup(srv.Close) t.Cleanup(srv.Close)
b, err := New(Config{Token: "1:2", APIBaseURL: srv.URL, BotUsername: "B", BotLinkURL: "https://t.me/b/a"}, zap.NewNop()) b, err := New(Config{Token: "1:2", APIBaseURL: srv.URL, BotUsername: "B", BotLinkURL: "https://telegram.me/b/a"}, zap.NewNop())
if err != nil { if err != nil {
t.Fatalf("new: %v", err) t.Fatalf("new: %v", err)
} }
+1 -1
View File
@@ -29,7 +29,7 @@ pnpm codegen # regenerate src/gen from edge.proto + scrabble.fbs (dev-time)
gateway origin for a packaged (non-proxied) build. `VITE_TELEGRAM_BOT_ID` gateway origin for a packaged (non-proxied) build. `VITE_TELEGRAM_BOT_ID`
enables the "Link Telegram" web sign-in (the Login Widget) — inert until the site enables the "Link Telegram" web sign-in (the Login Widget) — inert until the site
domain is registered with BotFather (`/setdomain`); `VITE_TELEGRAM_LINK` is the domain is registered with BotFather (`/setdomain`); `VITE_TELEGRAM_LINK` is the
friend-invite Mini App link for the single bot (full URL `https://t.me/<bot>/<app>`). friend-invite Mini App link for the single bot (full URL `https://telegram.me/<bot>/<app>`).
`VITE_TELEGRAM_GAME_CHANNEL_NAME` is the "Play in Telegram" link shown on the landing page. `VITE_TELEGRAM_GAME_CHANNEL_NAME` is the "Play in Telegram" link shown on the landing page.
The **native (Capacitor) build** additionally reads `VITE_DICT_VERSION` (the bundled-dictionary version The **native (Capacitor) build** additionally reads `VITE_DICT_VERSION` (the bundled-dictionary version
the offline path requests, matching the packaged DAWGs), `VITE_PAYMENTS_DISABLED=1` (hide in-app purchases the offline path requests, matching the packaged DAWGs), `VITE_PAYMENTS_DISABLED=1` (hide in-app purchases
+74
View File
@@ -39,3 +39,77 @@ test('in-game UX: turn strip, bag badge + table footer, staged-play highlight an
await expect(page.locator('.scorebadge')).toBeVisible(); await expect(page.locator('.scorebadge')).toBeVisible();
await expect(page.locator('.make')).toBeVisible(); await expect(page.locator('.make')).toBeVisible();
}); });
test('online game offline: banner shows, the tray and comms entry freeze, and it thaws on recovery', async ({ page }) => {
await page.goto('/');
await page.getByRole('button', { name: /guest/i }).click();
await page.getByRole('button', { name: /🎲/ }).click();
await page.getByRole('button', { name: 'Random player' }).click();
await page.locator('.variant').first().click();
await page.getByRole('button', { name: /Start game/i }).click();
await page.evaluate(() => (window as unknown as { __mock: { joinOpponent(): void } }).__mock.joinOpponent());
await expect(page.getByText('Robo')).toBeVisible();
// Baseline online: no banner, the tray is interactive, the comms (chat/dictionary) entry is enabled.
await expect(page.locator('.offline-banner')).toHaveCount(0);
await expect(page.locator('.rack .tile').first()).toBeEnabled();
// The connection drops (the net hook rides past the hysteresis straight to offline).
await page.evaluate(() => (window as unknown as { __net: { offline(): void } }).__net.offline());
// The explicit "connection lost" banner appears and every rack tile freezes (cannot compose a move).
await expect(page.locator('.offline-banner')).toBeVisible({ timeout: 15000 });
const tiles = page.locator('.rack .tile');
const n = await tiles.count();
expect(n).toBeGreaterThan(0);
for (let i = 0; i < n; i++) await expect(tiles.nth(i)).toBeDisabled();
// The comms entry (💬) and the resign control are HIDDEN offline (like the frozen social controls);
// both live in the history drawer, so open it and assert no action icons remain in its header.
await page.locator('.scoreboard').click();
await expect(page.locator('.hhead button.hicon')).toHaveCount(0);
// Recovery: the network returns, the banner clears, the tray is interactive and the comms entry returns.
await page.evaluate(() => (window as unknown as { __net: { online(): void } }).__net.online());
await expect(page.locator('.offline-banner')).toHaveCount(0, { timeout: 15000 });
await expect(page.locator('.rack .tile').first()).toBeEnabled();
await expect(page.locator('button:has(.chat-ico)')).toBeVisible();
});
test('online game offline: the dictionary still accepts input and checks (variant seeded from cache)', async ({ page }) => {
await page.goto('/');
await page.getByRole('button', { name: /guest/i }).click();
// A Russian (erudit_ru — first in the mock's variant preferences) game, so the offline dictionary is
// exercised with a non-Latin alphabet that differs from the panel's default 'scrabble_en'. The panel
// seeds its variant + pinned version from the cached game; the mock resolves gameState even offline
// (no kill switch), so on the real backend — where that fetch fails offline — the seed is what keeps
// the input and the dawg fallback working. This guards the offline dictionary flow end-to-end.
await page.getByRole('button', { name: /🎲/ }).click();
await page.getByRole('button', { name: 'Random player' }).click();
await page.locator('.variant').first().click();
await page.getByRole('button', { name: /Start game/i }).click();
await page.evaluate(() => (window as unknown as { __mock: { joinOpponent(): void } }).__mock.joinOpponent());
await expect(page.getByText('Robo')).toBeVisible();
// Go offline, then open the dictionary directly (its in-game entry is hidden offline) so CheckScreen
// mounts with no network and must seed the variant/version from the cached game, not a failed fetch.
await page.evaluate(() => (window as unknown as { __net: { offline(): void } }).__net.offline());
await expect(page.locator('.offline-banner')).toBeVisible({ timeout: 15000 });
await page.evaluate(() => {
const route = location.hash.slice(1); // /game/<id>
(window as unknown as { __router: { navigate(p: string): void } }).__router.navigate(route + '/check');
});
// The check input keeps the Cyrillic word (the variant is known) and the Check button is usable —
// the outcome the cache seed guarantees on the real backend where the offline gameState fetch fails.
const input = page.locator('.check input');
await expect(input).toBeVisible();
await input.fill('СЛОВО');
await expect(input).toHaveValue('СЛОВО');
await expect(page.locator('.check button')).toBeEnabled();
// Running the check resolves to a verdict (or the neutral offline note), proving the fallback path
// executes rather than hanging.
await page.locator('.check button').click();
await expect(page.locator('.verdict')).toBeVisible();
});
+1 -1
View File
@@ -70,7 +70,7 @@ test('the landing footer links to the legal documents and the feedback bot', asy
['Пользовательское соглашение', '/eula/'], ['Пользовательское соглашение', '/eula/'],
['Политика конфиденциальности', '/privacy/'], ['Политика конфиденциальности', '/privacy/'],
['Публичная оферта', '/offer/'], ['Публичная оферта', '/offer/'],
['Обратная связь', 'https://t.me/Erudit_GameBot'], ['Обратная связь', 'https://telegram.me/Erudit_GameBot'],
]; ];
for (const [name, href] of links) { for (const [name, href] of links) {
const link = page.getByRole('link', { name }); const link = page.getByRole('link', { name });
+5 -5
View File
@@ -207,7 +207,7 @@ If the limitation or exclusion of liability is prohibited by applicable law, the
The Company cares about the protection of personal data. Personal data collected by the Company in the context of this document is subject to automated processing in accordance with applicable law. All information collected as part of providing the service is registered by the Company, which is the data controller. This is very important for the functioning of the services offered by the Company. The Company cares about the protection of personal data. Personal data collected by the Company in the context of this document is subject to automated processing in accordance with applicable law. All information collected as part of providing the service is registered by the Company, which is the data controller. This is very important for the functioning of the services offered by the Company.
To exercise one or more of their rights, the User must provide an identity document and contact the person responsible for data protection at the Company (via service support on Telegram: [@Erudit_GameBot](https://t.me/Erudit_GameBot), or send us your request in writing to: 236020, Kaliningrad, Pribrezhny district, Parkovaya St. 1, recipient — Ilya Arkadyevich Denisov). To exercise one or more of their rights, the User must provide an identity document and contact the person responsible for data protection at the Company (via service support on Telegram: [@Erudit_GameBot](https://telegram.me/Erudit_GameBot), or send us your request in writing to: 236020, Kaliningrad, Pribrezhny district, Parkovaya St. 1, recipient — Ilya Arkadyevich Denisov).
In the event of a complaint, the User may contact the personal-data supervisory authority of their country of residence. In the event of a complaint, the User may contact the personal-data supervisory authority of their country of residence.
@@ -247,7 +247,7 @@ Although the Company does everything possible to ensure data confidentiality and
**15.8.** The Company reserves the right to revise the terms of this Licence Agreement, in particular, the Game Rules and/or the Forum Rules, by updating the Licence Agreement at [`erudit-game.ru/eula/`](/eula/) or by notifying the User by email. The revised Licence Agreement enters into force from the day of its publication. The User is advised to check the aforementioned website periodically for notices of such changes. The User's failure to take steps to review does not serve as grounds for the User's failure to perform their obligations and non-compliance with the restrictions established by this Licence Agreement. The User's continued use of the Game is deemed acceptance of any revised terms. **15.8.** The Company reserves the right to revise the terms of this Licence Agreement, in particular, the Game Rules and/or the Forum Rules, by updating the Licence Agreement at [`erudit-game.ru/eula/`](/eula/) or by notifying the User by email. The revised Licence Agreement enters into force from the day of its publication. The User is advised to check the aforementioned website periodically for notices of such changes. The User's failure to take steps to review does not serve as grounds for the User's failure to perform their obligations and non-compliance with the restrictions established by this Licence Agreement. The User's continued use of the Game is deemed acceptance of any revised terms.
**15.9.** On matters related to the performance of this Licence Agreement and/or the use of the Game, the User may contact the Company on Telegram: [@Erudit_GameBot](https://t.me/Erudit_GameBot) and at ilia.denissov@gmail.com. **15.9.** On matters related to the performance of this Licence Agreement and/or the use of the Game, the User may contact the Company on Telegram: [@Erudit_GameBot](https://telegram.me/Erudit_GameBot) and at ilia.denissov@gmail.com.
Denisov I.A. | 2026 Denisov I.A. | 2026
@@ -312,7 +312,7 @@ The Company reserves the right to identify and locate all of the User's Accounts
**5.4.** The User is prohibited from attempting to benefit from deliberate (or repeated) participation in the game process as part of a game group (team) with other Users who have violated paragraphs 5.1, 5.5 and/or 5.6 of the Game Rules. **5.4.** The User is prohibited from attempting to benefit from deliberate (or repeated) participation in the game process as part of a game group (team) with other Users who have violated paragraphs 5.1, 5.5 and/or 5.6 of the Game Rules.
**5.5.** The User is prohibited from using and distributing information, calling for the use of and publicly distributing any errors, both within the Game and in any other software. A User who discovers such errors in the Game must stop using it and report them to the Company on Telegram: [@Erudit_GameBot](https://t.me/Erudit_GameBot) and at ilia.denissov@gmail.com, setting out in detail and truthfully all the circumstances of such discovery and use. If the User has any doubts as to whether the functioning of any particular game process, In-game items or In-game currency is currently normal or has deviations, malfunctions or errors, the User must stop using such process, In-game items or In-game currency and contact the Company at ilia.denissov@gmail.com for the relevant explanations. **5.5.** The User is prohibited from using and distributing information, calling for the use of and publicly distributing any errors, both within the Game and in any other software. A User who discovers such errors in the Game must stop using it and report them to the Company on Telegram: [@Erudit_GameBot](https://telegram.me/Erudit_GameBot) and at ilia.denissov@gmail.com, setting out in detail and truthfully all the circumstances of such discovery and use. If the User has any doubts as to whether the functioning of any particular game process, In-game items or In-game currency is currently normal or has deviations, malfunctions or errors, the User must stop using such process, In-game items or In-game currency and contact the Company at ilia.denissov@gmail.com for the relevant explanations.
**5.6.** The User is prohibited from decompiling, decoding and reconstructing data, bypassing data-protection systems, hacking or attempting to hack the software components of the Game or its services, and/or intercepting data coming to or from the server. Prohibited are: (in particular) any modification, alteration, decompilation, decoding, sale or distribution of modified materials of the Game in whole or in part (or the means and materials necessary to perform such actions), the use of programming errors, making changes to the program code and obtaining unauthorised access to the server and database of the Game. In certain special cases, the Company has the right to immediately suspend the User's access to the Game and send a request to the relevant authorities to prevent any violation of the Licence Agreement and/or provisions of applicable law. **5.6.** The User is prohibited from decompiling, decoding and reconstructing data, bypassing data-protection systems, hacking or attempting to hack the software components of the Game or its services, and/or intercepting data coming to or from the server. Prohibited are: (in particular) any modification, alteration, decompilation, decoding, sale or distribution of modified materials of the Game in whole or in part (or the means and materials necessary to perform such actions), the use of programming errors, making changes to the program code and obtaining unauthorised access to the server and database of the Game. In certain special cases, the Company has the right to immediately suspend the User's access to the Game and send a request to the relevant authorities to prevent any violation of the Licence Agreement and/or provisions of applicable law.
@@ -350,7 +350,7 @@ The Company reserves the right to identify and locate all of the User's Accounts
**8.2.** The User is prohibited from offering such arguments as "in accordance with the role"/"role-play" in defence of unlawful actions of any kind. **8.2.** The User is prohibited from offering such arguments as "in accordance with the role"/"role-play" in defence of unlawful actions of any kind.
**8.3.** The User is prohibited from deliberately providing false information when contacting the Company on Telegram: [@Erudit_GameBot](https://t.me/Erudit_GameBot) or at ilia.denissov@gmail.com, as well as from falsifying the data provided. **8.3.** The User is prohibited from deliberately providing false information when contacting the Company on Telegram: [@Erudit_GameBot](https://telegram.me/Erudit_GameBot) or at ilia.denissov@gmail.com, as well as from falsifying the data provided.
--- ---
@@ -406,7 +406,7 @@ The Company reserves the right to identify and locate all of the User's forum ac
**3.9.** Trading (discussion of trading) in game characters, In-game items, In-game currency, forum accounts, character level boosting. **3.9.** Trading (discussion of trading) in game characters, In-game items, In-game currency, forum accounts, character level boosting.
**3.10.** Discussion of vulnerabilities or shortcomings of the Game, as well as any actions that in any way violate the Game Rules, or their discussion. Upon discovering vulnerabilities or shortcomings, the User must report them to the Company on Telegram: [@Erudit_GameBot](https://t.me/Erudit_GameBot) or at ilia.denissov@gmail.com. **3.10.** Discussion of vulnerabilities or shortcomings of the Game, as well as any actions that in any way violate the Game Rules, or their discussion. Upon discovering vulnerabilities or shortcomings, the User must report them to the Company on Telegram: [@Erudit_GameBot](https://telegram.me/Erudit_GameBot) or at ilia.denissov@gmail.com.
**3.11.** Publication of messages causing negative consequences for the game process; provocation of Users to violate the Game Rules. **3.11.** Publication of messages causing negative consequences for the game process; provocation of Users to violate the Game Rules.
+5 -5
View File
@@ -207,7 +207,7 @@
Компания заботится о защите персональных данных. Персональные данные, собранные Компанией в контексте настоящего документа, подлежат автоматизированной обработке в соответствии с действующим законодательством. Вся информация, собранная в рамках предоставления услуги, регистрируется Компанией, которая является контролером данных. Это очень важно для функционирования предлагаемых Компанией услуг. Компания заботится о защите персональных данных. Персональные данные, собранные Компанией в контексте настоящего документа, подлежат автоматизированной обработке в соответствии с действующим законодательством. Вся информация, собранная в рамках предоставления услуги, регистрируется Компанией, которая является контролером данных. Это очень важно для функционирования предлагаемых Компанией услуг.
Для осуществления одного или нескольких своих прав Пользователь должен предоставить документ, удостоверяющий личность, и связаться с лицом, ответственным за защиту данных в Компании (через сервисную поддержку в Telegram: [@Erudit_GameBot](https://t.me/Erudit_GameBot), либо отправьте нам свой запрос в письменном виде по адресу: 236020, г. Калининград, мкр. Прибрежный, ул. Парковая, д. 1, получатель — Денисов Илья Аркадьевич). Для осуществления одного или нескольких своих прав Пользователь должен предоставить документ, удостоверяющий личность, и связаться с лицом, ответственным за защиту данных в Компании (через сервисную поддержку в Telegram: [@Erudit_GameBot](https://telegram.me/Erudit_GameBot), либо отправьте нам свой запрос в письменном виде по адресу: 236020, г. Калининград, мкр. Прибрежный, ул. Парковая, д. 1, получатель — Денисов Илья Аркадьевич).
В случае возникновения жалобы Пользователь может обратиться в надзорный орган по защите персональных данных страны своего проживания. В случае возникновения жалобы Пользователь может обратиться в надзорный орган по защите персональных данных страны своего проживания.
@@ -247,7 +247,7 @@
**15.8.** Компания оставляет за собой право пересматривать условия настоящего Лицензионного соглашения, в частности, Правила Игры и/или Правила форума, обновляя Лицензионное соглашение на странице [`erudit-game.ru/eula/`](/eula/) или уведомляя Пользователя по электронной почте. Пересмотренное Лицензионное соглашение вступает в силу со дня его опубликования. Пользователю рекомендуется периодически проверять вышеуказанный веб-сайт на наличие уведомлений о таких изменениях. Отказ Пользователя от действий по ознакомлению не может служить основанием для невыполнения обязательств Пользователя и несоблюдения Пользователем ограничений, установленных настоящим Лицензионным соглашением. Продолжение использования Игры Пользователем считается принятием любых пересмотренных условий. **15.8.** Компания оставляет за собой право пересматривать условия настоящего Лицензионного соглашения, в частности, Правила Игры и/или Правила форума, обновляя Лицензионное соглашение на странице [`erudit-game.ru/eula/`](/eula/) или уведомляя Пользователя по электронной почте. Пересмотренное Лицензионное соглашение вступает в силу со дня его опубликования. Пользователю рекомендуется периодически проверять вышеуказанный веб-сайт на наличие уведомлений о таких изменениях. Отказ Пользователя от действий по ознакомлению не может служить основанием для невыполнения обязательств Пользователя и несоблюдения Пользователем ограничений, установленных настоящим Лицензионным соглашением. Продолжение использования Игры Пользователем считается принятием любых пересмотренных условий.
**15.9.** По вопросам, связанным с исполнением настоящего Лицензионного соглашения и/или использованием Игры, Пользователь может связаться с Компанией через Telegram: [@Erudit_GameBot](https://t.me/Erudit_GameBot) и по адресу ilia.denissov@gmail.com. **15.9.** По вопросам, связанным с исполнением настоящего Лицензионного соглашения и/или использованием Игры, Пользователь может связаться с Компанией через Telegram: [@Erudit_GameBot](https://telegram.me/Erudit_GameBot) и по адресу ilia.denissov@gmail.com.
Денисов И.А. | 2026 Денисов И.А. | 2026
@@ -312,7 +312,7 @@
**5.4.** Пользователю запрещается пытаться извлечь выгоду из преднамеренного (или неоднократного) участия в игровом процессе в составе игровой группы (команды) с другими Пользователями, нарушившими пункты 5.1, 5.5 и/или 5.6 Правил Игры. **5.4.** Пользователю запрещается пытаться извлечь выгоду из преднамеренного (или неоднократного) участия в игровом процессе в составе игровой группы (команды) с другими Пользователями, нарушившими пункты 5.1, 5.5 и/или 5.6 Правил Игры.
**5.5.** Пользователю запрещается использовать и распространять информацию, призывать к использованию и публично распространять любые ошибки, как внутри Игры, так и в любом другом программном обеспечении. Пользователь, обнаруживший такие ошибки в Игре, должен прекратить её использование и сообщить о них Компании через Telegram: [@Erudit_GameBot](https://t.me/Erudit_GameBot) и по адресу ilia.denissov@gmail.com, подробно и достоверно изложив все обстоятельства такого обнаружения и использования. В случае возникновения у Пользователя каких-либо сомнений в том, является ли такое функционирование какого-либо конкретного игрового процесса, Внутриигровых предметов или Внутриигровой валюты в настоящий момент нормальным или имеет отклонения, сбои, ошибки, Пользователь должен прекратить использование таких процесса, Внутриигровых предметов или Внутриигровой валюты и обратиться к Компании через ilia.denissov@gmail.com для получения соответствующих разъяснений. **5.5.** Пользователю запрещается использовать и распространять информацию, призывать к использованию и публично распространять любые ошибки, как внутри Игры, так и в любом другом программном обеспечении. Пользователь, обнаруживший такие ошибки в Игре, должен прекратить её использование и сообщить о них Компании через Telegram: [@Erudit_GameBot](https://telegram.me/Erudit_GameBot) и по адресу ilia.denissov@gmail.com, подробно и достоверно изложив все обстоятельства такого обнаружения и использования. В случае возникновения у Пользователя каких-либо сомнений в том, является ли такое функционирование какого-либо конкретного игрового процесса, Внутриигровых предметов или Внутриигровой валюты в настоящий момент нормальным или имеет отклонения, сбои, ошибки, Пользователь должен прекратить использование таких процесса, Внутриигровых предметов или Внутриигровой валюты и обратиться к Компании через ilia.denissov@gmail.com для получения соответствующих разъяснений.
**5.6.** Пользователю запрещается декомпилировать, декодировать и реконструировать данные, обходить системы защиты данных, взламывать или пытаться взломать программные компоненты Игры или её сервисов, и/или перехватывать данные, поступающие на сервер или с него. Запрещается: (в частности) любое модифицирование, изменение, декомпиляция, декодирование, продажа или распространение модифицированных материалов Игры в целом или по частям (или средств и материалов, необходимых для выполнения таких действий), использование ошибок программирования, внесение изменений в программный код и получение несанкционированного доступа к серверу и базе данных Игры. В определённых особых случаях Компания имеет право немедленно приостановить доступ Пользователя к Игре и направить запрос в соответствующие органы о предотвращении любого нарушения Лицензионного соглашения и/или положений применимого законодательства. **5.6.** Пользователю запрещается декомпилировать, декодировать и реконструировать данные, обходить системы защиты данных, взламывать или пытаться взломать программные компоненты Игры или её сервисов, и/или перехватывать данные, поступающие на сервер или с него. Запрещается: (в частности) любое модифицирование, изменение, декомпиляция, декодирование, продажа или распространение модифицированных материалов Игры в целом или по частям (или средств и материалов, необходимых для выполнения таких действий), использование ошибок программирования, внесение изменений в программный код и получение несанкционированного доступа к серверу и базе данных Игры. В определённых особых случаях Компания имеет право немедленно приостановить доступ Пользователя к Игре и направить запрос в соответствующие органы о предотвращении любого нарушения Лицензионного соглашения и/или положений применимого законодательства.
@@ -350,7 +350,7 @@
**8.2.** Пользователю запрещается предлагать такие аргументы, как «в соответствии с ролью»/«отыгрыш роли», в защиту неправомерных действий любого рода. **8.2.** Пользователю запрещается предлагать такие аргументы, как «в соответствии с ролью»/«отыгрыш роли», в защиту неправомерных действий любого рода.
**8.3.** Пользователю запрещается преднамеренно предоставлять ложную информацию в случае обращения к Компании через Telegram: [@Erudit_GameBot](https://t.me/Erudit_GameBot) или по адресу ilia.denissov@gmail.com, а также фальсифицировать предоставленные данные. **8.3.** Пользователю запрещается преднамеренно предоставлять ложную информацию в случае обращения к Компании через Telegram: [@Erudit_GameBot](https://telegram.me/Erudit_GameBot) или по адресу ilia.denissov@gmail.com, а также фальсифицировать предоставленные данные.
--- ---
@@ -406,7 +406,7 @@
**3.9.** Торговля (обсуждение торговли) игровыми персонажами, Внутриигровыми предметами, Внутриигровой валютой, учётными записями на форумах, повышение уровня персонажа. **3.9.** Торговля (обсуждение торговли) игровыми персонажами, Внутриигровыми предметами, Внутриигровой валютой, учётными записями на форумах, повышение уровня персонажа.
**3.10.** Обсуждение уязвимостей или недоработок Игры, а также любые действия, каким-либо образом нарушающие Правила Игры, или их обсуждение. При обнаружении уязвимостей или недоработок Пользователь должен сообщить об этом Компании через Telegram: [@Erudit_GameBot](https://t.me/Erudit_GameBot) или по адресу ilia.denissov@gmail.com. **3.10.** Обсуждение уязвимостей или недоработок Игры, а также любые действия, каким-либо образом нарушающие Правила Игры, или их обсуждение. При обнаружении уязвимостей или недоработок Пользователь должен сообщить об этом Компании через Telegram: [@Erudit_GameBot](https://telegram.me/Erudit_GameBot) или по адресу ilia.denissov@gmail.com.
**3.11.** Публикация сообщений, вызывающих негативные последствия для игрового процесса; провокация нарушения Пользователями Правил Игры. **3.11.** Публикация сообщений, вызывающих негативные последствия для игрового процесса; провокация нарушения Пользователями Правил Игры.
+1 -1
View File
@@ -152,4 +152,4 @@ Disputes or disagreements on which the Parties have not reached agreement are su
Ilya Arkadyevich Denisov, TIN 290210610742. Ilya Arkadyevich Denisov, TIN 290210610742.
Feedback on Telegram: [@Erudit_GameBot](https://t.me/Erudit_GameBot). Feedback on Telegram: [@Erudit_GameBot](https://telegram.me/Erudit_GameBot).
+1 -1
View File
@@ -152,4 +152,4 @@
Денисов Илья Аркадьевич, ИНН 290210610742. Денисов Илья Аркадьевич, ИНН 290210610742.
Обратная связь в Telegram: [@Erudit_GameBot](https://t.me/Erudit_GameBot). Обратная связь в Telegram: [@Erudit_GameBot](https://telegram.me/Erudit_GameBot).
+1 -1
View File
@@ -133,7 +133,7 @@ The transfer of personal data to recipients (regardless of their legal status) i
## 11. Contact us ## 11. Contact us
**11.1.** If you have any questions, please contact Support on Telegram: [@Erudit_GameBot](https://t.me/Erudit_GameBot), or send us your request in writing to: 236020, Kaliningrad, Pribrezhny district, Parkovaya St. 1, recipient — Ilya Arkadyevich Denisov. Please refer to this Privacy Policy so that we can process your request effectively. We will try to respond to you within 30 days of receiving your request. **11.1.** If you have any questions, please contact Support on Telegram: [@Erudit_GameBot](https://telegram.me/Erudit_GameBot), or send us your request in writing to: 236020, Kaliningrad, Pribrezhny district, Parkovaya St. 1, recipient — Ilya Arkadyevich Denisov. Please refer to this Privacy Policy so that we can process your request effectively. We will try to respond to you within 30 days of receiving your request.
**11.2.** All correspondence we receive from you (written or electronic requests) is classified as restricted-access information and may not be disclosed without your written consent. Personal data and other information about you may not be used without your consent for any purpose other than responding to the request, except in cases expressly provided for by law. **11.2.** All correspondence we receive from you (written or electronic requests) is classified as restricted-access information and may not be disclosed without your written consent. Personal data and other information about you may not be used without your consent for any purpose other than responding to the request, except in cases expressly provided for by law.
+1 -1
View File
@@ -133,7 +133,7 @@
## 11. Связаться с нами ## 11. Связаться с нами
**11.1.** Если у вас есть какие-либо вопросы, пожалуйста, свяжитесь со Службой поддержки в Telegram: [@Erudit_GameBot](https://t.me/Erudit_GameBot), либо отправьте нам свой запрос в письменном виде по адресу: 236020, г. Калининград, мкр. Прибрежный, ул. Парковая, д. 1, получатель — Денисов Илья Аркадьевич. Пожалуйста, ссылайтесь на настоящую Политику конфиденциальности, чтобы мы смогли эффективно обработать ваш запрос. Мы постараемся ответить вам в течение 30 дней с момента получения запроса. **11.1.** Если у вас есть какие-либо вопросы, пожалуйста, свяжитесь со Службой поддержки в Telegram: [@Erudit_GameBot](https://telegram.me/Erudit_GameBot), либо отправьте нам свой запрос в письменном виде по адресу: 236020, г. Калининград, мкр. Прибрежный, ул. Парковая, д. 1, получатель — Денисов Илья Аркадьевич. Пожалуйста, ссылайтесь на настоящую Политику конфиденциальности, чтобы мы смогли эффективно обработать ваш запрос. Мы постараемся ответить вам в течение 30 дней с момента получения запроса.
**11.2.** Вся полученная нами от вас корреспонденция (письменные или электронные запросы) классифицируется как информация с ограниченным доступом и не может быть разглашена без вашего письменного согласия. Персональные данные и другая информация о вас не могут быть использованы без вашего согласия для каких-либо целей, кроме как для ответа на запрос, за исключением случаев, прямо предусмотренных законом. **11.2.** Вся полученная нами от вас корреспонденция (письменные или электронные запросы) классифицируется как информация с ограниченным доступом и не может быть разглашена без вашего письменного согласия. Персональные данные и другая информация о вас не могут быть использованы без вашего согласия для каких-либо целей, кроме как для ответа на запрос, за исключением случаев, прямо предусмотренных законом.
+1 -1
View File
@@ -134,7 +134,7 @@
<span class="sep" aria-hidden="true">|</span> <span class="sep" aria-hidden="true">|</span>
<!-- The feedback bot: the Telegram contact the legal documents list as the seller's contact; <!-- The feedback bot: the Telegram contact the legal documents list as the seller's contact;
external, opens in a new tab. --> external, opens in a new tab. -->
<a class="doc" href="https://t.me/Erudit_GameBot" target="_blank" rel="noopener noreferrer">{t('landing.feedback')}</a> <a class="doc" href="https://telegram.me/Erudit_GameBot" target="_blank" rel="noopener noreferrer">{t('landing.feedback')}</a>
</span> </span>
<span>{t('about.version', { v: __APP_VERSION__ })}</span> <span>{t('about.version', { v: __APP_VERSION__ })}</span>
</footer> </footer>
+1 -1
View File
@@ -15,7 +15,7 @@
function openBot() { function openBot() {
if (username) { if (username) {
const url = `https://t.me/${username}`; const url = `https://telegram.me/${username}`;
// Inside Telegram, openTelegramLink navigates to the bot chat natively; elsewhere fall // Inside Telegram, openTelegramLink navigates to the bot chat natively; elsewhere fall
// back to a new tab (routed out of the Android VK WebView by openExternalUrl). // back to a new tab (routed out of the Android VK WebView by openExternalUrl).
if (!telegramOpenLink(url)) openExternalUrl(url); if (!telegramOpenLink(url)) openExternalUrl(url);
+1 -1
View File
@@ -18,7 +18,7 @@
function openBot() { function openBot() {
if (username) { if (username) {
const url = `https://t.me/${username}`; const url = `https://telegram.me/${username}`;
// Inside Telegram, openTelegramLink navigates to the bot chat natively; elsewhere fall // Inside Telegram, openTelegramLink navigates to the bot chat natively; elsewhere fall
// back to a new tab (routed out of the Android VK WebView by openExternalUrl). // back to a new tab (routed out of the Android VK WebView by openExternalUrl).
if (!telegramOpenLink(url)) openExternalUrl(url); if (!telegramOpenLink(url)) openExternalUrl(url);
+44 -7
View File
@@ -2,11 +2,13 @@
import { onMount } from 'svelte'; import { onMount } from 'svelte';
import { gateway } from '../lib/gateway'; import { gateway } from '../lib/gateway';
import { gameSource, isLocalGameId } from '../lib/gamesource'; import { gameSource, isLocalGameId } from '../lib/gamesource';
import { connection } from '../lib/connection.svelte';
import { handleError, showToast } from '../lib/app.svelte'; import { handleError, showToast } from '../lib/app.svelte';
import { t } from '../lib/i18n/index.svelte'; import { t } from '../lib/i18n/index.svelte';
import { alphabetLetters } from '../lib/alphabet'; import { alphabetLetters } from '../lib/alphabet';
import { canCheckWord, dictionaryLookupUrl, sanitizeCheckWord } from '../lib/checkword'; import { canCheckWord, dictionaryLookupUrl, sanitizeCheckWord } from '../lib/checkword';
import { onExternalLinkClick } from '../lib/links'; import { onExternalLinkClick } from '../lib/links';
import { getCachedGame } from '../lib/gamecache';
import type { Variant } from '../lib/model'; import type { Variant } from '../lib/model';
// Word-check on its own screen: unlimited dictionary lookups, each with a // Word-check on its own screen: unlimited dictionary lookups, each with a
@@ -14,25 +16,40 @@
let { id }: { id: string } = $props(); let { id }: { id: string } = $props();
let variant = $state<Variant>('scrabble_en'); let variant = $state<Variant>('scrabble_en');
let dictVersion = $state('');
let word = $state(''); let word = $state('');
let result = $state<{ word: string; legal: boolean } | null>(null); let result = $state<{ word: string; legal: boolean } | null>(null);
// unavailable is set when an offline check cannot run because the game's pinned dictionary is not
// on the device (not cached/bundled); the panel then shows a neutral "offline" note.
let unavailable = $state(false);
let cooling = $state(false); let cooling = $state(false);
const checked = new Map<string, boolean>(); const checked = new Map<string, boolean>();
onMount(async () => { onMount(async () => {
// Seed the variant + pinned version from the cached game first (present once the board has opened,
// and preloaded for ongoing games): an online game's dictionary must keep working while offline,
// where the gameState call below cannot run. The variant's alphabet is already cached from opening
// the board, so input sanitising and the offline dawg fallback work off the seed alone.
const cached = getCachedGame(id)?.view.game;
if (cached) {
variant = cached.variant;
dictVersion = cached.dictVersion;
}
try { try {
// Include the alphabet so input sanitising + the check accept the variant's letters. Routed // Refresh over the network (and cache the alphabet on a cold deep-link that skipped the board).
// through gameSource so an offline (local) game resolves its state from the device, not the
// network.
const st = await gameSource(id).gameState(id, true); const st = await gameSource(id).gameState(id, true);
variant = st.game.variant; variant = st.game.variant;
dictVersion = st.game.dictVersion;
} catch (e) { } catch (e) {
handleError(e); // Offline or a transient failure: the cache seed already set variant + version, so only surface
// an error when there was nothing cached to fall back to.
if (!cached) handleError(e);
} }
}); });
function onInput(e: Event) { function onInput(e: Event) {
word = sanitizeCheckWord((e.target as HTMLInputElement).value, alphabetLetters(variant)); word = sanitizeCheckWord((e.target as HTMLInputElement).value, alphabetLetters(variant));
unavailable = false;
} }
// Disabled while cooling, for an already-checked word, or an out-of-range length. // Disabled while cooling, for an already-checked word, or an out-of-range length.
function canCheck(): boolean { function canCheck(): boolean {
@@ -43,7 +60,25 @@
const w = word.trim().toUpperCase(); const w = word.trim().toUpperCase();
cooling = true; cooling = true;
setTimeout(() => (cooling = false), 5000); setTimeout(() => (cooling = false), 5000);
unavailable = false;
try { try {
// Offline in an online game the network check_word would fail, so fall back to the game's own
// pinned dictionary read on-device: exact when that dawg is cached/bundled, otherwise the check
// is unavailable. A local game already resolves checkWord on-device, so it keeps that path.
if (!isLocalGameId(id) && !connection.online) {
// Dynamically imported so the dawg reader/loader stays out of the app entry bundle (the dict
// subsystem is lazy everywhere else too); it loads only when an offline check actually runs.
const { localWordCheck } = await import('../lib/dict/check');
const legal = await localWordCheck(variant, dictVersion, w);
if (legal === null) {
result = null;
unavailable = true;
return;
}
checked.set(w, legal);
result = { word: w, legal };
return;
}
const r = await gameSource(id).checkWord(id, w, variant); const r = await gameSource(id).checkWord(id, w, variant);
checked.set(w, r.legal); checked.set(w, r.legal);
result = { word: w, legal: r.legal }; result = { word: w, legal: r.legal };
@@ -73,7 +108,9 @@
/> />
<button onclick={runCheck} disabled={!canCheck()}>{t('game.check')}</button> <button onclick={runCheck} disabled={!canCheck()}>{t('game.check')}</button>
</div> </div>
{#if result} {#if unavailable}
<p class="verdict">{t('game.checkOffline')}</p>
{:else if result}
<p class="verdict" class:ok={result.legal} class:bad={!result.legal}> <p class="verdict" class:ok={result.legal} class:bad={!result.legal}>
{result.legal {result.legal
? t('game.wordLegal', { word: result.word }) ? t('game.wordLegal', { word: result.word })
@@ -82,10 +119,10 @@
<div class="actions"> <div class="actions">
<!-- Complaints go to the admin over the network; a local (offline) game has no backend to <!-- Complaints go to the admin over the network; a local (offline) game has no backend to
receive them, so the control is dropped there. --> receive them, so the control is dropped there. -->
{#if !isLocalGameId(id)} {#if !isLocalGameId(id) && connection.online}
<button class="complain" onclick={complain}>{t('game.complain')}</button> <button class="complain" onclick={complain}>{t('game.complain')}</button>
{/if} {/if}
{#if result.legal} {#if result.legal && connection.online}
<a <a
class="lookup" class="lookup"
href={dictionaryLookupUrl(result.word, variant)} href={dictionaryLookupUrl(result.word, variant)}
+27 -2
View File
@@ -15,6 +15,7 @@
import { app, handleError, showToast, markChatRead, seedChatUnread } from '../lib/app.svelte'; import { app, handleError, showToast, markChatRead, seedChatUnread } from '../lib/app.svelte';
import { connection } from '../lib/connection.svelte'; import { connection } from '../lib/connection.svelte';
import { offlineMode } from '../lib/offline.svelte'; import { offlineMode } from '../lib/offline.svelte';
import { netState } from '../lib/netstate.svelte';
import { maybeShowInterstitial } from '../lib/ads'; import { maybeShowInterstitial } from '../lib/ads';
import { GatewayError } from '../lib/client'; import { GatewayError } from '../lib/client';
import { t, type MessageKey } from '../lib/i18n/index.svelte'; import { t, type MessageKey } from '../lib/i18n/index.svelte';
@@ -63,6 +64,10 @@
// disabled while disconnected or in offline mode (which also stops the "something went wrong" // disabled while disconnected or in offline mode (which also stops the "something went wrong"
// toasts a blocked call would otherwise raise). // toasts a blocked call would otherwise raise).
const netReady = $derived(isLocalGameId(id) || (connection.online && !offlineMode.active)); const netReady = $derived(isLocalGameId(id) || (connection.online && !offlineMode.active));
// offlineInGame marks an online game that has lost the connection (confirmed offline, on any
// platform including Telegram/VK): the play area shows an explicit "connection lost" banner and
// the tray/actions freeze. It follows netState (not offlineMode, which is inert in the mini-apps).
const offlineInGame = $derived(!isLocalGameId(id) && netState.offline);
// Unsubscribes from a local game's robot-reply events (offline only; null for a network game). // Unsubscribes from a local game's robot-reply events (offline only; null for a network game).
let localUnsub: (() => void) | null = null; let localUnsub: (() => void) | null = null;
@@ -1463,6 +1468,7 @@
// a local (offline) game, whose seats are account-less: vs_ai, and hotseat's synthetic seat ids. // a local (offline) game, whose seats are account-less: vs_ai, and hotseat's synthetic seat ids.
if (view?.game.vsAi || isLocalGameId(id)) return false; if (view?.game.vsAi || isLocalGameId(id)) return false;
return ( return (
netReady &&
!!s.accountId && !!s.accountId &&
!app.profile?.isGuest && !app.profile?.isGuest &&
s.accountId !== app.session?.userId && s.accountId !== app.session?.userId &&
@@ -1476,7 +1482,7 @@
// An already-blocked opponent hides it (both controls go, and the name is struck). // An already-blocked opponent hides it (both controls go, and the name is struck).
function canBlock(s: { accountId: string; seat: number }): boolean { function canBlock(s: { accountId: string; seat: number }): boolean {
if (view?.game.vsAi || isLocalGameId(id)) return false; if (view?.game.vsAi || isLocalGameId(id)) return false;
return !!s.accountId && !app.profile?.isGuest && s.accountId !== app.session?.userId && !seatBlocked(s); return netReady && !!s.accountId && !app.profile?.isGuest && s.accountId !== app.session?.userId && !seatBlocked(s);
} }
</script> </script>
@@ -1490,6 +1496,9 @@
selfInset={landscape} selfInset={landscape}
> >
{#if view} {#if view}
{#if offlineInGame}
<div class="offline-banner" role="status">{t('game.offlineBanner')}</div>
{/if}
{#if landscape} {#if landscape}
<div class="game-land"> <div class="game-land">
<div class="leftpane"> <div class="leftpane">
@@ -1598,6 +1607,10 @@
{:else if view.game.hotseat} {:else if view.game.hotseat}
<!-- Hotseat resign is a host (referee) action, not self-serve; keep the slot empty. --> <!-- Hotseat resign is a host (referee) action, not self-serve; keep the slot empty. -->
<span aria-hidden="true"></span> <span aria-hidden="true"></span>
{:else if !netReady}
<!-- Offline: resigning needs the server, so hide the control (like the frozen social controls);
an empty slot keeps the header's space-between layout. -->
<span aria-hidden="true"></span>
{:else} {:else}
<button class="hicon" onclick={onResignClick} disabled={waitingForOpponent} aria-label={t('game.dropGame')}>🏁</button> <button class="hicon" onclick={onResignClick} disabled={waitingForOpponent} aria-label={t('game.dropGame')}>🏁</button>
{/if} {/if}
@@ -1606,7 +1619,7 @@
chat, so its hub is Dictionary-only (CommsHub), and once finished the dictionary closes too chat, so its hub is Dictionary-only (CommsHub), and once finished the dictionary closes too
— so drop the entry only when a local game is finished; an active local game keeps it for — so drop the entry only when a local game is finished; an active local game keeps it for
the Dictionary. An online game always keeps it (chat outlives the game). --> the Dictionary. An online game always keeps it (chat outlives the game). -->
{#if !(gameOver && (view.game.vsAi || view.game.hotseat))} {#if netReady && !(gameOver && (view.game.vsAi || view.game.hotseat))}
<button class="hicon" onclick={() => navigate(`/game/${id}/chat`)} aria-label={t('game.chat')}> <button class="hicon" onclick={() => navigate(`/game/${id}/chat`)} aria-label={t('game.chat')}>
{#key chatBlink}<span class="chat-ico" class:blink={chatBlink > 0 && !app.reduceMotion}>💬</span>{/key} {#key chatBlink}<span class="chat-ico" class:blink={chatBlink > 0 && !app.reduceMotion}>💬</span>{/key}
</button> </button>
@@ -1694,6 +1707,7 @@
draggingId={reorderDragId} draggingId={reorderDragId}
dropIndex={reorderTo} dropIndex={reorderTo}
confirm={!gameOver && placement.pending.length > 0 && !recallOverRack ? confirmBtn : undefined} confirm={!gameOver && placement.pending.length > 0 && !recallOverRack ? confirmBtn : undefined}
frozen={!netReady}
ondown={onRackDown} ondown={onRackDown}
/> />
{/if} {/if}
@@ -2178,6 +2192,17 @@
color: var(--text-muted); color: var(--text-muted);
padding: 40px; padding: 40px;
} }
/* An online game that lost the connection: a slim, non-blocking strip at the top of the play area.
A solid token background (not color-mix) keeps it visible on the old-WebView floor. */
.offline-banner {
padding: 7px var(--pad);
text-align: center;
font-size: 0.85rem;
line-height: 1.25;
color: var(--text);
background: var(--surface-2);
border-bottom: 1px solid var(--border);
}
.ghost { .ghost {
position: fixed; position: fixed;
width: 40px; width: 40px;
+10
View File
@@ -13,6 +13,7 @@
shuffling = false, shuffling = false,
draggingId = null, draggingId = null,
dropIndex = null, dropIndex = null,
frozen = false,
confirm, confirm,
ondown, ondown,
}: { }: {
@@ -26,6 +27,10 @@
// the drag ghost stands in) and dropIndex is the slot where a gap opens. // the drag ghost stands in) and dropIndex is the slot where a gap opens.
draggingId?: number | null; draggingId?: number | null;
dropIndex?: number | null; dropIndex?: number | null;
/** frozen disables tile interaction (offline in an online game): tiles cannot be picked up to
* compose or reorder a move until the connection returns. The confirm control is gated
* separately by the parent. */
frozen?: boolean;
/** The confirm-move control, rendered as the fixed 7th slot of the rack while a play is staged /** The confirm-move control, rendered as the fixed 7th slot of the rack while a play is staged
* (the parent owns the button and its enablement; the rack only positions it). */ * (the parent owns the button and its enablement; the rack only positions it). */
confirm?: Snippet; confirm?: Snippet;
@@ -65,6 +70,7 @@
class:selected={selected === slot.index} class:selected={selected === slot.index}
class:shift={dropIndex != null && i >= dropIndex} class:shift={dropIndex != null && i >= dropIndex}
data-rack-index={slot.index} data-rack-index={slot.index}
disabled={frozen}
animate:hop={shuffling} animate:hop={shuffling}
onpointerdown={(e) => ondown(e, slot.index)} onpointerdown={(e) => ondown(e, slot.index)}
> >
@@ -114,6 +120,10 @@
outline: 3px solid var(--accent); outline: 3px solid var(--accent);
outline-offset: -3px; outline-offset: -3px;
} }
/* Frozen (offline in an online game): the tray is inert until the connection returns. */
.tile:disabled {
opacity: 0.5;
}
/* While reordering, tiles at/after the drop slot slide right to open a gap there (one /* While reordering, tiles at/after the drop slot slide right to open a gap there (one
tile width plus the rack gap), so the drop position is visible. */ tile width plus the rack gap), so the drop position is visible. */
.rack.reordering .tile { .rack.reordering .tile {
+3 -3
View File
@@ -32,8 +32,8 @@ describe('shareLink', () => {
}); });
it('wraps a payload in a startapp link', () => { it('wraps a payload in a startapp link', () => {
vi.stubEnv('VITE_TELEGRAM_LINK', 'https://t.me/bot/app'); vi.stubEnv('VITE_TELEGRAM_LINK', 'https://telegram.me/bot/app');
expect(shareLink('f123456')).toBe('https://t.me/bot/app?startapp=f123456'); expect(shareLink('f123456')).toBe('https://telegram.me/bot/app?startapp=f123456');
}); });
}); });
@@ -60,7 +60,7 @@ describe('botUsername', () => {
}); });
it('extracts the bot handle from the Mini App link', () => { it('extracts the bot handle from the Mini App link', () => {
vi.stubEnv('VITE_TELEGRAM_LINK', 'https://t.me/bot/app'); vi.stubEnv('VITE_TELEGRAM_LINK', 'https://telegram.me/bot/app');
expect(botUsername()).toBe('bot'); expect(botUsername()).toBe('bot');
}); });
}); });
+2 -2
View File
@@ -43,7 +43,7 @@ function envVar(name: string): string | undefined {
} }
/** /**
* telegramBase returns the single bot's Mini App link base (e.g. https://t.me/<bot>/<app>) * telegramBase returns the single bot's Mini App link base (e.g. https://telegram.me/<bot>/<app>)
* from VITE_TELEGRAM_LINK, or null when it is not configured. * from VITE_TELEGRAM_LINK, or null when it is not configured.
*/ */
function telegramBase(): string | null { function telegramBase(): string | null {
@@ -52,7 +52,7 @@ function telegramBase(): string | null {
/** /**
* botUsername extracts the bot's @username (without the @) from the configured Mini App * botUsername extracts the bot's @username (without the @) from the configured Mini App
* link: the first path segment of https://t.me/<bot>/<app>. Returns null when no base is * link: the first path segment of https://telegram.me/<bot>/<app>. Returns null when no base is
* configured or the link carries no path. * configured or the link carries no path.
*/ */
export function botUsername(): string | null { export function botUsername(): string | null {
+49
View File
@@ -0,0 +1,49 @@
import { afterEach, describe, expect, it, vi } from 'vitest';
import { setAlphabet } from '../alphabet';
// getDawg is the only side-effecting dependency; stub it so the test exercises localWordCheck's
// own logic (load -> encode -> membership -> null handling) against a fake reader, not a real DAWG.
vi.mock('./loader', () => ({ getDawg: vi.fn() }));
import { getDawg } from './loader';
import { localWordCheck } from './check';
const mockedGetDawg = vi.mocked(getDawg);
// A tiny cached alphabet so indexForLetter can encode the test words.
setAlphabet('scrabble_en', [
{ index: 0, letter: 'A', value: 1 },
{ index: 1, letter: 'B', value: 3 },
{ index: 2, letter: 'C', value: 3 },
]);
describe('localWordCheck', () => {
afterEach(() => vi.clearAllMocks());
it('returns true when the dawg contains the word', async () => {
mockedGetDawg.mockResolvedValue({ indexOf: () => 5 } as never);
expect(await localWordCheck('scrabble_en', 'v1', 'AB')).toBe(true);
});
it('returns false when the dawg does not contain the word', async () => {
mockedGetDawg.mockResolvedValue({ indexOf: () => -1 } as never);
expect(await localWordCheck('scrabble_en', 'v1', 'AB')).toBe(false);
});
it('returns null when the dawg is unavailable (offline and uncached)', async () => {
mockedGetDawg.mockResolvedValue(null);
expect(await localWordCheck('scrabble_en', 'v1', 'AB')).toBe(null);
});
it('encodes the word to alphabet indices for the membership test', async () => {
const indexOf = vi.fn(() => 0);
mockedGetDawg.mockResolvedValue({ indexOf } as never);
await localWordCheck('scrabble_en', 'v1', 'CAB');
expect(indexOf).toHaveBeenCalledWith([2, 0, 1]);
});
it('loads the dawg for the pinned (variant, version)', async () => {
mockedGetDawg.mockResolvedValue({ indexOf: () => 1 } as never);
await localWordCheck('scrabble_en', 'v1.3.0', 'AB');
expect(mockedGetDawg).toHaveBeenCalledWith('scrabble_en', 'v1.3.0');
});
});
+26
View File
@@ -0,0 +1,26 @@
// Offline word-check fallback for an online game. An online game's dictionary check normally goes to
// the gateway (game.check_word); while disconnected that call fails, so the check falls back to the
// game's own pinned dictionary read locally. Using the game's pinned (variant, version) means the
// answer matches the server's — no divergence — as long as that dawg is available on-device; if it
// is not cached/bundled offline, the check is simply unavailable (null). The membership test mirrors
// the local engine's dictionaryHas (encode to alphabet indices, then Dawg.indexOf).
import { getDawg } from './loader';
import { indexForLetter } from '../alphabet';
import type { Variant } from '../model';
/**
* localWordCheck reports whether word is in the (variant, version) dictionary using the on-device
* dawg, or null when that dawg cannot be obtained (offline and neither cached nor bundled). word
* must already be sanitised to the variant's alphabet (as the check panel does).
*/
export async function localWordCheck(
variant: Variant,
version: string,
word: string,
): Promise<boolean | null> {
const dawg = await getDawg(variant, version);
if (!dawg) return null;
const idx = Array.from(word, (ch) => indexForLetter(variant, ch));
return dawg.indexOf(idx) >= 0;
}
+2
View File
@@ -133,6 +133,8 @@ export const en = {
'game.complaintSent': 'Thanks, sent for review.', 'game.complaintSent': 'Thanks, sent for review.',
'game.check': 'Check', 'game.check': 'Check',
'game.checkWait': 'Please wait a moment.', 'game.checkWait': 'Please wait a moment.',
'game.checkOffline': 'Word check is unavailable offline.',
'game.offlineBanner': 'Connection lost — your move will send once youre back online.',
'game.noHintOptions': 'No options with your letters.', 'game.noHintOptions': 'No options with your letters.',
'game.thinking': 'thinking…', 'game.thinking': 'thinking…',
+2
View File
@@ -133,6 +133,8 @@ export const ru: Record<MessageKey, string> = {
'game.complaintSent': 'Спасибо, отправлено на проверку.', 'game.complaintSent': 'Спасибо, отправлено на проверку.',
'game.check': 'Проверить', 'game.check': 'Проверить',
'game.checkWait': 'Секунду, пожалуйста.', 'game.checkWait': 'Секунду, пожалуйста.',
'game.checkOffline': 'Проверка недоступна офлайн.',
'game.offlineBanner': 'Связь потеряна — ход отправится, когда вернётся сеть.',
'game.noHintOptions': 'Нет вариантов с вашим набором.', 'game.noHintOptions': 'Нет вариантов с вашим набором.',
'game.thinking': 'думает…', 'game.thinking': 'думает…',
+1 -1
View File
@@ -6,7 +6,7 @@ describe('telegramChannelLink', () => {
it('builds the t.me link from the channel name', () => { it('builds the t.me link from the channel name', () => {
vi.stubEnv('VITE_TELEGRAM_GAME_CHANNEL_NAME', '@Erudit_Game'); // a leading @ is tolerated vi.stubEnv('VITE_TELEGRAM_GAME_CHANNEL_NAME', '@Erudit_Game'); // a leading @ is tolerated
expect(telegramChannelLink()).toBe('https://t.me/Erudit_Game'); expect(telegramChannelLink()).toBe('https://telegram.me/Erudit_Game');
}); });
it('returns null when the channel name is unset or blank', () => { it('returns null when the channel name is unset or blank', () => {
+1 -1
View File
@@ -11,7 +11,7 @@
export function telegramChannelLink(): string | null { export function telegramChannelLink(): string | null {
const raw = import.meta.env.VITE_TELEGRAM_GAME_CHANNEL_NAME; const raw = import.meta.env.VITE_TELEGRAM_GAME_CHANNEL_NAME;
const name = (raw as string | undefined)?.trim().replace(/^@/, ''); const name = (raw as string | undefined)?.trim().replace(/^@/, '');
return name ? `https://t.me/${name}` : null; return name ? `https://telegram.me/${name}` : null;
} }
/** /**
+4 -4
View File
@@ -13,9 +13,9 @@ describe('openExternalUrl', () => {
origin: 'https://app.example', origin: 'https://app.example',
}); });
vi.stubGlobal('window', { open }); vi.stubGlobal('window', { open });
openExternalUrl('https://t.me/some_bot'); openExternalUrl('https://telegram.me/some_bot');
expect(open).toHaveBeenCalledWith( expect(open).toHaveBeenCalledWith(
`https://vk.com/away.php?to=${encodeURIComponent('https://t.me/some_bot')}`, `https://vk.com/away.php?to=${encodeURIComponent('https://telegram.me/some_bot')}`,
'_blank', '_blank',
); );
}); });
@@ -23,7 +23,7 @@ describe('openExternalUrl', () => {
it('opens a plain new tab elsewhere', () => { it('opens a plain new tab elsewhere', () => {
const open = vi.fn(); const open = vi.fn();
vi.stubGlobal('window', { open }); vi.stubGlobal('window', { open });
openExternalUrl('https://t.me/some_bot'); openExternalUrl('https://telegram.me/some_bot');
expect(open).toHaveBeenCalledWith('https://t.me/some_bot', '_blank'); expect(open).toHaveBeenCalledWith('https://telegram.me/some_bot', '_blank');
}); });
}); });
+1 -1
View File
@@ -148,7 +148,7 @@ describe('routeExternalLinkInTelegram', () => {
it('leaves a t.me link to the native handler (openTelegramLink owns those)', () => { it('leaves a t.me link to the native handler (openTelegramLink owns those)', () => {
const openLink = stubInside(); const openLink = stubInside();
expect(routeExternalLinkInTelegram({ href: 'https://t.me/some_bot', target: '_blank' })).toBe(false); expect(routeExternalLinkInTelegram({ href: 'https://telegram.me/some_bot', target: '_blank' })).toBe(false);
expect(openLink).not.toHaveBeenCalled(); expect(openLink).not.toHaveBeenCalled();
}); });
+3 -3
View File
@@ -226,19 +226,19 @@ export function isExternalHttpUrl(href: string): boolean {
export function routeExternalLinkInTelegram(anchor: { href: string; target: string }): boolean { export function routeExternalLinkInTelegram(anchor: { href: string; target: string }): boolean {
if (!insideTelegram()) return false; if (!insideTelegram()) return false;
if (anchor.target !== '_blank') return false; if (anchor.target !== '_blank') return false;
if (anchor.href.startsWith('https://t.me/')) return false; if (anchor.href.startsWith('https://telegram.me/')) return false;
if (!isExternalHttpUrl(anchor.href)) return false; if (!isExternalHttpUrl(anchor.href)) return false;
return telegramOpenExternalLink(anchor.href); return telegramOpenExternalLink(anchor.href);
} }
/** /**
* shareTelegramLink opens Telegram's native "share to a chat" picker for url with a * shareTelegramLink opens Telegram's native "share to a chat" picker for url with a
* caption, through the Mini App SDK (https://t.me/share/url). Returns false outside * caption, through the Mini App SDK (https://telegram.me/share/url). Returns false outside
* Telegram or when the SDK lacks the method, so the caller can fall back to the Web * Telegram or when the SDK lacks the method, so the caller can fall back to the Web
* Share API. Works consistently on iOS and Android within Telegram. * Share API. Works consistently on iOS and Android within Telegram.
*/ */
export function shareTelegramLink(url: string, text: string): boolean { export function shareTelegramLink(url: string, text: string): boolean {
return telegramOpenLink(`https://t.me/share/url?url=${encodeURIComponent(url)}&text=${encodeURIComponent(text)}`); return telegramOpenLink(`https://telegram.me/share/url?url=${encodeURIComponent(url)}&text=${encodeURIComponent(text)}`);
} }
/** TelegramLaunch is the data a Mini App launch carries. */ /** TelegramLaunch is the data a Mini App launch carries. */