Compare commits

..

28 Commits

Author SHA1 Message Date
developer 5ce9f7e9b4 Merge pull request 'chore(release): promote development to master' (#258) from development into master 2026-07-13 22:21:59 +00:00
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 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 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
5 changed files with 3 additions and 186 deletions
+2 -4
View File
@@ -107,10 +107,8 @@ dropped). Horizontal scaling is explicit future work.
the header **"Connecting…"** spinner, chrome stays online); `offlineNoNetwork` (sustained loss — blue
chrome, a local-only lobby, the transport **kill switch**); and `offlineVersionLocked` (the gateway is
reachable but the build is below the hard minimum — the *update* notice, the client-version gate below). **Offline is
implicit** — detected from failed calls, a reachability probe, a **live-stream heartbeat watchdog**
(a silence past ~25 s of the gateway's 10 s keep-alive `heartbeat` means a silently-dead stream — e.g.
the radio was cut with no stream error; `ui/src/lib/streamwatchdog.ts`, background-aware) and the OS hint
(`navigator` / `@capacitor/network`), **never a user toggle** — with **hysteresis** so a brief blip lives entirely in
implicit** — detected from failed calls, a reachability probe and the OS hint (`navigator` /
`@capacitor/network`), **never a user toggle** — with **hysteresis** so a brief blip lives entirely in
`connecting` (K consecutive probe failures *or* a debounce window trips `offlineNoNetwork`; the first
probe/call success heals back, with a toast). The transport **auto-retries with capped exponential
backoff** — every op on a rate-limit (the gateway rejected it before processing, so it is safe), but
-29
View File
@@ -10,7 +10,6 @@ import { navigate, router } from './router.svelte';
import { errorKey, localeFrom, setLocale, t, type Locale } from './i18n/index.svelte';
import { languageNeedsServerSync } from './language';
import { startLocalEvalMetrics } from './localeval-metrics';
import { createStreamWatchdog } from './streamwatchdog';
import { applyReduceMotion, applyTelegramTheme, applyTheme, type ThemePref, type TelegramThemeParams } from './theme';
import {
insideTelegram,
@@ -216,7 +215,6 @@ function bannerSuppressed(): boolean {
function goBackground(): void {
backgrounded = true;
streamWatchdog.pause(); // do not count silence against a suspended (throttled) stream
}
function goForeground(): void {
@@ -229,7 +227,6 @@ function goForeground(): void {
// The stream stayed alive across the suspend, so the reconnect refetch will not fire — but an
// event may have been shed from a full hub buffer while away; nudge an open game to refetch.
app.resync++;
streamWatchdog.resume(); // resume liveness watching paused on the way to the background
}
void refreshNotifications();
}
@@ -356,35 +353,12 @@ function viewingGame(gameId: string): boolean {
return router.route.name === 'game' && router.route.params.id === gameId;
}
// The live stream carries a heartbeat every GATEWAY_PUSH_HEARTBEAT_INTERVAL (10 s by default), so a
// healthy stream delivers an event well inside this window even when the game is idle. A silence
// past it means the stream died without erroring (e.g. airplane mode cut the radio): tear the dead
// subscription down and drop, so the machine leaves "online" instead of waiting for the next call.
// 25 s tolerates a couple of missed heartbeats; it must stay above the server interval + slack.
const STREAM_IDLE_TIMEOUT_MS = 25000;
const streamWatchdog = createStreamWatchdog({
timeoutMs: STREAM_IDLE_TIMEOUT_MS,
onDead: () => {
// Do not trip while suspended: timers are throttled and a backgrounded stream is legitimately
// silent (the reopen on resume covers a genuine drop).
if (backgrounded || documentHidden()) return;
// Abort the dead subscription explicitly — an aborted stream skips onError (transport gates on
// signal.aborted) — then follow the same drop path as an error close.
unsubscribeStream?.();
unsubscribeStream = null;
app.streamAlive = false;
if (!bannerSuppressed()) reportOffline();
scheduleReconnect();
},
});
function openStream(): void {
closeStream();
app.streamAlive = true;
unsubscribeStream = gateway.subscribe(
(e) => {
reportOnline(); // a delivered event proves the gateway is reachable
streamWatchdog.reset(); // any event (incl. the heartbeat) proves the stream is still live
app.lastEvent = e;
if (e.kind === 'chat_message' && e.message.senderId !== app.session?.userId) {
// While the player is in that game's comms hub (chat or dictionary tab), neither
@@ -483,7 +457,6 @@ function openStream(): void {
}
},
() => {
streamWatchdog.clear();
app.streamAlive = false;
// A background suspend drops the single-shot stream. Keep the indicator hidden while
// backgrounded or just-resumed (bannerSuppressed); otherwise show "Connecting…" (the
@@ -492,7 +465,6 @@ function openStream(): void {
scheduleReconnect();
},
);
streamWatchdog.reset(); // start the liveness watch for this stream (armed by the first heartbeat)
}
/** scheduleReconnect reopens a dropped stream once, after a short delay, while the
@@ -557,7 +529,6 @@ export async function refreshFeedbackBadge(): Promise<void> {
}
function closeStream(): void {
streamWatchdog.clear();
if (reconnectTimer) {
clearTimeout(reconnectTimer);
reconnectTimer = null;
+1 -8
View File
@@ -829,14 +829,7 @@ export class MockGateway implements GatewayClient {
// --- live stream ---
subscribe(onEvent: (e: PushEvent) => void): Unsubscribe {
this.subs.add(onEvent);
// Mirror the gateway's idle keep-alive (an immediate heartbeat, then one every 10 s) so the
// client's stream watchdog sees a live stream through an idle mock session, as in production.
onEvent({ kind: 'heartbeat' });
const hb = setInterval(() => onEvent({ kind: 'heartbeat' }), 10000);
return () => {
clearInterval(hb);
this.subs.delete(onEvent);
};
return () => this.subs.delete(onEvent);
}
// Fabricate an opponent reply shortly after the human moves, then hand the turn back.
-82
View File
@@ -1,82 +0,0 @@
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest';
import { createStreamWatchdog } from './streamwatchdog';
describe('createStreamWatchdog', () => {
beforeEach(() => vi.useFakeTimers());
afterEach(() => vi.useRealTimers());
it('fires onDead after the timeout with no activity', () => {
const onDead = vi.fn();
const w = createStreamWatchdog({ timeoutMs: 25000, onDead });
w.reset();
vi.advanceTimersByTime(24999);
expect(onDead).not.toHaveBeenCalled();
vi.advanceTimersByTime(1);
expect(onDead).toHaveBeenCalledTimes(1);
});
it('reset before the timeout re-arms and prevents the fire', () => {
const onDead = vi.fn();
const w = createStreamWatchdog({ timeoutMs: 25000, onDead });
w.reset();
vi.advanceTimersByTime(20000);
w.reset(); // a fresh event arrived; the deadline moves out
vi.advanceTimersByTime(20000);
expect(onDead).not.toHaveBeenCalled();
vi.advanceTimersByTime(5000);
expect(onDead).toHaveBeenCalledTimes(1);
});
it('a steady heartbeat keeps it from ever firing', () => {
const onDead = vi.fn();
const w = createStreamWatchdog({ timeoutMs: 25000, onDead });
w.reset();
// A 10 s server heartbeat resets it well inside the 25 s window, indefinitely.
for (let i = 0; i < 20; i++) {
vi.advanceTimersByTime(10000);
w.reset();
}
expect(onDead).not.toHaveBeenCalled();
});
it('pause clears the pending timer so it cannot fire', () => {
const onDead = vi.fn();
const w = createStreamWatchdog({ timeoutMs: 25000, onDead });
w.reset();
w.pause();
vi.advanceTimersByTime(60000);
expect(onDead).not.toHaveBeenCalled();
});
it('resume re-arms after a pause and then fires on continued silence', () => {
const onDead = vi.fn();
const w = createStreamWatchdog({ timeoutMs: 25000, onDead });
w.reset();
w.pause();
vi.advanceTimersByTime(60000); // stayed silent while paused: no fire
expect(onDead).not.toHaveBeenCalled();
w.resume();
vi.advanceTimersByTime(24999);
expect(onDead).not.toHaveBeenCalled();
vi.advanceTimersByTime(1);
expect(onDead).toHaveBeenCalledTimes(1);
});
it('clear stops a pending fire and does not re-arm', () => {
const onDead = vi.fn();
const w = createStreamWatchdog({ timeoutMs: 25000, onDead });
w.reset();
w.clear();
vi.advanceTimersByTime(60000);
expect(onDead).not.toHaveBeenCalled();
});
it('reset after a pause resumes watching (unpauses)', () => {
const onDead = vi.fn();
const w = createStreamWatchdog({ timeoutMs: 25000, onDead });
w.pause();
w.reset(); // a reopen after a suspend must start watching again
vi.advanceTimersByTime(25000);
expect(onDead).toHaveBeenCalledTimes(1);
});
});
-63
View File
@@ -1,63 +0,0 @@
// A liveness watchdog for the live event stream. The gateway pushes a `heartbeat` event on a
// fixed cadence (GATEWAY_PUSH_HEARTBEAT_INTERVAL, default 10 s), so a healthy stream delivers at
// least one event well inside the timeout even when the game itself is idle. A network that dies
// silently — e.g. airplane mode cutting the radio — leaves the streaming fetch neither erroring
// nor delivering; without this watchdog the machine would stay "online" until the next active
// call fails (often only on a foreground resync). The watchdog resets on every delivered event
// and fires onDead once the stream has been silent for the whole timeout, so the app can report
// the drop and reconnect.
/** StreamWatchdog is the control surface returned by {@link createStreamWatchdog}. */
export interface StreamWatchdog {
/** reset (re)starts the countdown; call on stream open and on every delivered event. It also
* clears a prior pause, so a reopen after a suspend resumes watching. */
reset(): void;
/** pause stops the countdown while the app is backgrounded (timers are throttled while hidden,
* and a suspended stream legitimately delivers nothing — neither should trip a false drop). */
pause(): void;
/** resume restarts the countdown after a pause (on returning to the foreground). */
resume(): void;
/** clear stops the countdown without re-arming; call when the stream is closed or has errored. */
clear(): void;
}
/**
* createStreamWatchdog builds a watchdog that calls onDead once no reset has happened for
* timeoutMs. timeoutMs must exceed the server heartbeat interval plus proxy/jitter slack, or a
* healthy but idle stream would trip it.
*/
export function createStreamWatchdog(opts: { timeoutMs: number; onDead: () => void }): StreamWatchdog {
let timer: ReturnType<typeof setTimeout> | null = null;
let paused = false;
function stop(): void {
if (timer !== null) {
clearTimeout(timer);
timer = null;
}
}
function arm(): void {
stop();
timer = setTimeout(opts.onDead, opts.timeoutMs);
}
return {
reset(): void {
paused = false;
arm();
},
pause(): void {
paused = true;
stop();
},
resume(): void {
if (paused) {
paused = false;
arm();
}
},
clear(): void {
stop();
},
};
}