diff --git a/ANDROID_PLAN.md b/ANDROID_PLAN.md index 97791da..d77436d 100644 --- a/ANDROID_PLAN.md +++ b/ANDROID_PLAN.md @@ -91,7 +91,17 @@ Kept current as parts land so a fresh session resumes without re-deriving. Verif `clientver`/config/`connectsrv` tests green, `svelte-check` 0, `vitest` 591, e2e 232 (incl. `update.spec.ts`), client build clean. Silent reconciliation seam deferred to D (owner); in-app store-update SDK noted Out of scope. -- **D–G β€” pending.** +- **D. Offline-first β€” 🚧 IN PROGRESS (foundations done & committed `bcd5a1d`, 2026-07-12).** The + additive, web-inert groundwork landed; the boot rewrite + reconciliation + Profile soft-sign-in + + tests remain (see Β§D for the detailed remaining path and the session decisions). Done: + `ui/scripts/bundle-dicts.mjs` (release DAWGs β†’ `dist/dict/@.dawg`, keyed on + `VITE_DICT_VERSION`, `OUT_DIR` override for the e2e); the `dict/loader.ts` **bundled tier** (between + IndexedDB and network, **native-gated** β€” see the Β§D.1 correction); `__DICT_VERSION__` vite define + + declaration; `lib/localguest.ts` (persisted device-local guest id, no DB row) + `common.guest` i18n; + `NewGame.svelte` offline vs_ai/hotseat creates fall back to `__DICT_VERSION__` and + `localGuestId()`/`t('common.guest')` when there is no session (inert until the boot lands). + `svelte-check` 0, `vitest` 591, native + web builds clean. +- **E–G β€” pending.** Open logistics (not code): **mandatory icon rebrand (future)** β€” author ONE vector master and generate *every* icon from it (web `favicon.svg` / PWA `icon-*` / maskable, Android adaptive, future iOS). Today @@ -394,69 +404,130 @@ purchase actions are absent; `pnpm check` + `pnpm test:unit` pass. **Done when:** Go `clientver` + gate tests pass; `pnpm check`/`test:unit`/`test:e2e` pass; a local gateway with `GATEWAY_MIN_CLIENT_VERSION=v99.0.0` shows the overlay, unset β‡’ unchanged. -### D. Offline-first (bundled dicts + local guest + cold boot + reconciliation) +### D. Offline-first (bundled dicts + local guest + cold boot + reconciliation) β€” 🚧 IN PROGRESS Both offline modes already exist and are gated on `offlineMode.active`: local vs_ai and 2-4-player hotseat (pass-and-play with a host PIN referee) β€” see `ui/src/lib/localgame/source.hotseat.test.ts` and `ui/src/screens/NewGame.svelte:177-431`. This phase makes them reachable on a **cold first launch with no network**. -1. **Bundle dictionaries in the APK.** - - New build step `ui/scripts/bundle-dicts.mjs`: copy the three DAWGs from the unpacked - **`scrabble-dictionary` release** (`scrabble-dawg-.tar.gz` β†’ a dict dir passed via - `DICT_DIR`; **NOT** `../scrabble-solver/dawg`) β†’ `ui/dist/dict/.dawg` (the - `dictKey(variant, version)` naming the loader's bundled tier fetches). Run only in the native - pipeline (after `pnpm build`, before `cap sync`). Web builds stay slim. `VITE_DICT_VERSION` must - equal the release's `DICT_VERSION` so the bundled `(variant, version)` matches what the loader - requests. - - Vite `define`: add `__DICT_VERSION__` from `VITE_DICT_VERSION` (mirrors `__APP_VERSION__`); - declare it in `vite-env.d.ts`. - - **Loader tier** β€” `ui/src/lib/dict/loader.ts` `load()`: between the IndexedDB tier (line 73-85) - and the network tier (line 87), add a **bundled tier** that `fetch('./dict/'+key+'.dawg')` - (relative β€” served from app assets on native, 404 on web β†’ falls through). On a hit, build the - `Dawg`, cache in memory, seed IndexedDB (`idbPutDawg`), and return. This is the only change that - lets a never-online device obtain a dictionary. - - Offline local-game creation must request the **bundled version**: where `NewGame.svelte` - offline-create and `localgame` resolve the dict version, use `__DICT_VERSION__` so the requested - `(variant, version)` matches the bundled file (grep the offline `localSource.create` calls in - `NewGame.svelte:83-96,289-296` and the dict-version resolution in `gamesource.ts`/`localgame`). -2. **Local-guest identity** β€” new `ui/src/lib/localguest.ts`: a device-local id + default display - name (e.g. localized "Π“ΠΎΡΡ‚ΡŒ"), persisted (IndexedDB `scrabble` DB / localStorage). Used as the - human seat's account id in a local vs_ai game when there is no server session (today that id comes - from the session β€” see `localgame/source.list.test.ts:64`). Hotseat seats stay independent local - identities (already so). -3. **Cold offline-first boot** β€” `ui/src/lib/offline.ts` + `ui/src/lib/app.svelte.ts`: - - Extend `shouldBootOffline` (offline.ts:85) so that on a **native** channel, no session may still - boot offline as the local guest (dicts are bundled). Keep the current web rule intact (web still - needs a prior online session). - - In `bootstrap()` (app.svelte.ts): when native and (no cached session) and (offline preference on - OR the network is unreachable at boot), set `setOfflineMode(true, …)`, establish the local guest, - preload the bundled dicts, and land in the **lobby** (not `Login`). Preserve the existing online - path (guest/email) when reachable. - - `offlinePreloadEligible` (offline.ts:69): treat a native channel as eligible (drop the - `standalone && hasEmail` web gate for native), or bypass preload entirely on native since the - dicts are bundled. -4. **Lazy server-guest reconciliation** β€” when native and online and no server session: background - `auth.guest`, cache the session, adopt it; unlock online features. Guard against duplicates (only - when no cached session). Local games remain device-only. **Add the silent update-path variant here** - (a flag on the transport `exec`/a dedicated silent `auth.guest`) so reconciliation swallows - `update_required` and stays offline β€” no overlay. This is the seam deferred from C. -5. **Ensure both offline modes render for the local guest.** `NewGame.svelte` gates the offline - flows on `guest || offlineMode.active`; the local guest makes `offlineMode.active` true, so both - the "quick" (vs_ai) and "with friends" (hotseat) flows show. Verify the vs_ai human seat uses the - local-guest id. -6. **Soft registration** stays: the local/server guest can register by email β†’ upgrade (existing - flow), adopting the server guest first if present. +**Status:** the additive foundations (D.1, D.2) are **done & committed (`bcd5a1d`)** and inert on the +web; the boot rewrite (D.3), reconciliation + the silent seam (D.4), the Profile soft-sign-in (D.6) and +the tests remain. **Verify every line ref below against current code** (they are as of 2026-07-12). -**Tests:** extend the local-game/offline unit tests for the bundled-dict tier (a `fetch` mock -returning a blob) and the local-guest boot decision (`shouldBootOffline` native-no-session); a -Playwright offline-first spec that boots with the network hook off and plays a local vs_ai move and -starts a hotseat game. (Follow `docs/TESTING.md` layers; the mock e2e bypasses the codec, so keep the -gate's server path in Go tests.) +**Decisions locked this session (owner-approved) β€” bake these before implementing:** +- **The blocking Login is bypassed on native only.** Web / PWA / Telegram / VK keep the current + online-session rule (they still need a prior session). The native channel always lands the user in the + lobby, online or offline. +- **Soft registration reuses the existing `Profile` screen** as the guest sign-in / account surface (it + already shows the email / Telegram / VK upgrade options for a guest). No new sign-in UI is built in D. +- **Hide the Telegram + VK link buttons on the native build** on Profile: VK ID web-login is a full-page + redirect to `id.vk.com` that cannot return into the Capacitor app (it strands on the web redirect URI), + and the Telegram Login Widget is unreliable in a WebView. **Email works** (pure gateway calls, no + redirect). Native Telegram/VK login (native SDKs / deep-link OAuth β€” "the pretty native popups") is a + **separate later stage**, consistent with the locked "guest+email" surface and "Out of scope: VK/Telegram + login in the native build". +- **Local-guest display name** = localized `common.guest` ("Π“ΠΎΡΡ‚ΡŒ" / "Guest"). + +1. **Bundle dictionaries in the APK. βœ… DONE.** + - `ui/scripts/bundle-dicts.mjs`: copies the DAWGs from the unpacked **`scrabble-dictionary` release** + (dir via **`DICT_DIR`**; **NOT** `../scrabble-solver/dawg`) β†’ `/dict/@.dawg`. + `dictKey(variant,version)` = `` `${variant}@${version}` `` (`lib/dict/store.ts:31`). `dawgFor` maps + `scrabble_enβ†’en_sowpods`, `scrabble_ruβ†’ru_scrabble`, `erudit_ruβ†’ru_erudit` (mirrors `e2e-dict.mjs`). + Version = `VITE_DICT_VERSION` (default `dev`); `OUT_DIR` overrides the root (the e2e points it at + `dist-e2e`). Run only in the native pipeline (after `pnpm build`, before `cap sync`); web builds skip + it and stay slim. + - Vite `define` `__DICT_VERSION__` (from `VITE_DICT_VERSION`, default `dev`; mirrors `__APP_VERSION__`) + + declared in `vite-env.d.ts`. + - **Loader tier** β€” `ui/src/lib/dict/loader.ts` `load()`: a **bundled tier** sits between the IndexedDB + tier (now tier 1) and the network tier (now tier 3). **CORRECTION vs the original plan:** it is + **native-gated** (`clientChannel()` android/ios), NOT "fetch always, 404 on web". A relative + `fetch('./dict/'+key+'.dawg')` on the web would hit the **gateway's own session-gated `/dict/` route** + (a real server route), not a clean 404 β€” so the bundled fetch is skipped off-native and only tried in + a packaged app (its own assets). On a hit: build the `Dawg`, cache in memory, seed IndexedDB, return + (no network metric β€” a bundled hit is a local asset). The e2e simulates native (see Tests). + - Offline creates request the **bundled version**: `NewGame.svelte` offline vs_ai (`~line 84`) and + hotseat (`~line 277`) use `app.profile?.dictVersions?.[v] ?? __DICT_VERSION__`, so a profile-less + local guest gets the bundled `(variant, version)`. +2. **Local-guest identity. βœ… DONE.** `ui/src/lib/localguest.ts`: `localGuestId()` mints + persists a + device-local id in `localStorage` (`scrabble.localGuestId`, prefix `localguest:`, no crypto API for the + old engines) + `isLocalGuestId()`. The **display name is not in the module** β€” it is `t('common.guest')` + at the call site, so the module stays i18n-free and node-testable. `NewGame.svelte` vs_ai human seat + (`~line 99`) now uses `accountId: app.session?.userId ?? localGuestId()` and + `name: app.profile?.displayName ?? t('common.guest')`. Hotseat seats stay independent local identities + (`buildSeats` β€” unchanged). +3. **Cold offline-first boot β€” β–’ TODO** (`ui/src/lib/offline.ts` + `ui/src/lib/app.svelte.ts`). **High + blast-radius: this is app startup for every platform β€” gate every change on the native channel and + leave the web / PWA / TG / VK paths byte-for-byte.** + - The blocking Login is **`bootstrap()` app.svelte.ts ~line 989-991**: + `} else if (router.route.name !== 'login' && router.route.name !== 'confirm') { navigate('/login'); }` + β€” the `else` of `if (saved)` (no cached session). **On native, replace this branch** with the + offline-first entry: establish the local guest (`localGuestId()`), `setOfflineMode(true, false)` + (**non-sticky** auto-offline, so reconciliation may clear it β€” cf. the sticky arg at ~line 968), and + land in the **lobby** (leave the route / `navigate('/')`), `app.ready = true`. Never navigate to + `/login` on native. + - The native no-session boot **always lands in the lobby**, online or offline. If reachable at boot, + also kick reconciliation (D.4); if not, stay a local guest until the network returns. The lobby + renders without a session β€” offline mode shows local games and `NewGame` gates its offline flows on + `guest || offlineMode.active`. + - `shouldBootOffline` (offline.ts:85, currently `offlineActive && hasSession && hasProfile`): add a + **native-no-session** path so a native cold start can boot offline as the local guest with no cached + session/profile. Keep the web rule intact. + - `offlinePreloadEligible` (offline.ts:69): **bypass the server preload on native** β€” the dicts are + bundled, so there is nothing to preload; keep the web `standalone && hasEmail` gate. +4. **Lazy server-guest reconciliation + the silent seam β€” β–’ TODO** (the seam was deferred from C). + - **Silent seam:** the C transport calls `reportUpdateRequired()` in three places β€” `exec` result-code + branch (`transport.ts ~line 86`), `exec` catch (`~line 73`), `subscribe` catch (`~line 366`). Add a + **silent path** that does NOT raise the overlay: e.g. an `exec(msgType, payload, signal, opts?: { + silent?: boolean })` flag, plus an `authGuestSilent(locale)` on the `GatewayClient` interface + (`lib/client.ts`), the real transport, **and the mock** (`lib/mock/client.ts` β€” the e2e reconciliation + leg goes through the mock). Foreground `auth.*` keep the overlay. + - **Reconciliation:** when native + online + **no** server session β†’ background `authGuestSilent`, + `saveSession` + adopt (`setToken` + `app.session`/`app.profile`, or reuse `adoptSession`), then + **clear the auto-offline** (`setOfflineMode(false)`) so online features + Profile light up. Guard + duplicates (only when no cached session β€” never mint a second server guest). On `update_required` it + stays offline silently (no overlay). Local games stay device-only. Fire it on boot (if reachable) and + on network-recovery (a connection-online handler). +5. **Both offline modes render for the local guest β€” βœ… mostly (verify at D close).** `NewGame` gates the + offline flows on `guest || offlineMode.active`; the local guest makes `offlineMode.active` true, so both + "quick" (vs_ai) and "with friends" (hotseat) show. The vs_ai human seat already uses the local-guest id + (D.2). +6. **Soft registration = reuse the `Profile` screen β€” β–’ TODO.** + - Profile is already reachable for an **online** guest (guests keep the Profile tab β€” + `SettingsHub.svelte:27` only hides friends/wallet for guests; `:33` hides profile/friends/wallet in + **offline** mode). So once the native guest is online and reconciled (D.4 clears the auto-offline), + Profile shows and email sign-in works. Offline you cannot register anyway (every method needs the + server), so the offline-hidden behaviour is correct β€” **no change is needed to reach Profile**, provided + D.4 clears the auto-offline when online. + - **Hide tg/vk on native:** `Profile.svelte` β€” the Telegram link button (`~line 485`, gated on + `telegramLinkable = loginWidgetAvailable()`, `:62`) and the VK link button (`~line 499`). Add a + `clientChannel()` android/ios check to hide both on the native build; keep email + account management. + (See the decision block above for why.) + +**Tests β€” β–’ TODO** (docs/TESTING.md layers; the mock e2e bypasses the codec, so the gate's server path +stays in the Go tests): +- **Unit (vitest, node):** the bundled-dict tier β€” a `fetch` mock returning a dawg blob **with the native + channel forced** (`vi.stubGlobal('window', { Capacitor: { getPlatform: () => 'android' } })`, since the + tier is native-gated; `idbPutDawg`/`requestPersist` are best-effort `void`); `shouldBootOffline` + native-no-session; `localguest` (`localGuestId` persistence + `isLocalGuestId`). (`update.svelte.ts`-style + reminder: a `$state` rune module can't be imported under this project's plugin-less vitest.) +- **Playwright offline-first spec:** **simulate native** by injecting `window.Capacitor = { getPlatform: + () => 'android' }` via `addInitScript` (makes `clientChannel()` return `android`, activating the bundled + tier + the native boot). Place bundled dicts at **`dist-e2e/dict/@dev.dawg`** β€” extend the + `playwright.config.ts` webServer command to also run `DICT_DIR=$E2E_DICT_DIR OUT_DIR=dist-e2e node + scripts/bundle-dicts.mjs` (`VITE_DICT_VERSION` unset β†’ `dev`, matching `__DICT_VERSION__`). Boot with the + network hook off (`window.__conn.offline()` / the offline pref) β†’ assert the **lobby** (not login), play a + local vs_ai move, start a hotseat game; then network on β†’ reconciliation lights up. + **GOTCHA:** with `window.Capacitor` injected, `initNativeShell` (`App.svelte:40`) will `await + import('@capacitor/app')` and call `App.addListener('backButton', …)` β€” there is no real Capacitor + bridge in the browser, so guard/stub it (inject a fake `Capacitor.Plugins.App`, or make `initNativeShell` + tolerate a missing bridge) or the boot may throw. **Done when:** a native build with airplane mode on cold-launches to the lobby as a guest, starts and plays both a local vs_ai game and a 2-player hotseat game with no network; turning the network on -silently establishes a server guest and lights up online play. +silently establishes a server guest (Profile + online play light up). Emulator smoke recipe: `.claude/CLAUDE.md` +β†’ "Native Android build", with the extra `DICT_DIR= VITE_DICT_VERSION= node +scripts/bundle-dicts.mjs` between `pnpm build` and `cap sync`. ### E. CI β€” signed APK artifact