diff --git a/.project/objectives/README.md b/.project/objectives/README.md
index c8379f2d..bca9e57e 100644
--- a/.project/objectives/README.md
+++ b/.project/objectives/README.md
@@ -15,10 +15,10 @@
| Priority | ✅ | 🟡 | 🔴 | ❌ | ⚫ | Total |
|---|---|---|---|---|---|---|
| **P0** | 27 | 5 | 3 | 0 | 0 | 35 |
-| **P1** | 13 | 3 | 2 | 0 | 1 | 19 |
-| **P2** | 9 | 6 | 0 | 9 | 0 | 24 |
+| **P1** | 13 | 3 | 3 | 1 | 1 | 21 |
+| **P2** | 9 | 6 | 0 | 12 | 0 | 27 |
| **P3 (oos)** | 0 | 0 | 0 | 0 | 17 | 17 |
-| **total** | **49** | **14** | **5** | **9** | **18** | **95** |
+| **total** | **49** | **14** | **6** | **13** | **18** | **100** |
@@ -26,10 +26,10 @@
| Team Lead | Remaining |
|---|---|
+| [tourguide](../team-leads/tourguide.md) | 7 |
| [asset-sprite](../team-leads/asset-sprite.md) | 7 |
| [warcouncil](../team-leads/warcouncil.md) | 6 |
-| [wireguard](../team-leads/wireguard.md) | 4 |
-| [tourguide](../team-leads/tourguide.md) | 3 |
+| [wireguard](../team-leads/wireguard.md) | 5 |
| [shipwright](../team-leads/shipwright.md) | 2 |
| [testwright](../team-leads/testwright.md) | 2 |
| [asset-audio](../team-leads/asset-audio.md) | 1 |
@@ -95,6 +95,7 @@
| [p1-12](p1-12-build-output-docs-alignment.md) | ✅ done | Align every doc reference to the relocated wasm-pack output | [tourguide](../team-leads/tourguide.md) | 2026-04-17 |
| [p1-13](p1-13-guide-dev-route-coverage.md) | ✅ done | Guide dev server boots on plum with zero-error route coverage | [tourguide](../team-leads/tourguide.md) | 2026-04-17 |
| [p1-15](p1-15-guide-next-deploy-infra.md) | ✅ done | Deploy dev guide to https://mc.next.black.local | [tourguide](../team-leads/tourguide.md) | 2026-04-17 |
+| [p1-16](p1-16-guide-game1-scope-hygiene.md) | ❌ missing | Purge Game 2/3 scope bleed from user-visible Game 1 guide copy | [tourguide](../team-leads/tourguide.md) | 2026-04-18 |
| [p1-17](p1-17-guide-next-auto-deploy.md) | 🟡 partial | Forgejo workflow auto-deploys dev guide on push to main | [tourguide](../team-leads/tourguide.md) | 2026-04-17 |
| [p1-18](p1-18-village-discovery-feedback.md) | 🔴 stub | Village discovery — world-map feedback (notification, reward popup, minimap ping) | [wireguard](../team-leads/wireguard.md) | 2026-04-17 |
| [p1-19](p1-19-tutorial-opt-in.md) | 🔴 stub | Tutorial opt-in — HUD button, disappears after turn 5, starts from Step 1 | [wireguard](../team-leads/wireguard.md) | 2026-04-17 |
@@ -128,6 +129,9 @@
| [p2-27](p2-27-city-population-tier-sprites.md) | ❌ missing | City population-tier sprites — city_q1 through city_q5 | [asset-sprite](../team-leads/asset-sprite.md) | 2026-04-17 |
| [p2-28](p2-28-sprite-provenance-ledger.md) | ❌ missing | Sprite provenance ledger — LICENSES.md per-file attribution | [asset-sprite](../team-leads/asset-sprite.md) | 2026-04-17 |
| [p2-29](p2-29-guide-welcome-homepage-theme-alignment.md) | ❌ missing | Welcome modal + HomePage lore + guide theme align to the player's chosen race/gender | [tourguide](../team-leads/tourguide.md) | 2026-04-17 |
+| [p2-30](p2-30-guide-shared-primitives.md) | ❌ missing | Consolidate duplicate page styled-components into shared PagePrimitives | [tourguide](../team-leads/tourguide.md) | 2026-04-18 |
+| [p2-31](p2-31-guide-url-bound-state.md) | ❌ missing | Migrate guide filter + tab state from useState to URL search params | [tourguide](../team-leads/tourguide.md) | 2026-04-18 |
+| [p2-32](p2-32-guide-data-driven-enums.md) | ❌ missing | Replace hardcoded page enums with JSON data reads | [tourguide](../team-leads/tourguide.md) | 2026-04-18 |
## Out of Scope (Game 2 / Game 3)
diff --git a/.project/objectives/p1-16-guide-game1-scope-hygiene.md b/.project/objectives/p1-16-guide-game1-scope-hygiene.md
new file mode 100644
index 00000000..01bf49ae
--- /dev/null
+++ b/.project/objectives/p1-16-guide-game1-scope-hygiene.md
@@ -0,0 +1,103 @@
+---
+id: p1-16
+title: Purge Game 2/3 scope bleed from user-visible Game 1 guide copy
+priority: p1
+status: missing
+scope: game1
+owner: tourguide
+updated_at: 2026-04-18
+evidence:
+ - public/games/age-of-dwarves/guide/src/pages/HomePage.tsx
+ - public/games/age-of-dwarves/guide/src/pages/CommunicationsPage.tsx
+ - public/games/age-of-dwarves/guide/src/pages/PromotionsPage.tsx
+ - public/games/age-of-dwarves/guide/src/pages/survival-guide/data.ts
+ - public/games/age-of-dwarves/guide/src/pages/progress-report/OverviewTab.tsx
+ - public/games/age-of-dwarves/guide/e2e/scope-hygiene.spec.ts
+---
+
+## Summary
+
+CLAUDE.md's Game 1 scope rule is clear: Age of Dwarves Early Access
+ships with **no magic** — no magic schools, no Archons, no leylines,
+no mana. The `` component + `VITE_DEV_GUIDE=1`
+dev-bundle flag (p1-15) are the enforcement mechanism. But a 2026-04-18
+Explore sweep catalogued six user-visible surfaces in the default Game 1
+build that still advertise or document Game 2/3 cosmology:
+
+| File:Line | Failure |
+|---|---|
+| `HomePage.tsx:189–190` (FEATURES) | "5 Magic Schools" card with "cross-school fusions, 10 hybrid disciplines" |
+| `HomePage.tsx:246–251` (Pitch) | "16 asymmetric races, 5 magic schools … pursue arcane power" |
+| `HomePage.tsx:256–275` (LoreSection) | Two paragraphs of mana-nodes + ley-lines + school-aligned energy |
+| `CommunicationsPage.tsx:97–98` | "Archon Telepathy (Magic civs)" row in mundane radio-tower rules |
+| `PromotionsPage.tsx:5,12–15,156–193` | Magic-data imports + "Mana Infusions" section + "Dispellable by Aether" / "High Archon dies" body text |
+| `SurvivalGuidePage` data.ts:85 | "Life T3 quarantine spell blocks adjacency transmission" in mundane survival scenario |
+
+These are **RED** — visible to someone opening the staging / production
+build without `VITE_DEV_GUIDE=1`. A player launching Early Access with
+one dwarf race should not be told the game has "5 magic schools" or
+read Archon mechanics in the communications tower rules.
+
+YELLOW items (dev-bundle-only, flagged for opportunistic cleanup):
+
+- `progress-report/OverviewTab.tsx:137,183` — hardcoded "5 trees,
+ 30 policies" + "12 additional races" roadmap rows
+- `GovernmentPage.tsx:48` (SKIP_MODS) — defensive filter for the
+ Game-2 `no_spell_pact_opposing_school` modifier
+
+## Why P1 (not P2)
+
+This is the inverse of p1-14 (which tried to ADD magic-school gating
+work to Game 1 and was rightly OOS'd). Here the work is **removing**
+Game 2/3 copy from Game 1 UI — exactly the spirit of p2-09's
+scope-narrow pass, but at the copy level. Early Access cannot ship
+with a homepage advertising mechanics that don't exist. Gated-behind-
+EpisodeGate OR pure Dwarf rewrite both count as "done"; the win is
+that `grep` stops finding magic terminology in default-build output.
+
+## Acceptance
+
+- **HomePage** — `Hero` / `Pitch` / `FEATURES` / `LoreSection` no
+ longer mention `magic schools`, `Archons`, `mana`, `ley lines`,
+ `arcane`, or cross-race counts ("16 races") in the default build.
+ Either the prose is rewritten Dwarves-first (craft, industry,
+ fortress cities, subterranean engineering, steam) or the magic
+ paragraphs are wrapped in `` so they reappear
+ cleanly in the `VITE_DEV_GUIDE=1` dev bundle.
+- **CommunicationsPage** — the "Archon Telepathy (Magic civs)" rules
+ row is ``-wrapped. The rest of the Radio Tower
+ section reads mundane (wireless infra only).
+- **PromotionsPage** — `disciplinesData` / `infusionTrees` imports
+ removed (they were empty stubs anyway). The "Mana Infusions" body
+ block (lines 156–193) + its `Dispellable by Aether` /
+ `High Archon dies` text all wrap in ``. The XP
+ + promotion-tree body above is mundane and unchanged.
+- **SurvivalGuidePage data.ts** — the Pandemic scenario's "Life T3
+ quarantine spell" mitigation replaced with a mundane equivalent
+ (e.g. "Quarantine House building" once that building lands, or
+ "isolate infected cities" as a pure game mechanic until then).
+- **Scope-rule grep gate** — from the repo root, the command
+ `grep -RE "magic schools|High Archon|mana nodes|ley lines" public/games/age-of-dwarves/guide/src/ 2>/dev/null | grep -v 'EpisodeGate\|episode *>= *2\|VITE_DEV_GUIDE'`
+ returns **zero** matches. Mechanically-enforced scope integrity.
+- **New e2e spec** — `public/games/age-of-dwarves/guide/e2e/scope-hygiene.spec.ts`
+ walks the five key Game 1 routes (home, communications, promotions,
+ survival, combat) and asserts the rendered DOM contains none of the
+ forbidden terms. Included in the `pnpm test:e2e` default run.
+
+## Non-goals
+
+- Rewriting the WelcomeModal — that's p2-29.
+- Converting the FEATURES array to a JSON data file — that's p2-32
+ (data-driven enums). p1-16 just gates / rewrites the magic cards;
+ the data-drive pass comes later.
+- Fixing `GovernmentPage.tsx:48` SKIP_MODS defensive filter — YELLOW,
+ dev-bundle-only, addressed opportunistically.
+- Shared-primitive consolidation on the same pages — that's p2-30.
+ Keep edits minimal and scope-only here.
+
+## Why tourguide owns it
+
+Mechanical scope enforcement on the dev-preview + staging deploy
+(`mc.next.black.local`) is squarely in Tourguide's mandate. The grep
+gate + `scope-hygiene.spec.ts` extend the Wave-1 e2e harness from
+p1-13, the same substrate Tourguide authored.
diff --git a/.project/objectives/p2-30-guide-shared-primitives.md b/.project/objectives/p2-30-guide-shared-primitives.md
new file mode 100644
index 00000000..6219bee7
--- /dev/null
+++ b/.project/objectives/p2-30-guide-shared-primitives.md
@@ -0,0 +1,98 @@
+---
+id: p2-30
+title: Consolidate duplicate page styled-components into shared PagePrimitives
+priority: p2
+status: missing
+scope: game1
+owner: tourguide
+updated_at: 2026-04-18
+evidence:
+ - src/packages/guide/src/components/ui/PagePrimitives.tsx
+ - public/games/age-of-dwarves/guide/src/pages/BiomeBrowserPage.tsx
+ - public/games/age-of-dwarves/guide/src/pages/SpeciesBrowserPage.tsx
+ - public/games/age-of-dwarves/guide/src/pages/MapTypesPage.tsx
+ - public/games/age-of-dwarves/guide/src/pages/ExpansionsPage.tsx
+ - public/games/age-of-dwarves/guide/src/pages/TeamPage.tsx
+ - public/games/age-of-dwarves/guide/src/pages/EpisodeDwarvesPage.tsx
+---
+
+## Summary
+
+An Explore sweep on 2026-04-18 counted ~180 redundant styled-component
+declarations across 15 guide pages. Each page declares its own
+`Card`, `CardHeader`, `CardTitle`, `StatsGrid`, `Stat`, `Badge`,
+`SectionLabel`, `Subtitle`, etc. that already exist (or could exist) in
+the shared `src/packages/guide/src/components/ui/PagePrimitives.tsx`.
+The duplication:
+
+- Makes theme-token changes N-way rather than one-way (change the Dwarf
+ copper accent → audit 15 pages).
+- Hides inconsistencies. `SpeciesBrowserPage`'s `Card` has 12 px
+ border-radius; `BiomeBrowserPage`'s has 10 px. Nobody notices until
+ screenshots diverge.
+- Inflates each page's file size toward the 500-LOC cap, forcing
+ awkward splits (`BiomeBrowserPage.tsx` is already 355 LoC of styled
+ alone, ignoring JSX).
+- Forecloses easy swap-to-Markdown + swap-to-data adoption because
+ each page has its own local idiom.
+
+Highest-ROI candidates (counted by Explore):
+
+- `BiomeBrowserPage.tsx:191–355` — 23 custom styled, largest footprint.
+- `SpeciesBrowserPage.tsx:236–330` — 16 custom styled.
+- `MapTypesPage.tsx:9–103` — Name / StatsGrid / Stat / SectionLabel /
+ TopologyCard / TopologyBadge / TopologyDesc restate PagePrimitives.
+- `ExpansionsPage.tsx:10–132`, `TeamPage.tsx:6–30` — near-total overlap.
+- `EpisodeDwarvesPage.tsx` — inline `INCLUDED_SYSTEMS` array + own
+ styled.
+
+## Acceptance
+
+- **Extend PagePrimitives** with the missing shared shapes:
+ - `` — the surface+border+radius+padding base used by
+ Species / Biome / MapTypes / Expansions. Takes an optional
+ `$variant` prop (`"base"` default, `"compact"`, `"topology"`).
+ - `` + `` — keyed label/value grid used in
+ MapTypes + Species + Biome.
+ - `` — shared dot/range element used by Species
+ + Biome for quality tiers.
+ - `` — extracted from BiomeBrowserPage's FloraBar;
+ theme-token-driven (no hardcoded hex).
+- **Per-page migration**, in order:
+ 1. BiomeBrowserPage (biggest win). Delete duplicates; swap to shared.
+ 2. SpeciesBrowserPage (closest cousin of Biome).
+ 3. MapTypesPage.
+ 4. ExpansionsPage + TeamPage (trivial two-liner migrations).
+ 5. EpisodeDwarvesPage (also touches p2-32 data-drive work).
+- **Metric gate** — from the guide directory,
+ `grep -RE "^const (Card|Badge|CardHeader|CardTitle|Stat|StatsGrid) = styled" src/pages/ | wc -l`
+ drops by ≥60% from the pre-pass baseline. Measure + record the
+ baseline in the closure entry.
+- **No regressions** — `pnpm typecheck` clean after each page
+ migration; `pnpm test:e2e --grep all-routes` stays 51/51 green;
+ MCP / curl screenshots of migrated pages match the pre-migration
+ visuals (no per-page theme drift).
+- **Each page's `styled.ts` shrinks** to only genuinely page-specific
+ variants (e.g. a `` that only the biome page ever
+ uses stays local).
+
+## Non-goals
+
+- Migrating the non-listed pages (KeywordsPage, ItemsPage, etc.) in
+ this objective. They get swept when someone else touches them; the
+ goal here is to establish the shared primitives + migrate the
+ highest-duplication offenders, not a total rewrite.
+- Changing visual design — colors / padding / border-radius of the
+ new shared primitives should match the existing `SpeciesBrowserPage`
+ values as the canonical reference (any visible visual change is a
+ regression).
+- FloraBar color tokens — promoting the hardcoded `#1a9928` /
+ `#8cc634` / `#9040a0` to theme palette entries is a prerequisite
+ the shared `` assumes; the palette additions land
+ in the same commit as the primitive.
+
+## Dependency
+
+- p2-29 (welcome/theme alignment) and p1-16 (scope hygiene) edit
+ HomePage + some of the same pages. Sequence after both of those
+ close or rebase on top cleanly. Prefer after.
diff --git a/.project/objectives/p2-31-guide-url-bound-state.md b/.project/objectives/p2-31-guide-url-bound-state.md
new file mode 100644
index 00000000..4c4410bd
--- /dev/null
+++ b/.project/objectives/p2-31-guide-url-bound-state.md
@@ -0,0 +1,76 @@
+---
+id: p2-31
+title: Migrate guide filter + tab state from useState to URL search params
+priority: p2
+status: missing
+scope: game1
+owner: tourguide
+updated_at: 2026-04-18
+evidence:
+ - public/games/age-of-dwarves/guide/src/pages/SpeciesBrowserPage.tsx
+ - public/games/age-of-dwarves/guide/src/pages/BiomeBrowserPage.tsx
+ - public/games/age-of-dwarves/guide/src/pages/ClimateEventsPage.tsx
+ - public/games/age-of-dwarves/guide/src/pages/progress-report/ObjectivesTab.tsx
+ - public/games/age-of-dwarves/guide/src/pages/ProgressReportPage.tsx
+---
+
+## Summary
+
+The Progress Report's Details tab set a precedent: its filter chip
+state + per-objective modal round-trip through `useSearchParams()`
+(see `progress-report/ObjectivesTab.tsx` + `ProgressReportPage.tsx`).
+Bookmarking `?tab=details&filter=partial&objective=p0-01` restores the
+exact view.
+
+Three other browsable pages still keep their filter / tab state in
+`useState`, so deep links don't work and users can't share a filtered
+view by URL:
+
+- `SpeciesBrowserPage.tsx:57` — role / biome / quality filters
+- `BiomeBrowserPage.tsx:121` — category filter + potentially a
+ highlighted biome
+- `ClimateEventsPage.tsx` — category tabs
+
+## Acceptance
+
+- **Shared pattern** — copy (or better: extract as a helper hook) the
+ `ObjectivesTab.tsx` pattern:
+ - `const [searchParams, setSearchParams] = useSearchParams()`
+ - `const filter = coerceFilter(searchParams.get('filter'))`
+ - `setFilter(next)` updates `searchParams` and writes back
+ (`replace: true` for filter changes — don't pollute history).
+ - A small `coerceFilter` narrowing that whitelists known values
+ and falls back to the default on unknown input.
+- **Per-page migration**:
+ - `SpeciesBrowserPage` — `?role=…&biome=…&quality=…&species=`.
+ Clicking a species opens a modal (or inline detail) bound to the
+ `species` param.
+ - `BiomeBrowserPage` — `?category=…&biome=` with an inline or
+ modal highlight.
+ - `ClimateEventsPage` — `?category=` for the tabs.
+- **Back button** — closing a modal or navigating between tabs
+ registers as a history entry; Back restores the previous view.
+- **Deep-link smoke** — a new e2e spec `shareable-urls.spec.ts` hits
+ five representative URLs (e.g. `/climate/ecosystem/fauna?role=grazer`,
+ `/climate/ecosystem/biomes?category=aquatic&biome=shallow-coast`,
+ `/climate/weather?category=pandemic`) and asserts the filter state
+ is visibly applied in the first render.
+- **No regressions** — route-coverage e2e stays 51/51 green (the
+ new URL shapes don't break the default no-param render).
+
+## Reusable helper
+
+Extract a small `useUrlFilter(key: string, values: readonly T[], fallback: T)` hook in `public/games/age-of-dwarves/guide/src/hooks/useUrlFilter.ts` so each page isn't copy-pasting the same 15 lines. Three concrete call sites qualify: Species, Biome, ClimateEvents.
+
+## Non-goals
+
+- Migrating welcome-modal preference state — that's a long-lived
+ cross-session concern handled by `usePlayerPreferences` + localStorage;
+ URL params are orthogonal.
+- Porting OBJECTIVES tab patterns to ClimateSimulation (its URL state
+ is already custom and tuned for the sim worker — leave it alone).
+
+## Dependency
+
+Cleanest after p2-30 (shared primitives) since some of the same pages
+are touched, but not blocking. Can land independently.
diff --git a/.project/objectives/p2-32-guide-data-driven-enums.md b/.project/objectives/p2-32-guide-data-driven-enums.md
new file mode 100644
index 00000000..16da9d74
--- /dev/null
+++ b/.project/objectives/p2-32-guide-data-driven-enums.md
@@ -0,0 +1,75 @@
+---
+id: p2-32
+title: Replace hardcoded page enums with JSON data reads
+priority: p2
+status: missing
+scope: game1
+owner: tourguide
+updated_at: 2026-04-18
+evidence:
+ - public/games/age-of-dwarves/guide/src/pages/MapTypesPage.tsx
+ - public/games/age-of-dwarves/guide/src/pages/EpisodeDwarvesPage.tsx
+ - public/games/age-of-dwarves/guide/src/pages/HomePage.tsx
+ - public/games/age-of-dwarves/guide/src/pages/progress-report/OverviewTab.tsx
+ - public/games/age-of-dwarves/data/map-topologies.json
+ - public/games/age-of-dwarves/data/episodes/ep1-systems.json
+ - public/games/age-of-dwarves/data/homepage-features.json
+---
+
+## Summary
+
+Rail #2 in CLAUDE.md says "JSON game packs are the canonical content
+store — neither Rust nor GDScript hardcodes game content." Several
+guide pages still violate the spirit of that rule by hand-typing
+data arrays in `.tsx` files. When the design changes ("we decided
+it's 20 races, not 16" — or "Arcana tree got merged into Scholarship"),
+the fix has to hop four files in TypeScript rather than editing one JSON.
+
+Hardcoded arrays the Explore sweep found:
+
+| File:Line | Array | Target JSON |
+|---|---|---|
+| `MapTypesPage.tsx:105–124` | `TOPOLOGY_MODES` — 3 topologies with `{id, label, desc, math, isDefault}` | `public/games/age-of-dwarves/data/map-topologies.json` (new) |
+| `EpisodeDwarvesPage.tsx:8–20` | `INCLUDED_SYSTEMS` — 10 system-name strings | `public/games/age-of-dwarves/data/episodes/ep1-systems.json` (new) |
+| `HomePage.tsx:180–210` (FEATURES) | "What makes this game different" cards | `public/games/age-of-dwarves/data/homepage-features.json` (new) — Episode 1 cards first, Episode 2+ behind an EpisodeGate at render time |
+| `progress-report/OverviewTab.tsx:114–165` | Hand-typed "Coming in v1.0.0" + "After Full Release" roadmap tables | `public/games/age-of-dwarves/data/shipping-roadmap.json` (new) |
+
+## Acceptance
+
+- **New JSON files** — each created at the path above with the exact
+ field shape the current page consumes (no data migration on the
+ reader side beyond swapping `const X = [...]` to
+ `import X from '@data/...'`).
+- **Consistent loader pattern** — single-file reads go through the
+ `@data/` alias that already exists in `vite.config.ts`. No new
+ `import.meta.glob` patterns unless a directory layout is warranted
+ (episode-systems maybe, since there will be ep2+ siblings).
+- **HomePage FEATURES** — each card carries an optional `min_episode`
+ field (default 1). The page filters `feature.min_episode <= activeEpisode`
+ so the Episode 2+ cards (if any) appear in dev bundle only. Source
+ of truth: the JSON, not the gate.
+- **OverviewTab roadmap tables** — same pattern; each row carries a
+ priority + labels, rendered from the JSON.
+- **No regressions** — `pnpm typecheck` clean, e2e 51/51 green,
+ visual parity with the pre-pass rendering.
+- **JSON schema validation** — the game-data schema validator
+ (`tools/validate-game-data.py`) gains entries for the new files so
+ `./run verify` step 0 catches schema drift.
+
+## Non-goals
+
+- Converting other hand-typed arrays that were not in the Explore
+ sweep (e.g. `promotions-data` is already partly JSON-sourced; other
+ pages may have inline arrays of <4 items that don't warrant
+ migration).
+- Adding a general-purpose `useGameData(path)` abstraction —
+ overkill for 4 new reads. Each page imports its JSON directly via
+ `@data/` alias.
+- Game 2+ episode-systems files — those land when the Kzzykt guide
+ shell does.
+
+## Dependency
+
+Best after p1-16 (scope hygiene) so the homepage-features JSON can
+be authored knowing which cards should ship Game 1-only vs gated.
+Independent of p2-30 and p2-31.
diff --git a/.project/team-leads/tourguide.md b/.project/team-leads/tourguide.md
index e8770abd..38ef8c89 100644
--- a/.project/team-leads/tourguide.md
+++ b/.project/team-leads/tourguide.md
@@ -7,10 +7,14 @@ objectives:
- p1-12
- p1-13
- p1-15
+ - p1-16
- p1-17
- p2-20
- p2-21
- p2-29
+ - p2-30
+ - p2-31
+ - p2-32
---
## Mandate
diff --git a/public/games/age-of-dwarves/data/objectives.json b/public/games/age-of-dwarves/data/objectives.json
index 7998485d..418ebbb3 100644
--- a/public/games/age-of-dwarves/data/objectives.json
+++ b/public/games/age-of-dwarves/data/objectives.json
@@ -1,12 +1,12 @@
{
- "generated_at": "2026-04-18T07:05:04Z",
+ "generated_at": "2026-04-18T07:13:06Z",
"totals": {
- "stub": 5,
- "done": 49,
- "oos": 18,
+ "missing": 13,
"partial": 14,
- "missing": 9,
- "total": 95
+ "done": 49,
+ "stub": 6,
+ "oos": 18,
+ "total": 100
},
"objectives": [
{
@@ -519,6 +519,16 @@
"updated_at": "2026-04-17",
"summary": ""
},
+ {
+ "id": "p1-16",
+ "title": "Purge Game 2/3 scope bleed from user-visible Game 1 guide copy",
+ "priority": "p1",
+ "status": "missing",
+ "scope": "game1",
+ "owner": "tourguide",
+ "updated_at": "2026-04-18",
+ "summary": "CLAUDE.md's Game 1 scope rule is clear: Age of Dwarves Early Access\nships with **no magic** — no magic schools, no Archons, no leylines,\nno mana. The `` component + `VITE_DEV_GUIDE=1`\ndev-bundle flag (p1-15) are the enforcement mechanism. But a 2026-04-18\nExplore sweep catalogued six user-visible surfaces in the default Game 1\nbuild that still advertise or document Game 2/3 cosmology:\n\n| File:Line | Failure |\n|---|---|\n| `HomePage.tsx:189–190` (FEATURES) | \"5 Magic Schools\" card with \"cross-school fusions, 10 hybrid disciplines\" |\n| `HomePage.tsx:246–251` (Pitch) | \"16 asymmetric races, 5 magic schools … pursue arcane power\" |\n| `HomePage.tsx:256–275` (LoreSection) | Two paragraphs of mana-nodes + ley-lines + school-aligned energy |\n| `CommunicationsPage.tsx:97–98` | \"Archon Telepathy (Magic civs)\" row in mundane radio-tower rules |\n| `PromotionsPage.tsx:5,12–15,156–193` | Magic-data imports + \"Mana Infusions\" section + \"Dispellable by Aether\" / \"High Archon dies\" body text |\n| `SurvivalGuidePage` data.ts:85 | \"Life T3 quarantine spell blocks adjacency transmission\" in mundane survival scenario |\n\nThese are **RED** — visible to someone opening the staging / production\nbuild without `VITE_DEV_GUIDE=1`. A player launching Early Access with\none dwarf race should not be told the game has \"5 magic schools\" or\nread Archon mechanics in the communications tower rules.\n\nYELLOW items (dev-bundle-only, flagged for opportunistic cleanup):\n\n- `progress-report/OverviewTab.tsx:137,183` — hardcoded \"5 trees,\n 30 policies\" + \"12 additional races\" roadmap rows\n- `GovernmentPage.tsx:48` (SKIP_MODS) — defensive filter for the\n Game-2 `no_spell_pact_opposing_school` modifier"
+ },
{
"id": "p1-17",
"title": "Forgejo workflow auto-deploys dev guide on push to main",
@@ -549,6 +559,16 @@
"updated_at": "2026-04-17",
"summary": "The first-run tutorial currently auto-shows on game start (gated by\n`TutorialOverlay.should_show_on_first_run()`). This is hostile to returning\nplayers and to playtesting — the tutorial interrupts real gameplay every fresh\nboot. Assume the player doesn't need a tutorial by default. Offer it as a\nbutton on the world-map HUD that disappears after turn 5.\n\nAdditionally, when the tutorial IS started, it begins at **Step 1** (camera\npan), not Step 2. This is already the authoritative step order in\n`tutorial_overlay.gd:_STEPS` — the fix is only to make sure `_current_step`\ninitializes to `1` (it does) and that no code skips ahead."
},
+ {
+ "id": "p1-20",
+ "title": "Unit patrol orders — assign a unit to loop between waypoint tiles",
+ "priority": "p1",
+ "status": "stub",
+ "scope": "game1",
+ "owner": "wireguard",
+ "updated_at": "2026-04-18",
+ "summary": "Both the human player and the AI clans need a *standing order* that keeps a\nunit moving along a fixed route turn after turn without per-turn micro-management.\nCanonical use cases: escorting a worker loop, covering a chokepoint, sweeping\nscout fog between two outposts.\n\nToday a unit has two durable states: idle-on-tile, or fortified (via\n`unit.gd:250`). `Skip` ends the turn but does not persist. A player who wants\na scout to pace between two tiles must hand-move it every single turn — which\nbreaks down entirely once the empire has more than a few units, and which the\nAI cannot express at all because `mc-ai/tactical/movement.rs` re-plans from\nscratch each turn.\n\nThis objective adds a third durable state — **patrol** — with a small\nwaypoint list and a direction cursor. While patrolling, the unit auto-advances\nalong its route during the turn processor before the player's input phase, so\nturn N+1 opens with the unit already at the next step on its loop."
+ },
{
"id": "p2-01",
"title": "Minimap — fog reflection and unit markers",
@@ -789,6 +809,36 @@
"updated_at": "2026-04-17",
"summary": "The guide already exposes a welcome modal that lets the player pick a race\n(Dwarf in Game 1 — `CONCRETE_RACES = ['dwarf']`) and a gender, plus a\n`RaceThemeProvider` that merges a per-race/per-gender palette into the\nstyled-components theme. But the three surfaces don't line up:\n\n- **WelcomeModal copy** reads like a settings dialog (\"Settings\", field\n labels) rather than an invitation into the story, so the player's first\n impression is admin-UI-shaped.\n- **HomePage ``** *does* pull the player's race + name via\n `usePreferences()`, but the surrounding `` / `` / ``\n hardcode out-of-scope narrative (\"16 asymmetric races, 5 magic schools\")\n that is Game 2/3 territory and does NOT update when the player picks a\n dwarf leader.\n- **Theme application** — the palette change from `RaceThemeProvider` fires\n on confirm, but a browser already on the HomePage may not re-derive the\n Hero/Pitch colors in a visually coherent way (Cinzel serif, Dwarf copper\n `#c07040`, etc.). The \"align with welcome\" contract is not exercised.\n\nWhen the player picks **Dwarf + Female** in the modal and clicks Begin,\nall three surfaces should read as one piece: a dwarf-themed guide,\nreferring to the named dwarf leader, in Dwarf scope language (no \"5\nmagic schools\" pitch, no generic cross-race framing)."
},
+ {
+ "id": "p2-30",
+ "title": "Consolidate duplicate page styled-components into shared PagePrimitives",
+ "priority": "p2",
+ "status": "missing",
+ "scope": "game1",
+ "owner": "tourguide",
+ "updated_at": "2026-04-18",
+ "summary": "An Explore sweep on 2026-04-18 counted ~180 redundant styled-component\ndeclarations across 15 guide pages. Each page declares its own\n`Card`, `CardHeader`, `CardTitle`, `StatsGrid`, `Stat`, `Badge`,\n`SectionLabel`, `Subtitle`, etc. that already exist (or could exist) in\nthe shared `src/packages/guide/src/components/ui/PagePrimitives.tsx`.\nThe duplication:\n\n- Makes theme-token changes N-way rather than one-way (change the Dwarf\n copper accent → audit 15 pages).\n- Hides inconsistencies. `SpeciesBrowserPage`'s `Card` has 12 px\n border-radius; `BiomeBrowserPage`'s has 10 px. Nobody notices until\n screenshots diverge.\n- Inflates each page's file size toward the 500-LOC cap, forcing\n awkward splits (`BiomeBrowserPage.tsx` is already 355 LoC of styled\n alone, ignoring JSX).\n- Forecloses easy swap-to-Markdown + swap-to-data adoption because\n each page has its own local idiom.\n\nHighest-ROI candidates (counted by Explore):\n\n- `BiomeBrowserPage.tsx:191–355` — 23 custom styled, largest footprint.\n- `SpeciesBrowserPage.tsx:236–330` — 16 custom styled.\n- `MapTypesPage.tsx:9–103` — Name / StatsGrid / Stat / SectionLabel /\n TopologyCard / TopologyBadge / TopologyDesc restate PagePrimitives.\n- `ExpansionsPage.tsx:10–132`, `TeamPage.tsx:6–30` — near-total overlap.\n- `EpisodeDwarvesPage.tsx` — inline `INCLUDED_SYSTEMS` array + own\n styled."
+ },
+ {
+ "id": "p2-31",
+ "title": "Migrate guide filter + tab state from useState to URL search params",
+ "priority": "p2",
+ "status": "missing",
+ "scope": "game1",
+ "owner": "tourguide",
+ "updated_at": "2026-04-18",
+ "summary": "The Progress Report's Details tab set a precedent: its filter chip\nstate + per-objective modal round-trip through `useSearchParams()`\n(see `progress-report/ObjectivesTab.tsx` + `ProgressReportPage.tsx`).\nBookmarking `?tab=details&filter=partial&objective=p0-01` restores the\nexact view.\n\nThree other browsable pages still keep their filter / tab state in\n`useState`, so deep links don't work and users can't share a filtered\nview by URL:\n\n- `SpeciesBrowserPage.tsx:57` — role / biome / quality filters\n- `BiomeBrowserPage.tsx:121` — category filter + potentially a\n highlighted biome\n- `ClimateEventsPage.tsx` — category tabs"
+ },
+ {
+ "id": "p2-32",
+ "title": "Replace hardcoded page enums with JSON data reads",
+ "priority": "p2",
+ "status": "missing",
+ "scope": "game1",
+ "owner": "tourguide",
+ "updated_at": "2026-04-18",
+ "summary": "Rail #2 in CLAUDE.md says \"JSON game packs are the canonical content\nstore — neither Rust nor GDScript hardcodes game content.\" Several\nguide pages still violate the spirit of that rule by hand-typing\ndata arrays in `.tsx` files. When the design changes (\"we decided\nit's 20 races, not 16\" — or \"Arcana tree got merged into Scholarship\"),\nthe fix has to hop four files in TypeScript rather than editing one JSON.\n\nHardcoded arrays the Explore sweep found:\n\n| File:Line | Array | Target JSON |\n|---|---|---|\n| `MapTypesPage.tsx:105–124` | `TOPOLOGY_MODES` — 3 topologies with `{id, label, desc, math, isDefault}` | `public/games/age-of-dwarves/data/map-topologies.json` (new) |\n| `EpisodeDwarvesPage.tsx:8–20` | `INCLUDED_SYSTEMS` — 10 system-name strings | `public/games/age-of-dwarves/data/episodes/ep1-systems.json` (new) |\n| `HomePage.tsx:180–210` (FEATURES) | \"What makes this game different\" cards | `public/games/age-of-dwarves/data/homepage-features.json` (new) — Episode 1 cards first, Episode 2+ behind an EpisodeGate at render time |\n| `progress-report/OverviewTab.tsx:114–165` | Hand-typed \"Coming in v1.0.0\" + \"After Full Release\" roadmap tables | `public/games/age-of-dwarves/data/shipping-roadmap.json` (new) |"
+ },
{
"id": "g2-01",
"title": "Ley lines — Game 2 (Age of Kzzykt)",
diff --git a/src/game/engine/src/modules/ai/ai_turn_bridge.gd b/src/game/engine/src/modules/ai/ai_turn_bridge.gd
index 25d23b27..21307181 100644
--- a/src/game/engine/src/modules/ai/ai_turn_bridge.gd
+++ b/src/game/engine/src/modules/ai/ai_turn_bridge.gd
@@ -261,6 +261,12 @@ static func _player_to_dict(p: RefCounted) -> Dictionary:
"hp_max": maxi(1, int(u.max_hp)),
"moves_left": maxi(0, int(u.movement_remaining)),
"fortified": bool(u.is_fortified),
+ # Data-driven founder flag — clan-themed units like
+ # "dwarf_tribe" aren't recognized by string match alone,
+ # so we pass the engine's already-computed boolean
+ # through to the Rust port. Matches the settle.rs
+ # `is_settler()` fallback.
+ "can_found_city": bool(u.get("can_found_city") == true),
})
ui += 1
var cities: Array = []
diff --git a/src/simulator/crates/mc-ai/src/tactical/settle.rs b/src/simulator/crates/mc-ai/src/tactical/settle.rs
index cd2da8e2..ef51875d 100644
--- a/src/simulator/crates/mc-ai/src/tactical/settle.rs
+++ b/src/simulator/crates/mc-ai/src/tactical/settle.rs
@@ -104,11 +104,13 @@ pub(crate) fn decide_settle(
actions
}
-/// A settler is any unit whose `kind` encodes a founder role. Age-of-Dwarves
-/// uses `"settler"`; `"founder"` is accepted as a legacy alias so the port
-/// doesn't depend on a particular data-pack version shipping the rename.
+/// A settler is any unit flagged `can_found_city` by the engine's data pack.
+/// Prefer the data-driven flag over string matching on `kind` — clan-themed
+/// founders like `"dwarf_tribe"` wouldn't match a hardcoded id set.
+/// Falls back to string match on legacy fixtures (where `can_found_city` is
+/// default-false) so existing tests continue to pass.
fn is_settler(unit: &TacticalUnit) -> bool {
- matches!(unit.kind.as_str(), "settler" | "founder")
+ unit.can_found_city || matches!(unit.kind.as_str(), "settler" | "founder")
}
/// Return the action for a single settler, or `None` if the settler cannot
diff --git a/src/simulator/crates/mc-ai/src/tactical/state.rs b/src/simulator/crates/mc-ai/src/tactical/state.rs
index 1403740c..62c40d82 100644
--- a/src/simulator/crates/mc-ai/src/tactical/state.rs
+++ b/src/simulator/crates/mc-ai/src/tactical/state.rs
@@ -105,6 +105,13 @@ pub struct TacticalUnit {
pub moves_left: u32,
/// Fortify-in-place flag.
pub fortified: bool,
+ /// True when this unit can found a city — data-driven from the engine's
+ /// `unit.can_found_city` flag. NEVER match on `kind` string alone because
+ /// clan-themed founder units (e.g. `"dwarf_tribe"`) DO NOT literally spell
+ /// "settler" or "founder". Default `false` for serde back-compat with
+ /// fixtures that predate this field.
+ #[serde(default)]
+ pub can_found_city: bool,
}
/// A city.
|