game-flow gains the re-casing-only rule and its percent-encoding exception
(/game/%74phcm plays, because the router decodes the segment before the page
sees it; reading the raw form would mean giving up static rendering on all 85
pages), plus per-region titles.
project-structure gains InlineScript and the e2e entries that had drifted:
routing.spec.js is most of the suite now, and global-setup.js is otherwise
deletion bait.
Two plan corrections: a success criterion was ticked against DN-HOANGSA, which
is not a region at all -- that URL 404s and never demonstrated the coverage
panel it claimed to. TPHCM-CUCHI is the fixture actually tested. And the build
gate named `npm run build`, which exits 0 without building when a dev server
holds .next; build:check is the gate that cannot silently pass.
Reports carry the full evidence trail, including two claims corrected after
measurement contradicted them.
There was no app-wide not-found route, so any unmatched path got Next's
stock page: unstyled text over the full-bleed background, no footer, and
no link back -- its own full-height wrapper pushes the footer off screen.
Both 404s now share NotFoundPanel, since they say the same three things
and only the wording differs: what is missing, why it probably happened,
and the one way out. One action each, deliberately -- a "try again" on a
deterministically invalid URL is a button guaranteed to reproduce the
same page.
The region 404 is a client component for one reason: a thrown notFound()
is served from Next's error shell, which carries none of the root
layout's pre-paint theme script, so a dark-theme visitor got a white page
permanently. Re-applying on mount costs a brief light flash on a rare
page and fixes the palette. The app-wide route needs none of this -- an
unmatched path prerenders inside the root layout, where the script runs.
Both are covered by e2e now, including the theme, since neither failure
mode is visible from a status code.
Also records the Windows ISR case-collision finding in the debugger's
agent memory: a local next start case-folds cache keys on NTFS, so
redirect and dynamicParams behaviour cannot be verified from a local
production build. The region route no longer redirects, so nothing trips
it today, but the verification guidance outlives this change.
Switching region fires three requests for it. Two carry the previous
viewport: the badge row resizes, the map's ResizeObserver calls
invalidateSize, and the moveend that follows reports the old bounds under
the new region. Only the third carries the boundary, and it is the oldest
of the three, so the stale-response guard threw it away -- leaving the
outline undrawn, the map parked on the region before it, and every
panorama out of view.
The boundary is now applied on the region it describes rather than on
request order.
Also adds a project .ckignore so src/app/debug/coverage/ reads as the
application source it is rather than a test-coverage report directory.
Draws now exclude the last 50 panoramas a browser has been shown, so
grinding one district no longer serves the same street corner twice.
An anonymous httpOnly vng_pid cookie carries the identity. The username
was not usable for this: it lives in localStorage, is renameable, and is
shared by anyone who types it, so a rename would wipe the history and a
name collision would merge two players'. The cookie holds nothing else
and is never joined to a username or a score.
The history is a preference, not a rule. Where excluding it would empty
a small region's pool, fetchRegionPanorama drops it and allows a repeat:
a repeat always beats telling a player that a region they can see has no
coverage. Only a redraw that finds something logs the downgrade, so a
region mid-reseed holding zero rows is not blamed on the filter.
Locations are recorded when the round is created rather than at guess
time, so a skipped round also counts as seen. A Redis failure on either
end costs a repeat, never a round.
Stored as a JSON array in one string key on the existing adapter rather
than a Redis LIST, which would need four new primitives and a new value
type in the in-memory fake to hold fifty short strings. The
read-modify-write is not atomic; the worst case is one dropped entry.
pano-history joins the client-safety FORBIDDEN list: its newest entry is
the live round's answer id, and a panorama id is one Mapillary lookup
from the coordinates.
Reconcile every doc with the shipped code. All six described a flat
five-city model, and features.md, tech-stack.md, game-flow.md and
project-structure.md still documented the dart-throw over /images?bbox=
that the prebuilt panorama indexes replaced.
project-overview.md gains a Coverage note that classifies absent coverage
into its three causes -- not yet added, no street imagery, missing from
the boundary -- because a note that only says "partial" teaches
maintainers to ignore real gaps. Cu Chi is named as the one instance of
the third, which is the only one that is a defect.
A context hook had been denying access to src/app/debug/coverage/page.js,
so this also lands the two items earlier phases recorded as
undeliverable: the api/debug/city-coverage -> region-coverage rename, and
the page's migration from a flat city list to RegionSelect. With its last
caller gone, game.js drops CITIES, cities, cityNames, cityCenters and
cityBboxes; getCityIndex, indexedCities and fetchCityPanorama take names
that match what they now take.
Selecting a district with no boundary returned a 400 and left the
previous region's panorama count and outline on screen beside the error,
so Ho Chi Minh's 184,938 read as Cu Chi's. The error path now clears
everything derived from the previous region, and a null boundary removes
the outline rather than skipping the redraw.
Two plan-time claims did not survive contact with the code and the docs
follow the code: VN scores like any other node rather than being a
zero-scoring exploration mode, and the fan-out credits two levels when a
panorama falls outside every district outline.
The home page is a province accordion. The province row plays that province
and only the chevron expands it, so picking Ha Noi stays one click while its
thirty districts stay reachable. Districts with no coverage are listed and
disabled rather than hidden, with the reason shown -- absent coverage is
something the tree knows about, and a district that silently vanishes is more
confusing than one that says why.
The leaderboard modal browses by level and then by region. It used to fetch
every board when it opened, which was twelve requests for five cities and
would have been a hundred and thirty-four for sixty-seven regions; it now
fetches only the board on screen, caches within a session, and clears on open
so a player who just scored does not see a stale total. A failed fetch says so
instead of rendering as an empty board, which would have read as wiped
leaderboards during an outage.
A round shows one rank row per level it credited, and reveals where the
panorama actually was -- the interesting part when the player chose a province
or the whole country. A submission that did not record now says that plainly.
It previously rendered as a confident nine-hundred-and-ninety-nine-kilometre
miss, indistinguishable from a real one.
Removing the old fixed global/city rank state left its setters behind as free
identifiers, which would have thrown on every Next Round and Skip. Lint, the
build and all two hundred and sixty tests passed over it, because the preset
enables neither no-undef nor no-unused-vars and this project has no type
checker. no-undef is now an error, verified by reintroducing the fault.
The accordion and select come from Radix. Both were absent from the component
library, and hand-rolling an accessible accordion and a grouped listbox would
have shipped broken keyboard support.
The debug coverage page is unchanged: it cannot be read in this environment,
so it still lists cities and still calls the route by its old name. The city
lookups in lib/game.js remain live for that reason.
A round now carries two regions. The one the player picked is public and comes
back in every response; the one the panorama actually sits in is a secret, and
it is what the leaderboard fans out from. Revealing the second before the
guess would collapse a country-wide round to a single district, so it joins
the exact coordinates on the never-serialized list -- including in the session
lookup handler, which echoes fields back to whoever holds the session id, and
that is the player.
Session consumption is now a claim rather than a courtesy. Reading a session
and then deleting it is not a guard: ten concurrent submits all read it alive,
all delete it, and all score. DEL is atomic and returns how many keys it
removed, so exactly one caller sees a 1 -- the route scores only if it won
that. Consuming before the writes also closes the sequential case, where a
failure partway through the fan-out would otherwise leave the session alive
for half an hour and let a retry re-credit every level that already succeeded.
A guess lost to a mid-write failure is the accepted cost.
Region parsing lives in one place. Four routes accept a region, and four
slightly different ideas about casing and defaulting is how a typo becomes a
leaderboard key nobody reads. Unknown and uncovered codes are rejected with a
400 that names the region, rather than served as an empty board that looks
exactly like a region nobody has played yet.
Sessions created before this change still score, at province level, since they
carry no district. They expire within half an hour, so the fallback can go a
release from now.
The debug coverage route serves any region with an outline rather than only
the five provinces. Its directory keeps the city-coverage name for now: the
page that calls it cannot be read in this environment, and renaming the route
without updating its caller would break it.
Every panorama now carries the district it falls in, so a guess can be
attributed to a leaf rather than only to a province. The assignment runs
against the indexes already on disk: districts are a property of a panorama,
not a separate dataset, and re-fetching per district would multiply a
~2,800-tile build against Mapillary's 50,000/day cap for bytes we already
hold.
The district is stored as an integer offset into a per-province districts
array rather than as a code string, which costs about five bytes an entry
instead of twelve. Across 424,617 entries that is the difference between the
2.85 MB the data actually grew and something closer to five.
Coverage is judged on distinct places, not on raw counts. The index is thinned
at 33m, so a count overstates how many different places a district offers by
roughly thirty times -- a district can hold hundreds of panoramas and still be
one street seen from many angles. Playability therefore needs both a panorama
floor, which exists because the Mapillary lookup retries three times with a
different candidate, and a floor on distinct ~1.1km cells. Sixty-four of the
sixty-seven regions clear it. The three that do not are one of each kind the
coverage note describes: Cu Chi has no boundary, while Cam Le and Hoa Vang
have no street imagery at all.
A point that falls in a sliver between two simplified outlines is placed in
the nearest district by distance to the outline itself. Ranking by bounding-box
centre instead was measured putting points up to six kilometres inside the
wrong district, because a compact district's centre can beat a sprawling
neighbour whose edge is metres away. The stranded tally is recorded per
province and the build refuses to write above two percent, since that is the
signal that the leaf simplification tolerance has opened gaps along shared
borders. It currently sits at 0.05 percent, worst case forty-six metres.
fetchCityPanorama now reports the district of the attempt that succeeded.
Each retry draws a fresh candidate, potentially from a different district, so
carrying the first one forward would credit the wrong place. An exhausted pool
returns a failure rather than escaping as a 500.
Panorama arrays handed out at runtime are frozen. They are process-global and
cached for the life of the server, and an in-place sort on the shared district
array had already once repointed every panorama at the wrong district.
Nothing is wired to scoring yet: the resolved district is computed and
returned but not stored on the session. That lands with the leaderboard
fan-out.
Removes permissions.defaultMode acceptEdits, so file changes are
confirmed rather than applied automatically. The setting is checked in
and applied to everyone working in the repository, which is the wrong
place to decide that.