feat(ui): offline-first native boot + lazy server-guest reconciliation

Native (Capacitor) cold boot with no cached session now enters as a
device-local guest in auto-offline mode and lands straight in the lobby
(never /login), so the app opens and plays local vs_ai / hotseat with the
APK's bundled dictionaries and zero network. When the gateway becomes
reachable, reconcileServerGuest silently mints + adopts a server guest and
clears the auto-offline, lighting up online features. Web / PWA / Telegram /
VK are byte-for-byte unchanged.

- transport: exec gains { silent, allowOffline } so background reconciliation
  bypasses the offline kill switch (like the reachability probe) and never
  raises the terminal update overlay (a too-old client stays a local guest);
  new authGuestSilent on the client interface, the real transport and the mock.
- app: native no-session boot branch; reconcileServerGuest fired at boot, by
  the recovery poll and the online event; the poll routes the session-less
  guest through reconciliation, since checkReachable needs a token it lacks.
- native: initNativeShell tolerates a missing Capacitor bridge.
- e2e: new native.spec.ts (inject window.androidBridge; boot -> offline lobby,
  local vs_ai move, reconcile -> online, hotseat start) + playwright.config
  bundles the dawgs into dist-e2e/dict for the loader's bundled tier.
This commit is contained in:
Ilia Denisov
2026-07-12 18:03:52 +02:00
parent a035edfb54
commit e077258567
9 changed files with 318 additions and 79 deletions
+100
View File
@@ -0,0 +1,100 @@
import { test, expect, type Page } from './fixtures';
// Simulate the native (Capacitor) channel. @capacitor/core — loaded during boot by initNativeShell —
// derives the platform from window.androidBridge / window.webkit and REPLACES any pre-set
// window.Capacitor with its own shim (whose getPlatform() would otherwise report 'web'), so the
// reliable native signal is the bridge marker, injected before any app script runs. clientChannel()
// then resolves to 'android', which flips the offline-first cold boot (app.svelte.ts), the loader's
// bundled-dict tier (lib/dict/loader.ts) and every native gate. The bridge is a stub with no real
// native runtime behind it; initNativeShell tolerates that (its @capacitor/app addListener is wrapped).
async function simulateNative(page: Page): Promise<void> {
await page.addInitScript(() => {
(window as unknown as { androidBridge: { postMessage: () => void } }).androidBridge = { postMessage: () => {} };
(window as unknown as { Capacitor: { getPlatform: () => string } }).Capacitor = { getPlatform: () => 'android' };
});
}
// The native cold boot skips the blocking login and lands straight in the lobby as a device-local
// guest in (auto) offline mode: the header carries the offline tint and the tab bar is present with
// no guest sign-in click. Offline the seeded online game (vs Ann) is hidden and Stats is disabled.
async function expectOfflineGuestLobby(page: Page): Promise<void> {
await expect(page.locator('header.nav.offline')).toBeVisible();
await expect(page.locator('button.tab').nth(2)).toBeVisible();
await expect(page.getByText('Ann', { exact: false })).toHaveCount(0);
await expect(page.locator('button.tab').nth(1)).toBeDisabled();
}
// Pick a rack tile by its glyph and drop it on a board square (the pinned-seed rack is all-distinct).
async function placeTile(page: Page, glyph: string, row: number, col: number): Promise<void> {
await page.locator('.rack .tile', { hasText: glyph }).first().click();
await page.locator(`[data-cell][data-row="${row}"][data-col="${col}"]`).click();
}
// typePin clicks the on-screen keypad digits (mirrors hotseat.spec — the pad has no OK button, so the
// 4th digit is the action; wait for the dots to clear so a call right after a previous entry keeps them).
async function typePin(page: Page, digits: string): Promise<void> {
await expect(page.locator('.pad .dot.on')).toHaveCount(0);
for (const d of digits) await page.locator('.pad .key', { hasText: d }).click();
}
test.describe('native offline-first', () => {
test('cold-boots to the offline guest lobby, plays local vs_ai, reconciles a server guest online', async ({ page }) => {
await simulateNative(page);
await page.goto('/');
await expectOfflineGuestLobby(page);
// Play a local English vs_ai game with a pinned bag seed (deals the rack NEWYMAO). The dictionary
// comes from the APK's bundled tier (./dict/scrabble_en@dev.dawg served from dist-e2e), so no
// network is needed — this is the device-local guest playing with zero connectivity.
await page.evaluate(() => (window as unknown as { __mock: { setLocalSeed(s: string): void } }).__mock.setLocalSeed('1'));
await page.locator('button.tab').nth(0).click();
await page.locator('.variant', { hasText: 'Scrabble' }).click();
await page.locator('button.invite').click();
await expect(page.locator('[data-cell]').first()).toBeVisible();
// The human plays WAY horizontally across the centre (7,5)-(7,7); the local robot replies with a
// real move (which needs the bundled dictionary), so the board gains tiles beyond WAY's three.
await placeTile(page, 'W', 7, 5);
await placeTile(page, 'A', 7, 6);
await placeTile(page, 'Y', 7, 7);
await expect(page.locator('[data-cell].pending')).toHaveCount(3);
await page.locator('.make').click();
await expect(async () => {
expect(await page.locator('[data-cell].filled').count()).toBeGreaterThan(3);
}).toPass({ timeout: 15000 });
// Back to the lobby, then the network returns: reconciliation silently mints + adopts a server
// guest and clears the auto-offline, so online lights up — the offline tint drops, the seeded
// online game (vs Ann) appears and the Stats tab re-enables. (The local vs_ai game stays
// device-only, so the online lobby list no longer shows it — that is the intended split.)
await page.getByRole('button', { name: /Back|Назад/i }).click();
await expect(page.locator('button.tab').nth(2)).toBeVisible();
await page.evaluate(() => (window as unknown as { __native: { reconcile(): void } }).__native.reconcile());
await expect(page.locator('header.nav.offline')).toHaveCount(0);
await expect(page.getByText('Ann', { exact: false }).first()).toBeVisible({ timeout: 15000 });
await expect(page.locator('button.tab').nth(1)).toBeEnabled();
});
test('starts a 2-player hotseat game as an offline guest', async ({ page }) => {
await simulateNative(page);
await page.goto('/');
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, English, two open seats.
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 page.locator('.variant', { hasText: 'Scrabble' }).click();
await page.locator('.pname').nth(0).fill('Ann');
await page.locator('.pname').nth(1).fill('Bob');
await page.locator('button.invite').click();
// The hotseat game starts: the board renders — a 2-player local pass-and-play game running with no
// network, reached straight from the native offline-first boot as the device-local guest.
await expect(page.locator('[data-cell]').first()).toBeVisible();
});
});