Compare commits
41 Commits
513b772cd4
..
master
| Author | SHA1 | Date | |
|---|---|---|---|
| d94d56b461 | |||
| d48797e297 | |||
| fe29c6fe58 | |||
| 3429dbec5f | |||
| 74026223ee | |||
| 896b7f57a7 | |||
| aa803260a1 | |||
| 904e92aac8 | |||
| 8758cbd35d | |||
| 1d306ec0fc | |||
| e836dfefba | |||
| 39e03d22e4 | |||
| aaf162de40 | |||
| 5ce9f7e9b4 | |||
| 3456a0b3ff | |||
| a41281c495 | |||
| 516ffbe5f0 | |||
| 6badc20078 | |||
| 0ca01133b5 | |||
| 45f0b34881 | |||
| 18785efc8c | |||
| 780ff68ec2 | |||
| 57ff2d03f8 | |||
| a9d0986e74 | |||
| 45957bdcd6 | |||
| 829e29a726 | |||
| 399508f2f0 | |||
| 3a18e683ca | |||
| 93d086a8a3 | |||
| 8fe1bdba6b | |||
| 7923b3cc09 | |||
| 4891216749 | |||
| f1b8769c89 | |||
| b6f28a2423 | |||
| e32ee9ce68 | |||
| dc946a1faf | |||
| 384bd143d0 | |||
| c5d22fceca | |||
| deaa7a29c5 | |||
| 24017bcb7f | |||
| 2c4f4b10dc |
@@ -10,6 +10,7 @@ import (
|
|||||||
"database/sql"
|
"database/sql"
|
||||||
"errors"
|
"errors"
|
||||||
"fmt"
|
"fmt"
|
||||||
|
"math/rand/v2"
|
||||||
"strings"
|
"strings"
|
||||||
"time"
|
"time"
|
||||||
|
|
||||||
@@ -525,8 +526,13 @@ func (s *Store) create(ctx context.Context, kind, externalID string, seed provis
|
|||||||
return created, nil
|
return created, nil
|
||||||
}
|
}
|
||||||
|
|
||||||
// guestDisplayName is the display name stamped on a freshly provisioned guest.
|
// guestDisplayName mints the display name stamped on a freshly provisioned guest: "Guest" plus a
|
||||||
const guestDisplayName = "Guest"
|
// 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
|
// 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
|
// 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.
|
stmt := table.Accounts.
|
||||||
INSERT(table.Accounts.AccountID, table.Accounts.DisplayName, table.Accounts.IsGuest, table.Accounts.TimeZone, table.Accounts.PreferredLanguage).
|
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)
|
RETURNING(table.Accounts.AllColumns)
|
||||||
|
|
||||||
var row model.Accounts
|
var row model.Accounts
|
||||||
|
|||||||
@@ -5,6 +5,7 @@ package inttest
|
|||||||
import (
|
import (
|
||||||
"context"
|
"context"
|
||||||
"errors"
|
"errors"
|
||||||
|
"regexp"
|
||||||
"testing"
|
"testing"
|
||||||
"time"
|
"time"
|
||||||
|
|
||||||
@@ -241,6 +242,10 @@ func TestProvisionSeedsTimeZone(t *testing.T) {
|
|||||||
if guest.PreferredLanguage != "ru" {
|
if guest.PreferredLanguage != "ru" {
|
||||||
t.Errorf("guest PreferredLanguage = %q, want the seeded ru", guest.PreferredLanguage)
|
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, "", "")
|
plainGuest, err := store.ProvisionGuest(ctx, "", "")
|
||||||
if err != nil {
|
if err != nil {
|
||||||
t.Fatalf("provision plain guest: %v", err)
|
t.Fatalf("provision plain guest: %v", err)
|
||||||
|
|||||||
+1
-1
@@ -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
|
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
|
⚙️ 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,
|
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
|
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 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
|
lobby walk-through on their first trip back. Both series work the same in portrait and landscape (the
|
||||||
|
|||||||
@@ -52,7 +52,7 @@ Telegram-бот, который документы указывают как к
|
|||||||
следующей; после последней подсказки оверлей убирается навсегда. Серий две. Серия **лобби**
|
следующей; после последней подсказки оверлей убирается навсегда. Серий две. Серия **лобби**
|
||||||
указывает на вкладки ⚙️ настроек, ✏️ статистики и 🎲 новой игры. Серия **игры**, на первой партии
|
указывает на вкладки ⚙️ настроек, ✏️ статистики и 🎲 новой игры. Серия **игры**, на первой партии
|
||||||
игрока, указывает на шапку со счётом (история ходов / чат / проверка слова), кнопки 🔄 пас-и-обмен,
|
игрока, указывает на шапку со счётом (история ходов / чат / проверка слова), кнопки 🔄 пас-и-обмен,
|
||||||
🛟 подсказок и 🔀 перемешивания, и на стойку с фишками. Серия считается просмотренной только
|
✨ подсказок и 🔀 перемешивания, и на стойку с фишками. Серия считается просмотренной только
|
||||||
**после последней подсказки**, поэтому выход на середине в следующий раз покажет её с начала;
|
**после последней подсказки**, поэтому выход на середине в следующий раз покажет её с начала;
|
||||||
запоминается она по устройству. Триггер серии лобби — попадание в лобби, так что игрок, пришедший
|
запоминается она по устройству. Триггер серии лобби — попадание в лобби, так что игрок, пришедший
|
||||||
по deeplink сразу в Настройки → Друзья, всё равно получит проводку по лобби при первом возврате.
|
по deeplink сразу в Настройки → Друзья, всё равно получит проводку по лобби при первом возврате.
|
||||||
|
|||||||
+2
-2
@@ -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
|
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 →
|
**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
|
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
|
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;
|
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
|
opens a dialog — pick tiles to **Exchange N**, or pick
|
||||||
none to **Pass without exchanging**; pass is always available on your turn, exchange only
|
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
|
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
|
no confirm), which
|
||||||
**animates** — tiles hop along a low parabola to their new slots (duration scaled by the
|
**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
|
distance, the longest ≤ 0.3 s; off under reduce-motion) with a short haptic shake. A **thin strip
|
||||||
|
|||||||
@@ -1,5 +1,5 @@
|
|||||||
ext {
|
ext {
|
||||||
minSdkVersion = 24
|
minSdkVersion = 29
|
||||||
compileSdkVersion = 36
|
compileSdkVersion = 36
|
||||||
targetSdkVersion = 36
|
targetSdkVersion = 36
|
||||||
androidxActivityVersion = '1.11.0'
|
androidxActivityVersion = '1.11.0'
|
||||||
|
|||||||
@@ -97,14 +97,15 @@ test.describe('native offline-first', () => {
|
|||||||
await expectOfflineGuestLobby(page);
|
await expectOfflineGuestLobby(page);
|
||||||
|
|
||||||
// New game -> the offline mode selector offers "with friends" (pass-and-play) to the local guest.
|
// 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
|
// A minimal create: set the mandatory host (master) PIN. A guest host skips the "do you take a
|
||||||
// (Erudit — the profileless guest's default), two open seats.
|
// 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.locator('button.tab').nth(0).click();
|
||||||
await page.getByRole('button', { name: /With friends|друзьями/i }).click();
|
await page.getByRole('button', { name: /With friends|друзьями/i }).click();
|
||||||
await page.locator('.hostpin .plink').click();
|
await page.locator('.hostpin .plink').click();
|
||||||
await typePin(page, '9999');
|
await typePin(page, '9999');
|
||||||
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('.variant').click();
|
||||||
await page.locator('.pname').nth(0).fill('Ann');
|
await page.locator('.pname').nth(0).fill('Ann');
|
||||||
await page.locator('.pname').nth(1).fill('Bob');
|
await page.locator('.pname').nth(1).fill('Bob');
|
||||||
|
|||||||
+13
-3
@@ -10,6 +10,7 @@
|
|||||||
import Rack from './Rack.svelte';
|
import Rack from './Rack.svelte';
|
||||||
import { gateway } from '../lib/gateway';
|
import { gateway } from '../lib/gateway';
|
||||||
import { gameSource, localSource, isLocalGameId } from '../lib/gamesource';
|
import { gameSource, localSource, isLocalGameId } from '../lib/gamesource';
|
||||||
|
import { localGuestId } from '../lib/localguest';
|
||||||
import { notePreviewLocal, notePreviewNetwork } from '../lib/localeval-metrics';
|
import { notePreviewLocal, notePreviewNetwork } from '../lib/localeval-metrics';
|
||||||
import { navigate } from '../lib/router.svelte';
|
import { navigate } from '../lib/router.svelte';
|
||||||
import { app, handleError, showToast, markChatRead, seedChatUnread } from '../lib/app.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
|
// 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
|
// opponent" placeholder for an open game's still-empty seat (no account), otherwise the
|
||||||
// display name.
|
// 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 {
|
function seatName(s: { accountId: string; displayName: string } | undefined): string {
|
||||||
if (!s) return '';
|
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.
|
// An honest-AI game shows the robot opponent as 🤖, never its (pooled human-like) name.
|
||||||
if (view?.game.vsAi) return '🤖';
|
if (view?.game.vsAi) return '🤖';
|
||||||
if (!s.accountId) return t('game.searchingForOpponent');
|
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
|
idle gate while it is closed; tapping then only explains when it opens (doHint), and the lock
|
||||||
lifts live at the unlock moment. -->
|
lifts live at the unlock moment. -->
|
||||||
<button class="tab" disabled={busy || !isMyTurn || !netReady} onclick={doHint}>
|
<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>
|
<span class="lbl">{t('game.hint')}</span>
|
||||||
</button>
|
</button>
|
||||||
{:else}
|
{:else}
|
||||||
@@ -1745,7 +1755,7 @@
|
|||||||
disabled={busy || !isMyTurn || !netReady || hintCount <= 0}
|
disabled={busy || !isMyTurn || !netReady || hintCount <= 0}
|
||||||
onconfirm={doHint}
|
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>
|
<span class="lbl">{t('game.hint')}</span>
|
||||||
</TapConfirm>
|
</TapConfirm>
|
||||||
{/if}
|
{/if}
|
||||||
|
|||||||
@@ -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),
|
// to online when the network returns. Web-only reaches here (the Mini-App branches returned above),
|
||||||
// so isStandalone gates it.
|
// so isStandalone gates it.
|
||||||
const cachedProfile = await loadProfile();
|
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) {
|
if (canOffline) {
|
||||||
const interfaceOffline = typeof navigator !== 'undefined' && navigator.onLine === false;
|
const interfaceOffline = typeof navigator !== 'undefined' && navigator.onLine === false;
|
||||||
gateway.setToken(saved.token);
|
gateway.setToken(saved.token);
|
||||||
|
|||||||
@@ -91,7 +91,7 @@ export const en = {
|
|||||||
'onboarding.lobbyNew': 'Practise against the AI', // 🎲
|
'onboarding.lobbyNew': 'Practise against the AI', // 🎲
|
||||||
'onboarding.gameHeader': 'Move history, chat and word check', // scoreboard strip
|
'onboarding.gameHeader': 'Move history, chat and word check', // scoreboard strip
|
||||||
'onboarding.gameTurn': 'Pass a turn, swap tiles', // 🔄
|
'onboarding.gameTurn': 'Pass a turn, swap tiles', // 🔄
|
||||||
'onboarding.gameHints': 'Available hints', // 🛟
|
'onboarding.gameHints': 'Available hints', // ✨
|
||||||
'onboarding.gameShuffle': 'Shuffle your tiles', // 🔀
|
'onboarding.gameShuffle': 'Shuffle your tiles', // 🔀
|
||||||
'onboarding.gameRack': 'Pick a tile and place it on the board', // rack
|
'onboarding.gameRack': 'Pick a tile and place it on the board', // rack
|
||||||
|
|
||||||
|
|||||||
@@ -91,7 +91,7 @@ export const ru: Record<MessageKey, string> = {
|
|||||||
'onboarding.lobbyNew': 'Потренируйтесь с ИИ', // 🎲
|
'onboarding.lobbyNew': 'Потренируйтесь с ИИ', // 🎲
|
||||||
'onboarding.gameHeader': 'История ходов, чат и проверка слова', // шапка над доской
|
'onboarding.gameHeader': 'История ходов, чат и проверка слова', // шапка над доской
|
||||||
'onboarding.gameTurn': 'Пропуск хода, обмен фишек', // 🔄
|
'onboarding.gameTurn': 'Пропуск хода, обмен фишек', // 🔄
|
||||||
'onboarding.gameHints': 'Доступные подсказки', // 🛟
|
'onboarding.gameHints': 'Доступные подсказки', // ✨
|
||||||
'onboarding.gameShuffle': 'Встряхните фишки', // 🔀
|
'onboarding.gameShuffle': 'Встряхните фишки', // 🔀
|
||||||
'onboarding.gameRack': 'Выбирайте фишку и ставьте на доску', // rack
|
'onboarding.gameRack': 'Выбирайте фишку и ставьте на доску', // rack
|
||||||
|
|
||||||
|
|||||||
@@ -175,7 +175,10 @@ function applyEffect(effect: Effect): void {
|
|||||||
* poll immediately (the native reconcile) rather than after the first interval.
|
* poll immediately (the native reconcile) rather than after the first interval.
|
||||||
*/
|
*/
|
||||||
export function bootOffline(kickNow = false): void {
|
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);
|
arm(kickNow ? 0 : OFFLINE_POLL_MS);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -248,3 +248,31 @@ describe('reduce — purity', () => {
|
|||||||
expect(prev).toEqual({ state: 'connecting', fails: 1, connectingSince: 5 });
|
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
@@ -74,6 +74,14 @@ export interface NetSnapshot {
|
|||||||
* it (0 while not `connecting`).
|
* it (0 while not `connecting`).
|
||||||
*/
|
*/
|
||||||
connectingSince: number;
|
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
|
/** 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) {
|
switch (ev.type) {
|
||||||
case 'callOk':
|
case 'callOk':
|
||||||
case 'probeOk':
|
case 'probeOk':
|
||||||
// The probe (or a reconcile call) won — self-heal to online with a "back online" toast.
|
// The probe (or a reconcile call) won — heal to online. A boot-assumed (provisional) offline
|
||||||
return { next: onlineSnapshot(), effects: [{ kind: 'toast', toast: 'online' }] };
|
// 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':
|
case 'osOnline':
|
||||||
// Trigger a probe; stay offline until it actually wins (a captive portal can report online).
|
// Trigger a probe; stay offline until it actually wins (a captive portal can report online).
|
||||||
return { next: prev, effects: [{ kind: 'startProbe' }] };
|
return { next: prev, effects: [{ kind: 'startProbe' }] };
|
||||||
default:
|
default:
|
||||||
// callFailed / probeFailed / osOffline (still offline), versionRecommended (no nudge): no-op.
|
// osOffline (still offline), versionRecommended (no nudge): no-op.
|
||||||
return { next: prev, effects: [] };
|
return { next: prev, effects: [] };
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -265,7 +265,10 @@
|
|||||||
case 'host-set':
|
case 'host-set':
|
||||||
if (r.kind === 'set') {
|
if (r.kind === 'set') {
|
||||||
hostPin = r.lock;
|
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;
|
break;
|
||||||
case 'host-change':
|
case 'host-change':
|
||||||
|
|||||||
Reference in New Issue
Block a user