Commit Graph
9 Commits
Author SHA1 Message Date
tiennm99 c9a35dca9a docs: describe the region tree, and finish the coverage page migration
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.
2026-08-30 19:46:13 +07:00
tiennm99 4770de51d4 feat(leaderboard): roll a guess up through the region tree
A guess is credited to the district its panorama sits in, then to that
district's province, then to Vietnam. Each level keeps its own board, so a
player can top District 7 without touching the national table.

Every existing point survives untouched. The country maps to the
leaderboard:vietnam and distance:vietnam keys that already exist rather than
to a new leaderboard:city:vn, so the national board keeps accumulating instead
of restarting. The ':city:' segment in the key namespace is now a misnomer --
it holds district and province codes alike -- but renaming it to ':region:'
would strand every key holding a player's history, so it stays, with a comment
recording why. Da Lat and Duc Hoa keep their bare codes for the same reason.
Ha Noi, Da Nang and Ho Chi Minh keep their totals on the province: those
points predate districts and cannot be attributed to one, so their boards stay
continuous while district boards start at zero.

Levels are written in parallel. They are independent keys and none reads
another's state, so serialising them would add two round trips of latency to
every guess for nothing.

Region codes and the leaderboard limit are validated inside the library rather
than only in the routes. The key builder lowercases whatever it is handed
straight into a Redis key, and the routes are not its only callers -- the
migration script and anything added later bypass them entirely.

The adapter gains scanKeys, which the migration needs to enumerate what it is
about to touch. It applies the key prefix to the pattern and strips it from
the results, because a caller that scanned 'leaderboard:*' directly would
match nothing at all: every physical key carries the prefix, and an empty
result is indistinguishable from an empty database. An empty KEY_PREFIX is no
longer accepted, since enumeration with no prefix would reach every other
project sharing the database.

The migration seeds Lam Dong and Long An from Da Lat and Duc Hoa, whose
history they inherit. It runs after the deploy, not before: migrating first
leaves a window where a Da Lat guess credits the town and the country but not
the province. It copies absolute scores so a second run converges rather than
doubling, and empties the destination first so a player trimmed out of the
source cannot survive in the copy with a stale score. An empty source is
refused before that delete -- otherwise the destination is wiped and nothing
written back, which reads as a clean no-op. Afterwards it confirms each
destination matches its source and that nothing else went backwards, accepting
that boards grow while the app serves traffic. Its backup can be restored
through the same script.

The migration logic lives in scripts/lib/ so its guards are reachable from a
test rather than only by running it against a live database.
2026-08-30 17:45:37 +07:00
tiennm99 b7f8879fe0 feat(regions): credit panoramas to the district they sit in
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.
2026-08-30 17:09:41 +07:00
tiennm99 f41ba32f30 feat(regions): restructure cities into a country > province > district tree
Replaces the hand-maintained flat CITIES map with a generated 67-node tree:
one country, five provinces, 61 districts and towns. Names, centres and
extents now come from the boundary build rather than being duplicated in
src/lib/game.js, so there is one source instead of two that a test had to
police for drift.

Boundaries are built per district and unioned upward. A single-child province
byte-copies its child rather than re-simplifying it, so the two cannot
disagree along a border and strand panoramas in the gap. The country has no
polygon of its own: the real Vietnam outline is vastly larger than the covered
area and would mislead on a map, so its extent is the envelope of its
provinces.

OpenStreetMap has applied the 2025 merger, so the pre-2025 units survive only
as boundary/historic relations whose rendered parent is the current province.
A qualified lookup misses them entirely and a bare one can match a same-named
unit elsewhere in the country, so each leaf tries both forms and the hit is
validated against the parent pre-2025 extent. 60 of 61 leaves resolve; Cu Chi
has no boundary relation left at all, which is why the previous outline
already recorded a missing part and no panorama in the index sits there.

The generated barrels now sanitise import identifiers and quote object keys.
Region codes contain hyphens, which are legal in a filename but not in a bare
identifier, and the barrel is rewritten after every region -- emitting them
raw would have broken the build from the first district onward.

Panorama indexes are re-clipped against the rebuilt outlines, dropping 74 of
424,691 entries that fell outside the retightened boundaries. Da Lat and Duc
Hoa keep their bare codes so their leaderboard history stays attached, and
their index files move to the province codes that now own them.

