Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
Original file line number Diff line number Diff line change
Expand Up @@ -3,7 +3,7 @@ import { waitFor } from '@testing-library/react';
import userEvent from '@testing-library/user-event';
import { createMockActivityData } from '@vers/contract-activity/test-utils';
import type { StartStatus } from '@vers/idle-client';
import { advanceWriterGeneration, setEngagedActivityID } from '@vers/idle-client';
import { advanceWriterGeneration, setEngagedActivityID, setResyncStatus } from '@vers/idle-client';
import { ActivityFailureAction } from '@vers/idle-core';
import { createMockActivitySnapshot } from '@vers/idle-core/test-utils';
import { setSelectedNode } from '@vers/worldmap-client';
Expand Down Expand Up @@ -107,6 +107,73 @@ test('it sends one start call for the selected node once initialized', async ()
});
});

test('it withholds the start call while an offline catch-up is fast-forwarding', async () => {
const signedIn = await createSignedInUser();

await createActiveAvatar({ userID: signedIn.userID });

setSelectedNode(createMockWorldMapNode({ id: 'a9lp75' }));
setResyncStatus({ attempts: 3, kind: 'fast-forwarding', levelUps: 0 });

const client = createStubWorkerClient({
startActivity: () => Promise.resolve({ kind: 'failed' }),
});

setIdleWorkerHandle({
activity: undefined,
client,
failureAction: ActivityFailureAction.Abort,
initialized: true,
writerAbortSignal: new AbortController().signal,
});

await withRequestContext({ cookies: signedIn.cookies }, async () => {
const rendered = renderWithRouter(<ExploreCurrentPanel orpc={orpc} />);

// a start that went out would resolve to `failed` and render the retry action — its absence
// within a generous window stands in for the call never having been sent
await expect(
rendered.findByTestId('start-activity-retry', undefined, { timeout: 300 }),
).toReject();

expect(client.startActivity).not.toHaveBeenCalled();
});
});

test('it sends the start once the offline catch-up clears', async () => {
const signedIn = await createSignedInUser();
const avatar = await createActiveAvatar({ userID: signedIn.userID });

setSelectedNode(createMockWorldMapNode({ id: 'a9lp75' }));
setResyncStatus({ attempts: 3, kind: 'fast-forwarding', levelUps: 0 });

const client = createStubWorkerClient({
startActivity: () => new Promise(() => {}),
});

const writerAbortSignal = new AbortController().signal;

setIdleWorkerHandle({
activity: undefined,
client,
failureAction: ActivityFailureAction.Abort,
initialized: true,
writerAbortSignal,
});

await withRequestContext({ cookies: signedIn.cookies }, async () => {
renderWithRouter(<ExploreCurrentPanel orpc={orpc} />);
setResyncStatus(null);

await waitFor(() => {
expect(client.startActivity).toHaveBeenCalledExactlyOnceWith(
{ avatarID: avatar.id, scopeID: 'a9lp75', scopeType: 'world_map_node' },
{ signal: writerAbortSignal },
);
});
});
});

