Compare commits

..

31 Commits

Author SHA1 Message Date
developer 3456a0b3ff Merge pull request 'release: v1.18.0 — /support command in the Telegram bot' (#252) from development into master 2026-07-13 07:39:06 +00:00
developer 93ccc8c449 Merge pull request 'feat(telegram): add /support command with support-desk info reply' (#251) from feature/telegram-support-command into development
CI / changes (push) Successful in 2s
CI / unit (push) Successful in 11s
CI / conformance (push) Successful in 9s
CI / gate (push) Successful in 0s
CI / changes (pull_request) Successful in 2s
CI / integration (push) Successful in 20s
CI / ui (push) Has been skipped
CI / deploy (push) Successful in 1m44s
CI / integration (pull_request) Successful in 27s
CI / ui (pull_request) Has been skipped
CI / unit (pull_request) Successful in 10s
CI / conformance (pull_request) Successful in 9s
CI / gate (pull_request) Successful in 0s
CI / deploy (pull_request) Has been skipped
2026-07-13 07:32:59 +00:00
Ilia Denisov e0360c98e2 feat(telegram): add /support command with support-desk info reply
CI / changes (pull_request) Successful in 2s
CI / unit (pull_request) Successful in 12s
CI / integration (pull_request) Successful in 21s
CI / ui (pull_request) Has been skipped
CI / conformance (pull_request) Successful in 11s
CI / gate (pull_request) Successful in 0s
CI / deploy (pull_request) Successful in 1m55s
Intercept /support in the main bot with a dedicated handler (registered
like /start), replying with a fixed support-desk info message: the
operators' working hours and what to include (a description and, when
possible, screenshots). The reply is Russian or English by the sender's
reported Telegram language, and the command is listed in the bot's
command menu (localized).

Being a dedicated handler, it intercepts /support before the support
relay, so the command line itself is not forwarded into an operator
topic while the user's following description still is.

Update the telegram README and FUNCTIONAL.md (+ _ru mirror).
2026-07-13 09:25:20 +02:00
developer a41281c495 Merge pull request 'release: v1.17.0 — implicit offline model + two-tier client-version gate + unified lobby' (#250) from development into master 2026-07-13 01:06:35 +00:00
developer 11f89f6477 Merge pull request 'feat(offline): implicit net-state model, two-tier version gate, unified lobby' (#249) from feature/android-native into development
CI / changes (push) Successful in 2s
CI / unit (push) Successful in 11s
CI / integration (push) Successful in 19s
CI / ui (push) Successful in 1m13s
CI / conformance (push) Successful in 10s
CI / gate (push) Successful in 0s
CI / deploy (push) Successful in 1m53s
CI / changes (pull_request) Successful in 1s
CI / unit (pull_request) Successful in 11s
CI / integration (pull_request) Successful in 19s
CI / ui (pull_request) Successful in 1m13s
CI / conformance (pull_request) Successful in 10s
CI / gate (pull_request) Successful in 0s
CI / deploy (pull_request) Has been skipped
2026-07-13 00:59:16 +00:00
Ilia Denisov 2d1fadb50c feat(offline): implicit net-state model, two-tier version gate, unified lobby
CI / changes (pull_request) Successful in 2s
CI / unit (pull_request) Successful in 10s
CI / integration (pull_request) Successful in 20s
CI / ui (pull_request) Successful in 1m12s
CI / conformance (pull_request) Successful in 10s
CI / gate (pull_request) Successful in 0s
CI / deploy (pull_request) Successful in 1m54s
Land the offline-model redesign (ANDROID_PLAN.md O1-O7): replace the explicit offline toggle with a single detected net-state machine, unify the lobby, and add a two-tier client-version gate. Contour-safe: both version vars empty => dormant, the wire change is additive, so web/pwa/vk/tg behaviour is unchanged unless the gate is deliberately configured.

O1 net-state reducer (test-first). O2 store + wiring (connection/offline shims; +@capacitor/network). O3 remove the offline toggle + migrate the pref. O4 two-tier gate: hard update_required degrades to an offline Update/Play-offline notice (not terminal); soft GATEWAY_RECOMMENDED_CLIENT_VERSION -> X-Update-Recommended nudge. O5 unified lobby (device-local + greyed-from-cache server games; self-set identity; closes G-step-0). O6 create flows (with-friends online/offline segment + offline dict guard). O7 docs.

Telegram/VK stay online-only (contour review): the offline model is channel-gated via offlineCapable() (native + plain web; false in the mini-apps). offlineMode.active is hard-gated on it, so the whole model (blue chrome, unified/greyed lobby, transport kill switch, device-local vs_ai/hotseat create) stays inert in Telegram/VK regardless of the detected net state (closing a version-lock path that could have flipped them offline); and New Game's with-friends hides the online/offline segment there, leaving the remote invite alone. ARCHITECTURE already declared "Telegram/VK are exempt" - this makes the code match.

Deploy/CI: wire the version gate through the deploy (GATEWAY_MIN_CLIENT_VERSION + GATEWAY_RECOMMENDED_CLIENT_VERSION as plain unprefixed vars via compose + ci.yaml + prod-deploy.yaml + write-prod-env.sh + .env.example) so the gate is live when set (test-contour commit-hash version fails open; empty => dormant). Disable the manual android-build CI workflow for now (rename .disabled). Bump the app bundle budget 127->130 KB for the added always-loaded wiring. Fix the UI Docker stage (gateway/Dockerfile): install --ignore-scripts so the Alpine/musl SPA build no longer tries to native-build sharp (a local android:assets tool, unused in the image); esbuild's binary is an optional dep so Vite still builds.

Tests: gateway go green (two-tier gate over a real HTTP handler); docker build of the landing + gateway targets green locally; compose --no-interpolate confirms the gateway env; two new telegram.spec.ts e2e (offline signal keeps the chrome online + quick-match opponent choice visible => server enqueue; with-friends shows no pass-and-play), RED-verified; svelte-check 0/0, vitest 617, e2e 248 (chromium + webkit), build + bundle-size 127.8/130.
2026-07-13 02:50:41 +02:00
developer 516ffbe5f0 Merge pull request 'release: v1.16.0 — offer live pricing, bot health telemetry, edge blocklist' (#247) from development into master 2026-07-11 11:56:09 +00:00
developer 6badc20078 Merge pull request 'Release v1.15.0 — in-game UX + wallet redesign + WAL-alert fix' (#243) from development into master 2026-07-10 16:15:20 +00:00
developer 0ca01133b5 Merge pull request 'Release v1.14.1 — ansible certs-dir fix + Robokassa go-live' (#239) from development into master 2026-07-10 10:58:38 +00:00
developer 45f0b34881 Merge pull request 'Release v1.14.0 — monetization launch (E5-E8)' (#237) from development into master 2026-07-10 10:11:02 +00:00
developer 18785efc8c Merge pull request 'release v1.13.0: payments wallet mechanics + database point-in-time recovery' (#220) from development into master 2026-07-08 23:42:15 +00:00
developer 780ff68ec2 Merge pull request 'release: offline mode + local pass-and-play (hotseat) — proposed v1.12.0' (#212) from development into master 2026-07-07 14:40:43 +00:00
developer 57ff2d03f8 Merge pull request 'Release v1.11.0: PWA install + code-only PWA login + email/metrics fixes' (#187) from development into master 2026-07-05 21:02:08 +00:00
developer a9d0986e74 Merge pull request 'Release v1.10.0: banner colours + urgent, and 3 UI fixes' (#183) from development into master 2026-07-05 14:10:25 +00:00
developer 45957bdcd6 Merge pull request 'Release: promote development → master (old Android WebView support + unsupported-engine telemetry)' (#178) from development into master 2026-07-04 21:23:29 +00:00
developer 829e29a726 Merge pull request 'Release v1.8.0: prod build fix (re-promote)' (#175) from development into master 2026-07-03 21:48:13 +00:00
developer 399508f2f0 Merge pull request 'Release v1.8.0: promote development → master' (#173) from development into master 2026-07-03 21:31:25 +00:00
developer 3a18e683ca Merge pull request 'release: VK Mini App + landscape UI + dict v1.3.1 seed (development→master)' (#144) from development into master 2026-06-30 05:37:43 +00:00
developer 93d086a8a3 Merge pull request 'release: v1.7.0 — Telegram Mini App embedding enhancements' (#138) from development into master 2026-06-24 11:49:44 +00:00
developer 8fe1bdba6b Merge pull request 'release: v1.6.0 — promo deep-link seeds EN variant (+ UI nits)' (#135) from development into master 2026-06-23 21:02:19 +00:00
developer 7923b3cc09 Merge pull request 'release v1.5.1: support-relay card + topic-reopen fixes' (#133) from development into master 2026-06-23 16:54:01 +00:00
developer 4891216749 Merge pull request 'release v1.5.0: Telegram bot support relay' (#131) from development into master 2026-06-23 16:16:04 +00:00
developer f1b8769c89 Merge pull request 'release: v1.4.1 — Telegram nav (windowed, own back button, debug panel)' (#129) from development into master 2026-06-23 13:27:31 +00:00
developer b6f28a2423 Merge pull request 'release: v1.4.0 — Telegram launch diagnostic + dynamic SDK load' (#127) from development into master 2026-06-23 08:40:09 +00:00
developer e32ee9ce68 Merge pull request 'Release: development → master' (#125) from development into master 2026-06-22 22:36:42 +00:00
developer dc946a1faf Merge pull request 'release v1.2.2: edge HTTP/3 stall fix + db-size dashboard threshold' (#121) from development into master 2026-06-22 19:50:58 +00:00
developer 384bd143d0 Merge pull request 'Promote development → master: banner tip set + banner/push language fix' (#114) from development into master 2026-06-22 18:28:00 +00:00
developer c5d22fceca Merge pull request 'Promote development → master: Erudit blank star + dictionary v1.3.0 pin' (#111) from development into master 2026-06-22 13:12:01 +00:00
developer deaa7a29c5 Merge pull request 'Promote development → master (docs finalize + UI tweaks + Telegram name fallback)' (#108) from development into master 2026-06-22 07:27:40 +00:00
developer 24017bcb7f Merge pull request 'Promote development → master (deploy v2: versioning + visible jobs + rollback)' (#106) from development into master 2026-06-22 06:01:03 +00:00
developer 2c4f4b10dc Merge pull request 'Promote development → master (initial production release: pre-release line + Stage 18)' (#104) from development into master 2026-06-22 05:05:48 +00:00
16 changed files with 285 additions and 32 deletions
+5
View File
@@ -353,6 +353,11 @@ jobs:
# for the server-side confidential code exchange — a SEPARATE VK app from the Mini # for the server-side confidential code exchange — a SEPARATE VK app from the Mini
# App above. One VK ID "Web" app serves every contour -> unprefixed secret. # App above. One VK ID "Web" app serves every contour -> unprefixed secret.
GATEWAY_VK_ID_CLIENT_SECRET: ${{ secrets.GATEWAY_VK_ID_CLIENT_SECRET }} GATEWAY_VK_ID_CLIENT_SECRET: ${{ secrets.GATEWAY_VK_ID_CLIENT_SECRET }}
# Client-version gate (ARCHITECTURE.md §2): one plain (unprefixed) variable serves every
# contour. In the test contour the stamped client version is a commit hash (unparseable ⇒
# fail-open), so setting these only enforces on real semver prod builds. Empty ⇒ dormant.
GATEWAY_MIN_CLIENT_VERSION: ${{ vars.GATEWAY_MIN_CLIENT_VERSION }}
GATEWAY_RECOMMENDED_CLIENT_VERSION: ${{ vars.GATEWAY_RECOMMENDED_CLIENT_VERSION }}
# Planted honeytoken bearer: presenting it flags the caller (logs + a ban metric on # Planted honeytoken bearer: presenting it flags the caller (logs + a ban metric on
# test where the IP ban is off; a 24h IP ban on prod). Per-contour secret; empty = trap off. # test where the IP ban is off; a 24h IP ban on prod). Per-contour secret; empty = trap off.
GATEWAY_HONEYTOKEN: ${{ secrets.TEST_GATEWAY_HONEYTOKEN }} GATEWAY_HONEYTOKEN: ${{ secrets.TEST_GATEWAY_HONEYTOKEN }}
+6
View File
@@ -112,6 +112,12 @@ jobs:
# derived from PUBLIC_BASE_URL in deploy/write-prod-env.sh. # derived from PUBLIC_BASE_URL in deploy/write-prod-env.sh.
VITE_VK_APP_ID: ${{ vars.VITE_VK_APP_ID }} VITE_VK_APP_ID: ${{ vars.VITE_VK_APP_ID }}
GATEWAY_VK_ID_CLIENT_SECRET: ${{ secrets.GATEWAY_VK_ID_CLIENT_SECRET }} GATEWAY_VK_ID_CLIENT_SECRET: ${{ secrets.GATEWAY_VK_ID_CLIENT_SECRET }}
# Client-version gate (ARCHITECTURE.md §2): plain (unprefixed) vars, shared across contours
# (empty ⇒ dormant). On prod the stamped client version is a real semver, so the gate enforces
# here — set GATEWAY_MIN_CLIENT_VERSION to the release in the same rollout that ships a breaking
# wire change; bump GATEWAY_RECOMMENDED_CLIENT_VERSION (≥ min) to nudge upgrades softly.
GATEWAY_MIN_CLIENT_VERSION: ${{ vars.GATEWAY_MIN_CLIENT_VERSION }}
GATEWAY_RECOMMENDED_CLIENT_VERSION: ${{ vars.GATEWAY_RECOMMENDED_CLIENT_VERSION }}
# Planted honeytoken bearer: presenting it earns a 24h IP ban + a high-severity alarm. # Planted honeytoken bearer: presenting it earns a 24h IP ban + a high-severity alarm.
# Per-contour secret; empty = trap off. Rendered by deploy/write-prod-env.sh. # Per-contour secret; empty = trap off. Rendered by deploy/write-prod-env.sh.
GATEWAY_HONEYTOKEN: ${{ secrets.PROD_GATEWAY_HONEYTOKEN }} GATEWAY_HONEYTOKEN: ${{ secrets.PROD_GATEWAY_HONEYTOKEN }}
+7
View File
@@ -115,6 +115,13 @@ TELEGRAM_MINIAPP_URL= # required; deploy derives it as PUBLIC_B
TELEGRAM_TEST_ENV=false TELEGRAM_TEST_ENV=false
TELEGRAM_API_BASE_URL= TELEGRAM_API_BASE_URL=
# --- Client-version gate (ARCHITECTURE.md §2) -------------------------------
# The hard minimum + the soft recommended client build. Empty ⇒ dormant. Plain (unprefixed) Gitea
# variables, shared across contours; in the test contour the stamped client version is a commit hash
# (unparseable ⇒ fail-open), so these only enforce on real semver prod builds. Recommended must be ≥ min.
GATEWAY_MIN_CLIENT_VERSION=
GATEWAY_RECOMMENDED_CLIENT_VERSION=
# --- VK Mini App ------------------------------------------------------------ # --- VK Mini App ------------------------------------------------------------
# The VK app's "protected key" (client_secret): the gateway verifies the Mini App # The VK app's "protected key" (client_secret): the gateway verifies the Mini App
# launch-parameter signature in-process under it (a pure offline HMAC, no VK API call). # launch-parameter signature in-process under it (a pure offline HMAC, no VK API call).
+6
View File
@@ -222,6 +222,12 @@ services:
GATEWAY_HTTP_ADDR: ":8081" GATEWAY_HTTP_ADDR: ":8081"
GATEWAY_BACKEND_HTTP_URL: http://backend:8080 GATEWAY_BACKEND_HTTP_URL: http://backend:8080
GATEWAY_BACKEND_GRPC_ADDR: backend:9090 GATEWAY_BACKEND_GRPC_ADDR: backend:9090
# Client-version gate (ARCHITECTURE.md §2): the hard minimum + the soft recommended client build.
# Empty ⇒ dormant. Plain (unprefixed) — the same value serves every contour; in the test contour
# the stamped client version is a commit hash (unparseable ⇒ fail-open), so the gate only bites
# real semver builds in prod. Validated at gateway start (recommended must be ≥ min).
GATEWAY_MIN_CLIENT_VERSION: ${GATEWAY_MIN_CLIENT_VERSION:-}
GATEWAY_RECOMMENDED_CLIENT_VERSION: ${GATEWAY_RECOMMENDED_CLIENT_VERSION:-}
# Telegram auth validates against the home validator (plaintext, internal). # Telegram auth validates against the home validator (plaintext, internal).
GATEWAY_VALIDATOR_ADDR: validator:9091 GATEWAY_VALIDATOR_ADDR: validator:9091
# VK Mini App auth verifies the launch-parameter signature in-process under the VK # VK Mini App auth verifies the launch-parameter signature in-process under the VK
+2
View File
@@ -49,6 +49,8 @@ export CADDY_SITE_ADDRESS='$CADDY_SITE_ADDRESS'
export LOG_LEVEL='${LOG_LEVEL:-info}' export LOG_LEVEL='${LOG_LEVEL:-info}'
export DICT_VERSION='$DICT_VERSION' export DICT_VERSION='$DICT_VERSION'
export APP_VERSION='$APP_VERSION' export APP_VERSION='$APP_VERSION'
export GATEWAY_MIN_CLIENT_VERSION='$GATEWAY_MIN_CLIENT_VERSION'
export GATEWAY_RECOMMENDED_CLIENT_VERSION='$GATEWAY_RECOMMENDED_CLIENT_VERSION'
export TELEGRAM_BOT_TOKEN='$TELEGRAM_BOT_TOKEN' export TELEGRAM_BOT_TOKEN='$TELEGRAM_BOT_TOKEN'
export TELEGRAM_MINIAPP_URL='$TELEGRAM_MINIAPP_URL' export TELEGRAM_MINIAPP_URL='$TELEGRAM_MINIAPP_URL'
export GATEWAY_VK_APP_SECRET='$GATEWAY_VK_APP_SECRET' export GATEWAY_VK_APP_SECRET='$GATEWAY_VK_APP_SECRET'
+9 -5
View File
@@ -116,8 +116,10 @@ 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, never fed an offline drop/recovery feeds the same machine. **Telegram/VK are exempt** — always online: they are never fed an
signal, so those channels never enter an offline state or show a local lobby. offline signal, and `offlineMode.active` is additionally hard-gated on `offlineCapable()` (false in
those mini-apps), so nothing — not even a version lock — puts them in offline mode: no blue chrome, no
local lobby, no transport kill switch and no device-local create paths.
**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
@@ -1393,9 +1395,11 @@ for install on Android) and powers **offline play**. Offline is **implicit** —
(the old deliberate Settings switch and its cold-start dialog are gone). The **unified lobby** merges the (the old deliberate Settings switch and its cold-start dialog are gone). The **unified lobby** merges the
device-local games (active — reconstructed by replaying the IndexedDB move journal) with the last-cached device-local games (active — reconstructed by replaying the IndexedDB move journal) with the last-cached
**server games shown greyed** (un-openable, a tap toasts *offline*); the Stats tab is disabled and **server games shown greyed** (un-openable, a tap toasts *offline*); the Stats tab is disabled and
invitations are hidden while offline. New Game's *with friends* carries an **online/offline segmented invitations are hidden while offline. On offline-capable channels (native / plain web) New Game's *with
control** — online = a friend invite, offline = **local pass-and-play (hotseat)** — with the online friends* carries an **online/offline segmented control** — online = a friend invite, offline = **local
segment disabled and offline forced when there is no network; a `vs_ai` or hotseat create is guarded when pass-and-play (hotseat)** — with the online segment disabled and offline forced when there is no network;
the online-only Telegram/VK mini-apps skip the segment and show the remote invite alone. A `vs_ai` or
hotseat create is guarded when
the chosen variant's dictionary is not available offline. A device-local `vs_ai` game is created through the chosen variant's dictionary is not available offline. A device-local `vs_ai` game is created through
the in-browser engine and driven by the same game screen, the robot replying locally. the in-browser engine and driven by the same game screen, the robot replying locally.
A hotseat game is a device-local **2-4 human** game built through the same engine (`hotseat` + A hotseat game is a device-local **2-4 human** game built through the same engine (`hotseat` +
+9 -4
View File
@@ -277,7 +277,9 @@ add-friend are off. AI games are **practice** — they never count toward a play
statistics. statistics.
### Offline mode ### Offline mode
The app plays **offline automatically** — there is **no on/off switch**. When it cannot reach the The **web and native apps** play **offline automatically** — there is **no on/off switch**; the
online-only **Telegram and VK mini-apps** never go offline (everything below applies to the web and
native apps only). When it cannot reach the
server the header turns blue with an *Offline* chip and play is confined to games on the device; a server the header turns blue with an *Offline* chip and play is confined to games on the device; a
momentary blip is ridden out as a quiet *"Connecting…"* (it never drops you offline), and when the momentary blip is ridden out as a quiet *"Connecting…"* (it never drops you offline), and when the
connection returns the app goes back online on its own with a brief *back online* toast. Offline, the connection returns the app goes back online on its own with a brief *back online* toast. Offline, the
@@ -295,6 +297,7 @@ a short note.
New Game's **with friends** offers a choice — **invite a friend** to play online, or a **local New Game's **with friends** offers a choice — **invite a friend** to play online, or a **local
pass-and-play** game for 2-4 people sharing one device; with no network only pass-and-play is available. pass-and-play** game for 2-4 people sharing one device; with no network only pass-and-play is available.
(In the online-only Telegram/VK mini-apps *with friends* is the friend invite alone — no pass-and-play.)
In pass-and-play you first set a **host (leader) password** — a 4-digit PIN entered on a lock-screen-style In pass-and-play you first set a **host (leader) password** — a 4-digit PIN entered on a lock-screen-style
keypad; until it is set the player rows stay locked. The app then asks whether you are playing too — keypad; until it is set the player rows stay locked. The app then asks whether you are playing too —
if so you take the first seat with your profile name. You name each player (2 to 4; add or remove if so you take the first seat with your profile name. You name each player (2 to 4; add or remove
@@ -427,9 +430,11 @@ unanswered reply. Guests cannot send feedback (the entry is hidden). A player th
barred from feedback (a role, not a full account block) sees the send control disabled. barred from feedback (a role, not a full account block) sees the send control disabled.
### Telegram support chat ### Telegram support chat
A user can also reach the operators straight from the Telegram bot: anything they send the bot A user can also reach the operators straight from the Telegram bot. The `/support` command replies
other than `/start` — text, a photo, a voice message, a file — is forwarded into the operators' with the support desk's working hours and what to include (a description and, when possible,
private support group, grouped into a per-user thread. The user sees no automatic reply; an screenshots). Anything else they send the bot other than `/start` — text, a photo, a voice message,
a file — is forwarded into the operators' private support group, grouped into a per-user thread. The
user sees no automatic reply to a forwarded message; an
operator answers from that thread and the bot delivers the answer back as an ordinary bot message, operator answers from that thread and the bot delivers the answer back as an ordinary bot message,
so to the user it is a quiet one-to-one conversation. Operators can block a user (the bot then so to the user it is a quiet one-to-one conversation. Operators can block a user (the bot then
silently ignores their messages) or clear a thread's messages. This is independent of the in-app silently ignores their messages) or clear a thread's messages. This is independent of the in-app
+11 -6
View File
@@ -283,7 +283,9 @@ e-mail) либо ввод фразы. Активные игры форфейтя
редкими ходами вопреки плану, затухающими к эндшпилю), а чат, nudge и «добавить в друзья» выключены. Партии с ИИ — это **тренировка**: они не идут в статистику игрока. редкими ходами вопреки плану, затухающими к эндшпилю), а чат, nudge и «добавить в друзья» выключены. Партии с ИИ — это **тренировка**: они не идут в статистику игрока.
### Офлайн-режим ### Офлайн-режим
Приложение играет **офлайн автоматически** — никакого переключателя нет. Когда оно не может достучаться **Веб- и нативное приложения** играют **офлайн автоматически** — никакого переключателя нет; онлайн-только
**мини-приложения Telegram и VK** никогда не уходят в офлайн (всё ниже относится только к веб- и нативному
приложениям). Когда оно не может достучаться
до сервера, шапка синеет с меткой *Офлайн*, а игра ограничивается партиями на устройстве; кратковременный до сервера, шапка синеет с меткой *Офлайн*, а игра ограничивается партиями на устройстве; кратковременный
сбой пережидается как тихое *«Подключение…»* (в офлайн не роняет), а при возврате связи приложение само сбой пережидается как тихое *«Подключение…»* (в офлайн не роняет), а при возврате связи приложение само
возвращается онлайн с коротким тостом *соединение восстановлено*. В офлайне приложение не обращается к возвращается онлайн с коротким тостом *соединение восстановлено*. В офлайне приложение не обращается к
@@ -299,8 +301,9 @@ e-mail) либо ввод фразы. Активные игры форфейтя
варианта недоступен офлайн, создание такой игры отключается с короткой заметкой. варианта недоступен офлайн, создание такой игры отключается с короткой заметкой.
«С друзьями» в Новой игре предлагает выбор — **пригласить друга** для игры онлайн или **локальную игру «С друзьями» в Новой игре предлагает выбор — **пригласить друга** для игры онлайн или **локальную игру
по очереди (hotseat)** на 2–4 человек за одним устройством; без сети доступна только игра по очереди. В по очереди (hotseat)** на 2–4 человек за одним устройством; без сети доступна только игра по очереди.
игре по очереди сначала задаётся **пароль ведущего** — 4-значный PIN на клавиатуре в стиле (В онлайн-только мини-приложениях Telegram/VK «с друзьями» — это только приглашение друга, без игры по очереди.)
В игре по очереди сначала задаётся **пароль ведущего** — 4-значный PIN на клавиатуре в стиле
экрана блокировки; пока он не задан, строки игроков заблокированы. Затем приложение спрашивает, экрана блокировки; пока он не задан, строки игроков заблокированы. Затем приложение спрашивает,
играете ли вы сами — если да, вы занимаете первое место под именем из профиля. Вы называете каждого играете ли вы сами — если да, вы занимаете первое место под именем из профиля. Вы называете каждого
игрока (от 2 до 4; строки можно добавлять и удалять, удаление спрашивает пароль ведущего) и можете игрока (от 2 до 4; строки можно добавлять и удалять, удаление спрашивает пароль ведущего) и можете
@@ -438,9 +441,11 @@ Telegram. На сенсорном устройстве в настройках
обратную связь (роль, а не полная блокировка аккаунта), видит кнопку отправки недоступной. обратную связь (роль, а не полная блокировка аккаунта), видит кнопку отправки недоступной.
### Чат поддержки в Telegram ### Чат поддержки в Telegram
С операторами можно поговорить и прямо из Telegram-бота: всё, что пользователь присылает боту, С операторами можно поговорить и прямо из Telegram-бота. Команда `/support` отвечает графиком работы
кроме `/start` — текст, фото, голосовое, файл, — пересылается в закрытую группу поддержки операторов поддержки и напоминанием, что приложить (подробное описание и, по возможности, скриншоты). Всё
и собирается в отдельную ветку на пользователя. Пользователь не получает автоответа; оператор остальное, что пользователь присылает боту, кроме `/start` — текст, фото, голосовое, файл, —
пересылается в закрытую группу поддержки операторов и собирается в отдельную ветку на пользователя.
Пользователь не получает автоответа на пересланное сообщение; оператор
отвечает из этой ветки, и бот доставляет ответ обратно обычным сообщением — для пользователя это отвечает из этой ветки, и бот доставляет ответ обратно обычным сообщением — для пользователя это
тихий диалог один на один. Операторы могут заблокировать пользователя (тогда бот молча игнорирует тихий диалог один на один. Операторы могут заблокировать пользователя (тогда бот молча игнорирует
его сообщения) или очистить сообщения ветки. Это независимо от встроенной «Обратной связи» выше — его сообщения) или очистить сообщения ветки. Это независимо от встроенной «Обратной связи» выше —
+8 -3
View File
@@ -42,10 +42,15 @@ ENV VITE_TELEGRAM_BOT_ID=$VITE_TELEGRAM_BOT_ID \
VITE_APP_VERSION=$VITE_APP_VERSION \ VITE_APP_VERSION=$VITE_APP_VERSION \
VITE_ADS_STUB=$VITE_ADS_STUB VITE_ADS_STUB=$VITE_ADS_STUB
# Install with the lockfile first (the workspace file carries pnpm's build-script # Install with the lockfile first, then build. Committed src/gen/ means no codegen here.
# approval for esbuild), then build. Committed src/gen/ means no codegen here. # --ignore-scripts: the Vite build needs no dependency build script — esbuild's platform binary
# ships as an optional dependency (only its CLI-shim postinstall is skipped, which Vite does not use),
# and core-js-bundle's prebuilt file is read directly. It notably skips sharp's native build (a
# `pnpm android:assets` tool via @capacitor/assets, unused here), which would otherwise fail on this
# Alpine/musl stage with no prebuilt binary and no compiler. The workspace allowBuilds stay for local
# installs (where android:assets does need sharp built).
COPY ui/package.json ui/pnpm-lock.yaml ui/pnpm-workspace.yaml ./ COPY ui/package.json ui/pnpm-lock.yaml ui/pnpm-workspace.yaml ./
RUN pnpm install --frozen-lockfile RUN pnpm install --frozen-lockfile --ignore-scripts
COPY ui ./ COPY ui ./
RUN pnpm build RUN pnpm build
+12 -3
View File
@@ -9,8 +9,9 @@ it. See [`docs/ARCHITECTURE.md`](../../docs/ARCHITECTURE.md) §1/§3/§10/§12/
validation gRPC API the gateway calls during Telegram auth, on the trusted internal validation gRPC API the gateway calls during Telegram auth, on the trusted internal
network with no VPN. Because it needs no Telegram reachability, **game login stays up network with no VPN. Because it needs no Telegram reachability, **game login stays up
even when the bot or the bot-link is down**. even when the bot or the bot-link is down**.
- **`cmd/bot`** (remote) — runs the Bot API long-poll (Mini App launch + `/start` - **`cmd/bot`** (remote) — runs the Bot API long-poll (Mini App launch, `/start`
deep-links) and `sendMessage`, the only component reaching the Telegram Bot API. It deep-links, the `/support` info reply) and `sendMessage`, the only component reaching
the Telegram Bot API. It
holds **no inbound port**: it dials the gateway over a reverse **mTLS bot-link** and holds **no inbound port**: it dials the gateway over a reverse **mTLS bot-link** and
executes the send commands the gateway pushes, so its egress can run on a host with executes the send commands the gateway pushes, so its egress can run on a host with
native Telegram access (a VPN sidecar in the test contour, a separate host in prod). native Telegram access (a VPN sidecar in the test contour, a separate host in prod).
@@ -49,6 +50,13 @@ Telegram identity to an account from a browser. Both map a rejection to gRPC
chat" — rather than a dangling "@"). This is otherwise **self-contained** chat" — rather than a dangling "@"). This is otherwise **self-contained**
— the bot never calls back into the game, so `/start` onboarding works even when the game — the bot never calls back into the game, so `/start` onboarding works even when the game
is down. is down.
- **Support command.** `/support` replies with a fixed support-desk info message — the
operators' working hours and what to include (a description and, when possible,
screenshots). Like `/start` it answers only in a private chat and is **Russian or
English** by the sender's reported language, and it is listed in the bot's command menu
(localized, Russian/English). It is a dedicated command handler, so it intercepts
`/support` before the support relay below — the command line itself is not forwarded to
operators, while the user's following description still is.
- **Moderated-chat gating.** When `TELEGRAM_CHAT_ID` names a channel's linked discussion - **Moderated-chat gating.** When `TELEGRAM_CHAT_ID` names a channel's linked discussion
group, the bot gates who may write there. The group **allows sending by default** (a group, the bot gates who may write there. The group **allows sending by default** (a
human setting) and the bot only **restricts** — Telegram intersects the chat default with human setting) and the bot only **restricts** — Telegram intersects the chat default with
@@ -76,7 +84,8 @@ Telegram identity to an account from a browser. Both map a rejection to gRPC
message. State (user→topic map, block list, relayed ids) is a JSON file under message. State (user→topic map, block list, relayed ids) is a JSON file under
`TELEGRAM_SUPPORT_STATE_DIR` on a persistent volume — the bot host has no database. The bot must `TELEGRAM_SUPPORT_STATE_DIR` on a persistent volume — the bot host has no database. The bot must
be an **administrator** in the group with the **manage-topics** and **delete-messages** rights. be an **administrator** in the group with the **manage-topics** and **delete-messages** rights.
The relay is bot-local (no backend, no bot-link) and the user gets no automatic reply. The relay is bot-local (no backend, no bot-link) and a forwarded message gets no automatic
reply (the `/support` command above aside).
- **Promo bot (optional).** When `TELEGRAM_PROMO_BOT_TOKEN` is set, the container also - **Promo bot (optional).** When `TELEGRAM_PROMO_BOT_TOKEN` is set, the container also
runs a **second, standalone** bot whose only job is to answer `/start` with a localized runs a **second, standalone** bot whose only job is to answer `/start` with a localized
message and a button that opens the **main** bot's Mini App. The button is a **URL** to message and a button that opens the **main** bot's Mini App. The button is a **URL** to
+19 -2
View File
@@ -1,6 +1,7 @@
// Package bot wraps the Telegram Bot API client (github.com/go-telegram/bot): it // Package bot wraps the Telegram Bot API client (github.com/go-telegram/bot): it
// runs the long-poll update loop — replying to /start (with an optional deep-link // runs the long-poll update loop — replying to /start (with an optional deep-link
// payload) and any other message with a Mini App launch button — and sends the // payload), to /support with the support-desk info message, and to any other message
// with a Mini App launch button — and sends the
// notification and admin messages the connector requests. The bot token lives only // notification and admin messages the connector requests. The bot token lives only
// in this process. // in this process.
package bot package bot
@@ -136,6 +137,7 @@ func New(cfg Config, log *zap.Logger) (*Bot, error) {
opts := []tgbot.Option{ opts := []tgbot.Option{
tgbot.WithDefaultHandler(t.handleUpdate), tgbot.WithDefaultHandler(t.handleUpdate),
tgbot.WithMessageTextHandler("/start", tgbot.MatchTypePrefix, t.handleStart), tgbot.WithMessageTextHandler("/start", tgbot.MatchTypePrefix, t.handleStart),
tgbot.WithMessageTextHandler("/support", tgbot.MatchTypePrefix, t.handleSupport),
} }
if t.supportEnabled() { if t.supportEnabled() {
t.supportLocks = newKeyedMutex() t.supportLocks = newKeyedMutex()
@@ -186,11 +188,26 @@ func New(cfg Config, log *zap.Logger) (*Bot, error) {
// Run sets the bot commands and the Mini App menu button, then blocks on the // Run sets the bot commands and the Mini App menu button, then blocks on the
// long-poll update loop until ctx is cancelled. // long-poll update loop until ctx is cancelled.
func (t *Bot) Run(ctx context.Context) { func (t *Bot) Run(ctx context.Context) {
// Command menu: the default (fallback) list is English; a Russian-scoped list
// localises the labels for ru clients. A language scope replaces the whole list, so
// it repeats /start with its own Russian label.
if _, err := t.api.SetMyCommands(ctx, &tgbot.SetMyCommandsParams{ if _, err := t.api.SetMyCommands(ctx, &tgbot.SetMyCommandsParams{
Commands: []models.BotCommand{{Command: "start", Description: "Open Scrabble"}}, Commands: []models.BotCommand{
{Command: "start", Description: "Play Scrabble"},
{Command: "support", Description: "Contact the administration"},
},
}); err != nil { }); err != nil {
t.log.Warn("set commands failed", zap.Error(err)) t.log.Warn("set commands failed", zap.Error(err))
} }
if _, err := t.api.SetMyCommands(ctx, &tgbot.SetMyCommandsParams{
LanguageCode: "ru",
Commands: []models.BotCommand{
{Command: "start", Description: "Играть в «Эрудита»"},
{Command: "support", Description: "Связаться с администрацией"},
},
}); err != nil {
t.log.Warn("set ru commands failed", zap.Error(err))
}
if _, err := t.api.SetChatMenuButton(ctx, &tgbot.SetChatMenuButtonParams{ if _, err := t.api.SetChatMenuButton(ctx, &tgbot.SetChatMenuButtonParams{
MenuButton: models.MenuButtonWebApp{ MenuButton: models.MenuButtonWebApp{
Type: models.MenuButtonTypeWebApp, Type: models.MenuButtonTypeWebApp,
@@ -0,0 +1,63 @@
package bot
import (
"context"
"strings"
tgbot "github.com/go-telegram/bot"
"github.com/go-telegram/bot/models"
"go.uber.org/zap"
)
// The /support command replies with a fixed support-desk info message — the operators'
// working hours and what to include (a description and, when possible, screenshots). It
// is a dedicated command handler (registered like /start), so it intercepts "/support"
// before the support relay: the command line itself is not forwarded into an operator
// topic, while the user's following description still is.
// handleSupport replies to /support with the localized support-desk info message. Like
// handleStart it answers only in a private chat — in the moderated group the bot never
// chats — and it sends a plain text message with no launch button.
func (t *Bot) handleSupport(ctx context.Context, api *tgbot.Bot, update *models.Update) {
if update.Message == nil {
return
}
if update.Message.Chat.Type != models.ChatTypePrivate {
return
}
// The sender's Telegram language rides on the message itself (Message.from.language_code);
// fall back to English when it is absent, matching startText.
lang := ""
if update.Message.From != nil {
lang = update.Message.From.LanguageCode
}
if _, err := api.SendMessage(ctx, &tgbot.SendMessageParams{
ChatID: update.Message.Chat.ID,
Text: supportText(lang),
}); err != nil {
t.log.Warn("reply to support failed", zap.Error(err))
}
}
// supportText returns the localized /support reply. Russian is used when lang (the IETF
// language tag the Telegram client reports on the message's sender) starts with "ru",
// English otherwise and when it is absent — mirroring startText's language choice.
func supportText(lang string) string {
if strings.HasPrefix(strings.ToLower(lang), "ru") {
return ruSupport
}
return enSupport
}
// ruSupport and enSupport are the Russian and English /support replies; the English one
// is the fallback for any non-Russian or missing sender language.
const (
ruSupport = "Поддержка «Эрудита» на связи с 08:00 до 18:00 (по времени UTC). " +
"Если Вы столкнулись с проблемой, подробно опишите её и добавьте, по возможности, скриншоты. " +
"Также будем рады выслушать Ваши пожелания и предложения по функционалу игры. " +
"Ответим в самое ближайшее время."
enSupport = "“Erudite” support is available from 08:00 to 18:00 (UTC). " +
"If you have run into a problem, please describe it in detail and attach screenshots if you can. " +
"We would also be glad to hear your wishes and suggestions about the game's features. " +
"We will reply as soon as possible."
)
@@ -0,0 +1,51 @@
package bot
import (
"context"
"strings"
"testing"
"github.com/go-telegram/bot/models"
)
func TestHandleSupportRepliesPrivateOnly(t *testing.T) {
t.Run("english by default", func(t *testing.T) {
api := &fakeBotAPI{}
b := newTestBot(t, api)
b.handleSupport(context.Background(), b.api, &models.Update{Message: &models.Message{
Chat: models.Chat{ID: 42, Type: models.ChatTypePrivate}, Text: "/support",
}})
if api.chatID != "42" {
t.Errorf("chat_id = %q, want 42", api.chatID)
}
// No reported language -> English support reply.
if !strings.Contains(api.text, "support is available") {
t.Errorf("text = %q, want the English support reply", api.text)
}
// A plain info message carries no launch button.
if api.replyMarkup != "" {
t.Errorf("reply_markup = %q, want none", api.replyMarkup)
}
})
t.Run("russian for a ru sender", func(t *testing.T) {
api := &fakeBotAPI{}
b := newTestBot(t, api)
b.handleSupport(context.Background(), b.api, &models.Update{Message: &models.Message{
Chat: models.Chat{ID: 42, Type: models.ChatTypePrivate}, Text: "/support",
From: &models.User{ID: 7, LanguageCode: "ru"},
}})
if !strings.Contains(api.text, "Поддержка «Эрудита»") {
t.Errorf("text = %q, want the Russian support reply", api.text)
}
})
t.Run("group ignored", func(t *testing.T) {
api := &fakeBotAPI{}
b := newTestBot(t, api)
b.handleSupport(context.Background(), b.api, &models.Update{Message: &models.Message{
Chat: models.Chat{ID: -100, Type: models.ChatTypeSupergroup}, Text: "/support",
}})
if api.chatID != "" {
t.Errorf("group /support got a reply (chat=%q); the bot never chats in the group", api.chatID)
}
})
}
+48
View File
@@ -122,6 +122,54 @@ test('outside Telegram, the /telegram/ entry shows the launch diagnostic, not a
await expect(page.getByRole('button', { name: /guest/i })).toHaveCount(0); await expect(page.getByRole('button', { name: /guest/i })).toHaveCount(0);
}); });
test('inside Telegram (online-only), an offline signal never switches the app to offline mode', async ({
page,
}) => {
// The Telegram mini-app is online-only: the offline model (implicit offline, device-local games,
// the transport kill switch) must stay inert there. Even when the net-state machine is driven to an
// offline state, offlineMode must remain false — otherwise the chrome turns blue, the lobby greys the
// server games and, critically, a vs_ai start would create a device-local game instead of enqueuing.
await page.addInitScript((stub) => {
Object.assign(window, stub);
}, webAppStub());
await page.goto('/');
await expect(page.getByText('Your turn')).toBeVisible(); // the durable mini-app session, in the lobby
// Drive the net-state machine to an offline state through the mock hook.
await page.evaluate(() => (window as unknown as { __net: { offline(): void } }).__net.offline());
// Because the channel is online-only, offlineMode stays inert: on the lobby the chrome never turns blue
// and no server game is greyed…
await expect(page.locator('header.nav.offline')).toHaveCount(0);
await expect(page.locator('.rowwrap.greyed')).toHaveCount(0);
// …and New Game's quick match still offers the opponent choice (AI / random), which is hidden only in
// real offline mode. Its presence is the positive proof that offlineMode is false — so the vs_ai start,
// whose device-local branch is gated on offlineMode, enqueues on the server instead of creating a
// device-local game.
await page.locator('button.tab').nth(0).click();
await expect(page.locator('button.opt', { hasText: '🤖' })).toBeVisible();
});
test('inside Telegram (online-only), New Game with friends offers remote invites only', async ({
page,
}) => {
// A Telegram launch authenticates a durable account, so New Game shows the auto/with-friends selector.
// "Play with friends" must offer only the remote invite — never the online/offline segment, whose
// "Pass and play" is the device-local hotseat flow that has no place in an online-only mini-app.
await page.addInitScript((stub) => {
Object.assign(window, stub);
}, webAppStub());
await page.goto('/');
await expect(page.getByText('Your turn')).toBeVisible();
await page.locator('button.tab').nth(0).click(); // the New tab opens the create screen
await page.getByRole('button', { name: 'Play with friends' }).click();
// The online/offline segment (Invite a friend / Pass and play) is absent — only the invite form shows.
await expect(page.getByRole('button', { name: 'Pass and play' })).toHaveCount(0);
await expect(page.getByRole('button', { name: 'Invite a friend' })).toHaveCount(0);
});
test('a blocked telegram-web-app.js does not hang the diagnostic screen', async ({ page }) => { test('a blocked telegram-web-app.js does not hang the diagnostic screen', async ({ page }) => {
// Simulate a network where telegram.org is unreachable: the SDK fetch fails. Because the SPA // Simulate a network where telegram.org is unreachable: the SDK fetch fails. Because the SPA
// loads the SDK dynamically with a timeout (not a render-blocking <script>), a failed/blocked // loads the SDK dynamically with a timeout (not a render-blocking <script>), a failed/blocked
+15 -2
View File
@@ -10,11 +10,24 @@ import { insideTelegram } from './telegram';
import { insideVK } from './vk'; import { insideVK } from './vk';
import type { Profile } from './model'; import type { Profile } from './model';
/**
* offlineCapable reports whether this channel supports the offline model — implicit offline mode,
* device-local games (vs_ai and hotseat) and the transport kill switch. Native builds and the plain web
* app do; the Telegram and VK mini-apps are online-only wrappers, so the whole offline model stays inert
* there (offlineMode.active is forced false regardless of the detected net state, and the create flow
* hides its device-local segment).
*/
export function offlineCapable(): boolean {
return !insideTelegram() && !insideVK();
}
/** offlineMode exposes the reactive offline flags; read them in markup / $derived. */ /** offlineMode exposes the reactive offline flags; read them in markup / $derived. */
export const offlineMode = { export const offlineMode = {
/** active is true while the app is offline (the machine's offlineNoNetwork / offlineVersionLocked). */ /** active is true while the app is offline (the machine's offlineNoNetwork / offlineVersionLocked) on a
* channel that supports offline play; it is always false in the online-only Telegram/VK mini-apps, so
* every offline-model consumer (chrome, lobby, kill switch, device-local create) stays inert there. */
get active(): boolean { get active(): boolean {
return netState.offline; return netState.offline && offlineCapable();
}, },
}; };
+14 -7
View File
@@ -10,7 +10,7 @@
import { gateway } from '../lib/gateway'; import { gateway } from '../lib/gateway';
import { app, handleError, showToast } from '../lib/app.svelte'; import { app, handleError, showToast } from '../lib/app.svelte';
import { connection } from '../lib/connection.svelte'; import { connection } from '../lib/connection.svelte';
import { offlineMode } from '../lib/offline.svelte'; import { offlineMode, offlineCapable } from '../lib/offline.svelte';
import { localSource, isLocalGameId } from '../lib/gamesource'; import { localSource, isLocalGameId } from '../lib/gamesource';
import { newLocalGameId, randomSeed } from '../lib/localgame/id'; import { newLocalGameId, randomSeed } from '../lib/localgame/id';
import { localGuestId } from '../lib/localguest'; import { localGuestId } from '../lib/localguest';
@@ -51,10 +51,14 @@
]; ];
const guest = $derived(app.profile?.isGuest ?? true); const guest = $derived(app.profile?.isGuest ?? true);
// Whether this channel supports device-local play. Native and plain web do; the Telegram/VK mini-apps
// are online-only, so they offer no offline/hotseat segment and never create a device-local game. The
// channel is fixed for the session, so a plain const (not reactive) is enough.
const canPlayOffline = offlineCapable();
let mode = $state<'auto' | 'friends'>('auto'); let mode = $state<'auto' | 'friends'>('auto');
// Within "with friends": online = a remote invite, offline = a pass-and-play (hotseat) game on this // Within "with friends" (offline-capable channels only): online = a remote invite, offline = a
// device. Both exist regardless of connectivity, but with no network the online segment is disabled // pass-and-play (hotseat) game on this device. With no network the online segment is disabled and
// and offline is forced — so the create flow always works. // offline is forced — so the create flow always works.
let friendsMode = $state<'online' | 'offline'>('online'); let friendsMode = $state<'online' | 'offline'>('online');
$effect(() => { $effect(() => {
if (offlineMode.active) friendsMode = 'offline'; if (offlineMode.active) friendsMode = 'offline';
@@ -410,14 +414,17 @@
onlogin={() => { limitOpen = false; navigate('/profile'); }} onlogin={() => { limitOpen = false; navigate('/profile'); }}
/> />
{:else} {:else}
<!-- With friends: an online remote invite, or an offline pass-and-play (hotseat) on this <!-- With friends: an online remote invite, or an offline pass-and-play (hotseat) on this device.
device. Both exist regardless of connectivity, but with no network the online segment is The online/offline segment is offered only on offline-capable channels (native / plain web);
disabled and offline is forced (friendsMode). --> the online-only Telegram/VK mini-apps skip it and show the remote invite directly. With no
network the online segment is disabled and offline is forced (friendsMode). -->
{#if canPlayOffline}
<div class="seg modes"> <div class="seg modes">
<button class="opt" class:active={friendsMode === 'online'} disabled={offlineMode.active} onclick={() => (friendsMode = 'online')}>{t('new.playRemote')}</button> <button class="opt" class:active={friendsMode === 'online'} disabled={offlineMode.active} onclick={() => (friendsMode = 'online')}>{t('new.playRemote')}</button>
<button class="opt" class:active={friendsMode === 'offline'} onclick={() => (friendsMode = 'offline')}>{t('new.playLocal')}</button> <button class="opt" class:active={friendsMode === 'offline'} onclick={() => (friendsMode = 'offline')}>{t('new.playLocal')}</button>
</div> </div>
{#if offlineMode.active}<p class="dictnote">{t('new.needsNetwork')}</p>{/if} {#if offlineMode.active}<p class="dictnote">{t('new.needsNetwork')}</p>{/if}
{/if}
{#if friendsMode === 'offline'} {#if friendsMode === 'offline'}
<!-- Offline pass-and-play (hotseat): a device-local 2-4 human game. The host sets a master <!-- Offline pass-and-play (hotseat): a device-local 2-4 human game. The host sets a master
PIN first (it gates the roster); each seat may add its own PIN. --> PIN first (it gates the roster); each seat may add its own PIN. -->