Scoring is untouched: a guess still credits one city and Vietnam. The rollup
through the tree lands in a later change.
2026-08-30 16:30:14 +07:00
tiennm99 3235312f7a build: keep verification builds out of the dev server's output directory
next dev and next build both own the output directory, so building while
a dev server is serving replaces manifests that server is still reading.
It logs a burst of ENOENT errors for those manifests, including paths
under pages/_app that this App Router project has no reason to touch,
which makes it look like the code being edited is at fault. Measured: 110
such errors from one overlapping build, none when they do not overlap.

build:check builds into a separate directory and leaves a running dev
server alone. The build script itself is untouched, because that is what
the deployment platform runs and it must keep writing the default
directory; distDir only moves when the new script sets the variable.

dev:clean covers the other trap: a build cache left inconsistent by a
killed build or a different Next version makes the dev server serve 500s
for everything, and restarting does not help while the cache is still
there.

Also records what actually needs a restart, which is nothing: routes,
libs and data all hot reload, Next restarts itself for its config, and it
reloads .env in place.
2026-08-30 13:35:51 +07:00
tiennm99 adeebc46e2 refactor(data): store city boundaries as .json behind a generated barrel
Bundlers do not treat .geojson as a module, so importing a boundary
statically failed to resolve. The content is JSON either way, and the
extension was the only thing standing in the way of the coverage page
reading a city outline.

The barrel matches the one the panorama indexes already use: generated
from what is on disk, rewritten after each city, so a build that has only
produced some of them still compiles.
2026-08-30 13:35:27 +07:00
tiennm99 ab2c271c87 perf(mapillary): play from a prebuilt index instead of searching bboxes
Rounds took a measured 4.9s at the median and failed most of the time. A
40-window sample returned 6 hits, 1 empty window and 33 errors.

The cause was not our retry tuning. /images?bbox= counts the images
inside the box before applying the limit, so it returns HTTP 500 in
exactly the places worth playing: District 1 and central Ha Noi failed on
every attempt, and limit=1 on a 0.0004 degree window failed the same as
limit=50. Backing off does not clear it, and neither does shrinking the
window, so the whole dart-throw goes: the racing, the empty and error
budgets, the widening backoff, and the per-city delta they needed.

Coverage now comes from the vector tile API, which answers everywhere
the Graph API refuses, and is walked once offline into a per-city list of
panorama ids. A round is a single lookup by id, measured at 482ms median
across all five cities with no failures. Locations are deduplicated to
one per 33m so the file holds distinct places rather than repeat passes
down the same street.

Cities gain real outlines in place of rectangles. Vietnam merged its
provinces in mid-2025, so today's Ho Chi Minh City covers 36,566 km2 and
reaches Vung Tau; these are the pre-merger extents, rebuilt by unioning
the district-level units OpenStreetMap still serves as historic
boundaries. Da Nang is enabled now that every location is known to have
imagery. Cu Chi is absent from Ho Chi Minh: its old relation is gone from
OSM and only sub-communes remain, so the file records the gap.

The build script refuses to write when a tile is missing after retries.
tiles.mapillary.com allows 50,000 requests a day and a full rebuild costs
about 2,800, so a quota failure is plausible, and a partial index would
be indistinguishable from genuinely absent coverage.
2026-08-30 12:23:44 +07:00
tiennm99 1e16bd8d42 chore(storage): drop redis dep and migration script post-Upstash cutover
The Upstash REST migration is verified in production; node-redis is no longer
needed and the one-shot migration script has done its job.

- Remove `redis` from package.json
- Delete scripts/migrate-upstash.js (recoverable from commit 53ee795)
- Add cleanup plan; close out migration plan Phase 3
2026-05-10 02:13:12 +07:00
tiennm99 53ee7957e7 refactor(storage): migrate to Upstash REST SDK with KEY_PREFIX
Replace node-redis with @upstash/redis REST client and route all keys through
a centralized adapter that prepends KEY_PREFIX (default 'vngeoguessr:'). The
new Upstash DB can now be safely shared with other Vercel projects without
key collisions.

- src/lib/upstash.js: new adapter with getJson/putJson/del + zAdd/zRange/
  zScore/zRank/zRevRank/zRemRangeByRank helpers; reads UPSTASH_REDIS_REST_URL
  or KV_REST_API_URL aliases
- src/lib/session.js, src/lib/leaderboard.js: use the adapter; public APIs
  unchanged so api routes stay untouched
- src/lib/redis.js: removed
- scripts/migrate-upstash.js: one-shot copy from old TCP DB to new REST DB,
  prepending the prefix; idempotent, with --dry-run flag
- docs/tech-stack.md: reflect the new SDK and prefix model
2026-05-10 00:00:47 +07:00