A 403, a malformed answer, a missing thumbnail or a Redis error inside the
retry all counted as the panorama being deleted, so one such failure on the
cached pick handed players after it a different round under the same
number. UpstreamError now carries a 'gone' code for a Mapillary 400 or 404
and a missing thumbnail, and only that moves the day; everything else is
thrown. The Redis calls sit outside the retry.
Also: the draw budget caps the request in flight, the token travels in an
Authorization header rather than the URL, and the daily route's comment no
longer claims the daily credits a board.
docs/development.md states the rules the code now enforces or keeps:
server-only modules and the import-graph test (which now also forbids
the daily pick and the region locator from client bundles), route
decides best-effort and the library never swallows, typed failure kinds
mapped to statuses, claim before write, one module per storage concern
through the guarded store, useEffectEvent for imperative callbacks,
unprefixed logical keys, three fixed region levels, and the response
contract. The parameter rule now says what it always meant: React
components destructure props. @radix-ui/react-tabs had no component
using it. Stale lines about tabs, pagination, a leaderboard migration
and a sub-second suite are corrected.
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.
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.
/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.
Below 3 points the result dialog says whether the guess had the right
district or province, located server-side from the boundaries. The
reveal links the spot on OpenStreetMap, and a Share button builds a
squares line with the region's URL. Region pages and the root carry
Open Graph metadata for link previews.
An expired round is worded as expired rather than as a failed save. A
skipped round gets a fresh session id so the unawaited delete cannot
kill the next round. Below sm the theme switch collapses to one cycling
button and the tally hides, so the mute control stays on a 360px
screen. The panorama viewer loads on demand, taking three.js out of
the first load.
Records what actually fires rather than what the plan intended: the
click sound is limited to Play and Back, and the error tone covers the
viewer failing to construct, not panorama-error, which falls back to a
flat image and leaves the round playable.
Also notes that the loop is mounted app-wide, which is what lets it play
unbroken across the menu and a round -- and means it plays on Credits
and the debug pages too.
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.
Region size no longer stretches the score thresholds. A guess is graded on
absolute precision, so a point means the same thing on the district, province
and country board, and the headline score matches what every level is credited.
Drops the per-round bands payload and its client plumbing: with one ladder the
result dialog reads the constant directly.
Adds an overlay to acknowledge Mapillary imagery copyright and a dedicated /credits page displaying all data sources and providers with proper attribution.
Implement region-scaled scoring: base ladder (0-5 points) adjusts thresholds
proportionally to the picked region's bbox diagonal. Leaderboards credited
separately using their own region's ladder, preventing country-round guesses
from earning inflated district-board points. Score and per-level points
returned in gameResult.bands and gameResult.levels.
- 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)
Adds offline region-tree matching with diacritic folding and Vietnamese
aliases (quận/q7 for District N), plus Photon geocoder for streets and
places bounded to the played region. Selection pans/zooms only; guess
placement remains click-only on the map. LeafletMap now supports zoomPosition
and proper teardown. Includes client-safety tests to prevent server-data
imports in this module.
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.
Each Mapillary window query costs ~1-5s and most windows hold no
panoramas, so throwing them one at a time made /api/new-game wait for
the sum of every miss. Measured in production: median 17.8s, max 90.5s
over 18 samples.
Throw 8 windows per round instead, race them with Promise.any and abort
the losers once one yields panoramas. Wall time becomes one round-trip
in the common case. Same 20-window budget, same per-city delta, same
401 short-circuit.
After the change, over 10 samples: median 5.6s, max 20.3s.