docs(telegram): invert chat-gate strategy in docs; tune logs; i18n text
CI / changes (pull_request) Successful in 2s
CI / unit (pull_request) Successful in 11s
CI / integration (pull_request) Successful in 18s
CI / ui (pull_request) Successful in 57s
CI / gate (pull_request) Successful in 0s
CI / deploy (pull_request) Successful in 1m15s
CI / changes (pull_request) Successful in 2s
CI / unit (pull_request) Successful in 11s
CI / integration (pull_request) Successful in 18s
CI / ui (pull_request) Successful in 57s
CI / gate (pull_request) Successful in 0s
CI / deploy (pull_request) Successful in 1m15s
- Bake the final default-allow + mute-the-ineligible strategy into docs/ARCHITECTURE.md, docs/FUNCTIONAL.md (+_ru), platform/telegram/README.md, the deploy compose comment and the PRERELEASE tracker. The live test proved a per-user grant cannot exceed a deny-by-default group (Telegram intersects the chat default with the per-user permission), so the chat allows sending by default and the bot restricts the ineligible instead of granting the eligible. - Lower the per-event chat_member trace and eligibility evaluation to Debug; keep the actual mute/unmute actions, the startup self-check and warnings at Info, so prod logs only what the bot did. - Update game.searchingForOpponent (Searching -> Waiting for opponent / Поиск -> Ждём соперника) and the quickmatch e2e assertions to match.
This commit is contained in:
+15
-11
@@ -41,7 +41,7 @@ the edge before prod. Each phase maps back to the owner's raw pre-release TODO l
|
||||
| DV | Dictionary version hygiene: CI + image/compose seed track the current release (`v1.2.1`); a **seed-drift guard** records the flat dir's seed in an authoritative `.seed_version` marker so a bumped build seed on a live volume is ignored (it can't relabel live bytes — which would mis-serve the dictionary + void games pinned to the prior label); `DICT_VERSION` is the fresh-volume seed only, a live contour migrates through the admin console | owner ad-hoc | **done** |
|
||||
| TX | Telegram egress off the main host: split the connector into a home **validator** (Mini App / Login-Widget HMAC, no VPN, no Bot API — so game login no longer depends on Telegram being reachable) and a remote **bot** (Bot API long-poll + `sendMessage`) that holds **no inbound port** and dials the gateway over a reverse **mTLS bot-link** (`pkg/proto/botlink/v1`); the gateway funnels out-of-app push (fire-and-forget, at-most-once) and the backend admin broadcasts (a relay that awaits the bot's ack) down the link. The bot is Telegram-rate-limited; **one bot now**, with seams (a bot registry + `owns_updates` + command ids) for N later; **no webhook** (rejected: one URL per token, adds inbound + a static address). The **unified test contour** runs the split (the bot keeps its VPN sidecar and dials the gateway by its internal name; certs from `deploy/gen-certs.sh`). The **prod** wiring — the bot on a separate host (no VPN), the gateway bot-link port published, `PROD_` certs with scheduled rotation, an SSH deploy of both hosts together — is the **deferred final stage** (Stage 18). | owner ad-hoc | **done** (code + test contour; prod wiring → Stage 18) |
|
||||
| AG | Anti-abuse IP ban + honeypot/honeytoken (prod-only): a fail2ban-style in-memory `ratelimit.Banlist` keyed by client IP, fed by sustained rate-limiter rejections (the IP-keyed public/email/admin classes — the user class stays the soft-flag's concern), a **honeypot** decoy path (the contour caddy tags `/.env`, `/.git`, `/wp-*`, … with `X-Scrabble-Honeypot` and routes them to the gateway), and a **honeytoken** (`GATEWAY_HONEYTOKEN`, a planted bearer). The `abuseGuard` edge middleware refuses a banned IP with **429** before any work — closing the R3 gap that the static SPA/landing was outside the token bucket. Off by default — it keys by the real client IP the shared-NAT test contour does not expose (detection still logs there); enabled in prod via `GATEWAY_ABUSE_BAN_ENABLED`. Operators see + lift bans on the console **Throttled** page; the gateway syncs its active set to the backend (`/api/v1/internal/bans/sync`, `internal/banview`) every 30 s and applies operator unbans. | owner ad-hoc | **done** (code + test contour; ban enabled in prod → Stage 18) |
|
||||
| CM | Channel-chat moderation + promo bot: a second standalone bot in the bot container answers `/start` with a localized message + a **URL** button into the **main** bot's Mini App (`?startapp`; a `web_app` button would sign initData with the promo token, which the main validator rejects). The **main** bot gates write access in a channel's linked discussion chat — granting on join when the Telegram user is registered and neither admin-suspended nor holding a new **`chat_muted`** role, and revoking/granting on the matching moderation change for a member currently in the chat (a `getChatMember` guard, since bots cannot list members). Eligibility = `registered AND NOT suspended AND NOT chat_muted` (the game suspension dominates), resolved once in the backend and reached two ways: the bot's join-time unary `ResolveChatEligibility` over the existing mTLS bot-link, and a backend `chat_access_changed` event → gateway → `ChatGate` command (idempotent, so a temporary-block-expiry sweeper may over-emit). No schema change — `chat_muted` reuses `account_roles`. | owner ad-hoc | **done** |
|
||||
| CM | Channel-chat moderation + promo bot: a second standalone bot in the bot container answers `/start` with a localized message + a **URL** button into the **main** bot's Mini App (`?startapp`; a `web_app` button would sign initData with the promo token, which the main validator rejects). The **main** bot gates write access in a channel's linked discussion chat. The chat **allows sending by default** and the bot only restricts (Telegram intersects the chat default with the per-user permission, so a per-user grant cannot exceed a deny-by-default group): it **mutes** a member who is not registered or is admin-suspended or holding a new **`chat_muted`** role, and **un-mutes** an eligible one it had muted, for a member currently in the chat (a `getChatMember` guard, since bots cannot list members). Eligibility = `registered AND NOT suspended AND NOT chat_muted` (the game suspension dominates), resolved once in the backend and reached two ways: the bot's `ResolveChatEligibility` on a `chat_member` event over the existing mTLS bot-link, and a backend `chat_access_changed` event → gateway → `ChatGate` command (emitted on block/unblock, a `chat_muted` change, a first registration, or a temporary-block expiry via a sweeper; idempotent). No schema change — `chat_muted` reuses `account_roles`. | owner ad-hoc | **done** |
|
||||
| → | Stage 18 — prod contour deploy | — | see [`PLAN.md`](PLAN.md) |
|
||||
|
||||
## Key findings (these reshaped the raw list — read before starting a phase)
|
||||
@@ -611,13 +611,17 @@ Then Stage 18.
|
||||
the sweeper window (unit + integration); gateway hub `ResolveChatEligibility` + the chat-gate command; bot
|
||||
`chat_member` grant + `ApplyChatGate` getChatMember-guard; promo `/start` localization + URL button; config
|
||||
parsing.
|
||||
- **Post-contour-test fixes (same PR):** a live test surfaced gaps. (1) **Join detection** — a
|
||||
default-deny discussion group reports a present member as `restricted`, not `member`, so the grant
|
||||
trigger now fires for any eligible in-chat member (`member` or `restricted`) still lacking the send
|
||||
right, with a loop guard (skip when send is already allowed, so the bot's own grant does not
|
||||
re-fire); revoking a now-ineligible user stays the chat-gate path's job, so this never fights a
|
||||
`chat_muted`/block. (2) **Join-before-register** — a user who joins before registering is covered by
|
||||
no `chat_member` event, so `ProvisionTelegram` now reports first contact and the Telegram auth
|
||||
handler emits `chat_access_changed` on it. (3) **Observability** — a startup self-check logs whether
|
||||
the bot is an admin-with-restrict in the chat (it caught a misconfigured `TELEGRAM_CHAT_ID` set to a
|
||||
channel id instead of the discussion-group id), plus per-event grant-path logging.
|
||||
- **Post-contour-test fixes (same PR):** a live test drove three corrections. (1) **Strategy
|
||||
inversion (the key one)** — the original "group default no-send, bot grants the eligible" cannot
|
||||
work: Telegram intersects the chat default with each user's permission, so a per-user grant never
|
||||
exceeds a deny-by-default group (the bot set `can_send=true` yet the user still could not write).
|
||||
The group now **allows sending by default** and the bot only **restricts** — it mutes an ineligible
|
||||
member (unregistered / admin-suspended / `chat_muted`) and un-mutes an eligible one it had muted,
|
||||
acting only when the current state differs (idempotent; the bot's own change is skipped by matching
|
||||
the actor id to the bot). A present member in a default-allow group can appear as `restricted` with
|
||||
`is_member`, so the gate reads both. (2) **Join-before-register** — a user who joins before
|
||||
registering is covered by no `chat_member` event, so `ProvisionTelegram` now reports first contact
|
||||
and the Telegram auth handler emits `chat_access_changed` on it. (3) **Observability** — a startup
|
||||
self-check logs whether the bot is an admin-with-restrict in the chat (it caught a misconfigured
|
||||
`TELEGRAM_CHAT_ID` set to a channel id, not the discussion-group id); the per-event trace is at
|
||||
Debug, the actual mute/unmute and warnings at Info.
|
||||
|
||||
@@ -269,8 +269,9 @@ services:
|
||||
TELEGRAM_BOT_TOKEN: ${TELEGRAM_BOT_TOKEN:-}
|
||||
TELEGRAM_GAME_CHANNEL_ID: ${TELEGRAM_GAME_CHANNEL_ID:-}
|
||||
# The moderated discussion chat (a channel's linked group) the bot gates write
|
||||
# access in. Empty disables gating; the bot must be an admin there with the
|
||||
# "Ban users" right and receives chat_member updates only as an admin.
|
||||
# access in. Empty disables gating. The group must ALLOW sending by default — the bot
|
||||
# only restricts (mutes the ineligible) — and the bot must be an admin there with the
|
||||
# "Ban users" right; chat_member updates are delivered only to a chat admin.
|
||||
TELEGRAM_CHAT_ID: ${TELEGRAM_CHAT_ID:-}
|
||||
# The optional standalone promo bot (its own token) answering /start with a button
|
||||
# into the main bot's app. Empty disables it; when set it needs the main bot's
|
||||
|
||||
+11
-7
@@ -997,13 +997,17 @@ sweeper for the gate — it recomputes against `now`). No operator identity is r
|
||||
Basic-Auth).
|
||||
|
||||
**Moderated discussion chat.** A channel's linked discussion group is gated by the Telegram bot
|
||||
(`TELEGRAM_CHAT_ID`): the group defaults to no-send, and a user may write only while they are
|
||||
**registered and neither admin-suspended nor holding the chat-only `chat_muted` role**
|
||||
(`eligible = registered AND NOT suspended AND NOT chat_muted` — the game suspension dominates). A
|
||||
single backend resolver behind `POST /api/v1/internal/chat-access` answers both directions: the
|
||||
bot's join-time `ResolveChatEligibility` (over the mTLS bot-link) grants write access to an
|
||||
eligible joiner, and a `chat_access_changed` event — emitted on a block/unblock, a `chat_muted`
|
||||
grant/revoke, or a temporary block lapsing (a dedicated `account.SuspensionSweeper`, since no
|
||||
(`TELEGRAM_CHAT_ID`). The group **allows sending by default** and the bot only **restricts**: Telegram
|
||||
intersects the chat default with each user's permission, so a per-user grant can never exceed a
|
||||
deny-by-default group — the gate must mute the ineligible, not grant the eligible. A user may write
|
||||
while they are **registered and neither admin-suspended nor holding the chat-only `chat_muted` role**
|
||||
(`eligible = registered AND NOT suspended AND NOT chat_muted` — the game suspension dominates); the bot
|
||||
**mutes** an ineligible member and **un-mutes** an eligible one it had muted, leaving an already-allowed
|
||||
eligible member untouched (it acts only when the current state differs, so it is idempotent and never
|
||||
loops on its own change). A single backend resolver behind `POST /api/v1/internal/chat-access` answers
|
||||
both directions: the bot's `ResolveChatEligibility` on a `chat_member` event (over the mTLS bot-link),
|
||||
and a `chat_access_changed` event — emitted on a block/unblock, a `chat_muted` grant/revoke, a first
|
||||
Telegram registration, or a temporary block lapsing (a dedicated `account.SuspensionSweeper`, since no
|
||||
request fires then) — drives a `ChatGate` command the gateway pushes to the bot. The bot applies it
|
||||
only to a member currently in the chat (a per-user `getChatMember` probe, since bots cannot list
|
||||
members); the signal is idempotent and is never an in-app or out-of-app message. `chat_muted` is an
|
||||
|
||||
+7
-7
@@ -323,13 +323,13 @@ plus the reason when one was given, and the app stops all background traffic wit
|
||||
temporary block lifts itself when it expires; the operator can also **unblock** from the user card
|
||||
at any time (games already lost stay lost).
|
||||
|
||||
Where the bot manages a channel's **linked discussion chat**, only a **registered** player who is
|
||||
**not blocked** may write there: the bot grants the right to write when such a player joins, while an
|
||||
unregistered or blocked one stays muted (the promo bot points newcomers at the game so they register).
|
||||
An operator can also **mute a player in the chat only** — a `chat_muted` role on the user card —
|
||||
without a full account block; an account block mutes them in the chat regardless. Muting/unmuting and
|
||||
blocking/unblocking take effect for a player already in the chat; one who is not in it is unaffected
|
||||
until they next join.
|
||||
Where the bot manages a channel's **linked discussion chat**, everyone may write by default and the
|
||||
bot **mutes** a player who is **not registered** or is **blocked**, un-muting them once they register
|
||||
or are unblocked. So an unregistered newcomer who comments is muted (the promo bot points them at the
|
||||
game to register, after which the bot restores their voice), and a registered, unblocked player simply
|
||||
writes. An operator can also **mute a player in the chat only** — a `chat_muted` role on the user card —
|
||||
without a full account block; an account block mutes them in the chat regardless. Muting and unmuting
|
||||
take effect for a player already in the chat; one who is not in it is unaffected until they next join.
|
||||
|
||||
From the user card the operator can also **top up a player's hint wallet**: an additive grant
|
||||
(1–100 hints per action) that raises the balance shown on the card. Grants are **raise-only** —
|
||||
|
||||
@@ -332,13 +332,14 @@ high-rate флага. С карточки пользователя операт
|
||||
истечении срока; оператор также может **разблокировать** с карточки пользователя в любой момент
|
||||
(уже проигранные партии не возвращаются).
|
||||
|
||||
Там, где бот ведёт **привязанный к каналу чат-обсуждение**, писать в нём может только
|
||||
**зарегистрированный** игрок, который **не заблокирован**: при входе такого игрока бот выдаёт право
|
||||
писать, а незарегистрированному или заблокированному — оставляет немым (промо-бот направляет
|
||||
новичков в игру, чтобы они зарегистрировались). Оператор также может **замьютить игрока только в
|
||||
чате** — роль `chat_muted` на карточке пользователя — без полной блокировки аккаунта; блокировка
|
||||
аккаунта всё равно мьютит его в чате. Мьют/размьют и блокировка/разблокировка срабатывают для
|
||||
игрока, уже находящегося в чате; того, кого в чате нет, это не затрагивает до его следующего входа.
|
||||
Там, где бот ведёт **привязанный к каналу чат-обсуждение**, по умолчанию писать может каждый, а бот
|
||||
**глушит** игрока, который **не зарегистрирован** или **заблокирован**, и снимает мьют, как только тот
|
||||
зарегистрируется или будет разблокирован. То есть незарегистрированного новичка, написавшего в чат,
|
||||
бот глушит (промо-бот направляет его в игру зарегистрироваться, после чего бот возвращает голос), а
|
||||
зарегистрированный незаблокированный игрок просто пишет. Оператор также может **замьютить игрока только
|
||||
в чате** — роль `chat_muted` на карточке пользователя — без полной блокировки аккаунта; блокировка
|
||||
аккаунта всё равно мьютит его в чате. Мьют и размьют срабатывают для игрока, уже находящегося в чате;
|
||||
того, кого в чате нет, это не затрагивает до его следующего входа.
|
||||
|
||||
С карточки пользователя оператор также может **пополнить кошелёк подсказок** игрока: аддитивное
|
||||
начисление (1–100 подсказок за раз), которое **только увеличивает** баланс на карточке. Начисления
|
||||
|
||||
@@ -43,15 +43,19 @@ Telegram identity to an account from a browser. Both map a rejection to gRPC
|
||||
code. This is **self-contained** — the bot never calls back into the game, so `/start`
|
||||
onboarding works even when the game is down.
|
||||
- **Moderated-chat gating.** When `TELEGRAM_CHAT_ID` names a channel's linked discussion
|
||||
group, the bot gates who may write there. The group is configured **default no-send**
|
||||
(a human setting); the bot grants write access to a user who **joins** when they are
|
||||
registered and neither admin-suspended nor `chat_muted`, asking the gateway over the
|
||||
bot-link (`ResolveChatEligibility`). When an operator blocks/unblocks an account or
|
||||
toggles its `chat_muted` role, the gateway pushes a `ChatGate` command and the bot
|
||||
applies it — but only to a member currently in the chat (it probes one user with
|
||||
`getChatMember`, since bots cannot list members). The bot must be an **administrator**
|
||||
there with the **"Ban users"** right (the Bot API `can_restrict_members`), and it
|
||||
subscribes to `chat_member` updates, which Telegram delivers only to a chat admin.
|
||||
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
|
||||
each user's permission, so a per-user grant cannot exceed a deny-by-default group, and the
|
||||
gate must mute the ineligible rather than grant the eligible. On a `chat_member` event the
|
||||
bot asks the gateway (`ResolveChatEligibility`) and **mutes** a member who is not registered
|
||||
or is admin-suspended or `chat_muted`, **un-mutes** an eligible one it had muted, and leaves
|
||||
an already-allowed eligible member untouched (it acts only when the state differs, so it is
|
||||
idempotent and skips its own change). When an operator blocks/unblocks an account, toggles
|
||||
its `chat_muted` role, or a user first registers, the gateway pushes a `ChatGate` command and
|
||||
the bot applies it — but only to a member currently in the chat (it probes one user with
|
||||
`getChatMember`, since bots cannot list members). The bot must be an **administrator** there
|
||||
with the **"Ban users"** right (the Bot API `can_restrict_members`), and it subscribes to
|
||||
`chat_member` updates, which Telegram delivers only to a chat admin.
|
||||
- **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
|
||||
|
||||
@@ -270,7 +270,7 @@ func (t *Bot) handleChatMember(ctx context.Context, cm *models.ChatMemberUpdated
|
||||
// Telegram delivers joins, for which chat, the transition, who performed it, and the
|
||||
// new member's send/membership state.
|
||||
canSend, isMember := restrictedSendState(cm.NewChatMember)
|
||||
t.log.Info("chat_member update",
|
||||
t.log.Debug("chat_member update",
|
||||
zap.Int64("chat_id", cm.Chat.ID),
|
||||
zap.Int64("configured_chat_id", t.chatID),
|
||||
zap.Int64("user_id", uid),
|
||||
@@ -318,7 +318,7 @@ func (t *Bot) handleChatMember(ctx context.Context, cm *models.ChatMemberUpdated
|
||||
t.log.Warn("chat access eligibility failed", zap.Int64("user_id", user.ID), zap.Error(err))
|
||||
return
|
||||
}
|
||||
t.log.Info("chat access evaluated",
|
||||
t.log.Debug("chat access evaluated",
|
||||
zap.Int64("user_id", user.ID), zap.Bool("eligible", eligible), zap.Bool("can_send", currentlyCanSend))
|
||||
// Desired: an eligible user may send, an ineligible one may not. Act only when the
|
||||
// current state differs — idempotent, a no-op for the common eligible member, and it
|
||||
@@ -356,7 +356,7 @@ func (t *Bot) ApplyChatGate(ctx context.Context, userID int64, allow bool) (bool
|
||||
t.log.Info("chat gate applied", zap.Int64("user_id", userID), zap.Bool("allow", allow))
|
||||
return true, nil
|
||||
default:
|
||||
t.log.Info("chat gate: user not in chat, skipped", zap.Int64("user_id", userID), zap.String("status", string(member.Type)))
|
||||
t.log.Debug("chat gate: user not in chat, skipped", zap.Int64("user_id", userID), zap.String("status", string(member.Type)))
|
||||
return false, nil // absent, or an admin/owner who cannot be restricted
|
||||
}
|
||||
}
|
||||
|
||||
@@ -19,7 +19,7 @@ test('quick game: enter immediately, wait for an opponent, then it joins', async
|
||||
|
||||
// Still waiting for an opponent: the opponent card shows the placeholder, and resign (in the
|
||||
// history panel) is disabled.
|
||||
await expect(page.getByText(/Searching for opponent/)).toBeVisible();
|
||||
await expect(page.getByText(/Waiting for opponent/)).toBeVisible();
|
||||
await page.locator('.scoreboard').click(); // open the history panel
|
||||
await expect(page.getByRole('button', { name: 'Drop game' })).toBeDisabled();
|
||||
|
||||
@@ -28,7 +28,7 @@ test('quick game: enter immediately, wait for an opponent, then it joins', async
|
||||
|
||||
// The opponent card shows its name, the placeholder is gone, and resign is enabled again.
|
||||
await expect(page.getByText('Robo')).toBeVisible();
|
||||
await expect(page.getByText(/Searching for opponent/)).toHaveCount(0);
|
||||
await expect(page.getByText(/Waiting for opponent/)).toHaveCount(0);
|
||||
await expect(page.getByRole('button', { name: 'Drop game' })).toBeEnabled();
|
||||
});
|
||||
|
||||
@@ -48,7 +48,7 @@ test('AI game: 🤖 opponent, no wait, chat disabled, dictionary still works', a
|
||||
// The player lands in an active game at once (no "searching" wait); the opponent shows as 🤖.
|
||||
await expect(page.locator('[data-cell]').first()).toBeVisible();
|
||||
await expect(page.locator('.scoreboard').getByText('🤖')).toBeVisible();
|
||||
await expect(page.getByText(/Searching for opponent/)).toHaveCount(0);
|
||||
await expect(page.getByText(/Waiting for opponent/)).toHaveCount(0);
|
||||
|
||||
// An AI game has no chat at all: the comms hub drops the Chat tab and lands on the Dictionary
|
||||
// alone, which stays usable.
|
||||
@@ -96,7 +96,7 @@ async function enterOpenGame(page: import('@playwright/test').Page): Promise<voi
|
||||
await page.getByRole('button', { name: 'Random player' }).click();
|
||||
await page.locator('.variant').first().click();
|
||||
await page.getByRole('button', { name: /Start game/i }).click();
|
||||
await expect(page.getByText(/Searching for opponent/)).toBeVisible();
|
||||
await expect(page.getByText(/Waiting for opponent/)).toBeVisible();
|
||||
}
|
||||
|
||||
test('quick game: a poll recovers a join missed while the live stream is down', async ({ page }) => {
|
||||
@@ -106,7 +106,7 @@ test('quick game: a poll recovers a join missed while the live stream is down',
|
||||
await page.evaluate(() => (window as unknown as { __stream: { drop(): void } }).__stream.drop());
|
||||
await page.evaluate(() => (window as unknown as { __mock: { joinOpponent(): void } }).__mock.joinOpponent());
|
||||
await expect(page.getByText('Robo')).toBeVisible();
|
||||
await expect(page.getByText(/Searching for opponent/)).toHaveCount(0);
|
||||
await expect(page.getByText(/Waiting for opponent/)).toHaveCount(0);
|
||||
});
|
||||
|
||||
test('quick game: a stream reconnect recovers a join missed while it was down', async ({ page }) => {
|
||||
@@ -117,7 +117,7 @@ test('quick game: a stream reconnect recovers a join missed while it was down',
|
||||
await page.evaluate(() => (window as unknown as { __mock: { joinOpponent(): void } }).__mock.joinOpponent());
|
||||
await page.evaluate(() => (window as unknown as { __stream: { restore(): void } }).__stream.restore());
|
||||
await expect(page.getByText('Robo')).toBeVisible();
|
||||
await expect(page.getByText(/Searching for opponent/)).toHaveCount(0);
|
||||
await expect(page.getByText(/Waiting for opponent/)).toHaveCount(0);
|
||||
});
|
||||
|
||||
test('quick game: a foreground resync recovers a join shed while the stream stayed alive', async ({ page }) => {
|
||||
@@ -125,11 +125,11 @@ test('quick game: a foreground resync recovers a join shed while the stream stay
|
||||
// The opponent joins but the event is shed with the stream still alive (no reconnect, and the
|
||||
// poll only runs while the stream is down): nothing has recovered yet.
|
||||
await page.evaluate(() => (window as unknown as { __mock: { joinOpponentSilently(): void } }).__mock.joinOpponentSilently());
|
||||
await expect(page.getByText(/Searching for opponent/)).toBeVisible();
|
||||
await expect(page.getByText(/Waiting for opponent/)).toBeVisible();
|
||||
// Returning to the foreground resyncs the open game (pageshow drives goForeground).
|
||||
await page.evaluate(() => window.dispatchEvent(new Event('pageshow')));
|
||||
await expect(page.getByText('Robo')).toBeVisible();
|
||||
await expect(page.getByText(/Searching for opponent/)).toHaveCount(0);
|
||||
await expect(page.getByText(/Waiting for opponent/)).toHaveCount(0);
|
||||
});
|
||||
|
||||
// Regression: an opponent joining must not freeze the screen. The in-game live-event effect tracked
|
||||
|
||||
@@ -63,7 +63,7 @@ export const en = {
|
||||
'game.yourTurn': 'Your turn',
|
||||
'game.yourTurnBy': '{name}: Your turn!',
|
||||
'game.opponentsTurn': "Opponent's turn",
|
||||
'game.searchingForOpponent': 'Searching for opponent…',
|
||||
'game.searchingForOpponent': 'Waiting for opponent…',
|
||||
'game.waiting': "Waiting for {name}",
|
||||
'game.makeMove': 'Make move',
|
||||
'game.reset': 'Reset',
|
||||
|
||||
@@ -64,7 +64,7 @@ export const ru: Record<MessageKey, string> = {
|
||||
'game.yourTurn': 'Ваш ход',
|
||||
'game.yourTurnBy': '{name}: Ваш ход!',
|
||||
'game.opponentsTurn': 'Ход соперника',
|
||||
'game.searchingForOpponent': 'Поиск соперника...',
|
||||
'game.searchingForOpponent': 'Ждём соперника…',
|
||||
'game.waiting': 'Ожидаем {name}',
|
||||
'game.makeMove': 'Сделать ход',
|
||||
'game.reset': 'Сброс',
|
||||
|
||||
Reference in New Issue
Block a user