release: offline mode + local pass-and-play (hotseat) — proposed v1.12.0 #212
Reference in New Issue
Block a user
Delete Branch "development"
Deleting a branch is permanent. Although the deleted branch may continue to exist for a short time before it actually gets removed, it CANNOT be undone in most cases. Continue?
Promote development → master. The offline-mode arc since v1.11.0 (79 commits), culminating in local pass-and-play.
Offline mode (PWA)
Deliberate + auto-detected offline, a transport kill-switch, dictionary-readiness gating, mid-session flight-mode reactivity, cold-boot from the cached session/profile.
Local vs_ai
Device-local vs_ai games through the in-browser engine; an idle-gated, wallet-free hint unified online + offline on a monotonic clock.
Local pass-and-play (hotseat) — #211
2-4 humans on one device: a mandatory host/referee master PIN + optional per-seat PINs (salted SHA-256 — a social lock, client-only), a board-visible seat unlock, the host menu (skip / exclude / terminate), random seating, per-seat place medals; no chat/hints.
Fixes bundled
iOS soft-keyboard shell alignment (visualViewport offset — Safari doesn't shrink the layout viewport); PWA stale-shell fix (SW navigation route registered before the precache route); tie-for-lead result unified across lobby + in-game (a shared victory, not a full draw); Enter dismisses the soft keyboard; email-code autosubmit + spread-digit style.
Docs baked throughout (ARCHITECTURE + FUNCTIONAL +_ru). Every change landed via an approved PR into development; CI green (incl. the post-merge run + contour deploy).
Next: approve → merge → tag
v1.12.0on master → prod deploy (workflow_dispatch, confirm=deploy) → monitor (build → deploy-main → deploy-bot → verify, auto-rollback).Migrate the former install-only public/sw.js to a custom (injectManifest) service worker built from ui/src/sw.ts by vite-plugin-pwa. It precaches the app shell + hashed assets (Workbox) so an installed web PWA cold-launches with no network, and falls in-scope navigations back to the precached shell (the hash router resolves the route client-side). This is the C1 prerequisite for a usable offline mode: without it a cold offline launch cannot load the bundle. - ui/src/sw.ts: skipWaiting + clientsClaim + cleanupOutdatedCaches + precacheAndRoute(__WB_MANIFEST) + a NavigationRoute fallback to index.html, deny-listing the RPC path and /_gm. The Connect stream and API POSTs are never precached nor intercepted. - vite.config.ts: VitePWA(injectManifest); manifest:false (we ship our own), injectRegister:false (registration stays manual + web-only in pwa.svelte.ts), disabled in the mock build (Playwright unperturbed); landing + polyfills excluded from the precache. - Removed public/sw.js; the registration path (dist/sw.js at /app/) is unchanged. - New dev deps: vite-plugin-pwa + workbox-{core,precaching,routing}. Docs: ARCHITECTURE + gateway/README. Offline cold-launch is contour-verified (the mock e2e harness disables the SW).Two offline bugs found on the contour: - The word-check screen (CheckScreen) called gateway.gameState/checkWord directly, so in an offline (local) game it hit the network and errored ("something went wrong"). Route both through gameSource(id) — the local source answers from the device dawg. The complaint control (online-only, no offline backend) is hidden for a local game. - The lobby did not react to the offline-mode toggle (which lives in Settings, so the lobby can stay mounted): an online game lingered until the next reload. Reload on an offlineMode flip so entering offline immediately shows only device-local games. Cold offline launch hanging on the splash (boot still fetches the profile over the network) is the separate C2 offline-boot follow-up (needs a persisted profile + a boot short-circuit).In a local game the human seat's account id was a synthetic 'local:human:0', so seatName's `accountId === session.userId` check never matched: the game header rendered BOTH seats as 🤖 (the vs_ai fallback), and the lobby's groupGames could not find the viewer's seat, so a human's turn read as 'Their turn' with the hourglass. Carry the real account id on the human seat (create -> record -> GameView); the robot keeps its synthetic id. Local games created before this fix keep the old display (no migration).A vs_ai hint is now unlimited and wallet-free, but idle-gated as an anti-frustration aid: it unlocks only after the player has been stuck ~30 min on a turn (timed from the robot's last move; the human's first move, before the robot has played, is exempt). While gated the hint button carries a small lock badge and a tap shows the remaining minutes ('Available in N min.'); the lock lifts live at the mark. The gate is enforced CLIENT-SIDE against a MONOTONIC clock (performance.now()), never a wall-clock timestamp: a device clock the player controls (or an auto-sync) must not be able to open or freeze it. The wait is therefore session-scoped -- a reload restarts it (there is no tamper-proof way to carry idle time across a relaunch without a wall clock). An online vs_ai game will gate the same way but from the server's clock (a follow-up). - lib/hints.ts: HINT_GATE_MS + pure hintGateRemainingMs (monotonic) + hintLockMinutes; removed the wall-clock hintRemainingMs. Unit-tested red->green. - Game.svelte: the monotonic gate (hintGateStart/monoNow, armed on the turn change), a vs_ai hint button (plain: no confirm, no count; lock badge + gated tap -> toast), a live 10s tick. - localgame: removed the wall-clock hint gate and the now-dead robotLastMoveAtUnix field from source.ts/serialize.ts (the client is authoritative); hint() just serves the top move. - i18n game.hintLockedIn. - offline.spec.ts: assert the first move is un-gated and the lock arms after the robot moves. - Docs FUNCTIONAL(+_ru) + ARCHITECTURE; bundle budget 114->115 (game-screen feature).navigate() wrote location.hash and left router.route to the asynchronous hashchange event. bootstrap flips app.ready in the same tick right after navigate('/login') on an unauthenticated cold start, so for one frame the app rendered with app.ready=true and the stale route ('lobby', from the empty hash) — briefly mounting the lobby shell (tab bar + a doomed games.list) under the new login screen before hashchange settled it. Update router.route synchronously inside navigate(); the later hashchange re-parses to the same value. This removes the boot lobby-flash (and its spurious games.list 401) and, as the root cause, the offline e2e flake: enterLobby could latch that transient lobby tab-bar instead of clicking through login, then the Settings-tab click at line 44 fought the tab detaching into the login slide (~7% of runs, both engines). Also: - offline.spec enterLobby: wait for the guest button and click it, instead of a point-in-time count() that a pre-login splash frame sampled as 0 (skipping the click and hanging / latching the transient). - New e2e router.spec pins the synchronous-route property (RED on the old code, which returned the previous route; GREEN now) via a mock-only __router seam. Verify: check 0 / unit 490 / build / bundle 114.3/115 / full e2e 200 / the offline+router stress at 40x on both engines 160/160 (was 6/80 failing).