# Area Tutorials (`Src\AreaGuide`)

**Portals:** Manage (the builder) + Main user portal (the player) · **Routes:** `manage.area-tutorials.*`,
`main.portal.area-tutorials.show` · **Nav:** Manage: Channel → **Portal**, the *Area Tutorials* hub tab
(beside Courses — portal content, not a setting); Main: no nav entry of its own — a tutorial is reached
from its area's story inside the [Area Guide](/docs/modules_handbook/main/area-guide/readMe.md) tab of
the Learning Hub, and its page carries a back link to that area · **Gated by:** Manage
`view-area-tutorials` / `manage-area-tutorials`; Main `['auth','main','contact.verified','lock.analyze']`
plus route middleware `feature:area-guide`, plus the Area Guide's own reader gate
[`AreaGuideViewerAccess::allowsGuide()`](/src/AreaGuide/Support/AreaGuideViewerAccess.php) — the temporary
lock (including an admin's `?preview=locked`), the five-day trainee lock and membership Function
access · **Added:** 2026-08-30 · **Last verified against the code:** 2026-09-14.

## What it does

A **guided drive along one corridor of an Area Guide area** — the pilot is Old Klang Road, Scott Garden
→ Southbank → Mid Valley — told twice on one screen:

- **Left, the map.** A Mapbox Standard 3D street map with the route drawn on it. A pin drives along the
  real road from stop to stop while the camera follows, then settles on the building. Each stop is a
  condo from the **Subsale Database** (`catalog_projects` — median psf with its 25–75 range, median price,
  rental yield, completion year, developer, transaction count *and the period it covers*, asking
  listings), a landmark from the **market catalysts** registry, or a free pin with the admin's own copy.
- **Right, the story.** A stepper (one screen per step, `?step=` in the URL): intro → one **investor
  card** per stop → a **summary table** of every stop (the thing an investor screenshots) → the team's
  **walkthrough video** (Vimeo), chaptered to the same stops: as the video plays the pin drives to
  whichever stop the footage is at, and clicking a stop seeks the video.

The reason it is a *corridor* and not an *address*: an investor weighing Old Klang Road is comparing
Scott Garden against Southbank against Petalz — psf, yield, completion year, which end of the road.
The buildings differ far more than the street does, so the comparison IS the content; the animation is
how the comparison is told in order.

## How it works

- **Data.** `area_tutorials` (one per corridor; `area_key` names the active Area Guide registry's
  stable area key) and
  `area_tutorial_stops` (ordered; `kind` = project / landmark / note; `catalog_project_id` +
  `catalog_project_uuid` / `market_catalyst_id` nullable; `video_at_seconds` nullable).
  **A tutorial never stores a price** —
  `AreaTutorialPresenter::project()` reads the catalogue at render time, so the card cannot go stale.
  How a stop points at a catalogue project is its own subject — see *Stops carry the catalogue pair*
  below.
- **Shared area identity, local tutorials (Phase 1, 2026-09-13).** Countries, regions and area
  definitions now belong to the [shared Area Guide registry](/docs/modules_handbook/shared/project-catalogue/area-guide-registry/readMe.md).
  `AREA_GUIDE_CONTENT_SOURCE=database` makes its configured content database authoritative;
  `legacy` retains the versioned import baseline. `area_tutorials`, stops, episode cells,
  route caches and watch/progress records retain their existing site-local ownership.
  `area_key` stays a string compatibility key; this phase adds no cross-database foreign key,
  tutorial copy or migration of tutorial-owned content.
- **The selector and links read the same registry.** On authenticated Manage/Main tutorial pages,
  `HandleInertiaRequests` supplies `areaGuideSource` plus lightweight `areaGuideCountries`
  (`AreaGuideRegistry::countries(false)`). `useAreaGuideRegistry()` drives builder options,
  filter labels, area lookup and the player's back link. It does not send full stories to
  these pages. New active areas appear on the next page read. An authoritative empty result
  or database failure stays empty; database mode never substitutes `countries.js`.
  If a saved tutorial's area is absent/inactive/archived, the form retains its existing key
  as an `(unavailable)` option and labels fall back to the key, so a content edit does not
  erase or reassign the local tutorial. The existing Form Request still validates the key's
  shape rather than requiring registry membership; the selector is not a new write constraint.
- **The route is drawn ONCE PER SET OF WAYPOINTS, and cached.** `AreaTutorialsController@fetchRoute` is
  the only endpoint that calls `MapboxDirectionsService` (Mapbox Directions, billed per request):
  start → every stop in order → end, as a GeoJSON LineString in `route_geojson` + distance + duration.
  Every stop write that changes the waypoints (add / move / delete / reorder) **clears the cache** in the
  repository, and moving either end in `update()` clears it too; editing copy or a timestamp does not.
  The builder's **Stops tab then redraws it by itself** — a 1.2 s debounce lets a burst of edits settle,
  then one Directions call. It is NOT "an admin fetches it once".
  **There is no retry loop.** A refused fetch answers with a flash + `back()`, which succeeds *as a
  visit*, so the tab proves the draw by the route that came back rather than by the callback firing: a
  failure remembers the exact waypoint signature (start + every stop + end) it was for and the auto-draw
  never redraws those same waypoints again. Only a real edit — a different signature — re-arms it, and
  the Show header's *Re-fetch route* is the manual retry. No member request can ever reach the API.
- **Publish is refused** without ≥ 1 stop and a fetched route — a pin needs a line to drive along — and
  that rule now lives in ONE place, `AreaTutorial::isPublishable()`, applied on **every** path to
  `STATUS_PUBLISHED`. The Publish action checks it, and so does the create/edit modal's Status select:
  the Form Request's `after()` hook returns a validation error on `status` whenever the save would
  TRANSITION a tutorial to published while it cannot be played. Editing a tutorial that is ALREADY
  published is not a transition, so its copy can still be saved while its route redraws. Deleting the
  **last** stop returns a published tutorial to draft inside the same transaction, and the stops
  controller flashes that plainly.
  *(The modal still offers "Published" on CREATE, where it can never succeed — a new tutorial has no
  stops. That is deliberate: the reader is told why, rather than the option quietly vanishing. And for
  the ~1.2 s between a stop edit and the auto-redraw, an already-published tutorial has no route line —
  blocking that would lock copy editing on every published tutorial while its path redraws.)*
- **The player** (`Pages/Main/Portal/AreaTutorial/Show.vue`) mounts
  [`AreaTutorialMap.vue`](/resources/js/Components/AreaGuide/AreaTutorialMap.vue) ONCE and drives it by
  `activeIndex` (-1 = overview, 0…n-1 = a stop). The drive is client-side geometry over the cached line
  ([`tutorialRoute.js`](/resources/js/utils/areaGuide/tutorialRoute.js): each stop is projected onto the
  route — a building sits beside the road, not on it — and the pin interpolates between those distances
  at a clamped speed; reduced-motion readers get the jump). Without a route (an admin previewing an
  unrouted draft) the pin flies point to point.
- **Video ↔ map sync.** The Vimeo Player SDK's `timeupdate` picks the last stop whose
  `video_at_seconds ≤ currentTime`; a chapter list under the video seeks. Watch engagement rides the
  existing `/track/video` beacon (`TrackVideoRequest::WATCHABLES['area_tutorial']`, published tutorials
  only) and shows on the builder.
- **Workflow the timestamps assume:** the director builds the stops here FIRST (this page is the shot
  list); the crew shoots in that order; after the edit, someone opens *Video & timestamps* and keys in
  where each stop appears ("Use current time" reads the player). Until then the stops simply have no
  chapter.
- **Members' gate — the Area Guide's own, not a copy of it.** A member gets published tutorials only; an
  admin may open a draft (the builder's *Preview* button) and sees a draft banner. The player calls
  `AreaGuideViewerAccess::allowsGuide($request)` rather than reading `features.area_guide_locked`
  itself, so the temporary lock, the **five-day trainee lock** and membership Function access all hold
  here too: a reader the guide tab would hold back is redirected to `/property/academy?tab=area-guide`
  instead of reaching the player through its direct link. (The Learning Hub withholds the tutorial cards
  from a locked reader as well, so the link is not offered in the first place.)
- **The player receives LESS than the builder.** `AreaTutorialPresenter::player()` is `full()` minus
  `shoot_script` — the AI episode document, its production notes and run metadata are builder-internal
  and a member page never receives them. The same rule governs the map's data fetches: of
  `AreaTutorialMap`'s four Manage-only lookups, projects is gated on `pickProjects`, airbnbs and
  amenities on `overlay`, and the train stations on `stationLayer` (default **false**, passed only by the
  builder's Stops tab and Stop modal). The portal passes none of the four, so the member player makes no
  Manage request at all.
- **Stops carry the catalogue PAIR.** `area_tutorial_stops` holds `catalog_project_uuid` beside
  `catalog_project_id` (migration `2026_09_14_100000`, nullable + indexed; its backfill reads the ids on
  the `catalogue` connection first and this deployment's own `catalog_projects` for the remainder, never
  joining across connections). `AreaTutorialStop` uses `BelongsToCatalogue` + `TracksCatalogueUuid` and
  its `catalogProject()` is a `catalogueBelongsTo()`, so a project **this platform created itself**
  resolves as well as a master one, and `area_tutorial_stops` is registered in
  `RepairCatalogueReferences::TABLES` so `catalogue:repair-refs` can rebuild the integer after a master
  re-key. The pickers follow the same rule: `projectOptions`, `projectsInBounds`, the stop
  `StoreRequest`'s exists-check and `mapInput`'s uuid → id resolution all go through
  `CatalogueFederationService` — each connection constrained and limited on its own side, merged
  master-first and deduped by uuid, never joined or UNIONed.
  **Still open:** the LANDMARK half is unchanged. `market_catalyst_id` has no uuid twin, it is resolved
  master-only (`MarketCatalyst::where('uuid')->value('id')`), `catalystOptions` is master-only, and that
  column is not in the repair map — so a master re-key of `market_catalysts` breaks landmark stops
  exactly as it used to break project stops. Impact is nil today (`market_catalysts` is empty in the live
  catalogue); the work is a dated migration with a two-connection backfill, a third entry in the shared
  `TracksCatalogueUuid` column map, federated catalyst lookups and a second `RepairCatalogueReferences`
  column.
- **The edit modal sends only the ends it actually knows.** The create/edit modal opens from BOTH the
  index row (the light card, which carries no coordinates) and the Show page (the full record), so it
  remembers whether the record it hydrated from carried `start` / `end` keys and drops the four
  coordinate fields from the payload when it did not; `mapInput` in turn maps each end field only when
  the request carries it. An edit that omits an end therefore leaves that end **and the fetched route**
  untouched, while a field sent empty still clears that end. Before this, a title edit from the list
  wiped both ends and the route with them.
- **Both corridor maps build their popups as HTML strings** (`innerHTML`, Mapbox `setHTML`, Leaflet
  `divIcon`), so every interpolated value goes through
  [`utils/areaGuide/escapeHtml.js`](/resources/js/utils/areaGuide/escapeHtml.js): admin-typed stop labels
  and headlines, and scraped catalogue text — project, station and amenity names, categories, and the
  amenity colour, which is interpolated inside a `style` attribute. Anyone adding a popup field escapes
  it.
- **AI episode settings survive the one-click rerun.** The Script tab's Setup saves its choices to
  `episode_config`; `episodeConfigFromRequest` fills any setting ABSENT from the request from that
  saved config before the defaults. The header's *AI write episode* button posts no editors — without
  this fallback it silently reset a 4-editor debate to the default Gemini + Claude pair (2026-08-31).
- **The map stage and the editors rerun separately.** Setup's *Editors only* button (`plan` with
  `editors_only: true` → `EpisodeDebate::startEditorsRun`) reruns drafts → debate → final cut on the
  LAST run's stop briefs (read from the run's STOP cells in the DB, so no cache dependency), keeping
  the same `run_key`; a full rerun re-analyses every stop through the engine + writer first — slow and
  billed per stop. Refused while the map stage still has pending stops (the last stop's `advance()`
  would dispatch a second draft set). A failed/skipped draft or critique cell reruns ALONE via *Run
  this model again* on its card (`POST {id}/cells/{cellId}/retry` → `EpisodeDebate::retryCell`) — the
  stage-advanced cache locks are cleared so the stage re-pauses for review when the retry lands, and a
  draft retry deletes any critiques (they critiqued the old draft set).
- **A paused run can still be resumed an hour later.** `proceed()` reads the run's settings from
  `episode-run:{runKey}`, which expires after 3600 s; when it is gone it falls back to the tutorial's
  saved `episode_config` and re-caches it, so the stage it dispatches can `advance()` too. When there is
  nothing left to fall back on, *Continue* says so as an error instead of reporting a false success.
  A run is also marked `generating` **inside** `EpisodeDebate`, before the first job is dispatched, so a
  synchronous queue can no longer run a whole chain and then have its paused stage overwritten by a
  status write that happened afterwards.
  **Open, on a SYNCHRONOUS queue only:** `startRun` creates and dispatches one stop cell at a time, so
  the first `AnalyzeStopCell` finishes and calls `advance(STOP)` while the later cells do not exist yet
  — `advance()`'s only gate is "no PENDING cells for this stage" — and the drafts go out before the rest
  of the corridor is analysed. Harmless on a real queue worker (all the cells are seeded before any
  runs); the fix would be to seed every stop cell before dispatching any of them.
  **Also open:** the prompt declares an optional `<FIELD_NOTES>` block (the host's on-site
  observations), but no code path supplies one yet — it is marked optional so the model tolerates its
  absence.
- **The per-stop analysis NEVER estimates.** `EpisodeDebate::stopAnalysis()` analyses a unit, so it needs
  the project's own transacted `price_median` and `psf_median` (the unit size is their quotient). When
  either is missing the engine is **not run on a made-up unit**: the analysis is an explicit
  `insufficient_data` marker naming what is missing, `mapUserMessage` turns it into an "INSUFFICIENT DATA
  … do NOT estimate or infer any figure" brief, and the Script tab's data preview badges the stop amber.
  Bedrooms are never guessed either — the engine receives `null`. The old synthetic unit (price 500 000 /
  size 900 / bedrooms 2 or 3) is gone. This makes some episodes shorter and more qualitative; that is the
  intent, not a regression.
- **Every model a run dispatches is clamped to the curated `EPISODE_MODELS` list** — the picker, the map
  writer, each editor AND the judge (`validModel()` requires both a credentialled provider model and
  membership of the list, and both fallback defaults are themselves in it), so a stale saved config can
  never dispatch a delisted model. The optional web-research pre-pass sits deliberately OUTSIDE the list
  as the named constant `EpisodeDebate::WEB_RESEARCH_MODEL`: it is not a choice, it is
  Gemini-with-grounding or nothing.
- **Writes go through the repositories; validation goes through Form Requests.** `planRoute`'s
  `episode_config` write is `AreaTutorialRepository::update()` (the column is in its whitelist); clearing
  an episode is one transactional `AreaTutorialRepository::clearEpisode()` (cells + `episode_config` +
  `shoot_script`); `chat`, `promptUpdate` and `promptModelUpdate` each take their own Form Request rather
  than an inline `$request->validate`. Deleted as dead code along the way: `corridorDataset()` /
  `corridorAmenities()`, `AreaTutorialRepository::applyPlan()` / `clearRoute()`, the unused
  `RouteExportBuilder` constructor injection and `AreaTutorialEpisodeCell::STAGE_REDUCE`.
- **`RouteExportBuilder` is still NOT WIRED** — nothing calls `build()`; it is kept as the Schema v2
  deliverable to wire in later, and the showrunner prompt now declares the inputs the pipeline really
  sends (`<CORRIDOR>` + `<STOP_BRIEFS>`, not `<ROUTE_EXPORT>`). What it can and cannot source, and what
  must be fixed before it is wired, is in
  [route-export-schema-v2-audit.md](route-export-schema-v2-audit.md).
- **The station layer's master list is aggregated in CHUNKS.** `masterStations()` scans every Klang
  Valley catalogue project's `amenities.train` (~10k rows and growing) via `toBase()->chunkById(1000)`
  — plucking through the Eloquent accessor held every decoded array at once and blew mod_php's 128M
  limit, so `stations-in-bounds` 500'd and the map silently lost all stations (the frontend swallows a
  non-OK response). Cached 1h; its endpoint is Manage-only, which is why the layer is opt-in
  (`stationLayer`, above); the 2D Leaflet fallback map has no station layer at all.

