fix: close the review findings on the dev implementation

The daily challenge credited the main boards while its panorama is the
same all day and the answer is in the first guess response, which made
it a five-points-per-two-requests loop; it is now scored and counted but
credits nothing. The score fan-out settles per level and reports what
landed rather than calling a half-credited round unsaved. The daily
caches only its pick and resolves the image URL per request, so a dead
URL never breaks the day. The players HyperLogLog gets its TTL on its
own first write. DailyCard no longer computes the day number during
static render. Board rows use the Vietnamese name. Share buttons
announce their outcome and treat a cancelled sheet as nothing. The
backup artifact is encrypted before upload.
This commit is contained in:
tiennm99 committed 2026-09-21 00:35:06 +07:00
1 parent addf2c0f2e
commit 77654d2889
26 files changed
+502 -134

No files matched your search

+16 -5
View File
@@ -1,8 +1,11 @@
# Weekly copy of every leaderboard, kept as a workflow artifact for 90 days.
# Upstash's free plan has no scheduled backups, and the boards are the only
# player data the game holds. Needs two repository secrets, the same pair the
# app reads: KV_REST_API_URL and KV_REST_API_TOKEN (plus KEY_PREFIX if the
# deployment sets one).
# player data the game holds. Needs three repository secrets: the Redis pair
# the app reads, KV_REST_API_URL and KV_REST_API_TOKEN (plus KEY_PREFIX if the
# deployment sets one), and BACKUP_PASSPHRASE. The file is encrypted with the
# passphrase before upload: on a public repository any signed-in GitHub user
# can download an artifact, and the boards list every player name.
# Restore with: openssl enc -d -aes-256-cbc -pbkdf2 -in <file>.enc -out <file>
name: Leaderboard backup
on:
@@ -20,13 +23,21 @@ jobs:
node-version: 24
cache: npm
- run: npm ci
- run: npm run leaderboard:export
- run: npm run leaderboard:export -- leaderboard-backup.json
env:
KV_REST_API_URL: ${{ secrets.KV_REST_API_URL }}
KV_REST_API_TOKEN: ${{ secrets.KV_REST_API_TOKEN }}
KEY_PREFIX: ${{ secrets.KEY_PREFIX }}
- name: Encrypt
run: |
test -n "$BACKUP_PASSPHRASE" || { echo "BACKUP_PASSPHRASE secret is not set"; exit 1; }
openssl enc -aes-256-cbc -pbkdf2 -pass env:BACKUP_PASSPHRASE \
-in leaderboard-backup.json -out leaderboard-backup.json.enc
rm leaderboard-backup.json
env:
BACKUP_PASSPHRASE: ${{ secrets.BACKUP_PASSPHRASE }}
- uses: actions/upload-artifact@v4
with:
name: leaderboard-backup
path: leaderboard-backup-*.json
path: leaderboard-backup.json.enc
retention-days: 90
+13 -8
View File
@@ -57,10 +57,11 @@ reads, with placeholders only. The sections below explain each one.
- `MAPILLARY_ACCESS_TOKEN` - Mapillary API token for image fetching
**Debug API (optional)**:
- `DEBUG_ACCESS_KEY` - On Vercel production the `/api/debug/*` routes return
404 unless the request carries this value as the `x-debug-key` header or the
`vng_debug` cookie. Unset, they are closed in production. Off production
(`VERCEL_ENV` not `production`) they are always open
- `DEBUG_ACCESS_KEY` - In production the `/api/debug/*` routes return 404
unless the request carries this value as the `x-debug-key` header or the
`vng_debug` cookie. Unset, they are closed in production. Production is
`VERCEL_ENV=production`, or `NODE_ENV=production` where `VERCEL_ENV` is
absent; development, tests and preview deployments are always open
**Link previews (optional)**:
- `NEXT_PUBLIC_SITE_URL` - Absolute origin for Open Graph URLs. Defaults to
@@ -83,10 +84,14 @@ For local Redis without an Upstash account, see *Running Upstash locally* below.
request. It needs no secrets: compiling the app opens no connection.
`.github/workflows/leaderboard-backup.yml` runs `npm run leaderboard:export`
every Monday and on demand, and keeps the JSON as a workflow artifact for 90
days. It needs the repository secrets `KV_REST_API_URL` and
`KV_REST_API_TOKEN` (and `KEY_PREFIX` if the deployment sets one); until they
are set the job fails harmlessly.
every Monday and on demand, encrypts the JSON and keeps it as a workflow
artifact for 90 days. It needs the repository secrets `KV_REST_API_URL`,
`KV_REST_API_TOKEN` (and `KEY_PREFIX` if the deployment sets one) and
`BACKUP_PASSPHRASE`; until they are set the job fails harmlessly. The
artifact is encrypted because the repository is public, any signed-in GitHub
user can download an artifact, and the boards list every player name.
Restore with
`openssl enc -d -aes-256-cbc -pbkdf2 -in leaderboard-backup.json.enc -out leaderboard-backup.json`.
### Testing & Completion
+24 -13
View File
@@ -65,8 +65,10 @@
`/api/debug/region-coverage` return panorama coordinates, which is the answer
to any live round. On Vercel production they answer only a request carrying
`DEBUG_ACCESS_KEY` as the `x-debug-key` header or the `vng_debug` cookie
(`src/lib/debug-access.js`); unset, they are closed. Local, test and preview
deployments keep them open. The `/debug` pages still render in production
(`src/lib/debug-access.js`); unset, they are closed. Production means
Vercel's production environment, or a production build anywhere Vercel's
variables are absent. Local development, tests and preview deployments keep
them open. The `/debug` pages still render in production
but their data calls fail without the key
- **Validated input**: the username rule lives once in `src/lib/username.js`
(2-20 characters of letters, digits, `-`, `_`, any script) and is enforced by
@@ -82,8 +84,10 @@
- **UUID Session IDs**: unique session identifiers via `crypto.randomUUID()`
- **30-minute Expiry**: automatic Redis session cleanup
- **Single-use sessions**: the session is claimed with an atomic `DEL` before any
score is written, so a replayed or concurrent submit scores exactly once. The
failure carries a `reason` (`session-expired`, `session-consumed`,
score is written, so a replayed or concurrent submit scores exactly once.
After the claim the score fan-out settles per level: the levels that wrote
are returned, `partial: true` marks a level that did not, and only a round
where no level wrote is reported as unsaved. The failure carries a `reason` (`session-expired`, `session-consumed`,
`invalid-guess`, `invalid-username`, `invalid-request`) and the result dialog
words each one differently, so an expired round is not reported as a failed
write
@@ -177,20 +181,26 @@ renaming it would orphan every score already recorded under it.
- **One panorama, the same for everyone, once a day** at `/daily`. The pick is
deterministic from the day (`pickPanoBySeed` in `src/lib/pano-index.js`,
province first like a country round) and cached in Redis as `daily:{day}`
for 48 hours with its resolved image URL, so the Postgres draw and the
Mapillary lookup happen once a day, not once a player
for 48 hours, so the Postgres draw happens once a day. The image URL is
resolved from Mapillary on every request, as every round does, so a signed
URL that stops working never breaks the day; a pick deleted upstream is
forgotten and the next seeded candidate takes over
- **Days roll over at midnight Vietnam time** (`src/lib/daily-calendar.js`),
numbered from 2026-09-20 as #1
- **Scored like any round**: `/api/daily` opens an ordinary session flagged
`mode: 'daily'`; `/api/guess` credits the boards once and counts the round
under its own `daily` level in the statistics. No skipping
- **Scored, counted, never credited**: `/api/daily` opens an ordinary session
flagged `mode: 'daily'`; `/api/guess` scores it on the same ladder and counts
it under its own `daily` level in the statistics, but credits no
leaderboard. The panorama is the same all day and the answer is in the first
guess response, so any board the daily fed would be five points for two
requests, repeatable all day. No skipping; the submit button says "Submit
final guess"
- **No daily leaderboard, on purpose**: the only identity is a cookie and a
localStorage name, so a dated board would be won by whoever opened the most
private windows. One attempt per day is enforced by the browser alone
(`src/lib/daily-progress.js`): the finished round is stored with its
panorama, pin and result, and reopening `/daily` shows that instead of a
second attempt. Clearing storage means playing again; with nothing to win,
that only cheats the player
second attempt. Clearing storage means playing again; since the daily feeds
no board, that only cheats the player
- **Streak**: consecutive days played, kept in the same record. The day after
the last play extends it, the same day keeps it, a gap restarts it. Shown in
the game header, the result dialog, the share text and the home page card
@@ -215,6 +225,7 @@ renaming it would orphan every score already recorded under it.
players, rounds per player, the zero-score share per level, and a return
rate from the union of several days against their sum. Both expire after 90
days. Nothing per player is stored; the HyperLogLog only counts
- **Cost**: two Redis commands per round plus an occasional EXPIRE. A failed
write is logged and never fails the guess
- **Cost**: two Redis commands per round, plus an EXPIRE on the hash for each
new field of the day and one on the HyperLogLog for each new player. A
failed write is logged and never fails the guess
- **Reading them**: `npm run stats [days]` prints the last N days
@@ -13,9 +13,10 @@ provider or stack change, nothing that costs money.
| `e649259` | 2 | Expired-round copy, region-hit line ("Right province, wrong district") located server-side, OpenStreetMap link on the reveal, Share button with squares text, Open Graph metadata, phone header collapse, fresh id after skip, PanoramaViewer loaded on demand (three.js out of first load). |
| `8450b75` | 3 | Daily challenge at `/daily`: deterministic pick per Vietnam day, cached 48h, no daily board, browser-side one-attempt and streak, home card, daily share text. |
| `6dbe2e0` | 4 | Vietnamese names: `nameVi` in the tree from the OSM queries, `regionName()` everywhere a name is shown, search matches both spellings, Geist `vietnamese` subset. |
| this | ops | CI workflow (lint, test, production compile) on push and PR. Weekly leaderboard export to a 90-day artifact. |
| `064c173` | ops | CI workflow (lint, test, production compile) on push and PR. Weekly leaderboard export to a 90-day artifact. |
| review fixes | — | Every finding of the three-agent review, see [synthesis-260921-0014-dev-review.md](synthesis-260921-0014-dev-review.md): the daily credits no board, partial credit reported honestly, stats TTL, hydration fix, Vietnamese board rows, encrypted backup, UX and a11y items. |
Gates at the end: 348 tests pass, lint 0 errors / 21 warnings (all the
Gates at the end: 357 tests pass, lint 0 errors / 21 warnings (all the
pre-existing localStorage-in-effect and callback-ref patterns), production
compile green with and without environment variables.
@@ -29,7 +30,7 @@ Redis per completed round: about 23 commands (was 27). Distance boards remain
- Optional `NEXT_PUBLIC_SITE_URL` for absolute Open Graph URLs (Vercel's
production URL is the default).
- Repository secrets `KV_REST_API_URL`, `KV_REST_API_TOKEN` (and `KEY_PREFIX`
if set) for the backup workflow.
if set) plus `BACKUP_PASSPHRASE` for the backup workflow.
- One Vercel WAF rate-limit rule on `/api/*`. Free on Hobby, dashboard only.
- Merge `dev` to `main` to deploy.
@@ -74,3 +74,49 @@ commands; 84 of 85 nodes carry `nameVi`; share text carries no coordinates.
- Should the daily credit the main boards at all? Recommendation: no.
- Real lifetime of a Mapillary `thumb_2048_url`. Decides how urgent S4 is.
- Is a world-readable username list in a backup artifact acceptable?
## Resolution (same day)
Every finding above was applied on `dev`; see the commit "fix: close the
review findings on the dev implementation".
- **B1** The daily credits no board. `/api/guess` skips both fan-outs for
`mode: 'daily'`; the round is scored and counted only. Test asserts no
leaderboard or distance key is written by a daily guess.
- **S1** The players HyperLogLog's TTL rides on its own first write (PFADD
returning new), not the hash counter. Test covers a cookieless first round.
- **S2** DailyCard derives the number from mounted state only; no render-time
fallback, so the static HTML and the client agree.
- **S3** `isPano` stored with the daily result and used on replay.
- **S4** The daily caches the pick only; the image URL is resolved per
request. A cached pick that stops resolving is forgotten and the next seeded
candidate takes over. Tests cover recovery and four-failure exhaustion.
- **S5** Score fan-out uses `allSettled`: levels that wrote are returned with
`partial: true`, and only a round where nothing wrote is a 500. Dialog says
"Some boards could not be updated" instead of "Nothing was scored". Test
injects a failing ZINCRBY on one key, on all keys, and a stats outage.
- **S6** Board rows and `/api/leaderboard` use `regionName()`.
- **S7** The home spec lists the accented province names; the stub no longer
emits `trimmed`.
- **UX H1** Card copy says "one guess"; the daily submit button reads "Submit
final guess".
- **UX H2** Share button label is its accessible name, a `role="status"`
sibling announces the outcome, a cancelled share sheet is `'cancelled'` and
shows the plain button again, failure shows an alert icon with "Retry".
- **UX M1–M5** Daily failure copy has no "start a new one"; DailyCard buttons
are default height (44px); the dialog action row is h-12 throughout with an
icon-only Share below `sm`; a province-level answer reads "Right province";
streak badges carry sr-only "-day streak" text.
- **Lows** "Play today's challenge"; badge truncation via an inner block span;
compact ThemeToggle is a single button, no one-child group; the failure
block drops `role="alert"` (the dialog description already announces);
reduced-motion covers the dialog's enter/exit animation.
- **Nits** `pickPanoBySeed` hashes the offset per province; the debug gate
treats a production build without Vercel variables as production; the
backup artifact is encrypted with a `BACKUP_PASSPHRASE` secret before upload.
- **Tests** Year-wrap `previousDay`; daily route day assertion bracketed
across a possible midnight; two-day stats TTL.
Not changed, by decision: two ThemeToggle instances swapped by breakpoint
(matches SoundToggle); CI running twice on a dev→main PR; `/api/daily`
unauthenticated like every other route (the Vercel WAF rule covers it).
+16 -5
View File
@@ -127,10 +127,18 @@ export async function POST(request) {
// Boards are credited per level from the raw distance against the same
// ladder, so every level records the identical points for this round. The
// two fan-outs touch disjoint keys, so they run together.
const [leaderboardResult, distanceResult] = await Promise.all([
submitRoundScore(username, distance, scoringRegion),
distanceOrNone(username, distance, scoringRegion),
]);
//
// Not for the daily. Its panorama is the same all day and the answer is in
// this very response, so a daily session that credited boards would be
// five points on three permanent boards for two requests, all day long.
// The daily is scored and counted, never credited.
const isDaily = session.mode === 'daily';
const [leaderboardResult, distanceResult] = isDaily
? [{ levels: [], message: '' }, null]
: await Promise.all([
submitRoundScore(username, distance, scoringRegion),
distanceOrNone(username, distance, scoringRegion),
]);
// Where the guess landed, against where the panorama was. Display only:
// it changes no score, but it turns "0 points" into "right province,
@@ -145,7 +153,7 @@ export async function POST(request) {
: 'country';
// A daily round is a country round everyone plays; counted apart so the
// two are not confused in the zero-score share.
const statsLevel = session.mode === 'daily' ? 'daily' : pickedLevel;
const statsLevel = isDaily ? 'daily' : pickedLevel;
await recordRoundOrIgnore(statsLevel, finalScore, readPlayerId(request));
// For monitoring. Deliberately without the name or either coordinate pair:
@@ -166,6 +174,9 @@ export async function POST(request) {
// One entry per level credited, outermost last. The client renders
// these directly rather than a fixed global/city pair.
levels: leaderboardResult.levels,
// True when a level failed to write after the session was consumed:
// the levels above are what actually landed.
partial: Boolean(leaderboardResult.partial),
distanceLevels: distanceResult?.levels ?? [],
// Where the panorama actually was. Safe now, and only now: the guess
// is in.
+2 -2
View File
@@ -1,6 +1,6 @@
import { NextResponse } from 'next/server';
import { getLeaderboard } from '../../../lib/leaderboard.js';
import { getRegion, COUNTRY_CODE } from '../../../lib/regions.js';
import { regionName, COUNTRY_CODE } from '../../../lib/regions.js';
import { resolveRegion } from '../../../lib/region-request.js';
export async function GET(request) {
@@ -30,7 +30,7 @@ export async function GET(request) {
success: true,
leaderboard,
count: leaderboard.length,
region: { code: regionCode, name: getRegion(regionCode).name },
region: { code: regionCode, name: regionName(regionCode) },
leaderboardType: type,
// Pre-tree field names, kept so existing callers keep working.
type: regionCode === COUNTRY_CODE ? 'global' : 'city',
+22 -10
View File
@@ -2,7 +2,7 @@
import { useEffect, useState } from 'react';
import Link from 'next/link';
import { ArrowRight, Calendar, Check, Share2 } from 'lucide-react';
import { AlertCircle, ArrowRight, Calendar, Check, Share2 } from 'lucide-react';
import { Button } from '@/components/ui/button';
import { Card, CardContent } from '@/components/ui/card';
import { dailyDay, dailyNumber } from '../../lib/daily-calendar';
@@ -36,9 +36,19 @@ export default function DailyCard({ onPlayClick }) {
});
}, []);
const number = state?.number ?? dailyNumber(dailyDay());
// Everything below comes from `state` alone. The page is prerendered, so a
// fallback computed at render time would freeze the build day's number into
// the HTML and mismatch the client's on every later day.
const number = state?.number ?? null;
const played = state?.played ?? null;
const streak = state?.streak ?? 0;
const shareLabel = shareState === 'copied' ? 'Copied' : shareState === 'failed' ? 'Retry' : 'Share';
const ShareIcon = shareState === 'copied' ? Check : shareState === 'failed' ? AlertCircle : Share2;
const shareStatus =
shareState === 'copied' ? 'Copied to the clipboard.'
: shareState === 'shared' ? 'Shared.'
: shareState === 'failed' ? 'Sharing failed. Try again.'
: '';
const handleShare = async () => {
const outcome = await shareText(
@@ -66,30 +76,32 @@ export default function DailyCard({ onPlayClick }) {
</span>
<div>
<p className="font-semibold text-foreground">
Daily Challenge #{number}
Daily Challenge{number ? ` #${number}` : ''}
{streak > 0 && (
<span className="ml-2 text-sm font-medium text-muted-foreground" title="Consecutive days played">
🔥 {streak}
<span aria-hidden="true">🔥</span> {streak}
<span className="sr-only">-day streak</span>
</span>
)}
</p>
<p className="text-sm text-muted-foreground">
{played
? `Played today: ${squares} ${played.result.score}/${MAX_POINTS} · ${formatDistance(played.result.distance)} away`
: 'One street view, the same for everyone. New at midnight, Vietnam time.'}
: 'One street view, one guess, the same for everyone. New at midnight, Vietnam time.'}
</p>
</div>
</div>
{played ? (
<div className="flex items-center gap-2">
<Button onClick={handleShare} variant="outline" size="sm" aria-label="Share today's result">
{shareState === 'copied' ? <Check className="size-4" aria-hidden="true" /> : <Share2 className="size-4" aria-hidden="true" />}
{shareState === 'copied' ? 'Copied' : 'Share'}
<Button onClick={handleShare} variant="outline" title="Share today's result">
<ShareIcon className="size-4" aria-hidden="true" />
{shareLabel}
</Button>
<Button asChild variant="ghost" size="sm">
<Button asChild variant="ghost">
<Link href="/daily">See result</Link>
</Button>
<p role="status" aria-live="polite" className="sr-only">{shareStatus}</p>
</div>
) : (
<Link
@@ -100,7 +112,7 @@ export default function DailyCard({ onPlayClick }) {
}}
className="inline-flex min-h-11 items-center gap-1.5 rounded-lg bg-brand px-4 py-2 text-sm font-semibold text-brand-foreground transition-colors hover:bg-brand/90 focus-visible:outline-none focus-visible:ring-[3px] focus-visible:ring-ring"
>
Play today&apos;s
Play today&apos;s challenge
<ArrowRight className="size-4" aria-hidden="true" />
</Link>
)}
+18 -6
View File
@@ -213,7 +213,7 @@ export default function GameClient({ region, daily = false }) {
const played = daily ? getDailyProgress() : null;
if (played && played.day === dailyDay()) {
setDailyInfo({ day: played.day, number: played.number, streak: played.streak });
setImageData({ url: played.imageUrl, isPano: true });
setImageData({ url: played.imageUrl, isPano: played.isPano ?? true });
setRoundKey((key) => key + 1);
setGuessCoordinates(played.guessCoordinates);
setResult(played.result);
@@ -361,7 +361,7 @@ export default function GameClient({ region, daily = false }) {
// Today is done. Stored with everything the result screen needs,
// so a revisit shows this rather than a fresh round.
const saved = saveDailyResult(
dailyInfo.day, dailyInfo.number, outcome, guessCoordinates, imageData.url
dailyInfo.day, dailyInfo.number, outcome, guessCoordinates, imageData.url, imageData.isPano
);
setDailyInfo({ ...dailyInfo, streak: saved.streak });
}
@@ -514,12 +514,17 @@ export default function GameClient({ region, daily = false }) {
<span className="text-sm font-bold text-foreground hidden sm:inline">VNGeoGuessr</span>
{/* Truncates rather than pushing the controls off a 360px screen:
a long district name loses its tail, not the mute button. */}
<Badge variant="brand" className="max-w-[8rem] truncate text-xs sm:max-w-none" title={daily ? "Today's daily challenge" : regionName}>
{daily ? `Daily${dailyInfo ? ` #${dailyInfo.number}` : ''}` : regionName}
<Badge variant="brand" className="max-w-[8rem] text-xs sm:max-w-none" title={daily ? "Today's daily challenge" : regionName}>
{/* The ellipsis needs a block-level child: `truncate` on the
inline-flex badge itself clips without one. */}
<span className="truncate">
{daily ? `Daily${dailyInfo ? ` #${dailyInfo.number}` : ''}` : regionName}
</span>
</Badge>
{daily && dailyInfo?.streak > 0 && (
<Badge variant="secondary" className="text-xs tabular-nums" title="Consecutive days played">
🔥 {dailyInfo.streak}
<span aria-hidden="true">🔥</span> {dailyInfo.streak}
<span className="sr-only">-day streak</span>
</Badge>
)}
{/* This visit's tally; invisible until the first round lands so the
@@ -657,7 +662,14 @@ export default function GameClient({ region, daily = false }) {
size="lg"
loading={submitting}
>
{submitting ? 'Processing...' : guessCoordinates ? 'Submit Guess' : 'Place a guess first'}
{/* The daily allows one guess, and this is the last moment to say so. */}
{submitting
? 'Processing...'
: !guessCoordinates
? 'Place a guess first'
: daily
? 'Submit final guess'
: 'Submit Guess'}
</Button>
{/* No skipping the daily: everyone gets the same one place. */}
{!daily && (
+53 -24
View File
@@ -4,7 +4,7 @@ import { useState } from 'react';
import { Button } from '@/components/ui/button';
import { Badge } from '@/components/ui/badge';
import { Dialog, DialogContent, DialogDescription, DialogHeader, DialogTitle } from '@/components/ui/dialog';
import { Check, ChevronDown, ExternalLink, Share2 } from 'lucide-react';
import { AlertCircle, Check, ChevronDown, ExternalLink, Share2 } from 'lucide-react';
import { formatDistance, SCORE_BANDS } from '../../lib/game';
import { useCountUp } from '../../lib/use-count-up';
import { regionSlug } from '../../lib/regions';
@@ -12,28 +12,46 @@ import { buildShareText, buildDailyShareText, shareText } from '../../lib/share'
import ResultMap, { MARKER_COLORS } from './ResultMap';
// Why a round was not recorded, in the player's terms. The server names the
// reason; without one, the honest default is that the write failed.
// reason; without one, the honest default is that the write failed. The daily
// has no next round to start, so its second sentence differs.
const FAILURE_COPY = {
'session-expired': {
title: 'This round expired',
body: 'Rounds last 30 minutes. Nothing was scored, so start a new one.',
daily: 'Rounds last 30 minutes. Nothing was scored; reload to try today again.',
},
'session-consumed': {
title: 'This round was already submitted',
body: 'Only the first guess counts. Nothing more was scored.',
daily: 'Only the first guess counts. Nothing more was scored.',
},
default: {
title: 'Your guess could not be saved',
body: 'Nothing was scored. The round has ended, so start a new one to try again.',
daily: 'Nothing was scored. Reload to try today again.',
},
};
// The one line the ladder cannot say: how much of the answer's region a miss
// still got right. Only worth showing when the ladder itself said little.
const HIT_COPY = {
district: 'Right district',
province: 'Right province, wrong district',
none: 'Wrong province',
// A panorama outside every district outline resolves to its province, and
// "wrong district" would be a district that does not exist -- so a
// province-level answer gets the shorter line.
function hitCopy(hit, resolvedPath) {
if (hit === 'district') return 'Right district';
if (hit === 'province') {
return (resolvedPath?.length ?? 3) < 3 ? 'Right province' : 'Right province, wrong district';
}
return 'Wrong province';
}
// What the share button did, for the screen reader; the visible label carries
// the same word so nothing depends on colour or an icon alone.
const SHARE_STATUS = {
shared: 'Shared.',
copied: 'Copied to the clipboard.',
failed: 'Sharing failed. Try again.',
cancelled: '',
};
// Background and text move together: the semantic tokens flip to lighter
@@ -108,10 +126,14 @@ export default function RoundResultDialog({
setShareOutcome({ result, state: await shareText(text) });
};
const failure = FAILURE_COPY[result?.reason] ?? FAILURE_COPY.default;
const failureCopy = FAILURE_COPY[result?.reason] ?? FAILURE_COPY.default;
const failure = { title: failureCopy.title, body: daily ? failureCopy.daily : failureCopy.body };
// A guess the ladder scored well already says where it landed; the region
// line earns its place under 3 points, where the ladder says only "beyond".
const hitLine = result && !result.failed && score < 3 && result.hit ? HIT_COPY[result.hit] : null;
const hitLine =
result && !result.failed && score < 3 && result.hit ? hitCopy(result.hit, result.resolvedPath) : null;
const shareLabel = shareState === 'copied' ? 'Copied' : shareState === 'failed' ? 'Retry' : 'Share';
const ShareIcon = shareState === 'copied' ? Check : shareState === 'failed' ? AlertCircle : Share2;
const hasScoreLevels = (result?.scoreLevels?.length ?? 0) > 0;
const hasDistanceLevels = result?.distanceLevels?.some((entry) => entry.rank) ?? false;
@@ -158,7 +180,10 @@ export default function RoundResultDialog({
// A failed submission is not a zero-point round. Showing the
// score circle here would present a write failure as a real miss,
// and the player would have no way to tell the difference.
<div className="space-y-3 py-6 text-center" role="alert">
// Not a live region: the dialog's own description already announces
// the outcome once when it opens, and a second announcement would
// read the same sentence twice.
<div className="space-y-3 py-6 text-center">
<p className="text-lg font-semibold text-foreground">{failure.title}</p>
<p className="text-sm text-muted-foreground">{failure.body}</p>
</div>
@@ -330,7 +355,12 @@ export default function RoundResultDialog({
</div>
)}
{result.leaderboardMessage && (
{result.partial && (
<p className="text-sm text-warning-foreground font-medium">
Some boards could not be updated this round. The ones above were.
</p>
)}
{result.leaderboardMessage && !result.partial && (
<p className="text-sm text-success font-medium">{result.leaderboardMessage}</p>
)}
</div>
@@ -344,9 +374,11 @@ export default function RoundResultDialog({
) : null}
</div>
{/* Actions */}
<div className="flex gap-3 pt-2">
<Button onClick={onNextRound} size="lg" className="flex-[2]">
{/* Actions. Every button is h-12 so the row reads as one; Share is
icon-only below sm (its word is still its accessible name) so the
three fit inside 280px on a 360px phone. */}
<div className="flex gap-2 pt-2 sm:gap-3">
<Button onClick={onNextRound} size="lg" className="min-w-0 flex-[2]">
{daily ? 'Done' : 'Next Round'}
</Button>
{result && !result.failed && (
@@ -354,26 +386,23 @@ export default function RoundResultDialog({
onClick={handleShare}
variant="outline"
size="lg"
aria-label={shareState === 'copied' ? 'Result copied' : 'Share this result'}
title="Share this result"
className="px-3"
className="shrink-0 px-3 sm:px-5"
>
{shareState === 'copied' ? (
<Check className="size-4" aria-hidden="true" />
) : (
<Share2 className="size-4" aria-hidden="true" />
)}
<span className="sr-only sm:not-sr-only">
{shareState === 'copied' ? 'Copied' : shareState === 'failed' ? 'Retry' : 'Share'}
</span>
<ShareIcon className="size-4" aria-hidden="true" />
<span className="sr-only sm:not-sr-only">{shareLabel}</span>
</Button>
)}
{!daily && (
<Button onClick={onMenu} variant="ghost" className="flex-1">
<Button onClick={onMenu} variant="ghost" size="lg" className="min-w-0 flex-1 px-3">
Menu
</Button>
)}
</div>
{/* Announced once per outcome; visually the button label already says it. */}
<p role="status" aria-live="polite" className="sr-only">
{SHARE_STATUS[shareState] ?? ''}
</p>
</DialogContent>
</Dialog>
);
+10 -11
View File
@@ -59,18 +59,17 @@ export default function ThemeToggle({ className = '', compact = false }) {
const current = THEMES[index === -1 ? 0 : index];
const next = THEMES[(index + 1) % THEMES.length];
const Icon = THEME_ICONS[current.value];
// One control, so no group role: a group of one only adds a stop.
return (
<div role="group" aria-label="Colour theme" className={groupClass}>
<button
type="button"
aria-label={`Theme: ${current.label}. Switch to ${next.label}`}
title={`Theme: ${current.label}`}
onClick={() => handleSelect(next.value)}
className="flex h-11 w-11 items-center justify-center rounded-lg text-muted-foreground outline-none transition-colors hover:bg-muted hover:text-foreground focus-visible:ring-[3px] focus-visible:ring-ring/50"
>
<Icon className="size-4" aria-hidden="true" />
</button>
</div>
<button
type="button"
aria-label={`Theme: ${current.label}. Switch to ${next.label}`}
title={`Theme: ${current.label}`}
onClick={() => handleSelect(next.value)}
className={`flex h-11 w-11 items-center justify-center rounded-lg border border-border bg-card text-muted-foreground outline-none transition-colors hover:bg-muted hover:text-foreground focus-visible:ring-[3px] focus-visible:ring-ring/50 ${className}`}
>
<Icon className="size-4" aria-hidden="true" />
</button>
);
}
+8
View File
@@ -339,6 +339,14 @@
animation: none;
opacity: 1;
}
/* The dialog's enter/exit (tw-animate's animate-in / animate-out) and the
Radix content transitions, so the result screen appears in place. */
.animate-in,
.animate-out,
[data-slot='dialog-content'],
[data-slot='dialog-overlay'] {
animation: none !important;
}
.city-card-accent::before {
transition: none;
}
+4 -1
View File
@@ -18,6 +18,7 @@ export const DAILY_STORAGE_KEY = 'vngeoguessr_daily';
* @property {Object} result The round result as the dialog rendered it.
* @property {number[]} guessCoordinates [lat, lng] the player guessed.
* @property {string} imageUrl The panorama, so the result can be shown again.
* @property {boolean} isPano Whether it renders in the 360 viewer or flat.
*/
/** @returns {DailyProgress|null} */
@@ -53,9 +54,10 @@ export function nextStreak(previous, day) {
* @param {Object} result
* @param {number[]} guessCoordinates
* @param {string} imageUrl
* @param {boolean} isPano
* @returns {DailyProgress} What was stored.
*/
export function saveDailyResult(day, number, result, guessCoordinates, imageUrl) {
export function saveDailyResult(day, number, result, guessCoordinates, imageUrl, isPano) {
const previous = getDailyProgress();
const progress = {
day,
@@ -64,6 +66,7 @@ export function saveDailyResult(day, number, result, guessCoordinates, imageUrl)
result,
guessCoordinates,
imageUrl,
isPano: isPano !== false,
};
if (typeof window !== 'undefined') {
try {
+37 -20
View File
@@ -1,4 +1,4 @@
import { getUpstash, getJson, putJson } from './upstash.js';
import { getUpstash, getJson, putJson, del } from './upstash.js';
import { pickPanoBySeed } from './pano-index.js';
import { fetchPanoramaById } from './mapillary.js';
@@ -7,49 +7,61 @@ import { fetchPanoramaById } from './mapillary.js';
// SERVER-SIDE ONLY, like pano-index.js: the round's answer passes through here.
//
// The pick is deterministic from the day, so two servers computing it at once
// agree, and it is cached in Redis for two days so the Mapillary lookup and the
// Postgres draw happen once a day rather than once a player. The cached record
// holds the image URL too: Mapillary's signed thumbnail URLs stay valid for
// weeks, far longer than the cache.
// agree, and the pick (id, coordinates, district) is cached in Redis for two
// days so the Postgres draw happens once a day. The image URL is NOT cached:
// it is resolved from Mapillary on every request, as every ordinary round
// already does, so a signed URL that stops working never breaks the day, and
// a panorama deleted upstream is replaced by the next seeded candidate.
//
// There is no daily leaderboard, on purpose. The only identity is a cookie and
// a localStorage name, so a dated board would be won by whoever opened the
// most private windows. One attempt per day is enforced by the browser alone
// (see daily-progress.js), for the same reason: with nothing to win, the only
// person a second attempt cheats is the player.
// There is no daily leaderboard and the daily credits none of the main boards
// either (see /api/guess): the answer is in the first guess response and the
// panorama is the same all day, so any board it fed would be farmable. One
// attempt per day is enforced by the browser alone (daily-progress.js), which
// is enough once nothing can be won by a second one.
const DAILY_PREFIX = 'daily:';
const DAILY_TTL_SECONDS = 48 * 60 * 60;
// A deterministic pick can land on an image deleted upstream; step the seed a
// few times before giving up on the day.
// A seeded pick can land on an image deleted upstream; step the seed a few
// times before giving up on the day.
const MAX_ATTEMPTS = 4;
/**
* The panorama for a day, resolved and cached.
* The panorama for a day, resolved to a displayable image.
* @param {string} day 'YYYY-MM-DD' from daily-calendar.js.
* @returns {Promise<{id: string, lat: number, lng: number, regionCode: string, url: string, isPano: boolean}>}
*/
export async function getDailyRound(day) {
const h = getUpstash();
const key = `${DAILY_PREFIX}${day}`;
const cached = await getJson(h, key);
if (cached) return cached;
const cached = await getJson(h, key);
if (cached) {
try {
return withImage(cached, await fetchPanoramaById(cached.id));
} catch (error) {
if (error.message === 'Mapillary authentication failed') throw error;
// The day's pick no longer resolves. Forget it and choose again, skipping
// this id, so the day recovers instead of failing until the cache expires.
console.error(`Daily ${day} cached panorama ${cached.id} failed: ${error.message}`);
await del(h, key);
}
}
const skip = new Set(cached ? [cached.id] : []);
let lastError = null;
for (let attempt = 0; attempt < MAX_ATTEMPTS; attempt++) {
const candidate = await pickPanoBySeed(`${day}:${attempt}`);
if (skip.has(candidate.id)) continue;
try {
const image = await fetchPanoramaById(candidate.id);
const round = {
const pick = {
id: candidate.id,
lat: image.lat ?? candidate.lat,
lng: image.lng ?? candidate.lng,
regionCode: candidate.regionCode,
url: image.url,
isPano: image.isPano,
};
await putJson(h, key, round, DAILY_TTL_SECONDS);
return round;
await putJson(h, key, pick, DAILY_TTL_SECONDS);
return withImage(pick, image);
} catch (error) {
if (error.message === 'Mapillary authentication failed') throw error;
lastError = error.message;
@@ -58,3 +70,8 @@ export async function getDailyRound(day) {
}
throw new Error(`No daily panorama could be loaded for ${day} (last: ${lastError})`);
}
/** The cached pick plus the image just resolved for it. */
function withImage(pick, image) {
return { ...pick, url: image.url, isPano: image.isPano };
}
+12 -5
View File
@@ -2,10 +2,11 @@
//
// Those routes return panorama coordinates by id and by region, which is the
// answer to any live round, and each call spends Neon compute or a Mapillary
// request. Off Vercel production they stay open: local development, the test
// suite and preview deployments are where they are used. In production they
// answer only a caller holding DEBUG_ACCESS_KEY, sent as the `x-debug-key`
// header or the `vng_debug` cookie; with no key configured they are closed.
// request. They stay open where they are used: local development, the test
// suite and Vercel preview deployments. In production -- Vercel's production
// environment, or any production build outside Vercel -- they answer only a
// caller holding DEBUG_ACCESS_KEY, sent as the `x-debug-key` header or the
// `vng_debug` cookie; with no key configured they are closed.
const DEBUG_HEADER = 'x-debug-key';
const DEBUG_COOKIE = 'vng_debug';
@@ -28,12 +29,18 @@ function readCookie(request, name) {
* @returns {boolean}
*/
export function debugAccessAllowed(request) {
if (process.env.VERCEL_ENV !== 'production') return true;
if (!isProduction()) return true;
const key = process.env.DEBUG_ACCESS_KEY;
if (!key) return false;
return request.headers.get(DEBUG_HEADER) === key || readCookie(request, DEBUG_COOKIE) === key;
}
/** Production means Vercel says so, or a production build with no Vercel at all. */
function isProduction() {
if (process.env.VERCEL_ENV) return process.env.VERCEL_ENV === 'production';
return process.env.NODE_ENV === 'production';
}
/** The response a closed debug route gives: a 404, so the route does not advertise itself. */
export function debugForbidden() {
return Response.json({ success: false, error: 'Not found' }, { status: 404 });
+21 -6
View File
@@ -7,7 +7,7 @@ import {
zRevRank,
zRemRangeByRank,
} from './upstash.js';
import { ancestorsOf, getRegion, isRegion, COUNTRY_CODE } from './regions.js';
import { ancestorsOf, getRegion, isRegion, regionName, COUNTRY_CODE } from './regions.js';
import { calculateScore } from './game.js';
// Leaderboard logical key constants (prefix is applied inside the adapter).
@@ -142,7 +142,7 @@ async function creditScore(h, regionCode, points, username) {
const rank = await zRevRank(h, key, username);
return {
code: regionCode,
name: getRegion(regionCode).name,
name: regionName(regionCode),
username,
// What this round added at this level.
points,
@@ -164,10 +164,24 @@ async function creditScore(h, regionCode, points, username) {
async function fanOutScore(h, username, regionCode, pointsFor) {
// In parallel: the levels are independent keys and no level reads another's
// state, so serialising them would add two round trips of latency to every
// guess for nothing. The four calls WITHIN a level stay ordered.
const levels = await Promise.all(
ancestorsOf(regionCode).map((code) => creditScore(h, code, pointsFor(code), username))
// guess for nothing. The calls WITHIN a level stay ordered.
//
// Settled, not all-or-nothing: the caller has already consumed the session,
// so a level that failed cannot be retried and a level that succeeded cannot
// be undone. Reporting what landed beats reporting a total failure over a
// round that is partly on the boards. Only when nothing landed is it one.
const codes = ancestorsOf(regionCode);
const settled = await Promise.allSettled(
codes.map((code) => creditScore(h, code, pointsFor(code), username))
);
const levels = settled.filter((r) => r.status === 'fulfilled').map((r) => r.value);
const failures = settled.filter((r) => r.status === 'rejected');
for (const [i, r] of settled.entries()) {
if (r.status === 'rejected') console.error(`Score credit failed at ${codes[i]}:`, r.reason);
}
if (levels.length === 0) {
throw failures[0]?.reason ?? new Error('No level could be credited');
}
// Named aliases alongside the array: the chain is district -> province ->
// country for a leaf, but only province -> country when a panorama fell
@@ -178,6 +192,7 @@ async function fanOutScore(h, username, regionCode, pointsFor) {
return {
success: true,
levels,
partial: failures.length > 0,
district: byLevel('district'),
province: byLevel('province'),
global: byLevel('country'),
@@ -281,7 +296,7 @@ async function creditDistance(h, regionCode, distance, entryId, username) {
const rank = await zRank(h, key, entryId);
return {
code: regionCode,
name: getRegion(regionCode).name,
name: regionName(regionCode),
username,
distance,
rank: rank !== null ? rank + 1 : null,
+1 -1
View File
@@ -200,7 +200,7 @@ export async function pickPanoBySeed(seed) {
const province = provinces[(start + step) % provinces.length];
const total = await countPanos(province);
if (total === 0) continue;
const offset = hash32(`${seed}:offset`) % total;
const offset = hash32(`${seed}:${province}:offset`) % total;
const rows = await query(
getPanoDb(),
`SELECT id, lat, lng, district FROM panoramas
+4 -3
View File
@@ -40,7 +40,7 @@ export function buildDailyShareText(number, score, distanceLabel, streak, url) {
/**
* Hand text to the platform share sheet, falling back to the clipboard.
* @param {string} text
* @returns {Promise<'shared'|'copied'|'failed'>} What actually happened.
* @returns {Promise<'shared'|'copied'|'cancelled'|'failed'>} What actually happened.
*/
export async function shareText(text) {
if (typeof navigator === 'undefined') return 'failed';
@@ -49,8 +49,9 @@ export async function shareText(text) {
await navigator.share({ text });
return 'shared';
} catch (error) {
// The player closed the sheet: nothing to fall back to, nothing went wrong.
if (error?.name === 'AbortError') return 'failed';
// The player closed the sheet: nothing went wrong and nothing to fall
// back to, so it must not read as a failure.
if (error?.name === 'AbortError') return 'cancelled';
}
}
try {
+8 -4
View File
@@ -10,8 +10,9 @@ import { getUpstash, hIncrBy, hGetAllNumbers, pfAdd, pfCount, expire, scanKeys }
// several days against their sum) how many players come back. Nothing here is
// per player: the HyperLogLog cannot be read back, only counted.
//
// Cost: two commands per round, plus an EXPIRE on each key the first time a
// new field appears that day. Both keys expire after STATS_TTL_DAYS.
// Cost: two commands per round, plus an EXPIRE on the hash each time a new
// field appears that day and one on the HyperLogLog for each new player. Both
// keys expire after STATS_TTL_DAYS.
const STATS_PREFIX = 'stats:';
const PLAYERS_PREFIX = 'stats:players:';
@@ -63,8 +64,11 @@ export async function recordRound(level, score, playerId, now = Date.now()) {
}
if (playerId) {
await pfAdd(h, playersKey(day), playerId);
if (count === 1) await expire(h, playersKey(day), STATS_TTL_SECONDS);
// The TTL rides on the HyperLogLog's OWN first write, not the hash's: the
// day's first round may carry no cookie, and a key created later than the
// hash's first field would otherwise never expire.
const isNewPlayer = await pfAdd(h, playersKey(day), playerId);
if (isNewPlayer) await expire(h, playersKey(day), STATS_TTL_SECONDS);
}
}
+3 -1
View File
@@ -13,8 +13,10 @@ describe('daily calendar', () => {
expect(dailyNumber('2026-10-01')).toBe(12);
});
it('steps back one day across a month boundary', () => {
it('steps back one day across month and year boundaries', () => {
expect(previousDay('2026-10-01')).toBe('2026-09-30');
expect(previousDay('2027-01-01')).toBe('2026-12-31');
expect(previousDay('2028-03-01')).toBe('2028-02-29');
});
it('recognises a well-formed day', () => {
+46 -4
View File
@@ -16,6 +16,7 @@ import { getGameSession } from '../src/lib/session.js';
import { dailyDay } from '../src/lib/daily-calendar.js';
import { readDay, statsDay } from '../src/lib/stats.js';
import { resetStore, storedKeys, ttlOf } from './redis-harness.js';
import { getLeaderboard } from '../src/lib/leaderboard.js';
import { seedPanoFixtures } from './pano-fixtures.js';
// The daily is the same panorama for everyone, chosen from the day alone and
@@ -23,6 +24,8 @@ import { seedPanoFixtures } from './pano-fixtures.js';
const ORIGINAL_TOKEN = process.env.MAPILLARY_ACCESS_TOKEN;
let mapillaryCalls = 0;
// Ids the Mapillary stub refuses, to simulate images deleted upstream.
const deadIds = new Set();
beforeAll(async () => {
await seedPanoFixtures(false);
@@ -31,12 +34,14 @@ beforeAll(async () => {
beforeEach(async () => {
await resetStore();
mapillaryCalls = 0;
deadIds.clear();
process.env.MAPILLARY_ACCESS_TOKEN = 'test-token';
const realFetch = globalThis.fetch;
vi.stubGlobal('fetch', async (url, init) => {
if (!String(url).includes('graph.mapillary.com')) return realFetch(url, init);
mapillaryCalls += 1;
const id = String(url).split('/').pop().split('?')[0];
if (deadIds.has(id)) return new Response('gone', { status: 404 });
return new Response(
JSON.stringify({
id,
@@ -71,11 +76,13 @@ describe('pickPanoBySeed', () => {
});
describe('getDailyRound', () => {
it('resolves once and serves the cached record afterwards, with a two-day TTL', async () => {
it('caches the pick for two days and resolves the image on every call', async () => {
const first = await getDailyRound('2026-09-21');
const second = await getDailyRound('2026-09-21');
expect(second).toEqual(first);
expect(mapillaryCalls).toBe(1);
// One lookup per call: the URL is never cached, so a signed URL that
// stops working cannot break the day.
expect(mapillaryCalls).toBe(2);
expect(first.url).toBe(`https://example.invalid/${first.id}.jpg`);
const key = (await storedKeys()).find((k) => k.endsWith('daily:2026-09-21'));
@@ -84,16 +91,37 @@ describe('getDailyRound', () => {
expect(ttl).toBeGreaterThan(47 * 3600);
expect(ttl).toBeLessThanOrEqual(48 * 3600);
});
it('replaces a cached pick that no longer resolves', async () => {
const first = await getDailyRound('2026-09-22');
deadIds.add(first.id);
const replacement = await getDailyRound('2026-09-22');
expect(replacement.id).not.toBe(first.id);
// And the replacement is what the day now serves.
expect((await getDailyRound('2026-09-22')).id).toBe(replacement.id);
});
it('gives up on the day when every seeded candidate fails', async () => {
for (let attempt = 0; attempt < 4; attempt++) {
deadIds.add((await pickPanoBySeed(`2026-09-23:${attempt}`)).id);
}
await expect(getDailyRound('2026-09-23')).rejects.toThrow(/No daily panorama/);
expect((await storedKeys()).some((k) => k.endsWith('daily:2026-09-23'))).toBe(false);
});
});
describe('GET /api/daily', () => {
const request = () => GET(new Request('http://localhost/api/daily'));
it('opens a daily session for today without revealing the answer', async () => {
// Bracketed rather than compared to one reading, so a run that straddles
// midnight in Vietnam does not flake.
const before = dailyDay();
const response = await request();
const after = dailyDay();
const body = await response.json();
expect(body.success).toBe(true);
expect(body.day).toBe(dailyDay());
expect([before, after]).toContain(body.day);
expect(body.number).toBeGreaterThan(0);
expect(body.imageData.url).toMatch(/^https:/);
@@ -115,7 +143,16 @@ describe('GET /api/daily', () => {
expect(a.sessionId).not.toBe(b.sessionId);
});
it('is scored by /api/guess like any round and counted under the daily level', async () => {
it('answers 500 when the day has no loadable panorama', async () => {
for (let attempt = 0; attempt < 4; attempt++) {
deadIds.add((await pickPanoBySeed(`${dailyDay()}:${attempt}`)).id);
}
const response = await request();
expect(response.status).toBe(500);
expect((await response.json()).success).toBe(false);
});
it('is scored by /api/guess and counted, but credits no leaderboard', async () => {
const { sessionId } = await (await request()).json();
const session = await getGameSession(sessionId);
const body = await (
@@ -134,5 +171,10 @@ describe('GET /api/daily', () => {
expect(body.success).toBe(true);
expect(body.gameResult.score).toBe(5);
expect((await readDay(statsDay())).byLevel.daily).toEqual({ rounds: 1, zero: 0 });
// The same panorama all day plus the answer in this response would make
// any board it fed farmable, so it feeds none.
expect(body.gameResult.levels).toEqual([]);
expect(await getLeaderboard('VN', 10, 'score')).toEqual([]);
expect((await storedKeys()).filter((k) => k.includes('leaderboard:') || k.includes('distance:'))).toEqual([]);
});
});
+12
View File
@@ -25,6 +25,18 @@ describe('debugAccessAllowed', () => {
expect(debugAccessAllowed(request())).toBe(true);
});
it('treats a production build with no Vercel environment as production', () => {
delete process.env.VERCEL_ENV;
const original = process.env.NODE_ENV;
process.env.NODE_ENV = 'production';
try {
delete process.env.DEBUG_ACCESS_KEY;
expect(debugAccessAllowed(request())).toBe(false);
} finally {
process.env.NODE_ENV = original;
}
});
it('is closed in production with no key configured', () => {
process.env.VERCEL_ENV = 'production';
delete process.env.DEBUG_ACCESS_KEY;
+1 -1
View File
@@ -44,7 +44,7 @@ export function guessResponse(username) {
// accumulated total. Values mirror the real route: 123m is 3 points on the
// one ladder, credited identically at all three levels.
const scoreLevel = (code, name, rank, points) =>
({ code, name, username, points, score: 3, rank, trimmed: false });
({ code, name, username, points, score: 3, rank });
const distanceLevel = (code, name, rank) => ({ code, name, username, distance: 123, rank });
return {
success: true,
+1 -1
View File
@@ -13,7 +13,7 @@ test.beforeEach(async ({ page }) => {
test('offers the country and every province', async ({ page }) => {
await expect(page.getByRole('link', { name: /Play anywhere in Vietnam/ })).toBeVisible();
for (const province of ['Ha Noi', 'Hồ Chí Minh', 'Da Nang', 'Lam Dong', 'Long An']) {
for (const province of ['Hà Nội', 'Hồ Chí Minh', 'Đà Nẵng', 'Lâm Đồng', 'Long An']) {
await expect(page.getByRole('button', { name: new RegExp(province) })).toBeVisible();
}
});
+103
View File
@@ -0,0 +1,103 @@
import { describe, it, expect, beforeEach, vi } from 'vitest';
vi.mock('@upstash/redis', async (importOriginal) => {
const { upstashModule } = await import('./mock-upstash.js');
return upstashModule(importOriginal);
});
// Two failure injections, both after the session has been consumed, which is
// the point of no return: a level's ZINCRBY throwing, and the statistics store
// throwing. Neither may turn a scored round into "nothing was scored".
const failingKeys = new Set();
vi.mock('../src/lib/upstash.js', async (importOriginal) => {
const actual = await importOriginal();
return {
...actual,
zIncrBy: async (h, key, increment, member) => {
if (failingKeys.has(key)) throw new Error(`injected failure on ${key}`);
return actual.zIncrBy(h, key, increment, member);
},
};
});
let statsDown = false;
vi.mock('../src/lib/stats.js', async (importOriginal) => {
const actual = await importOriginal();
return {
...actual,
recordRound: async (...args) => {
if (statsDown) throw new Error('injected stats outage');
return actual.recordRound(...args);
},
};
});
import { POST } from '../src/app/api/guess/route.js';
import { storeGameSession, getGameSession } from '../src/lib/session.js';
import { getLeaderboard } from '../src/lib/leaderboard.js';
import { resetStore } from './redis-harness.js';
const HCMC = { lat: 10.7712, lng: 106.7003 };
async function seedSession(sessionId) {
await storeGameSession(sessionId, {
sessionId,
pickedRegion: 'TPHCM',
regionCode: 'TPHCM-Q7',
exactLocation: HCMC,
imageId: '123',
createdAt: Date.now(),
});
}
function guess(sessionId) {
return POST(
new Request('http://localhost/api/guess', {
method: 'POST',
body: JSON.stringify({ username: 'mai', sessionId, guessLat: HCMC.lat, guessLng: HCMC.lng }),
})
);
}
describe('POST /api/guess after the session is consumed', () => {
beforeEach(async () => {
await resetStore();
failingKeys.clear();
statsDown = false;
});
it('reports the levels that landed when one level fails to write', async () => {
failingKeys.add('leaderboard:vietnam');
await seedSession('p1');
const response = await guess('p1');
const body = await response.json();
// The round scored on two boards; saying "nothing was scored" would be
// false, and the session is gone so there is nothing to retry.
expect(response.status).toBe(200);
expect(body.success).toBe(true);
expect(body.gameResult.partial).toBe(true);
expect(body.gameResult.levels.map((l) => l.code).sort()).toEqual(['TPHCM', 'TPHCM-Q7']);
expect((await getLeaderboard('TPHCM-Q7'))[0].score).toBe(5);
expect(await getLeaderboard('VN')).toEqual([]);
expect(await getGameSession('p1')).toBeNull();
});
it('fails the round only when no level could be written', async () => {
for (const key of ['leaderboard:city:tphcm-q7', 'leaderboard:city:tphcm', 'leaderboard:vietnam']) {
failingKeys.add(key);
}
await seedSession('p2');
const response = await guess('p2');
expect(response.status).toBe(500);
expect((await response.json()).success).toBe(false);
});
it('scores the round when the statistics store is down', async () => {
statsDown = true;
await seedSession('p3');
const body = await (await guess('p3')).json();
expect(body.success).toBe(true);
expect(body.gameResult.partial).toBe(false);
expect((await getLeaderboard('VN'))[0].score).toBe(5);
});
});
+17
View File
@@ -62,6 +62,23 @@ describe('daily statistics', () => {
}
});
it('gives the player key a TTL even when the day opened with a cookieless round', async () => {
// The HyperLogLog is created by the second round here, after the hash's
// first field; its TTL must ride on its own first write.
await recordRound('country', 0, null, DAY1);
await recordRound('country', 0, P1, DAY1);
expect(await ttlOf('vngeoguessr:stats:players:2026-09-20')).toBeGreaterThan(0);
});
it("expires each day's keys independently", async () => {
await recordRound('country', 0, P1, DAY1);
await recordRound('country', 0, P1, DAY2);
for (const day of ['2026-09-20', '2026-09-21']) {
expect(await ttlOf(`vngeoguessr:stats:${day}`)).toBeGreaterThan(0);
expect(await ttlOf(`vngeoguessr:stats:players:${day}`)).toBeGreaterThan(0);
}
});
it('tolerates a round with no player id', async () => {
await recordRound('district', 4, null, DAY1);
const day = await readDay('2026-09-20');