From aa803260a117a85d02e3491de2d01b2be7315213 Mon Sep 17 00:00:00 2001 From: Ilia Denisov Date: Wed, 15 Jul 2026 02:17:13 +0200 Subject: [PATCH 1/5] fix(game): recognise the device-local guest as "me" in the game header MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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. --- ui/src/game/Game.svelte | 12 +++++++++++- 1 file changed, 11 insertions(+), 1 deletion(-) diff --git a/ui/src/game/Game.svelte b/ui/src/game/Game.svelte index b5a7472..2e21ee4 100644 --- a/ui/src/game/Game.svelte +++ b/ui/src/game/Game.svelte @@ -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'); From 896b7f57a7daa7b4fb27042169b22b3de52a773c Mon Sep 17 00:00:00 2001 From: Ilia Denisov Date: Wed, 15 Jul 2026 02:17:13 +0200 Subject: [PATCH 2/5] 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. --- ui/e2e/native.spec.ts | 7 ++++--- ui/src/screens/NewGame.svelte | 5 ++++- 2 files changed, 8 insertions(+), 4 deletions(-) diff --git a/ui/e2e/native.spec.ts b/ui/e2e/native.spec.ts index 4fc1f5b..37301db 100644 --- a/ui/e2e/native.spec.ts +++ b/ui/e2e/native.spec.ts @@ -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'); diff --git a/ui/src/screens/NewGame.svelte b/ui/src/screens/NewGame.svelte index 0d44e71..5ffd6a0 100644 --- a/ui/src/screens/NewGame.svelte +++ b/ui/src/screens/NewGame.svelte @@ -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': From 74026223eefdbaec02436e79d3c9168a9ee0b906 Mon Sep 17 00:00:00 2001 From: Ilia Denisov Date: Wed, 15 Jul 2026 02:17:13 +0200 Subject: [PATCH 3/5] feat(account): give a fresh guest a distinct "Guest######" display name MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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. --- backend/internal/account/account.go | 12 +++++++++--- backend/internal/inttest/account_test.go | 5 +++++ 2 files changed, 14 insertions(+), 3 deletions(-) diff --git a/backend/internal/account/account.go b/backend/internal/account/account.go index 2324a2c..e4826d5 100644 --- a/backend/internal/account/account.go +++ b/backend/internal/account/account.go @@ -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 diff --git a/backend/internal/inttest/account_test.go b/backend/internal/inttest/account_test.go index 5527741..fcbdb70 100644 --- a/backend/internal/inttest/account_test.go +++ b/backend/internal/inttest/account_test.go @@ -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) From 3429dbec5f853b7e92716c8b12877e0c41af9ab1 Mon Sep 17 00:00:00 2001 From: Ilia Denisov Date: Wed, 15 Jul 2026 02:17:13 +0200 Subject: [PATCH 4/5] fix(boot): native offline-first launch is silent and fast (Android 10) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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. --- ui/src/lib/app.svelte.ts | 6 +++++- ui/src/lib/netstate.svelte.ts | 5 ++++- ui/src/lib/netstate.test.ts | 28 ++++++++++++++++++++++++++++ ui/src/lib/netstate.ts | 21 ++++++++++++++++++--- 4 files changed, 55 insertions(+), 5 deletions(-) diff --git a/ui/src/lib/app.svelte.ts b/ui/src/lib/app.svelte.ts index 44cce7e..f333135 100644 --- a/ui/src/lib/app.svelte.ts +++ b/ui/src/lib/app.svelte.ts @@ -982,7 +982,11 @@ export async function bootstrap(): Promise { // 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); diff --git a/ui/src/lib/netstate.svelte.ts b/ui/src/lib/netstate.svelte.ts index 939a4d8..65d0c80 100644 --- a/ui/src/lib/netstate.svelte.ts +++ b/ui/src/lib/netstate.svelte.ts @@ -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); } diff --git a/ui/src/lib/netstate.test.ts b/ui/src/lib/netstate.test.ts index 6aa4e3c..fdfd709 100644 --- a/ui/src/lib/netstate.test.ts +++ b/ui/src/lib/netstate.test.ts @@ -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' }]); + }); +}); diff --git a/ui/src/lib/netstate.ts b/ui/src/lib/netstate.ts index 894e963..84cd771 100644 --- a/ui/src/lib/netstate.ts +++ b/ui/src/lib/netstate.ts @@ -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: [] }; } } From fe29c6fe58ab29aed49d451c5604b78d9ff636d2 Mon Sep 17 00:00:00 2001 From: Ilia Denisov Date: Wed, 15 Jul 2026 02:17:13 +0200 Subject: [PATCH 5/5] chore(android): raise minSdk to 29 (Android 10) 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. --- ui/android/variables.gradle | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/ui/android/variables.gradle b/ui/android/variables.gradle index ee4ba41..147cb52 100644 --- a/ui/android/variables.gradle +++ b/ui/android/variables.gradle @@ -1,5 +1,5 @@ ext { - minSdkVersion = 24 + minSdkVersion = 29 compileSdkVersion = 36 targetSdkVersion = 36 androidxActivityVersion = '1.11.0'