Compare commits

..

41 Commits

Author SHA1 Message Date
developer d94d56b461 Merge pull request 'release: promote development → master (v1.23.0)' (#276) from development into master 2026-07-15 00:32:54 +00:00
developer d48797e297 Merge pull request 'native polish 2: two-robots fix, guest offline-host UX, Guest###### name, silent+fast native boot, minSdk 29' (#275) from feature/native-polish-2 into development
CI / changes (push) Successful in 2s
CI / unit (push) Successful in 11s
CI / integration (push) Successful in 22s
CI / ui (push) Successful in 1m15s
CI / conformance (push) Successful in 10s
CI / changes (pull_request) Successful in 2s
CI / gate (push) Successful in 0s
CI / deploy (push) Successful in 1m57s
CI / unit (pull_request) Successful in 11s
CI / integration (pull_request) Successful in 21s
CI / ui (pull_request) Successful in 1m14s
CI / conformance (pull_request) Successful in 11s
CI / gate (pull_request) Successful in 0s
CI / deploy (pull_request) Has been skipped
2026-07-15 00:26:27 +00:00
Ilia Denisov fe29c6fe58 chore(android): raise minSdk to 29 (Android 10)
CI / changes (pull_request) Successful in 2s
CI / unit (pull_request) Successful in 11s
CI / integration (pull_request) Successful in 22s
CI / ui (pull_request) Successful in 1m15s
CI / conformance (pull_request) Successful in 10s
CI / gate (pull_request) Successful in 0s
CI / deploy (pull_request) Successful in 1m56s
The bundled WebView build is only compatible from Android 10 (API 29); on Android 9 it lands on
the in-app unsupported-engine screen. Declaring minSdk 29 lets RuStore/Google Play block older
devices at install rather than shipping a known-incompatible build.
2026-07-15 02:17:13 +02:00
Ilia Denisov 3429dbec5f fix(boot): native offline-first launch is silent and fast (Android 10)
Two native offline-first boot glitches (seen on Android 10):
- bootOffline forced offlineNoNetwork, so its first successful reconcile healed to online with a
  spurious "back online" toast on a first launch that actually had connectivity. The boot-assumed
  offline is now PROVISIONAL: the first probe confirms it — a success heals silently (nothing to
  come back from), a failure turns it into a real offline whose later recovery does toast.
- A native launch with a cached guest session (no email) skipped the offline short-circuit and hung
  ~25 s on adoptSession's retrying profile fetch in airplane mode. The native channel is offline-first,
  so it now takes the same reachability-gated fallback (~3 s bound) as an installed email PWA.
2026-07-15 02:17:13 +02:00
Ilia Denisov 74026223ee feat(account): give a fresh guest a distinct "Guest######" display name
Every guest showed a bare "Guest" to opponents (e.g. in a random match). Mint the display name
as "Guest" + a random six-digit suffix at provisioning, so guests are distinguishable. Not an
identifier and not unique — a label only, so collisions are harmless.
2026-07-15 02:17:13 +02:00
Ilia Denisov 896b7f57a7 feat(newgame): skip the take-a-seat prompt for a guest offline host
A guest host has no meaningful display name to seat, so the offline 'with friends' flow's
"do you take a seat?" prompt only produced a nameless seat. For a guest the prompt is skipped:
after the master PIN, the two empty player seats show directly. A durable host is still asked.
2026-07-15 02:17:13 +02:00
Ilia Denisov aa803260a1 fix(game): recognise the device-local guest as "me" in the game header
The game header identified the viewer's seat only by app.session.userId, but a device-local
game (vs_ai / hotseat) created offline is seated under the local guest id (the lobby already
matches both). After a merge switched the active account to a durable id, the human seat matched
neither, so a vs_ai game showed BOTH seats as robots (the turn still worked — it is seat-index
based). seatName now matches app.session.userId OR localGuestId, like the lobby. Completes the
v1.22.0 repointLocalGameSeats fix, which only covered games seated under the retired session id.
2026-07-15 02:17:13 +02:00
developer 904e92aac8 Merge pull request 'release: promote development → master (v1.22.0)' (#274) from development into master 2026-07-14 23:13:25 +00:00
developer 8758cbd35d Merge pull request 'native polish: guest-locale banner, seamless guest sign-in (+local-game fix), 'Opponent's turn' label, deploy-authoritative dictionary' (#273) from feature/native-polish-and-dict-deploy into development
CI / changes (push) Successful in 2s
CI / unit (push) Successful in 11s
CI / integration (push) Successful in 23s
CI / ui (push) Successful in 1m16s
CI / conformance (push) Successful in 10s
CI / changes (pull_request) Successful in 2s
CI / gate (push) Successful in 0s
CI / deploy (push) Successful in 1m49s
CI / unit (pull_request) Successful in 11s
CI / integration (pull_request) Successful in 25s
CI / ui (pull_request) Successful in 1m14s
CI / conformance (pull_request) Successful in 10s
CI / gate (pull_request) Successful in 0s
CI / deploy (pull_request) Has been skipped
2026-07-14 23:07:05 +00:00
Ilia Denisov 1d306ec0fc chore(ui): change hints symbol for backward unicode compatibility
CI / changes (pull_request) Successful in 3s
CI / unit (pull_request) Successful in 11s
CI / integration (pull_request) Successful in 23s
CI / ui (pull_request) Successful in 1m15s
CI / conformance (pull_request) Successful in 11s
CI / gate (pull_request) Successful in 0s
CI / deploy (pull_request) Successful in 1m49s
2026-07-15 00:56:47 +02:00
developer e836dfefba Merge pull request 'release: promote development → master (v1.21.0)' (#272) from development into master 2026-07-14 20:29:04 +00:00
developer 39e03d22e4 Merge pull request 'release: v1.20.1 — real client IP to backend (last-login fix) + host UTC' (#265) from development into master 2026-07-14 09:44:43 +00:00
developer aaf162de40 Merge pull request 'release: v1.20.0 — offline-UX, catalog order + admin toggles, reward-config admin' (#263) from development into master 2026-07-14 09:02:54 +00:00
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
15 changed files with 97 additions and 22 deletions
+9 -3
View File
@@ -10,6 +10,7 @@ import (
"database/sql"
"errors"
"fmt"
"math/rand/v2"
"strings"
"time"
@@ -525,8 +526,13 @@ func (s *Store) create(ctx context.Context, kind, externalID string, seed provis
return created, nil
}
// guestDisplayName is the display name stamped on a freshly provisioned guest.
const guestDisplayName = "Guest"
// guestDisplayName mints the display name stamped on a freshly provisioned guest: "Guest" plus a
// random six-digit suffix (e.g. "Guest042317"), so a guest is distinguishable to opponents — in a
// random match the other player sees a distinct name rather than every guest reading a bare
// "Guest". Not an identifier and not unique (collisions are harmless — it is a label only).
func guestDisplayName() string {
return fmt.Sprintf("Guest%06d", rand.IntN(1_000_000))
}
// ProvisionGuest creates a fresh ephemeral guest account: a durable row carrying
// no identity, flagged is_guest, so it can hold a session and a game seat (both
@@ -553,7 +559,7 @@ func (s *Store) ProvisionGuest(ctx context.Context, browserTZ, language string)
}
stmt := table.Accounts.
INSERT(table.Accounts.AccountID, table.Accounts.DisplayName, table.Accounts.IsGuest, table.Accounts.TimeZone, table.Accounts.PreferredLanguage).
VALUES(accountID, guestDisplayName, true, tz, lang).
VALUES(accountID, guestDisplayName(), true, tz, lang).
RETURNING(table.Accounts.AllColumns)
var row model.Accounts
+5
View File
@@ -5,6 +5,7 @@ package inttest
import (
"context"
"errors"
"regexp"
"testing"
"time"
@@ -241,6 +242,10 @@ func TestProvisionSeedsTimeZone(t *testing.T) {
if guest.PreferredLanguage != "ru" {
t.Errorf("guest PreferredLanguage = %q, want the seeded ru", guest.PreferredLanguage)
}
// A guest's display name is "Guest" + a random six-digit suffix (distinct to opponents).
if m, _ := regexp.MatchString(`^Guest\d{6}$`, guest.DisplayName); !m {
t.Errorf("guest DisplayName = %q, want Guest + 6 digits", guest.DisplayName)
}
plainGuest, err := store.ProvisionGuest(ctx, "", "")
if err != nil {
t.Fatalf("provision plain guest: %v", err)
+1 -1
View File
@@ -50,7 +50,7 @@ tail — at a real control, and a **tap anywhere** advances to the next; after t
overlay is gone for good. Two independent series run. The **lobby** series points at the
⚙️ settings, ✏️ statistics and 🎲 new-game tabs. The **game** series, on the player's first game
board, points at the scoreboard header (move history / chat / word-check), the 🔄 pass-and-exchange,
🛟 hints and 🔀 shuffle controls, and the rack. A series counts as seen only **after its last hint**,
hints and 🔀 shuffle controls, and the rack. A series counts as seen only **after its last hint**,
so leaving mid-way replays it from the start next time; it is remembered per device. Arriving at the
lobby is the lobby trigger, so a player who deep-links straight into Settings → Friends still gets the
lobby walk-through on their first trip back. Both series work the same in portrait and landscape (the
+1 -1
View File
@@ -52,7 +52,7 @@ Telegram-бот, который документы указывают как к
следующей; после последней подсказки оверлей убирается навсегда. Серий две. Серия **лобби**
указывает на вкладки ⚙️ настроек, ✏️ статистики и 🎲 новой игры. Серия **игры**, на первой партии
игрока, указывает на шапку со счётом (история ходов / чат / проверка слова), кнопки 🔄 пас-и-обмен,
🛟 подсказок и 🔀 перемешивания, и на стойку с фишками. Серия считается просмотренной только
подсказок и 🔀 перемешивания, и на стойку с фишками. Серия считается просмотренной только
**после последней подсказки**, поэтому выход на середине в следующий раз покажет её с начала;
запоминается она по устройству. Триггер серии лобби — попадание в лобби, так что игрок, пришедший
по deeplink сразу в Настройки → Друзья, всё равно получит проводку по лобби при первом возврате.
+2 -2
View File
@@ -53,7 +53,7 @@ dismisses as soon as the lobby is ready. The pure layout and timing live in `lib
A one-time **coachmark overlay** introduces the interface to a new player. Like the splash it is an
**App-level overlay**; it runs two independent series chosen by the route — **lobby** (⚙️ settings →
✏️ stats → 🎲 new game) and **game** (scoreboard header → 🔄 pass/exchange → 🛟 hints → 🔀 shuffle →
✏️ stats → 🎲 new game) and **game** (scoreboard header → 🔄 pass/exchange → hints → 🔀 shuffle →
rack). It draws **one bubble at a time** over a light scrim (`rgba(0,0,0,0.32)`); the whole layer
captures pointer events, so a **tap anywhere advances** and the UI underneath stays inert. After the
last hint the series is recorded done (`session.ts` `onboarding` key) and the overlay removes itself;
@@ -284,7 +284,7 @@ e2e and the screenshots.
opens a dialog — pick tiles to **Exchange N**, or pick
none to **Pass without exchanging**; pass is always available on your turn, exchange only
while the bag still holds a full rack, below which the tiles disable and only the pass
remains), 🛟 Hint (with a remaining-count badge, disabled at zero); 🔀 Shuffle (no label,
remains), Hint (with a remaining-count badge, disabled at zero); 🔀 Shuffle (no label,
no confirm), which
**animates** — tiles hop along a low parabola to their new slots (duration scaled by the
distance, the longest ≤ 0.3 s; off under reduce-motion) with a short haptic shake. A **thin strip
+1 -1
View File
@@ -1,5 +1,5 @@
ext {
minSdkVersion = 24
minSdkVersion = 29
compileSdkVersion = 36
targetSdkVersion = 36
androidxActivityVersion = '1.11.0'
+4 -3
View File
@@ -97,14 +97,15 @@ test.describe('native offline-first', () => {
await expectOfflineGuestLobby(page);
// New game -> the offline mode selector offers "with friends" (pass-and-play) to the local guest.
// A minimal create: set the mandatory host (master) PIN, decline a seat, the sole offered variant
// (Erudit — the profileless guest's default), two open seats.
// A minimal create: set the mandatory host (master) PIN. A guest host skips the "do you take a
// seat?" prompt (no name to seat) and goes straight to the two open seats, so pick the sole
// offered variant (Erudit — the profileless guest's default) right after the PIN.
await page.locator('button.tab').nth(0).click();
await page.getByRole('button', { name: /With friends|друзьями/i }).click();
await page.locator('.hostpin .plink').click();
await typePin(page, '9999');
await typePin(page, '9999');
await page.getByRole('button', { name: /^(No|Нет)$/ }).click();
await expect(page.getByRole('button', { name: /^(No|Нет)$/ })).toHaveCount(0); // no seat prompt for a guest
await page.locator('.variant').click();
await page.locator('.pname').nth(0).fill('Ann');
await page.locator('.pname').nth(1).fill('Bob');
+13 -3
View File
@@ -10,6 +10,7 @@
import Rack from './Rack.svelte';
import { gateway } from '../lib/gateway';
import { gameSource, localSource, isLocalGameId } from '../lib/gamesource';
import { localGuestId } from '../lib/localguest';
import { notePreviewLocal, notePreviewNetwork } from '../lib/localeval-metrics';
import { navigate } from '../lib/router.svelte';
import { app, handleError, showToast, markChatRead, seedChatUnread } from '../lib/app.svelte';
@@ -1442,9 +1443,18 @@
// seatName renders a seat's name: "you" for the viewer, the localized "searching for
// opponent" placeholder for an open game's still-empty seat (no account), otherwise the
// display name.
// isMe reports whether a seat belongs to the viewer — matched against the server account id OR
// the device-local guest id, the same rule the lobby uses for "my games". A device-local game
// (vs_ai / hotseat) is seated under the local guest id when created offline, and its human seat
// must still read "You" after a merge switches the active account to a durable id; without the
// local-guest arm the seat matched neither and a vs_ai game showed BOTH seats as robots.
function isMe(accountId: string | undefined): boolean {
return !!accountId && (accountId === app.session?.userId || accountId === localGuestId());
}
function seatName(s: { accountId: string; displayName: string } | undefined): string {
if (!s) return '';
if (s.accountId === app.session?.userId) return t('common.you');
if (isMe(s.accountId)) return t('common.you');
// An honest-AI game shows the robot opponent as 🤖, never its (pooled human-like) name.
if (view?.game.vsAi) return '🤖';
if (!s.accountId) return t('game.searchingForOpponent');
@@ -1735,7 +1745,7 @@
idle gate while it is closed; tapping then only explains when it opens (doHint), and the lock
lifts live at the unlock moment. -->
<button class="tab" disabled={busy || !isMyTurn || !netReady} onclick={doHint}>
<span class="sq" data-coach="game-hints">🛟{#if hintGated}<span class="lock" aria-hidden="true">🔒</span>{/if}</span>
<span class="sq" data-coach="game-hints">{#if hintGated}<span class="lock" aria-hidden="true">🔒</span>{/if}</span>
<span class="lbl">{t('game.hint')}</span>
</button>
{:else}
@@ -1745,7 +1755,7 @@
disabled={busy || !isMyTurn || !netReady || hintCount <= 0}
onconfirm={doHint}
>
<span class="sq" data-coach="game-hints">🛟{#if hintCount > 0}<span class="badge">{hintCount}</span>{/if}</span>
<span class="sq" data-coach="game-hints">{#if hintCount > 0}<span class="badge">{hintCount}</span>{/if}</span>
<span class="lbl">{t('game.hint')}</span>
</TapConfirm>
{/if}
+5 -1
View File
@@ -982,7 +982,11 @@ export async function bootstrap(): Promise<void> {
// to online when the network returns. Web-only reaches here (the Mini-App branches returned above),
// so isStandalone gates it.
const cachedProfile = await loadProfile();
const canOffline = !!cachedProfile && !vkcb && isStandalone() && !!cachedProfile.email;
// A native app is offline-first, so a native launch — even a guest with no email — must fall back
// to the cached session's offline lobby rather than hang ~25 s on adoptSession's retrying profile
// fetch when the device is offline (e.g. airplane mode). Web keeps the installed-PWA + email gate.
const nativeChannel = clientChannel() === 'android' || clientChannel() === 'ios';
const canOffline = !vkcb && (nativeChannel || (!!cachedProfile && isStandalone() && !!cachedProfile.email));
if (canOffline) {
const interfaceOffline = typeof navigator !== 'undefined' && navigator.onLine === false;
gateway.setToken(saved.token);
+1 -1
View File
@@ -91,7 +91,7 @@ export const en = {
'onboarding.lobbyNew': 'Practise against the AI', // 🎲
'onboarding.gameHeader': 'Move history, chat and word check', // scoreboard strip
'onboarding.gameTurn': 'Pass a turn, swap tiles', // 🔄
'onboarding.gameHints': 'Available hints', // 🛟
'onboarding.gameHints': 'Available hints', //
'onboarding.gameShuffle': 'Shuffle your tiles', // 🔀
'onboarding.gameRack': 'Pick a tile and place it on the board', // rack
+1 -1
View File
@@ -91,7 +91,7 @@ export const ru: Record<MessageKey, string> = {
'onboarding.lobbyNew': 'Потренируйтесь с ИИ', // 🎲
'onboarding.gameHeader': 'История ходов, чат и проверка слова', // шапка над доской
'onboarding.gameTurn': 'Пропуск хода, обмен фишек', // 🔄
'onboarding.gameHints': 'Доступные подсказки', // 🛟
'onboarding.gameHints': 'Доступные подсказки', //
'onboarding.gameShuffle': 'Встряхните фишки', // 🔀
'onboarding.gameRack': 'Выбирайте фишку и ставьте на доску', // rack
+4 -1
View File
@@ -175,7 +175,10 @@ function applyEffect(effect: Effect): void {
* poll immediately (the native reconcile) rather than after the first interval.
*/
export function bootOffline(kickNow = false): void {
snap = { state: 'offlineNoNetwork', fails: 0, connectingSince: 0 };
// provisional: this offline is an assumption, not an observation — the first probe result confirms
// it (silent heal if reachable, so a first launch that had connectivity does not flash a spurious
// "back online" toast) or turns it into a real offline (see the reducer).
snap = { state: 'offlineNoNetwork', fails: 0, connectingSince: 0, provisional: true };
arm(kickNow ? 0 : OFFLINE_POLL_MS);
}
+28
View File
@@ -248,3 +248,31 @@ describe('reduce — purity', () => {
expect(prev).toEqual({ state: 'connecting', fails: 1, connectingSince: 5 });
});
});
// A boot-assumed (provisional) offline — bootOffline sets this — heals SILENTLY on its first
// successful probe (a first launch that actually had connectivity must not flash "back online"),
// while a failed probe confirms it as a real offline whose later recovery does toast.
describe('provisional (boot-assumed) offline', () => {
const bootOffline: NetSnapshot = { state: 'offlineNoNetwork', fails: 0, connectingSince: 0, provisional: true };
it('heals to online with NO toast when the first probe succeeds', () => {
const r = reduce(bootOffline, e('probeOk'), cfg);
expect(r.next.state).toBe('online');
expect(r.effects).toEqual([]); // no "back online" toast — it was never really offline
});
it('clears provisional on a failed probe, then toasts on the eventual recovery', () => {
const failed = reduce(bootOffline, e('probeFailed'), cfg);
expect(failed.next.state).toBe('offlineNoNetwork');
expect(failed.next.provisional).toBe(false);
expect(failed.effects).toEqual([]);
const healed = reduce(failed.next, e('probeOk'), cfg);
expect(healed.next.state).toBe('online');
expect(healed.effects).toEqual([{ kind: 'toast', toast: 'online' }]); // now a real recovery
});
it('a non-provisional (observed) offline still toasts on recovery', () => {
const r = reduce(offline, e('probeOk'), cfg);
expect(r.effects).toEqual([{ kind: 'toast', toast: 'online' }]);
});
});
+18 -3
View File
@@ -74,6 +74,14 @@ export interface NetSnapshot {
* it (0 while not `connecting`).
*/
connectingSince: number;
/**
* provisional marks an `offlineNoNetwork` that was ASSUMED at boot (bootOffline), not observed from
* a real online→offline transition. The first probe confirms it: a success means we were reachable
* all along, so the machine heals to online SILENTLY (no "back online" toast — there was nothing to
* come back from); a failure clears the flag, turning it into a confirmed offline whose later
* recovery does toast. Absent/false in every non-boot snapshot.
*/
provisional?: boolean;
}
/** NetInput is an event stamped with the wall-clock time it occurred at; only the debounce branch
@@ -198,13 +206,20 @@ export function reduce(prev: NetSnapshot, ev: NetInput, cfg: NetConfig): NetResu
switch (ev.type) {
case 'callOk':
case 'probeOk':
// The probe (or a reconcile call) won — self-heal to online with a "back online" toast.
return { next: onlineSnapshot(), effects: [{ kind: 'toast', toast: 'online' }] };
// The probe (or a reconcile call) won — heal to online. A boot-assumed (provisional) offline
// that is reachable on its very first probe was never really offline, so heal SILENTLY; a
// confirmed offline gets the "back online" toast.
return { next: onlineSnapshot(), effects: prev.provisional ? [] : [{ kind: 'toast', toast: 'online' }] };
case 'callFailed':
case 'probeFailed':
// A failed probe confirms a boot-assumed offline as real (its eventual recovery will then
// toast); a non-provisional offline is unchanged.
return prev.provisional ? { next: { ...prev, provisional: false }, effects: [] } : { next: prev, effects: [] };
case 'osOnline':
// Trigger a probe; stay offline until it actually wins (a captive portal can report online).
return { next: prev, effects: [{ kind: 'startProbe' }] };
default:
// callFailed / probeFailed / osOffline (still offline), versionRecommended (no nudge): no-op.
// osOffline (still offline), versionRecommended (no nudge): no-op.
return { next: prev, effects: [] };
}
}
+4 -1
View File
@@ -265,7 +265,10 @@
case 'host-set':
if (r.kind === 'set') {
hostPin = r.lock;
askHostPlays = true;
// A guest host has no meaningful display name to seat, so the "do you take a seat?"
// prompt would only produce a nameless seat — skip it and go straight to the (empty)
// player seats. A durable host is still asked.
if (!guest) askHostPlays = true;
}
break;
case 'host-change':