test('it renders the node and its codex fragment once the worker reports the start', async () => {
const signedIn = await createSignedInUser();
const avatar = await createActiveAvatar({ userID: signedIn.userID });
Expand Down
22 changes: 18 additions & 4 deletions apps/web/src/routes/-explore-current/explore-current-panel.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -2,7 +2,12 @@ import { useQuery } from '@tanstack/react-query';
import { useNavigate } from '@tanstack/react-router';
import { Button, CheckboxField, Spinner } from '@vers/design-system';
import type { StartStatus } from '@vers/idle-client';
import { setEngagedActivityID, useEngagedActivityID, useWriterGeneration } from '@vers/idle-client';
import {
setEngagedActivityID,
useEngagedActivityID,
useResyncStatus,
useWriterGeneration,
} from '@vers/idle-client';
import { ActivityFailureAction } from '@vers/idle-core';
import { useSelectedNode } from '@vers/worldmap-client';
import { Suspense, useEffect, useRef, useState } from 'react';
Expand Down Expand Up @@ -40,14 +45,17 @@ interface StartAttemptReport {
* bouncing the player back. A start rejected because the account's active avatar changed renders a
* distinct notice naming the current one, with a reload action rather than the generic retry — the
* route's own data still names the stale avatar, and only a reload re-runs every gate against the
* new one.
* new one. While an offline catch-up is still fast-forwarding, the panel withholds its start call
* — the lockout overlay above it covers the UI, but this keeps a mounted panel from auto-sending a
* start of its own underneath it.
*/
export function ExploreCurrentPanel(props: ExploreCurrentPanelProps) {
export function ExploreCurrentPanel(props: Readonly<ExploreCurrentPanelProps>) {
const navigate = useNavigate();
const idleWorkerHandle = useIdleWorkerHandle();
const selectedNode = useSelectedNode().node;
const avatarQuery = useQuery(buildActiveAvatarQueryOptions());
const writerGeneration = useWriterGeneration();
const resyncStatus = useResyncStatus();
const avatarID = avatarQuery.data?.id;
const isAutoRetryChecked = idleWorkerHandle.failureAction === ActivityFailureAction.Retry;

Expand Down Expand Up @@ -104,7 +112,12 @@ export function ExploreCurrentPanel(props: ExploreCurrentPanelProps) {
return;
}

if (avatarID === undefined || selectedNode === null || attemptScopeID === selectedNode.id) {
if (
avatarID === undefined ||
selectedNode === null ||
attemptScopeID === selectedNode.id ||
resyncStatus?.kind === 'fast-forwarding'
) {
return;
}

Expand Down Expand Up @@ -136,6 +149,7 @@ export function ExploreCurrentPanel(props: ExploreCurrentPanelProps) {
avatarID,
selectedNode,
attemptScopeID,
resyncStatus,
]);

useEffect(() => {
Expand Down
8 changes: 5 additions & 3 deletions apps/web/src/routes/-game/welcome-back-modal.test.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -13,19 +13,21 @@ test('it renders nothing while no resync is underway', () => {
expect(screen.queryByRole('dialog')).not.toBeInTheDocument();
});

test('it masks the catch-up from its zero-tally start', () => {
test('it masks the catch-up from its zero-tally start with a non-dismissible lockout', () => {
setResyncStatus({ attempts: 0, kind: 'fast-forwarding', levelUps: 0 });
render(<WelcomeBackModal />);

expect(screen.getByText('Welcome back')).toBeInTheDocument();
expect(screen.getByText('Catching up… 0 attempts, 0 level-ups so far.')).toBeInTheDocument();
expect(screen.queryByRole('button', { name: 'Close' })).not.toBeInTheDocument();
});

test('it reports the running tally while fast-forwarding', () => {
test('it reports the running tally while fast-forwarding, still with no close button', () => {
setResyncStatus({ attempts: 12, kind: 'fast-forwarding', levelUps: 1 });
render(<WelcomeBackModal />);

expect(screen.getByText('Catching up… 12 attempts, 1 level-ups so far.')).toBeInTheDocument();
expect(screen.getByText('Catching up… 12 attempts, 1 level-up so far.')).toBeInTheDocument();
expect(screen.queryByRole('button', { name: 'Close' })).not.toBeInTheDocument();
});

test('it reports the final tally when the catch-up is done', () => {
Expand Down
37 changes: 29 additions & 8 deletions apps/web/src/routes/-game/welcome-back-modal.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -17,6 +17,8 @@ import { useIdleWorkerHandle } from '../../lib/idle/use-idle-worker-handle';
* redirect returns them here, where the fresh session's own resync resumes the catch-up. A resync
* dropped for an avatar the account switched away from opens on a reload action — the tab's own
* state still names the old avatar, and only a reload re-runs every gate against the new one.
* While the catch-up is still fast-forwarding, the dialog is a non-dismissible lockout — the
* player can't act on stale state until the resync settles into one of its terminal outcomes.
*/
export function WelcomeBackModal() {
const resyncStatus = useResyncStatus();
Expand All @@ -31,6 +33,14 @@ export function WelcomeBackModal() {
return null;
}

if (resyncStatus.kind === 'fast-forwarding') {
return (
<Dialog dismissible={false} open title="Welcome back">
<Text>Catching up… {formatTally(resyncStatus)} so far.</Text>
</Dialog>
);
}

return (
<Dialog
onOpenChange={(open) => {
Expand All @@ -46,8 +56,24 @@ export function WelcomeBackModal() {
);
}

interface ResyncTally {
readonly attempts: number;
readonly levelUps: number;
}

function formatTally(tally: Readonly<ResyncTally>): string {
return `${formatCount(tally.attempts, 'attempt')}, ${formatCount(tally.levelUps, 'level-up')}`;
}

function formatCount(count: number, unit: string): string {
return `${count} ${unit}${count === 1 ? '' : 's'}`;
}

interface ResyncOutcomeProps {
readonly resyncStatus: Exclude<ResyncStatus, { readonly kind: 'active-elsewhere' }>;
readonly resyncStatus: Exclude<
ResyncStatus,
{ readonly kind: 'active-elsewhere' } | { readonly kind: 'fast-forwarding' }
>;
}

function ResyncOutcome(props: Readonly<ResyncOutcomeProps>) {
Expand Down Expand Up @@ -111,6 +137,7 @@ function formatResyncStatus(
| { readonly kind: 'active-elsewhere' }
| { readonly kind: 'avatar-switched' }
| { readonly kind: 'failed' }
| { readonly kind: 'fast-forwarding' }
| { readonly kind: 'session-expired' }
>
>,
Expand All @@ -119,11 +146,5 @@ function formatResyncStatus(
return 'Offline progress reached its cap. Your avatar held position — jump back in to continue.';
}

const tally = `${resyncStatus.attempts} attempts, ${resyncStatus.levelUps} level-ups`;

if (resyncStatus.kind === 'fast-forwarding') {
return `Catching up… ${tally} so far.`;
}

return `While you were away: ${tally}.`;
return `While you were away: ${formatTally(resyncStatus)}.`;
}
Original file line number Diff line number Diff line change
Expand Up @@ -11,3 +11,11 @@ export function Default() {
</Dialog>
);
}

export function NonDismissible() {
return (
<Dialog dismissible={false} open title="Welcome back">
<Text>Catching up… 12 attempts, 1 level-up so far.</Text>
</Dialog>
);
}
56 changes: 55 additions & 1 deletion libs/design/design-system/src/components/dialog/dialog.test.tsx
Original file line number Diff line number Diff line change
@@ -1,5 +1,5 @@
import { expect, mock, test } from 'bun:test';
import { render, screen } from '@testing-library/react';
import { act, fireEvent, render, screen } from '@testing-library/react';
import userEvent from '@testing-library/user-event';
import { Dialog } from './dialog';

Expand Down Expand Up @@ -64,3 +64,57 @@ test('it labels the close trigger with the given closeLabel', () => {

expect(screen.getByRole('button', { name: 'Continue' })).toBeInTheDocument();
});

test('it renders no close trigger when non-dismissible', () => {
render(
<Dialog dismissible={false} open title="Welcome back">
<p>You were away</p>
</Dialog>,
);

expect(screen.queryByRole('button', { name: 'Close' })).not.toBeInTheDocument();
});

test('it does not report a close request on escape when non-dismissible', async () => {
const user = userEvent.setup();
const onOpenChange = mock<(open: boolean) => void>();

render(
<Dialog dismissible={false} onOpenChange={onOpenChange} open title="Welcome back">
<p>You were away</p>
</Dialog>,
);

await user.keyboard('{Escape}');

expect(onOpenChange).not.toHaveBeenCalled();
expect(screen.getByRole('dialog')).toBeInTheDocument();
});

test('it does not report a close request on backdrop interaction when non-dismissible', async () => {
const onOpenChange = mock<(open: boolean) => void>();

render(
<Dialog dismissible={false} onOpenChange={onOpenChange} open title="Welcome back">
<p>You were away</p>
</Dialog>,
);

// the outside-interaction tracker registers its document listener a beat after mount — the
// dispatch below must land after that beat to exercise it at all
await act(
() =>
new Promise<void>((resolve) => {
setTimeout(resolve, 50);
}),
);

act(() => {
fireEvent.pointerDown(document.body);
fireEvent.pointerUp(document.body);
fireEvent.click(document.body);
});

expect(onOpenChange).not.toHaveBeenCalled();
expect(screen.getByRole('dialog')).toBeInTheDocument();
});
23 changes: 19 additions & 4 deletions libs/design/design-system/src/components/dialog/dialog.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -12,6 +12,13 @@ interface Props {
* Label for the built-in close trigger. Default "Close".
*/
closeLabel?: string;

/**
* Whether escape, the backdrop, and the built-in close trigger can close the dialog. Default
* true. `false` renders no close trigger and blocks escape and outside-interaction dismissal,
* leaving the caller's own `open` state as the only way to close it.
*/
dismissible?: boolean;
onOpenChange?: (open: boolean) => void;
open: boolean;
title: string;
Expand All @@ -23,6 +30,7 @@ const dialogRecipe = sva({
backgroundColor: '[rgba(0, 0, 0, 0.6)]',
inset: '0',
position: 'fixed',
zIndex: '[60]',
},
content: {
backgroundColor: 'bg.panelElevated',
Expand All @@ -42,6 +50,7 @@ const dialogRecipe = sva({
justifyContent: 'center',
padding: '4',
position: 'fixed',
zIndex: '[60]',
},
},
slots: ['backdrop', 'content', 'positioner'],
Expand All @@ -50,13 +59,17 @@ const dialogRecipe = sva({
/**
* Modal dialog over the Ark UI primitive: focus is trapped while open, and escape, the backdrop,
* and the built-in close trigger all report through `onOpenChange` — the open state itself is the
* caller's.
* caller's. `dismissible={false}` drops all three dismissal paths and the close trigger, for a
* lockout the caller's own state must resolve instead.
*/
export function Dialog(props: Readonly<Props>) {
const styles = dialogRecipe();
const dismissible = props.dismissible ?? true;

return (
<ArkDialog.Root
closeOnEscape={dismissible}
closeOnInteractOutside={dismissible}
onOpenChange={(details) => props.onOpenChange?.(details.open)}
open={props.open}
>
Expand All @@ -68,9 +81,11 @@ export function Dialog(props: Readonly<Props>) {
<Heading level={2}>{props.title}</Heading>
</ArkDialog.Title>
{props.children}
<ArkDialog.CloseTrigger asChild>
<Button type="button">{props.closeLabel ?? 'Close'}</Button>
</ArkDialog.CloseTrigger>
{dismissible && (
<ArkDialog.CloseTrigger asChild>
<Button type="button">{props.closeLabel ?? 'Close'}</Button>
</ArkDialog.CloseTrigger>
)}
</ArkDialog.Content>
</ArkDialog.Positioner>
</Portal>
Expand Down