# Walkable Session (Main · Area Guide sub-module)

**Portal:** Main · **Routes:** `main.portal.area-guide.stations` (`GET /property/academy/area-guide/{area}/stations`) · `main.portal.area-guide.visits.store` (`POST …/visits`) · **Nav:** none — on the [Area Guide](/docs/modules_handbook/main/area-guide/readMe.md)'s illustrated guide (Hong Kong, the UAE) the avatar is simply on the street map whenever an enabled area is open; on Malaysia's continuous map the area's map card carries a **Walk this area** button (see *Which map* below) · **Gated by:** `['auth','main','contact.verified']`, `feature:area-guide`, **the guide's own lock — enforced, not merely withheld (2026-09-14)**, and the active registry's walkable enable list (`config/area_guide_game.php` in legacy mode)

> **The lock is on the ENDPOINTS now (2026-09-14).** Both routes call
> [`AreaGuideViewerAccess::allowsGuide()`](/src/AreaGuide/Support/AreaGuideViewerAccess.php)
> — the tab's own three rules (temporary lock with the admin `?preview=locked`
> escape, the five-day trainee lock, membership Function access) — and
> `StoreVisitRequest::authorize()` repeats it before validation, so a refused
> reader never reaches the registry. Until then the lock only withheld the
> `gameAreas` prop: the frontend did not ask, but a direct request was served.
> Withholding a prop hides a control; it does not authorize an endpoint.

## What it does

Turns an Area Guide area into something a member **walks through** instead of
reads. On the area's real pitched street map, a small 3D character stands on
the actual roads; the developments that really stand in that area — Binastra
Cochrane, Sunway Cochrane — glow as beacons. Walk up to one and it opens: the
project's live catalogue figures, a question derived from those figures, and
the reading the guide wants the member to take away. Answering earns XP, and
answering every station in the area earns its badge.

The point is not the game. A member reading Cochrane's story is told to "count
the circle, not the block"; walking the circle and finding four towers handing
over in the same three years is the same sentence, learned instead of read.

> **Cochrane first, built to generalise (2026-09-08).** The code knows nothing
> about Cochrane. An area gets a session from the active registry's walkable
> configuration — a point, a radius and a beacon budget — and
> the stations are whatever the project catalogue says stands in that circle.
> In database mode, supported areas such as KLCC or Maluri are enabled through
> the shared Area Guide editor. Legacy mode retains the config enable list.

## How it works

- **Phase 1 content source (2026-09-13).**
  [AreaGuideRegistry::walkableAreas()](/src/AreaGuide/Services/AreaGuideRegistry.php)
  supplies area key → label, coordinates, `radius_m` and `stations` from the
  [shared registry](/docs/modules_handbook/shared/project-catalogue/area-guide-registry/readMe.md).
  In database mode, inactive/archived content and `soon` regions do not produce
  walkable worlds. In legacy mode the versioned content and
  `config/area_guide_game.php` supply the baseline. An unavailable database is
  never replaced with that baseline. `AREA_GUIDE_CONTENT_SOURCE` and
  `AREA_GUIDE_CONTENT_CONNECTION` select the source; the explicit
  `area_guide_local` development workspace does not change the catalogue
  connection used to read station projects.
- **Nothing is authored per project.** `AreaStationFinder` reads the area's
  centre, radius and beacon budget from that registry, pulls high-rise `catalog_projects` rows from
  the bounding box (a box is indexable; the true circle is applied in PHP
  afterwards on a few hundred rows), and returns them **launches first, each
  group nearest-first**, capped at the beacon budget. A launch ingested next
  month joins the session with no edit. The figures are the SAME
  [`AreaTutorialPresenter::project()`](/src/AreaGuide/Support/AreaTutorialPresenter.php)
  payload an Area Tutorial stop uses, so a project reads identically wherever
  it appears in the guide.
- **The question is derived, not written** —
  [`utils/areaGuide/game.js`](/resources/js/utils/areaGuide/game.js). Five
  question types are tried in order and the first whose data exists wins: PSF
  against the corridor → gross yield against the corridor → how many other
  towers hand over within 3 years → tenure → whether the transaction history
  is thick enough to price from. Each answer is **computed from the same
  numbers the card prints**, which is what makes a generated question
  checkable. A thin catalogue row therefore degrades to a simpler question
  rather than to a broken one, and a row the catalogue knows nothing about has
  no question at all — it is still a beacon worth walking to, because the
  circle is what you are counting. Two guards matter: a gap under 2% is not
  asked ("above or below" on a rounding difference teaches the wrong lesson),
  and the teaching line is shown whether the member was right or wrong.
