fix(game): split loading states, add inline error retry, improve round handling

Separate initialLoading/roundLoading/submitting concerns to prevent premature
panorama unmount. Add roundKey viewer remount trigger and round-epoch watchdog
to prevent stale fetches from replacing the current round. Next-round prefetch
in result dialog. Replace alert() with inline error+retry. Last-region write
on successful load.
This commit is contained in:
tiennm99 committed 2026-08-31 22:07:56 +07:00
1 parent 434409443c
commit 7123cdde76
4 files changed
+304 -62

No files matched your search

+212 -47
View File
@@ -11,19 +11,55 @@ import RoundResultDialog from './RoundResultDialog';
import { Button } from '@/components/ui/button';
import { Badge } from '@/components/ui/badge';
import { getUsername } from '../../lib/username';
import { setLastRegion } from '../../lib/last-region';
// The revealed path comes from /api/guess (the RESOLVED district), not from
// regionPath(pickedRegion) -- computing it client-side from what the player
// chose would make the reveal meaningless for a country round.
import { getRegion, isRegion } from '../../lib/regions';
/**
* Ask the server for a new round. Throws on failure; touches no state, so the
* result-screen prefetch can call it without disturbing the round on screen.
* @param {string} locationCode Region to play.
* @param {string|null} currentSessionId Session id to reuse, or null for a new one.
* @returns {Promise<Object>} The /api/new-game payload.
*/
async function fetchNewRound(locationCode, currentSessionId) {
// Encoded: an unencoded value carrying its own '&sessionId=' would let a
// shared link choose the session key a victim's round is stored under.
const params = new URLSearchParams({ region: locationCode });
if (currentSessionId) params.set('sessionId', currentSessionId);
const response = await fetch(`/api/new-game?${params.toString()}`);
const data = await response.json();
if (!data.success) {
throw new Error(data.error || 'No images found');
}
return data;
}
export default function GameClient() {
const searchParams = useSearchParams();
const router = useRouter();
const [location, setLocation] = useState('TPHCM');
const [imageData, setImageData] = useState(null);
// Bumped once per applied round and used as the viewer's key. The image URL
// is not enough: a small district can serve the same panorama twice in a
// row, and without a remount the viewer never fires 'ready' again, which is
// what clears roundLoading.
const [roundKey, setRoundKey] = useState(0);
const [sessionId, setSessionId] = useState(null);
const [loading, setLoading] = useState(true);
// Three loading concerns that must not share a flag: the first load owns the
// whole screen, a between-rounds load keeps the game chrome up, and a guess
// submit only spins the button. One flag for all three is how submitting a
// guess used to unmount the panorama viewer.
const [initialLoading, setInitialLoading] = useState(true);
const [roundLoading, setRoundLoading] = useState(false);
const [submitting, setSubmitting] = useState(false);
// A failed round fetch, shown in place with a retry. Never an alert(): that
// left an empty screen whose only way out was the browser back button.
const [loadError, setLoadError] = useState(null);
const [initialized, setInitialized] = useState(false);
const [guessCoordinates, setGuessCoordinates] = useState(null);
const [showResult, setShowResult] = useState(false);
@@ -51,54 +87,75 @@ export default function GameClient() {
const regionName = pickedRegion ? pickedRegion.name : 'Vietnam';
const initializingRef = useRef(false);
// The next round, fetched while the result dialog is open so Next Round can
// swap it in without a wait. Holds a promise; consumed exactly once.
const prefetchRef = useRef(null);
// Bumped by every action that starts a round load. A load compares the
// epoch it started under before applying: once the watchdog re-enables the
// controls, a slow fetch can otherwise resolve AFTER the player has moved
// on and replace the round they are looking at.
const roundEpochRef = useRef(0);
// The epoch of the round currently applied to the screen; see applyRound.
const appliedEpochRef = useRef(0);
const getRandomImage = useCallback(async (locationCode, currentSessionId = null) => {
const applyRound = useCallback((data) => {
setSessionId(data.sessionId);
setImageData({
url: data.imageData.url,
isPano: data.imageData.isPano
});
setRoundKey((key) => key + 1);
// A no-op on every happy path (round resets already cleared it), but a
// load that lands late -- released early by the watchdog -- must not
// inherit a pin the player placed on the panorama it is replacing.
setGuessCoordinates(null);
// Marks which epoch the round on screen belongs to, so a late 'ready'
// from the PREVIOUS round's still-mounted viewer cannot clear the
// loading flag of the round that is replacing it.
appliedEpochRef.current = roundEpochRef.current;
setLoadError(null);
}, []);
const loadRound = useCallback(async (locationCode, currentSessionId, epoch) => {
try {
// Encoded: an unencoded value carrying its own '&sessionId=' would let a
// shared link choose the session key a victim's round is stored under.
const params = new URLSearchParams({ region: locationCode });
if (currentSessionId) params.set('sessionId', currentSessionId);
const url = `/api/new-game?${params.toString()}`;
const response = await fetch(url);
const data = await response.json();
if (data.success) {
setSessionId(data.sessionId);
setImageData({
url: data.imageData.url,
isPano: data.imageData.isPano
});
setLoading(false);
} else {
throw new Error(data.error || 'No images found');
}
const data = await fetchNewRound(locationCode, currentSessionId);
// Superseded while in flight: the newer action owns the screen and the
// roundLoading flag, so touch nothing and report nothing to clear.
if (epoch !== roundEpochRef.current) return true;
applyRound(data);
return true;
} catch (error) {
if (epoch !== roundEpochRef.current) return true;
console.error('Error fetching image:', error);
setImageData(null);
setSessionId(null);
setLoading(false);
alert(`Failed to load street view image: ${error.message || 'No images found'}`);
setLoadError(error.message || 'No images found');
return false;
}
}, []);
}, [applyRound]);
const loadLibrariesAndInitialize = useCallback(async (locationCode) => {
if (initializingRef.current) return;
initializingRef.current = true;
setLoading(true);
setInitialLoading(true);
try {
const center = isRegion(locationCode) ? getRegion(locationCode).center : null;
const code = locationCode.toUpperCase();
const center = isRegion(code) ? getRegion(code).center : null;
if (center) setMapCenter(center);
await getRandomImage(locationCode);
roundEpochRef.current += 1;
const loaded = await loadRound(locationCode, null, roundEpochRef.current);
// Only a region that actually served a round is worth offering as
// "Continue in ..." on the home page.
if (loaded && isRegion(code)) setLastRegion(code);
setInitialized(true);
} catch (error) {
console.error('Failed to initialize:', error);
setLoading(false);
} finally {
initializingRef.current = false;
setInitialLoading(false);
}
}, [getRandomImage]);
}, [loadRound]);
useEffect(() => {
if (initialized) return;
@@ -141,21 +198,53 @@ export default function GameClient() {
};
const handlePanoramaReady = useCallback(() => {
setLoading(false);
// Only the round of the current epoch may clear the flag; the outgoing
// viewer stays mounted while its replacement loads and can fire late.
if (appliedEpochRef.current !== roundEpochRef.current) return;
setRoundLoading(false);
}, []);
const handlePanoramaError = useCallback((error) => {
console.error('Panorama error:', error);
setLoading(false);
if (appliedEpochRef.current !== roundEpochRef.current) return;
setRoundLoading(false);
}, []);
// roundLoading normally ends at the viewer's 'ready', but a texture load
// that stalls fires neither 'ready' nor 'panorama-error', and the flag
// disables every control except Back. Give the wait a ceiling: after it,
// the controls come back while the viewer keeps loading underneath.
useEffect(() => {
if (!roundLoading) return undefined;
const timer = setTimeout(() => setRoundLoading(false), 15_000);
return () => clearTimeout(timer);
}, [roundLoading]);
const handleMapClick = (coordinates) => {
setGuessCoordinates([coordinates.lat, coordinates.lng]);
};
// Start fetching the next round while the player reads the result, and warm
// the browser cache with its image. The wasted lookup when they leave from
// the dialog is one API call and one self-expiring session.
const startPrefetch = (locationCode, currentSessionId) => {
const promise = fetchNewRound(locationCode, currentSessionId).then((data) => {
if (typeof window !== 'undefined' && data.imageData?.url) {
const image = new window.Image();
image.src = data.imageData.url;
}
return data;
});
// Consumed (and error-handled) in handleNextRound; this keeps an abandoned
// prefetch from surfacing as an unhandled rejection.
promise.catch(() => {});
prefetchRef.current = promise;
};
const handleSubmitGuess = async () => {
if (!guessCoordinates || !imageData) return;
setLoading(true);
if (!guessCoordinates || !imageData || submitting) return;
setSubmitting(true);
const currentSession = sessionId;
try {
const submitted = await submitGameResult(guessCoordinates);
@@ -165,6 +254,7 @@ export default function GameClient() {
failed: false,
distance: submitted.distance,
score: submitted.score,
bands: submitted.bands ?? null,
exactLocation: submitted.exactLocation,
scoreLevels: submitted.levels ?? [],
distanceLevels: submitted.distanceLevels ?? [],
@@ -184,8 +274,9 @@ export default function GameClient() {
setResult({ failed: true });
}
setLoading(false);
setSubmitting(false);
setShowResult(true);
startPrefetch(location, currentSession);
};
// Everything a round accumulates. Both Next Round and Skip come through here,
@@ -197,16 +288,41 @@ export default function GameClient() {
setResult(null);
};
const handleNextRound = () => {
const handleNextRound = async () => {
// Radix keeps the dialog interactive through its exit animation, so a
// double-click would issue a second fetch and burn a session.
if (roundLoading) return;
roundEpochRef.current += 1;
const epoch = roundEpochRef.current;
setShowResult(false);
resetRoundState();
const currentSession = sessionId;
setSessionId(null);
getRandomImage(location, currentSession);
const prefetched = prefetchRef.current;
prefetchRef.current = null;
setRoundLoading(true);
if (prefetched) {
try {
const data = await prefetched;
if (epoch !== roundEpochRef.current) return;
applyRound(data);
// roundLoading stays up until the viewer's 'ready' clears it -- the
// fetch finishing is not the panorama being visible.
return;
} catch {
// The prefetch failed; fall through to a fresh fetch, unless the
// player already moved on while it was failing.
if (epoch !== roundEpochRef.current) return;
}
}
const loaded = await loadRound(location, currentSession, epoch);
if (!loaded) setRoundLoading(false);
};
const handleSkipGuess = async () => {
if (!imageData) return;
if (!imageData && !loadError) return;
try {
if (sessionId) {
@@ -221,16 +337,33 @@ export default function GameClient() {
}
resetRoundState();
prefetchRef.current = null;
roundEpochRef.current += 1;
const currentSession = sessionId;
setSessionId(null);
getRandomImage(location, currentSession);
// Skip is also the way out of the error panel; leaving the error up would
// suppress the spinner and read as a hang while the new round loads.
setLoadError(null);
setRoundLoading(true);
const loaded = await loadRound(location, currentSession, roundEpochRef.current);
if (!loaded) setRoundLoading(false);
};
const handleRetryLoad = async () => {
setLoadError(null);
roundEpochRef.current += 1;
setRoundLoading(true);
const loaded = await loadRound(location, null, roundEpochRef.current);
if (!loaded) setRoundLoading(false);
};
const handleGoBack = () => {
router.push('/');
};
if (loading) {
// Only the very first load owns the screen. Everything after it keeps the
// game chrome mounted -- tearing it down destroys the panorama viewer.
if (initialLoading) {
return (
<div className="h-dvh flex items-center justify-center vn-gradient-bg">
<div className="text-center space-y-4 animate-fade-in-up" role="status" aria-live="polite">
@@ -283,10 +416,27 @@ export default function GameClient() {
floating over it; lg and up keeps the original two-column split. */}
<div className="relative flex-1 min-h-0 lg:grid lg:grid-cols-2 lg:gap-3 lg:p-3">
{/* Panorama Viewer */}
<div className="absolute inset-0 bg-neutral-900 overflow-hidden lg:static lg:rounded-lg">
{imageData ? (
<div className="absolute inset-0 bg-neutral-900 overflow-hidden lg:relative lg:rounded-lg">
{loadError ? (
<div className="w-full h-full flex items-center justify-center p-6" role="alert">
<div className="text-center space-y-4 max-w-sm">
<p className="text-neutral-100 text-lg font-semibold">
Couldn&apos;t load a street view image
</p>
<p className="text-neutral-400 text-sm">{loadError}</p>
<div className="flex justify-center gap-3">
<Button onClick={handleRetryLoad} disabled={roundLoading}>
Try again
</Button>
<Button onClick={handleGoBack} variant="outline">
Back to menu
</Button>
</div>
</div>
</div>
) : imageData ? (
<PanoramaViewer
key={imageData.url}
key={roundKey}
imageUrl={imageData.url}
onReady={handlePanoramaReady}
onError={handlePanoramaError}
@@ -296,6 +446,19 @@ export default function GameClient() {
<p className="text-neutral-300">Loading panorama...</p>
</div>
)}
{/* Between-rounds indicator, over the outgoing panorama rather than
in place of the whole screen. */}
{roundLoading && !loadError && (
<div
className="absolute inset-0 z-10 flex items-center justify-center bg-neutral-900/60"
role="status"
aria-live="polite"
>
<div className="w-10 h-10 border-4 border-neutral-600 border-t-brand rounded-full animate-spin" aria-hidden="true" />
<span className="sr-only">Loading next round</span>
</div>
)}
</div>
{/* Map and Controls. display:contents on mobile so both children
@@ -314,16 +477,16 @@ export default function GameClient() {
<div className="absolute inset-x-0 bottom-0 z-[600] flex gap-2 border-t border-border bg-card/95 p-3 pb-[calc(0.75rem+env(safe-area-inset-bottom))] backdrop-blur lg:static lg:border-0 lg:bg-transparent lg:p-0 lg:pb-0 lg:backdrop-blur-none">
<Button
onClick={handleSubmitGuess}
disabled={!guessCoordinates || loading}
disabled={!guessCoordinates || !imageData || !sessionId || submitting || roundLoading}
className="flex-1"
size="lg"
loading={loading}
loading={submitting}
>
{loading ? 'Processing...' : guessCoordinates ? 'Submit Guess' : 'Place a guess first'}
{submitting ? 'Processing...' : guessCoordinates ? 'Submit Guess' : 'Place a guess first'}
</Button>
<Button
onClick={handleSkipGuess}
disabled={loading}
disabled={submitting || roundLoading}
variant="outline"
className="px-5"
>
@@ -335,7 +498,9 @@ export default function GameClient() {
<RoundResultDialog
open={showResult}
onOpenChange={() => setShowResult(false)}
// The dialog cannot be dismissed into a dead round: the session was
// consumed on submit, so the only ways out are Next Round and Menu.
onOpenChange={() => {}}
result={result}
guessCoordinates={guessCoordinates}
username={username}
+51 -2
View File
@@ -2,7 +2,7 @@
import { Button } from '@/components/ui/button';
import { Badge } from '@/components/ui/badge';
import { Dialog, DialogContent, DialogHeader, DialogTitle } from '@/components/ui/dialog';
import { Dialog, DialogContent, DialogDescription, DialogHeader, DialogTitle } from '@/components/ui/dialog';
import { formatDistance } from '../../lib/game';
import { useCountUp } from '../../lib/use-count-up';
import ResultMap from './ResultMap';
@@ -58,11 +58,27 @@ export default function RoundResultDialog({
<DialogContent
className="sm:max-w-xl max-h-[calc(100dvh-2rem)] grid-rows-[auto_minmax(0,1fr)_auto] overflow-hidden"
key={open ? 'open' : 'closed'}
// The round's session was consumed on submit, so closing this dialog
// would strand the player in a dead round whose re-submit reads as
// "Round Not Recorded". No X, and Esc/overlay are no-ops upstream: the
// only ways forward are Next Round and Menu.
showCloseButton={false}
>
<DialogHeader>
<DialogTitle className="text-center text-2xl font-bold">
{result?.failed ? 'Round Not Recorded' : 'Round Result'}
</DialogTitle>
{/* Carries the round outcome so screen readers hear it once via
aria-describedby when the dialog opens -- a live region would
race the dialog mount, and the visual count-up must not be
announced frame by frame. */}
<DialogDescription className="sr-only">
{result?.failed
? 'The guess could not be saved and nothing was scored.'
: result && Number.isFinite(result.distance)
? `Scored ${score} of 5 points, ${formatDistance(result.distance)} away.`
: ''}
</DialogDescription>
</DialogHeader>
{/* Scrollable body: everything between the title and the actions. */}
@@ -81,7 +97,7 @@ export default function RoundResultDialog({
</div>
) : result ? (
<>
<div className="text-center space-y-4 animate-fade-in-up" role="status" aria-live="polite">
<div className="text-center space-y-4 animate-fade-in-up">
{/* Score circle */}
<div className="flex flex-col items-center gap-2 animate-fade-in-up" style={{ animationDelay: '120ms' }}>
<div className={`inline-flex items-center justify-center w-20 h-20 rounded-full text-white text-3xl font-extrabold tabular-nums shadow-md ring-4 ring-background ${getScoreBg(score)}`}>
@@ -98,6 +114,36 @@ export default function RoundResultDialog({
</Badge>
</div>
{/* The ladder this round was scored against. Thresholds scale
with the picked region, so "how close did I need to be?"
has no fixed answer worth memorising. Older stubbed or
cached results carry no bands; then there is no strip. */}
{Array.isArray(result.bands) && result.bands.length > 0 && (
<div className="flex flex-wrap justify-center gap-1.5 text-xs animate-fade-in-up" style={{ animationDelay: '280ms' }}>
{result.bands.map((band) => (
<span
key={band.points}
className={`rounded-md px-2 py-1 tabular-nums ${
band.points === score
? 'bg-brand text-brand-foreground font-semibold'
: 'bg-muted text-muted-foreground'
}`}
>
{`≤${formatDistance(band.maxMeters)} = ${band.points}`}
</span>
))}
<span
className={`rounded-md px-2 py-1 tabular-nums ${
score === 0
? 'bg-brand text-brand-foreground font-semibold'
: 'bg-muted text-muted-foreground'
}`}
>
beyond = 0
</span>
</div>
)}
<p className="text-muted-foreground text-sm animate-fade-in-up" style={{ animationDelay: '320ms' }}>
{getResultMessage(score)}
</p>
@@ -123,6 +169,9 @@ export default function RoundResultDialog({
<p className="font-semibold">{entry.name}</p>
<p className="tabular-nums">
{entry.score === null ? 'Below top 200' : `Total: ${entry.score}`}
{/* Each board judges the round by its own regional
ladder, so what it added is worth naming. */}
{typeof entry.points === 'number' ? ` (+${entry.points})` : ''}
</p>
{entry.rank && (
<p className="text-xs tabular-nums opacity-80">Rank #{entry.rank}</p>
+11 -3
View File
@@ -28,20 +28,28 @@ test('plays a round to the reveal and into the next one', async ({ page }) => {
const armed = page.getByRole('button', { name: 'Submit Guess' });
await expect(armed).toBeEnabled();
// Registered before submit: the next round's image is prefetched while the
// result dialog is open, and this is the only proof the swap really loads
// a different panorama.
const nextRoundImage = page.waitForRequest((request) => request.url().includes('round=2'));
await armed.click();
// The reveal: score, formatted distance, and the resolved region path the
// client could not have known before the guess.
const dialog = page.getByRole('dialog', { name: /Round Result/ });
await expect(dialog).toBeVisible();
await expect(dialog.getByText('123m away')).toBeVisible();
// exact: the sr-only dialog description ("... 123m away.") also contains it.
await expect(dialog.getByText('123m away', { exact: true })).toBeVisible();
// The ladder the round was scored against, with the achieved band present.
await expect(dialog.getByText('≤442m = 5')).toBeVisible();
await expect(dialog.getByText('Vietnam › Ho Chi Minh › District 7')).toBeVisible();
await expect(dialog.getByText('Score added at 3 levels (+3)')).toBeVisible();
await expect(dialog.getByText('Score added at 3 levels (+4, +5, +5)')).toBeVisible();
await expect(dialog.getByText('District 7', { exact: true })).toBeVisible();
// Next round resets to a fresh, unguessed state.
// Next round resets to a fresh, unguessed state and swaps in a new image.
await dialog.getByRole('button', { name: 'Next Round' }).click();
await expect(dialog).toBeHidden();
await nextRoundImage;
await expect(page.getByRole('button', { name: /Place a guess first/ })).toBeDisabled();
});
+30 -10
View File
@@ -19,7 +19,7 @@ export const PANO_IMAGE_URL = 'https://pano.invalid/e2e-round.png';
export const USERNAME_STORAGE_KEY = 'vngeoguessr_username';
/** /api/new-game response for one TPHCM round. */
export function newGameResponse(sessionId) {
export function newGameResponse(sessionId, round) {
return {
success: true,
sessionId,
@@ -29,23 +29,40 @@ export function newGameResponse(sessionId) {
path: ['Vietnam', 'Ho Chi Minh'],
level: 'province',
},
imageData: { url: PANO_IMAGE_URL, isPano: true },
// A distinct URL per round, as in production (the session id is NOT the
// distinguisher there -- the route reuses it across rounds): a fixed URL
// would let a broken next-round image swap pass unnoticed.
imageData: { url: `${PANO_IMAGE_URL}?round=${round}`, isPano: true },
};
}
/** /api/guess response: a 3-point round revealed as District 7. */
/** /api/guess response: a 123m round on a TPHCM pick, revealed as District 7. */
export function guessResponse(username) {
const scoreLevel = (code, name, rank) => ({ code, name, username, score: 3, rank, trimmed: false });
// `points` is what this round added at that level (each board judges by its
// own ladder); `score` is the accumulated total. Values mirror what the
// real route computed for a 123m guess when this stub was written: 5 on the
// TPHCM (picked) ladder, +4 on District 7's own ladder, +5 above it.
const scoreLevel = (code, name, rank, points) =>
({ code, name, username, points, score: 3, rank, trimmed: false });
const distanceLevel = (code, name, rank) => ({ code, name, username, distance: 123, rank });
return {
success: true,
gameResult: {
distance: 123,
score: 3,
score: 5,
// The picked region's ladder as the real route returns it for TPHCM
// (bbox-scaled from the generated tree at the time of writing).
bands: [
{ maxMeters: 442, points: 5 },
{ maxMeters: 885, points: 4 },
{ maxMeters: 1770, points: 3 },
{ maxMeters: 4425, points: 2 },
{ maxMeters: 8849, points: 1 },
],
levels: [
scoreLevel('TPHCM-Q7', 'District 7', 1),
scoreLevel('TPHCM', 'Ho Chi Minh', 2),
scoreLevel('VN', 'Vietnam', 5),
scoreLevel('TPHCM-Q7', 'District 7', 1, 4),
scoreLevel('TPHCM', 'Ho Chi Minh', 2, 5),
scoreLevel('VN', 'Vietnam', 5, 5),
],
distanceLevels: [
distanceLevel('TPHCM-Q7', 'District 7', 1),
@@ -64,7 +81,7 @@ export function guessResponse(username) {
cityDistanceRank: 3,
exactLocation: { lat: 10.7411, lng: 106.7218 },
},
leaderboard: { message: 'Score added at 3 levels (+3)' },
leaderboard: { message: 'Score added at 3 levels (+4, +5, +5)' },
distance: { message: 'Distance record: 123m' },
message: 'Game result processed successfully',
};
@@ -95,7 +112,10 @@ export async function stubGameApis(page, username) {
let round = 0;
await page.route('**/api/new-game**', async (route) => {
round += 1;
await route.fulfill({ json: newGameResponse(`e2e-session-${round}`) });
// Echo a requested sessionId exactly as the real route does -- production
// reuses one id across a player's consecutive rounds.
const requested = new URL(route.request().url()).searchParams.get('sessionId');
await route.fulfill({ json: newGameResponse(requested || `e2e-session-${round}`, round) });
});
await page.route('**/api/guess', async (route) => {
await route.fulfill({ json: guessResponse(username) });