mirror of
https://github.com/tiennm99/vngeoguessr.git
synced 2026-10-20 02:18:30 +00:00
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:
1 parent
434409443c
commit
7123cdde76
4 files changed
+304
-62
No files matched your search
@@ -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'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}
|
||||
|
||||
@@ -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
@@ -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
@@ -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) });
|
||||
|
||||
Reference in new issue
Block a user