Compare commits

...

7 Commits

Author SHA1 Message Date
developer d94d56b461 Merge pull request 'release: promote development → master (v1.23.0)' (#276) from development into master 2026-07-15 00:32:54 +00:00
developer d48797e297 Merge pull request 'native polish 2: two-robots fix, guest offline-host UX, Guest###### name, silent+fast native boot, minSdk 29' (#275) from feature/native-polish-2 into development
CI / changes (push) Successful in 2s
CI / unit (push) Successful in 11s
CI / integration (push) Successful in 22s
CI / ui (push) Successful in 1m15s
CI / conformance (push) Successful in 10s
CI / changes (pull_request) Successful in 2s
CI / gate (push) Successful in 0s
CI / deploy (push) Successful in 1m57s
CI / unit (pull_request) Successful in 11s
CI / integration (pull_request) Successful in 21s
CI / ui (pull_request) Successful in 1m14s
CI / conformance (pull_request) Successful in 11s
CI / gate (pull_request) Successful in 0s
CI / deploy (pull_request) Has been skipped
2026-07-15 00:26:27 +00:00
Ilia Denisov fe29c6fe58 chore(android): raise minSdk to 29 (Android 10)
CI / changes (pull_request) Successful in 2s
CI / unit (pull_request) Successful in 11s
CI / integration (pull_request) Successful in 22s
CI / ui (pull_request) Successful in 1m15s
CI / conformance (pull_request) Successful in 10s
CI / gate (pull_request) Successful in 0s
CI / deploy (pull_request) Successful in 1m56s
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.
2026-07-15 02:17:13 +02:00
Ilia Denisov 3429dbec5f fix(boot): native offline-first launch is silent and fast (Android 10)
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.
2026-07-15 02:17:13 +02:00
Ilia Denisov 74026223ee feat(account): give a fresh guest a distinct "Guest######" display name
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.
2026-07-15 02:17:13 +02:00
Ilia Denisov 896b7f57a7 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.
2026-07-15 02:17:13 +02:00
Ilia Denisov aa803260a1 fix(game): recognise the device-local guest as "me" in the game header
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.
2026-07-15 02:17:13 +02:00
10 changed files with 89 additions and 14 deletions
+9 -3
View File
@@ -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
View File
@@ -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
View File
@@ -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'
+4 -3
View File
@@ -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');
+11 -1
View File
@@ -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');
+5 -1
View File
@@ -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);
+4 -1
View File
@@ -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);
} }
+28
View File
@@ -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
View File
@@ -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: [] };
} }
} }
+4 -1
View File
@@ -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':