Compare commits

..

1 Commits

Author SHA1 Message Date
Ilia Denisov 14918cc94f 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 11s
CI / integration (pull_request) Successful in 19s
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 1m50s
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.

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; svelte-check 0/0, vitest 617, e2e 122/122 (chromium + webkit).
2026-07-13 02:09:13 +02:00
10 changed files with 29 additions and 251 deletions
+5 -9
View File
@@ -116,10 +116,8 @@ dropped). Horizontal scaling is explicit future work.
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
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
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.
drop/recovery feeds the same machine. **Telegram/VK are exempt** — always online, never fed an offline
signal, so those channels never enter an offline state or show a local lobby.
**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
layer (`http.MaxBytesReader`) and as the Connect per-message read limit, so an oversized
@@ -1395,11 +1393,9 @@ 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
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
invitations are hidden while offline. On offline-capable channels (native / plain web) New Game's *with
friends* carries an **online/offline segmented control** — online = a friend invite, offline = **local
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
invitations are hidden while offline. New Game's *with friends* carries an **online/offline segmented
control** — online = a friend invite, offline = **local pass-and-play (hotseat)** — with the online
segment disabled and offline forced when there is no network; 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 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` +
+4 -9
View File
@@ -277,9 +277,7 @@ add-friend are off. AI games are **practice** — they never count toward a play
statistics.
### Offline mode
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
The app plays **offline automatically** — there is **no on/off switch**. When it cannot reach the
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
connection returns the app goes back online on its own with a brief *back online* toast. Offline, the
@@ -297,7 +295,6 @@ a short note.
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.
(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
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
@@ -430,11 +427,9 @@ 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.
### Telegram support chat
A user can also reach the operators straight from the Telegram bot. The `/support` command replies
with the support desk's working hours and what to include (a description and, when possible,
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
A user can also reach the operators straight from the Telegram bot: anything 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; an
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
silently ignores their messages) or clear a thread's messages. This is independent of the in-app
+6 -11
View File
@@ -283,9 +283,7 @@ e-mail) либо ввод фразы. Активные игры форфейтя
редкими ходами вопреки плану, затухающими к эндшпилю), а чат, nudge и «добавить в друзья» выключены. Партии с ИИ — это **тренировка**: они не идут в статистику игрока.
### Офлайн-режим
**Веб- и нативное приложения** играют **офлайн автоматически** — никакого переключателя нет; онлайн-только
**мини-приложения Telegram и VK** никогда не уходят в офлайн (всё ниже относится только к веб- и нативному
приложениям). Когда оно не может достучаться
Приложение играет **офлайн автоматически** — никакого переключателя нет. Когда оно не может достучаться
до сервера, шапка синеет с меткой *Офлайн*, а игра ограничивается партиями на устройстве; кратковременный
сбой пережидается как тихое *«Подключение…»* (в офлайн не роняет), а при возврате связи приложение само
возвращается онлайн с коротким тостом *соединение восстановлено*. В офлайне приложение не обращается к
@@ -301,9 +299,8 @@ e-mail) либо ввод фразы. Активные игры форфейтя
варианта недоступен офлайн, создание такой игры отключается с короткой заметкой.
«С друзьями» в Новой игре предлагает выбор — **пригласить друга** для игры онлайн или **локальную игру
по очереди (hotseat)** на 2–4 человек за одним устройством; без сети доступна только игра по очереди.
(В онлайн-только мини-приложениях Telegram/VK «с друзьями» — это только приглашение друга, без игры по очереди.)
В игре по очереди сначала задаётся **пароль ведущего** — 4-значный PIN на клавиатуре в стиле
по очереди (hotseat)** на 2–4 человек за одним устройством; без сети доступна только игра по очереди. В
игре по очереди сначала задаётся **пароль ведущего** — 4-значный PIN на клавиатуре в стиле
экрана блокировки; пока он не задан, строки игроков заблокированы. Затем приложение спрашивает,
играете ли вы сами — если да, вы занимаете первое место под именем из профиля. Вы называете каждого
игрока (от 2 до 4; строки можно добавлять и удалять, удаление спрашивает пароль ведущего) и можете
@@ -441,11 +438,9 @@ Telegram. На сенсорном устройстве в настройках
обратную связь (роль, а не полная блокировка аккаунта), видит кнопку отправки недоступной.
### Чат поддержки в Telegram
С операторами можно поговорить и прямо из Telegram-бота. Команда `/support` отвечает графиком работы
поддержки и напоминанием, что приложить (подробное описание и, по возможности, скриншоты). Всё
остальное, что пользователь присылает боту, кроме `/start` — текст, фото, голосовое, файл, —
пересылается в закрытую группу поддержки операторов и собирается в отдельную ветку на пользователя.
Пользователь не получает автоответа на пересланное сообщение; оператор
С операторами можно поговорить и прямо из Telegram-бота: всё, что пользователь присылает боту,
кроме `/start` — текст, фото, голосовое, файл, — пересылается в закрытую группу поддержки операторов
и собирается в отдельную ветку на пользователя. Пользователь не получает автоответа; оператор
отвечает из этой ветки, и бот доставляет ответ обратно обычным сообщением — для пользователя это
тихий диалог один на один. Операторы могут заблокировать пользователя (тогда бот молча игнорирует
его сообщения) или очистить сообщения ветки. Это независимо от встроенной «Обратной связи» выше —
+3 -12
View File
@@ -9,9 +9,8 @@ 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
network with no VPN. Because it needs no Telegram reachability, **game login stays up
even when the bot or the bot-link is down**.
- **`cmd/bot`** (remote) — runs the Bot API long-poll (Mini App launch, `/start`
deep-links, the `/support` info reply) and `sendMessage`, the only component reaching
the Telegram Bot API. It
- **`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
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
native Telegram access (a VPN sidecar in the test contour, a separate host in prod).
@@ -50,13 +49,6 @@ Telegram identity to an account from a browser. Both map a rejection to gRPC
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
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
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
@@ -84,8 +76,7 @@ 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
`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.
The relay is bot-local (no backend, no bot-link) and a forwarded message gets no automatic
reply (the `/support` command above aside).
The relay is bot-local (no backend, no bot-link) and the user gets no automatic reply.
- **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
message and a button that opens the **main** bot's Mini App. The button is a **URL** to
+2 -19
View File
@@ -1,7 +1,6 @@
// 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
// payload), to /support with the support-desk info message, and to any other message
// with a Mini App launch button — and sends the
// payload) and any other message with a Mini App launch button — and sends the
// notification and admin messages the connector requests. The bot token lives only
// in this process.
package bot
@@ -137,7 +136,6 @@ func New(cfg Config, log *zap.Logger) (*Bot, error) {
opts := []tgbot.Option{
tgbot.WithDefaultHandler(t.handleUpdate),
tgbot.WithMessageTextHandler("/start", tgbot.MatchTypePrefix, t.handleStart),
tgbot.WithMessageTextHandler("/support", tgbot.MatchTypePrefix, t.handleSupport),
}
if t.supportEnabled() {
t.supportLocks = newKeyedMutex()
@@ -188,26 +186,11 @@ func New(cfg Config, log *zap.Logger) (*Bot, error) {
// Run sets the bot commands and the Mini App menu button, then blocks on the
// long-poll update loop until ctx is cancelled.
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{
Commands: []models.BotCommand{
{Command: "start", Description: "Play Scrabble"},
{Command: "support", Description: "Contact the administration"},
},
Commands: []models.BotCommand{{Command: "start", Description: "Open Scrabble"}},
}); err != nil {
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{
MenuButton: models.MenuButtonWebApp{
Type: models.MenuButtonTypeWebApp,
@@ -1,63 +0,0 @@
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."
)
@@ -1,51 +0,0 @@
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,54 +122,6 @@ test('outside Telegram, the /telegram/ entry shows the launch diagnostic, not a
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 }) => {
// 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
+2 -15
View File
@@ -10,24 +10,11 @@ import { insideTelegram } from './telegram';
import { insideVK } from './vk';
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. */
export const offlineMode = {
/** 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. */
/** active is true while the app is offline (the machine's offlineNoNetwork / offlineVersionLocked). */
get active(): boolean {
return netState.offline && offlineCapable();
return netState.offline;
},
};
+7 -14
View File
@@ -10,7 +10,7 @@
import { gateway } from '../lib/gateway';
import { app, handleError, showToast } from '../lib/app.svelte';
import { connection } from '../lib/connection.svelte';
import { offlineMode, offlineCapable } from '../lib/offline.svelte';
import { offlineMode } from '../lib/offline.svelte';
import { localSource, isLocalGameId } from '../lib/gamesource';
import { newLocalGameId, randomSeed } from '../lib/localgame/id';
import { localGuestId } from '../lib/localguest';
@@ -51,14 +51,10 @@
];
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');
// Within "with friends" (offline-capable channels only): online = a remote invite, offline = a
// pass-and-play (hotseat) game on this device. With no network the online segment is disabled and
// offline is forced — so the create flow always works.
// Within "with friends": online = a remote invite, offline = a pass-and-play (hotseat) game on this
// device. Both exist regardless of connectivity, but with no network the online segment is disabled
// and offline is forced — so the create flow always works.
let friendsMode = $state<'online' | 'offline'>('online');
$effect(() => {
if (offlineMode.active) friendsMode = 'offline';
@@ -414,17 +410,14 @@
onlogin={() => { limitOpen = false; navigate('/profile'); }}
/>
{:else}
<!-- With friends: an online remote invite, or an offline pass-and-play (hotseat) on this device.
The online/offline segment is offered only on offline-capable channels (native / plain web);
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}
<!-- With friends: an online remote invite, or an offline pass-and-play (hotseat) on this
device. Both exist regardless of connectivity, but with no network the online segment is
disabled and offline is forced (friendsMode). -->
<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 === 'offline'} onclick={() => (friendsMode = 'offline')}>{t('new.playLocal')}</button>
</div>
{#if offlineMode.active}<p class="dictnote">{t('new.needsNetwork')}</p>{/if}
{/if}
{#if friendsMode === 'offline'}
<!-- 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. -->