## Related files

**Backend**
- Models: [`src/AreaGuide/AreaTutorial.php`](/src/AreaGuide/AreaTutorial.php) (`STATUSES`,
  `isPublishable()`, `waypoints()`),
  [`src/AreaGuide/AreaTutorialStop.php`](/src/AreaGuide/AreaTutorialStop.php) (`KINDS`,
  `BelongsToCatalogue` + `TracksCatalogueUuid`),
  [`src/AreaGuide/AreaTutorialEpisodeCell.php`](/src/AreaGuide/AreaTutorialEpisodeCell.php)
  (one model call of a run; `stage` = stop | draft | critique)
- Repositories: [`AreaTutorialRepository`](/src/AreaGuide/Repositories/AreaTutorialRepository.php)
  (`create` / `update` / `storeRoute` / `storeEpisodeScript` / `clearEpisode` / `publish` / `unpublish` / `delete`),
  [`AreaTutorialStopRepository`](/src/AreaGuide/Repositories/AreaTutorialStopRepository.php)
  (`create` / `update` / `delete` / `reorder` — each invalidates the route; `delete` also un-publishes
  a tutorial whose last stop it removed)
- Service: [`MapboxDirectionsService`](/src/AreaGuide/Services/MapboxDirectionsService.php) (`route(waypoints)`, ≤ 25 points)
- Presenter: [`AreaTutorialPresenter`](/src/AreaGuide/Support/AreaTutorialPresenter.php)
  (`card` / `full` / **`player`** / `stop` / `project` / `catalyst`, `vimeoEmbedUrl`)
