21 Commits
Author SHA1 Message Date
tiennm99 ceedf5dde9 feat(scripts): restore the leaderboards from a backup
npm run leaderboard:import writes a decrypted backup back to Redis. It
is a dry run until --apply, prints the destination prefix first, and
accepts only score and distance board keys. By default each backed-up
score is set and newer players are kept; --replace rewrites each board
exactly. Members go in up to 1000 per ZADD through a new zAddMany
helper.

docs/leaderboard-backup.md covers what the backup holds, decrypting it
and restoring it. The gitignore now covers the decrypted and encrypted
file names the workflow uses, not only the dated export.
2026-10-02 13:12:52 +07:00
tiennm99 35999a809d fix(api): give every store call a deadline, harden the small things
Every Upstash command and Neon query now has a deadline (5 s and 8 s): the
Redis client retried a hung connection past the browser's fifteen-second
wait, and the Neon client had no limit. Usernames are NFC-normalised so a
tone mark typed as a combining character is accepted and does not make two
board members of one name. The debug key is compared in constant time. A
panorama whose district the tree does not know fails the draw before a
session is written, not /api/guess after it is consumed. The country branch
of countPanos and the never-triggered per-city cap are gone.
2026-09-29 20:13:07 +07:00
tiennm99 4d25a82448 chore(ci): raise the test hook timeout, fail an empty backup
PGlite start-up alone takes about seven seconds on an ARM host, so the
10s beforeAll default failed whole files at random. The weekly backup
fails instead of uploading an empty export, and both workflows run with
read-only permissions; CI cancels superseded runs.
2026-09-28 15:01:41 +07:00
tiennm99 2bd5e4fbf2 refactor(scripts): one env loader, the region config as data, tests for district assignment
Four scripts each carried a copy of the .env parser, and one copy lacked
the unquoting the others had, so vercel env pull output broke it. They
share scripts/lib/env.mjs now. The hand-edited region configuration
moves out of the boundary builder into scripts/lib/region-config.mjs,
where it no longer shares a name with the generated tree the sibling
scripts import. scripts/lib/assign-districts.mjs, which decides which
district a panorama is credited to, gets its first tests, against
synthetic polygons so they pin geometry rules rather than data. A
data:refresh command runs the whole pipeline, with its Mapillary cost
stated where it is invoked.
2026-09-21 16:21:14 +07:00
tiennm99 064c1733b6 chore(ci): run the gates on every push and back the boards up weekly
One workflow runs lint, the tests and the production compile on pushes
to main and dev and on pull requests; it needs no secrets. A second
exports every leaderboard to JSON each Monday and keeps it as a 90-day
artifact, since the free Redis plan has no scheduled backups. The
rollout report records what shipped, the manual steps, and what was
left out.
2026-09-20 23:31:28 +07:00
tiennm99 6dbe2e01c8 feat(regions): show province and district names in Vietnamese
Every province and district in the tree now carries its accented name,
derived by the boundary script from the OSM query it already held, and
regionName() is what the picker, the game header, the reveal, the
search results, the API's region.name and the share text show. The
ASCII name stays as the stable form for codes, logs and search
aliases, and the map search matches either spelling. The font loads
the Vietnamese subset so the glyphs render in Geist.
2026-09-20 23:28:50 +07:00
tiennm99 8450b75bce feat(daily): one panorama a day, the same for everyone, with a streak
/api/daily opens an ordinary session on a panorama picked
deterministically from the day and cached in Redis for two days, so
the Postgres draw and the Mapillary lookup happen once a day. Days
roll over at midnight Vietnam time. The round is scored by /api/guess
like any other and counted under its own level in the statistics.

There is no daily leaderboard: the only identity is a cookie and a
localStorage name, so a dated board would be won by whoever opened the
most private windows. The browser keeps the finished round and replays
it on a revisit, and tracks the streak of consecutive days. The home
page carries the challenge card and the result dialog shares the
outcome as a squares line.
2026-09-20 23:23:02 +07:00
tiennm99 3314da1358 fix(api): validate every input, close the debug routes in production, keep every score total
The debug routes returned panorama coordinates by id and by region to
anyone, which is the answer to a live round; in production they now
answer only a request carrying DEBUG_ACCESS_KEY, and the bbox tester
that proxied Mapillary's failing search is removed.

/api/guess checks the username against the one rule the name prompt
uses, rejects non-finite coordinates before consuming the session, and
names why a submit failed. Session ids that are not UUIDs are replaced
on new-game and rejected on skip.

Score boards are no longer trimmed to 200: trimming deleted the running
total of anyone below the cut, so once 200th place held more than one
round's points no new player could ever get on. Scores are added with
ZINCRBY so concurrent rounds under one name both count. The distance
record is best-effort after scoring, so a distance failure no longer
reports a scored round as unsaved.

Two Redis keys per day count rounds by picked level and score and
distinct players; npm run stats prints them.
2026-09-20 23:02:05 +07:00
tiennm99 d1332cd689 feat(regions): open four more provinces for play
Dong Nai, Binh Duong, Thanh Hoa and Quang Nam join the tree, and Long An
gains Ben Luc and Can Giuoc, taking coverage from 5 provinces to 9 and the
panorama index from 425k locations to 493k.

A province outline is now simplified no more loosely than the leaves it is
the union of. It is not only drawn: the district assignment clips each
province's panoramas against it, and at the old tolerance the outline bulged
past its own districts, crediting every panorama in that band to a district
it does not sit in. That band held 571 panoramas across the tree, 2.53% of
Binh Duong's.

Unioning adjacent districts also leaves hairline sliver rings along shared
borders, some of only three points, which turf.simplify refuses to clean.
Those are dropped before the outline is simplified.
2026-09-04 15:20:13 +07:00
tiennm99 7212b67feb chore: remove leaderboard migration scripts and close plan
Completed one-shot leaderboard backfill migration verified in production.
Removed migration scripts, test, unused export, and npm script. Updated
documentation and closed UI/UX flow polish plan with session journal.
2026-09-01 17:26:21 +07:00
tiennm99 6569f095c9 feat(game): serve panorama index from Neon Postgres
- panoramas + pano_provinces tables replace 28MB bundled JSON; pano-index.js
  now draws via cached COUNT + ORDER BY id OFFSET with rejection sampling,
  composite (province,id)/(district,id) indexes support the skip
- scripts/seed-pano-db.mjs validates pipeline artifacts (the old real-data
  vitest invariants, extracted to scripts/lib/pano-artifacts.mjs), stages
  into panoramas_next, verifies, renames into place in one transaction,
  keeps panoramas_old as backup; --province reseeds in place; --check
  validates only
- pipeline writes gitignored data-build/panos/; pano barrel removed
- tests run against PGlite mocked in at the @neondatabase/serverless
  boundary, mirroring the fake-upstash pattern; fixtures replace real data
- infrastructure errors rethrow instead of reading as missing coverage;
  session ids via crypto.randomUUID (uuid package dropped)
2026-08-31 09:44:03 +07:00
tiennm99 3a965b7ede refactor(scripts): share data paths and barrel emitter across generators
Extract common path resolution and event-emitter barrel into shared modules
to reduce duplication and enable consistent data pipeline configuration.
2026-08-30 20:52:28 +07:00
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