Compare commits
6 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| d48797e297 | |||
| fe29c6fe58 | |||
| 3429dbec5f | |||
| 74026223ee | |||
| 896b7f57a7 | |||
| aa803260a1 |
@@ -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,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,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'
|
||||||
|
|||||||
@@ -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
@@ -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');
|
||||||
|
|||||||
@@ -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);
|
||||||
|
|||||||
@@ -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);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -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
@@ -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: [] };
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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':
|
||||||
|
|||||||
Reference in New Issue
Block a user