- AI episode: [`EpisodeDebate`](/src/AreaGuide/Support/EpisodeDebate.php) (the map-reduce run:
  `startRun` / `startEditorsRun` / `advance` / `proceed` / `retryCell` / `stopAnalysis`,
  `WEB_RESEARCH_MODEL`, `INSUFFICIENT_DATA`) and the five jobs in
  [`app/Jobs/AreaGuide/`](/app/Jobs/AreaGuide) (`ResearchCorridor`, `AnalyzeStopCell`, `EditorDraftCell`,
  `EditorCritiqueCell`, `JudgeEpisode`); prompt
  [`resources/prompts/area_tutorial_script.md`](/resources/prompts/area_tutorial_script.md)
- Schema v2 export: [`RouteExportBuilder`](/src/AreaGuide/Support/RouteExportBuilder.php) — **not wired**;
  see [route-export-schema-v2-audit.md](route-export-schema-v2-audit.md)
- Catalogue link: [`CatalogueFederationService`](/app/Services/Property/CatalogueFederationService.php)
  (every project lookup in this module) and
  [`RepairCatalogueReferences`](/app/Console/Commands/RepairCatalogueReferences.php)
  (`catalogue:repair-refs`, which lists `area_tutorial_stops.catalog_project_id`)
- Area identity: [`AreaGuideRegistry`](/src/AreaGuide/Services/AreaGuideRegistry.php),
  [`HandleInertiaRequests`](/app/Http/Middleware/HandleInertiaRequests.php),
  [`config/area_guide_content.php`](/config/area_guide_content.php) — source selection and scoped lightweight props;
  schema/import/admin ownership lives in the [registry handbook](/docs/modules_handbook/shared/project-catalogue/area-guide-registry/readMe.md).