- **The DECISION is derived twice, and the two are guarded (2026-09-14).**
  `game.js` owns the WORDING the card shows;
  [`StationQuestion`](/src/AreaGuide/Support/StationQuestion.php) is the PHP port
  of the same file's decision half — which question a row earns, its option keys
  and the computed answer — and it is what the visit endpoint judges the
  member's pick against. Both sides read the SAME figures
  (`AreaTutorialPresenter::project()` over the finder's stations), and
  [`stationQuestions.fixture.json`](/resources/js/utils/areaGuide/stationQuestions.fixture.json)
  is asserted by BOTH `game.test.js` and
  [StationQuestionTest](/tests/Unit/AreaGuide/StationQuestionTest.php).
  **The rule: change one derivation, add a fixture case, and both suites must
  stay green** — otherwise a divergence marks a member's right answer wrong with
  nothing failing. The fixture deliberately encodes the two traps that make a
  naive port wrong: JavaScript truthiness (`"0"` is a truthy STRING, so a PHP
  `(bool)` cast on `tenure` would score a real answer wrong) and JS division
  semantics when a corridor reference is zero.
- **The avatar is a Mapbox CUSTOM LAYER, not a second canvas** —
  [`utils/areaGuide/avatar.js`](/resources/js/utils/areaGuide/avatar.js). It
  shares Mapbox's GL context and depth buffer, so the character is occluded by
  the real buildings and stands on the real ground at the real pitch. It also
  means **no second `new Map()`**: Mapbox bills a map load per map, and a
  reader flips in and out of walk mode as freely as they flip
  Streets/Illustrated (the same rule as the parent module's `v-show` note).
  three.js is imported **on the way into walk mode**, so a reader who only ever
  reads the story never downloads the 500KB scene runtime.
  - The figure is built in code rather than loaded as a model: the guide's pins
    are already deliberate placeholders, and a glTF would be the first binary
    asset in the bundle. Swap `buildCharacter()` when an illustrated set
    exists; nothing else knows what the model is.
  - **How big it is on screen is a RULE, not a number** — `heightMetres(zoom, lat)`
    in avatar.js. The reader's brief, exactly: *big enough to see at the zoom
    the area opens at, and bigger as they zoom in.* A fixed real-world height
    fails the first half (14 m at the opening zoom of 15.2 is three pixels —
    it shipped that way once, at 13px, with every test green because none of
    them could see) and a fixed pixel height fails the second. So the screen
    height starts at `BASE_PX` (42) at `BASE_ZOOM` (15.2) and rises gently with
    zoom — roughly doubling every three levels, capped at `MAX_PX` (170) — and
    is converted to metres for the scene each frame, so a pinch-zoom resizes
    it live. Below its true `HEIGHT_M` (14 m) it stops shrinking and becomes
    real scale, which is where zooming in finally makes it grow the way a
    building does. The geometry is built once at 14 m; only the matrix scales.
    `avatar.test.js` pins all four clauses: BASE_PX at BASE_ZOOM, monotonic
    growth at every quarter-zoom from 14 to 22, never below HEIGHT_M, and
    true scale past the cap.
  - Mapbox GL passes the projection matrix as the second argument itself (v2,
    and v3's mercator path) or inside `defaultProjectionData.mainMatrix` (v3's
    globe-capable signature), as a plain `Array` or a `Float32Array` depending
    on the build. `render()` accepts all of them by **looking for 16 numbers
    rather than for a type** — reading one shape leaves the layer silently
    blank on the others, with no error anywhere.
- **Which map the walk is on (2026-09-13).** The walk needs
  [AreaStreetMap.vue](/resources/js/Components/AreaGuide/AreaStreetMap.vue) —
  the avatar is a custom layer of THAT map. On the illustrated guide the street
  map IS the area's view, so the avatar is simply there. Malaysia's areas now
  open on [MalaysiaMap.vue](/resources/js/Components/AreaGuide/MalaysiaMap.vue),
  one continuous map that flies from the peninsula down to the landmark and
  orbits it while the narration plays, and two Mapbox maps cannot share the
  screen. So on Malaysia the walk is a VIEW the reader steps into:
  [MalaysiaGuide.vue](/resources/js/Components/AreaGuide/MalaysiaGuide.vue)
  fetches the stations the moment an enabled area opens (as everywhere), shows
  **Walk this area · N stops** on the map card once they are in, and the
  button mounts the street map OVER the continuous one (`v-show` after the
  first time — a remount is a billed map load), with the HUD and the station
  card on top. The same button is the way back; the narration is stopped on
  the way in, since a voice about the skyline over a walk down the street is
  two things at once. This is not the old on/off switch returning — that one
  decided whether the avatar existed; this one decides which map is on screen.
- **It is just THERE, and the mouse drives it.** No switch: the avatar stands at
  the area's pin the moment an enabled area's street map is up, and clicking
  anywhere on the map walks it there (clicking a beacon walks to that beacon —
  a marker swallows the click the map would get, so it answers it itself). It
  turns towards the point and sets off, stopping within 6 m. Two details keep
  it from reading as broken: the turn takes the SHORT way round (`shortestTurn`
  — 350° to 10° is twenty degrees right, not three hundred and forty left, or
  the avatar spins almost full circle before setting off), and it turns on the
  spot while more than ~69° off target rather than crabbing sideways. The last
  step is clamped to the distance remaining, so it never overshoots and jitters.
  Mapbox does not fire `click` at the end of a drag, so panning never sends the
  avatar somewhere by accident. An earlier version had a **Walk this area**
  button, because it took the camera over (flew to a fixed zoom, rode behind
  the character) and needed a way back out; with the camera left to the reader
  the button was only a door in the way, and it is gone.
- **The camera is the reader's.** Nothing here calls `easeTo`/`jumpTo`; they
  zoom, pan and tilt exactly as before the avatar existed, and the map's own
  arrow-key handling is left alone. WASD (shift to run) is the one fine control
  kept, for the few who want it; touching it cancels a walk-to, because a steer
  that keeps steering while the reader is steering fights them for the avatar.
  The keydown handler ignores modified keys and anything typed into an input.
- **Speed is in SCREEN pixels per second, not metres.** `WALK_PX_PER_S` (110)
  is converted to metres each frame from the current zoom. A real 14 m/s at the
  opening zoom (4 m/px) is three pixels a second — a screensaver, not a game —
  and at a doorway zoom the same 110px/s is a brisk 34 m/s. Steps are also
  scaled by real elapsed time, so a 120Hz screen does not walk at twice the
  speed of a 60Hz one.
- **Arrival has hysteresis.** A beacon opens when the avatar comes within
  `reach_m` (45) and cannot re-open until it has gone `+25 m` past that.
  Without the pad, loitering on the edge of the circle reopens the same card
  several times a second.
- **The VERDICT is the server's too, not just the arithmetic (2026-09-14).**
  The browser posts `{station, answer}`, where `answer` is an OPTION KEY from
  `StationQuestion::OPTIONS` (`above` · `below` · `none` · `few` · `many` ·
  `freehold` · `leasehold` · `thick` · `thin`). **`answered` and `correct` are
  no longer read at all** — a body claiming them earns only the arrival. The
  controller derives the question again with `StationQuestion` from the same
  presented figures the stations endpoint sent, **422**s an option that
  station's question does not offer (or an answer to a station that has no
  question), and writes its own `is_answered` / `is_correct`. The response
  carries a `visit` block — `{station, reached, answered, correct}` — and the
  panel re-colours the beacon from that, so the map shows the server's verdict
  rather than the browser's claim.
- **XP is the SERVER's arithmetic.** The server recomputes
  `xp = reach + (correct ? correct_xp : 0)` from the row's own flags on every
  write and never increments. So reporting the same arrival ten times — which
  walking in and out of a circle will do — awards once, and a wrong answer
  after a right one cannot take the right one away. The station is checked
  against the **same finder that issued the beacons**, so a hand-written uuid
  cannot mint a visit.
- **An unreadable registry is a 503, never a 500 (2026-09-14).** Both endpoints
  resolve the world through `AreaGuideGameController::world()`, which catches
  `QueryException|LogicException` and aborts 503 with *"The Area Guide is
  temporarily unavailable."* — the same answer narration and chat give.
  `AreaStationFinder::area()` still lets the exception escape by design: the
  caller decides the answer, and its docblock says so. **Not covered:** a
  catalogue-connection failure inside `finder->stations()` (the bounding-box
  query) is still an uncaught 500 on both endpoints.
- **The enable list is the server's too.** `CoursesController::areaGuideState()`
  sends `gameAreas` (`walkableAreas()` keys) with the Mapbox token, and the guide
  asks the stations endpoint **only** for an area in that list — opening one of
  the other areas costs no request at all. It is withheld with the token when
  the guide is locked or its shared content is unavailable: a locked reader
  has no map to walk on. XP values and `reach_m` remain gameplay config in
  `config/area_guide_game.php`; the registry migration does not change scoring.
- **`walkableAreas()` enforces the supported COUNTRY itself (2026-09-14).** It
  skips any country whose `iso2` is not in `AreaGuideContentAccess::WALKABLE_COUNTRIES`
  (today `['MY']`) **before** it looks at regions, so the rule no longer lives
  only in the editor's Form Request: an area flagged walkable by an import or a
  direct database write outside Malaysia is never offered to the lesson. And a
  NULL `walk_radius_m` / `walk_stations` now falls back to
  `AreaGuideArea::DEFAULT_WALK_RADIUS_M` (1200 m) / `DEFAULT_WALK_STATIONS` (6)
  rather than to `0`, which found no stations at all. Those constants are the one
  source of the two numbers: `AreaStationFinder`'s own `??` fallbacks are now
  unreachable (the registry always sends a number) and name the constants rather
  than repeating them, and the Manage form's blank-field defaults repeat them only
  because a Vue form cannot read a PHP constant — a drift guard
  (`AreaGuideRegistryTest::test_the_manage_forms_walk_defaults_match_the_models_constants`)
  reads that `.vue` as a plain file so the two cannot separate.
- **The browser side is ONE composable.**
  [`composables/useAreaWalk.js`](/resources/js/composables/useAreaWalk.js)
  owns the session's state — loading the stations when an enabled area opens,
  reporting arrivals and answers, the progress the server hands back, and the
  open station's card (figures, standing line, question from `game.js`). Both
  guides call it with their own `area` ref: the illustrated guide's instance
  in AreaGuidePanel.vue and Malaysia's in MalaysiaGuide.vue. The panel's
  `area` is null while Malaysia is open, so one key never runs two sessions.
- **Progress belongs to the LEAD**, like analyses, wealth plans and journey
  cards — an account can be merged, and progress must follow the person. An
  account with no lead walks the area perfectly well and is told plainly that
  nothing is being saved, rather than earning nothing in silence.
  `area_guide_visits` remains on the site's default database and still records
  the stable `area_key` plus station identity. Sharing the area definition
  does not merge learner progress across sites. The registry, catalogue
  projects and local visits have separate ownership; no cross-database join
  or visit-data migration was added in Phase 1.

## Related files

**Backend**
- [AreaGuideGameController.php](/app/Http/Controllers/Main/Portal/AreaGuideGameController.php) — the two JSON endpoints (stations + visit) and the HUD's progress numbers.
- [AreaStationFinder.php](/src/AreaGuide/Services/AreaStationFinder.php) — the circle: active registry → bounding box → true radius → launches-first → beacon budget. `rank()` is split from the query because it is the part with a decision in it, and the part a test can pin without a database.
- [AreaGuideRegistry.php](/src/AreaGuide/Services/AreaGuideRegistry.php) · [config/area_guide_content.php](/config/area_guide_content.php) — authoritative source and walkable-world projection; see the [shared registry handbook](/docs/modules_handbook/shared/project-catalogue/area-guide-registry/readMe.md).
- [AreaGuideVisit.php](/src/AreaGuide/AreaGuideVisit.php) (+ [repository](/src/AreaGuide/Repositories/AreaGuideVisitRepository.php) / [facade](/src/AreaGuide/Facades/AreaGuideVisitRepository.php)) — one row per member per station. ONE write method on purpose: reaching and answering are the same row in two states, and a second writer of `xp` would drift the moment the rule moved.
- [StationQuestion.php](/src/AreaGuide/Support/StationQuestion.php) — the server's copy of the question DECISION (which question a row earns, its option keys, the right answer) and the `OPTIONS` whitelist the visit request validates against. The port's guard is [stationQuestions.fixture.json](/resources/js/utils/areaGuide/stationQuestions.fixture.json), asserted from both languages.
- [StoreVisitRequest.php](/app/Http/Requests/Main/Portal/AreaGuide/StoreVisitRequest.php) — the reader gate (`allowsGuide`, before the rules) plus shape: `station` and an optional `answer` restricted to `StationQuestion::OPTIONS`. That the station is really in this area — and whether the answer is right — is the controller's check, against the finder and `StationQuestion`.
- [AreaGuideViewerAccess.php](/src/AreaGuide/Support/AreaGuideViewerAccess.php) — the one reader gate both endpoints apply.
- [config/area_guide_game.php](/config/area_guide_game.php) — the legacy/import enable list and area settings; the XP rule and `reach_m` remain runtime gameplay configuration in both source modes.
- [CoursesController.php](/app/Http/Controllers/Main/Portal/CoursesController.php) — `areaGuideState()` gained the `gameAreas` prop.

**Frontend (Vue)**
- [composables/useAreaWalk.js](/resources/js/composables/useAreaWalk.js) — the session's state: loads the stations when an enabled area opens, reports arrivals and answers, holds the progress the server hands back and the open station's card.
- [AreaGuidePanel.vue](/resources/js/Components/AreaGuide/AreaGuidePanel.vue) — the illustrated guide's instance: the avatar is on the street map whenever an enabled area is open · [MalaysiaGuide.vue](/resources/js/Components/AreaGuide/MalaysiaGuide.vue) — Malaysia's instance, behind the **Walk this area** button that swaps the continuous map for the street map.
- [AreaStreetMap.vue](/resources/js/Components/AreaGuide/AreaStreetMap.vue) — gained the avatar layer, the beacons, click-to-walk, WASD, and the `reach` event. Same map, no second load, camera untouched.
- [StationDialog.vue](/resources/js/Components/AreaGuide/StationDialog.vue) — the dialogue box over the map (the reader has just walked to this building; a side panel would break that) · [AreaWalkHud.vue](/resources/js/Components/AreaGuide/AreaWalkHud.vue) — XP, stations found, badge.
- [utils/areaGuide/avatar.js](/resources/js/utils/areaGuide/avatar.js) — the three.js custom layer, the character, `step()` and `distanceM()`.
- [utils/areaGuide/game.js](/resources/js/utils/areaGuide/game.js) — the card's figures, the derived question and its computed answer.

**Migrations**
- [2026_09_08_160000_create_area_guide_visits_table.php](/database/migrations/2026_09_08_160000_create_area_guide_visits_table.php) — `area_guide_visits`, unique on (lead, area, station).

**Seeders** — none. The stations are catalogue rows; there is nothing to seed.

**Routes**
- [routes/main.php](/routes/main.php) — both endpoints, declared with the literal-prefixed academy routes **before** `GET property/academy/{course}`.

**Tests**
- [AreaStationFinderTest.php](/tests/Unit/AreaGuide/AreaStationFinderTest.php) — the circle (a box corner is not in it), launches-first, the cap, and the retained **legacy/import drift guard** between config and `malaysia.js`; [AreaGuideContentTest](/tests/Feature/Main/Portal/AreaGuide/AreaGuideContentTest.php) also checks the PHP copy. These baseline tests do not read later database edits.
- [AreaGuideRegistryConsumersTest.php](/tests/Unit/AreaGuide/AreaGuideRegistryConsumersTest.php) — the finder uses registry coordinates/budget, and locked/unavailable guide props expose no session capability. [AreaGuideRegistryTest.php](/tests/Unit/AreaGuide/AreaGuideRegistryTest.php) covers the separate content connection and registry projection.
- [AreaGuideGameTest.php](/tests/Feature/Main/Portal/AreaGuide/AreaGuideGameTest.php) — the beacons are the rows really in the area, an area with no session answers instead of 404ing, XP is awarded once however often an arrival is reported, a wrong answer cannot undo a right one, a station from elsewhere is refused, and the badge needs every station *answered*. Added 2026-09-14: **the server decides whether an answer is right**, a forged verdict with no `answer` earns only the arrival, an option the question does not offer is refused, and an unreadable registry is a 503 rather than a 500.
- [AreaGuideEndpointGateTest.php](/tests/Feature/Main/Portal/AreaGuide/AreaGuideEndpointGateTest.php) — all four reader endpoints (narration, chat, stations, visits) against a locked member, an admin, an admin previewing the lock, an enrolled day-1 trainee and an open member, with a provider spy proving a refused reader spends no AI call.
- [StationQuestionTest.php](/tests/Unit/AreaGuide/StationQuestionTest.php) — the PHP half of the question-parity fixture. Its twin is the `stationQuestions.fixture.json` block in `game.test.js`; both must stay green.
- [avatar.test.js](/resources/js/utils/areaGuide/avatar.test.js) — the on-screen size rule (all four clauses of the reader's brief), the click-to-walk maths (aiming at the four compass points, walking the whole way and arriving, and that a turn is never more than half a circle) plus the layer's arithmetic and its contract with Mapbox: the character lands on its own mercator coordinates at metre scale, it reads the pose rather than caching one, the legs swing only while moving, and **all four matrix shapes Mapbox may pass are accepted** (bare `Array`, `Float32Array`, and either inside a projection-data object) while garbage draws nothing instead of throwing. Only `WebGLRenderer` is stubbed — there is no GPU here — and the stub hands back the camera Mapbox would have drawn through, which is what lets the test assert the transform rather than merely that nothing threw.
- [game.test.js](/resources/js/utils/areaGuide/game.test.js) — which question a row earns, and that the answer is computed from the numbers the card prints.
- [AreaGuideWalk.test.js](/resources/js/Components/AreaGuide/AreaGuideWalk.test.js) — the stations are fetched the moment an enabled area opens and never for one without a session, the **Walk this area** button appears only once they are in and is the way back out (the street map stays mounted), arrival opens the card and reports once, and the XP shown is the server's. Runs on Malaysia's guide with a cut-down config (`malaysiaGuide.fixture.js`).

## Switching on another area

**Database mode:** edit the supported area in Manage → Portal → Area Guide.
Keep its country/region/area active and its region out of `soon`, set
`is_walkable`, its coordinates, `walk_radius_m` and `walk_stations`. The
next registry read supplies the world and `gameAreas`; beacons, figures and
questions still come from the catalogue. New-country map support does not by
itself establish country-specific station lessons. Two rules decide whether
the edit has any effect at all:

- **The area's COUNTRY must be in `AreaGuideContentAccess::WALKABLE_COUNTRIES`**
  (today `['MY']`). `walkableAreas()` filters on it, so setting `is_walkable`
  on a non-Malaysian area now does *nothing* — it is not merely refused by the
  form, it never reaches `gameAreas` even if an import or a direct database
  write sets the column.
- **Leaving `walk_radius_m` / `walk_stations` blank is fine**: a null column
  yields the 1200 m / 6-station defaults. It used to yield `0`, which produced
  an empty station list and a walk with nothing in it.

**Legacy/import-baseline mode only:** add the area's key, label, matching
`lat`/`lng`, `radius_m` and `stations` to `config/area_guide_game.php` and extend
the legacy drift guard. Database-mode edits do not require changes to these
bundled files. See the registry handbook before importing or switching sources.

## Known gaps

- **The character is a placeholder**, like the guide's emoji pins — a coded low-poly figure, not an illustrated set.
- **Nobody has walked it on real Mapbox terrain.** The build environment has no token and no GPU; the character and its camera framing were judged against a stand-in ground at the real zoom and pitch. Pathing is straight-line — the avatar walks THROUGH buildings rather than around them, which on a street grid is usually the same line a person would take, but is not routing.
- **No walking on the illustrated map.** The session needs real ground, so the button is offered only where a Mapbox token makes the street view real; without one the area keeps its story and its cartoon map.
- **The badge is per area and lives only in this table.** It is not yet shown anywhere outside the session's HUD, and it does not reach the DMAIC road's Analyze stage — the natural next step, since the walk is that stage's reading.
- **No leaderboard, and deliberately none yet.** XP here measures reading, not speed; ranking members on it would reward walking fast.
- **The badge is still brute-forceable, just more expensively (2026-09-14).**
  Posting `correct: true` is closed, but `is_correct` is a sticky OR and the
  response now returns the verdict, so someone who enumerates a station's 2–3
  option keys eventually lands the right one: full XP and the badge with no
  walking and no reading. Forging costs 2–3 requests per station instead of 1.
  The only server-side fix is "first answer only", which contradicts this
  module's own rule that an answer once right stays right, and would punish a
  mis-tap forever — so **the badge is not proof that someone walked the area**,
  and making it one is a product decision, not a review's.
- **A station list that changes mid-walk can silently flip the question.** The
  visit endpoint re-runs `finder->stations()`, a fresh catalogue query. If a
  project inside the circle is ingested or re-priced between the stations fetch
  and the answer, the corridor median can move and the derived question (or its
  answer) can change: the member's right answer then either 422s or scores
  wrong, and `useAreaWalk.report` only `devWarn`s, so it is silent. Rare, and no
  mitigation was added.
- **A catalogue outage is still a 500 on both endpoints.** The 503 covers the
  REGISTRY read only; the bounding-box query against `catalog_projects` is not
  wrapped.