- Controllers: [`Manage/AreaGuide/AreaTutorialsController`](/app/Http/Controllers/Manage/AreaGuide/AreaTutorialsController.php)
  — CRUD (index / store / show / update / destroy / publish / unpublish), the route (`fetchRoute`),
  the stop pickers (`projectOptions` / `catalystOptions` / `projectsInBounds` / `airbnbsInBounds` /
  `amenitiesInBounds` / `stationsInBounds`) and the AI episode (`planRoute` / `retryCell` /
  `proceedEpisode` / `chat` / `clearScript` / `dataPreview` / `cellBody` / `promptUpdate` /
  `promptModelUpdate`);
  [`Manage/AreaGuide/AreaTutorialStopsController`](/app/Http/Controllers/Manage/AreaGuide/AreaTutorialStopsController.php),
  [`Main/Portal/AreaTutorialsController`](/app/Http/Controllers/Main/Portal/AreaTutorialsController.php) (show);
  `Main/Portal/CoursesController@index` supplies `areaTutorials` (published cards keyed by area) to the
  Learning Hub — and withholds them from a locked reader
- Form Requests: `app/Http/Requests/Manage/AreaGuide/AreaTutorials/{QueryRequest,StoreRequest,UpdateRequest,ChatRequest,PromptUpdateRequest,PromptModelUpdateRequest}.php`
  (`StoreRequest::after()` is where the publish rule guards the status select; `UpdateRequest` inherits it),
  `app/Http/Requests/Manage/AreaGuide/AreaTutorialStops/{StoreRequest,UpdateRequest}.php` (reorder reuses `Manage/Lms/ReorderRequest`);
  `Main/Portal/TrackVideoRequest` registers the `area_tutorial` watchable
- Permissions: `Permission::VIEW_AREA_TUTORIALS` / `MANAGE_AREA_TUTORIALS` (group *Area Tutorials*)

**Frontend**
- Shared: [`Components/AreaGuide/AreaTutorialMap.vue`](/resources/js/Components/AreaGuide/AreaTutorialMap.vue)
  (`stationLayer` / `overlay` / `pickProjects` gate its Manage-only fetches) and its 2D fallback
  `Components/AreaGuide/AreaTutorialLeafletMap.vue`,
  [`utils/areaGuide/escapeHtml.js`](/resources/js/utils/areaGuide/escapeHtml.js) (every value either map
  interpolates into popup HTML),
  [`utils/areaGuide/tutorialRoute.js`](/resources/js/utils/areaGuide/tutorialRoute.js),
  [`utils/areaGuide/areaOptions.js`](/resources/js/utils/areaGuide/areaOptions.js) (pick-list helpers over supplied countries),
  [`composables/useAreaGuideRegistry.js`](/resources/js/composables/useAreaGuideRegistry.js) and
  [`utils/areaGuide/registry.js`](/resources/js/utils/areaGuide/registry.js) (authoritative runtime data;
  bundled `countries.js` exports remain legacy/import compatibility only)
- Manage: `Pages/Manage/AreaGuide/AreaTutorials/{Index,Show}.vue`,
  `Partials/{AreaTutorialForm,AreaTutorialFormModal,StopFormModal,EpisodeChat}.vue`,
  `Partials/Tabs/{StopsTab,VideoTab,ScriptTab}.vue`;
  the hub tab in `Components/Portal/PortalEngagementTabs.vue`; `/manage/area-tutorials` in `ManageLayout.vue`'s Portal `prefixes`
- Main: `Pages/Main/Portal/AreaTutorial/Show.vue` + `Partials/{StopCard,SummaryTable,VideoPanel}.vue`;
  `Components/AreaGuide/AreaGuidePanel.vue` (the *Drive the area* list in an area's story, from the `tutorials` prop);
  `Pages/Main/Portal/Lms/Index.vue` passes `areaTutorials` through

**Migrations**
- `2026_08_30_150000_create_area_tutorials_table.php`, `2026_08_30_150001_create_area_tutorial_stops_table.php`
- `2026_08_30_180000_add_shoot_script_to_area_tutorials.php`
- `2026_08_31_120000_create_area_tutorial_episode_cells.php` (also adds `episode_config`)
- `2026_09_14_100000_add_catalog_project_uuid_to_area_tutorial_stops.php` (the uuid twin + its backfill)

⚠️ **Two COMMITTED migration comments are stale and were deliberately left alone** — a committed
migration is never edited, so the correction lives here: `2026_08_30_150000:13-14` still says `area_key`
names static JS under `resources/js/utils/areaGuide/*.js` (it names an area in the shared Area Guide
registry), and `2026_08_31_120000:21` comments the `stage` column as `// draft | critique` (the code also
writes `stop`; `reduce` was never used and its constant is now deleted).

**Seeders** — none. Permissions come from `RolesSeeder` (run it after deploying: new constants 500 until it does). The
Old Klang Road pilot was created through the builder as a draft, not seeded.

**Routes** — `routes/web.php` (`area-tutorials` prefix; `options/*` literals before `{id}`; `{id}/route` is the
Directions call — the only endpoint that reaches Mapbox Directions, though the Stops tab, not an admin, is
what usually calls it), `routes/main.php` (`property/academy/area-tutorials/{id}`, declared before
`{course}`, with `feature:area-guide` on the route itself)

**Tests**
- [`tests/Feature/Manage/AreaGuide/AreaTutorialsTest.php`](/tests/Feature/Manage/AreaGuide/AreaTutorialsTest.php)
  — CRUD, the publish rule on every path (status select, last-stop delete), the player's gate and its
  `shoot_script`-free payload, the coordinate-omitting edit
- [`AreaTutorialCatalogueLinkTest.php`](/tests/Feature/Manage/AreaGuide/AreaTutorialCatalogueLinkTest.php)
  — the uuid pair, the federated pickers, `catalogue:repair-refs` and the migration's backfill over
  pre-existing rows
- [`AreaTutorialEpisodeTest.php`](/tests/Feature/Manage/AreaGuide/AreaTutorialEpisodeTest.php)
  — `proceed()`'s fallback and its honest error, `generating` before the first dispatch, the model clamp,
  the never-estimate null policy, `clearEpisode()`
- [`tests/Unit/AreaGuide/RouteExportBuilderTest.php`](/tests/Unit/AreaGuide/RouteExportBuilderTest.php)
  — the Schema v2 shapes (flags, the asking block, the implied-sqft basis, `bms_500m`'s distance rule)
- vitest: `AreaTutorialMap.test.js` (the station-layer opt-in, popup escaping),
  `Partials/Tabs/StopsTab.test.js` (one billed draw per waypoint set, no retry loop),
  `Partials/{AreaTutorialForm,AreaTutorialFormModal}.test.js`, `utils/areaGuide/escapeHtml.test.js`

Registry integration is additionally covered by
[`AreaGuideRegistryConsumersTest.php`](/tests/Unit/AreaGuide/AreaGuideRegistryConsumersTest.php)
(lightweight route scope and failure behavior) and
[`registry.test.js`](/resources/js/utils/areaGuide/registry.test.js)
(dynamic area lookup and authoritative empty content). The registry phase changed area metadata
consumption; it does not add a project drawer, uploaded building models, panorama uploads
or location video/cover uploads to the tutorial builder.
