diff --git a/.project/objectives/DASHBOARD_CATEGORIES.md b/.project/objectives/DASHBOARD_CATEGORIES.md
index adaf63b2..c874b828 100644
--- a/.project/objectives/DASHBOARD_CATEGORIES.md
+++ b/.project/objectives/DASHBOARD_CATEGORIES.md
@@ -205,6 +205,7 @@
| [p1-42](p1-42-ai-full-building-catalog.md) | š” partial | P1 | AI must consider the full 155-building catalog, not the hardcoded 8-id ladder | ā | š¢ |
| [p1-43](p1-43-building-stacking-upgrade.md) | š” partial | P1 | Building stacking ā per-category upgrade chains (military / science / culture / production / etc.) | ā | š¢ |
| [p1-43b](p1-43b-deep-chain-authoring.md) | ā
done | P1 | Deep chain authoring ā fill T6/T7/T8/T9/T10 building tiers across the 5 short chains | ā | š¢ |
+| [p1-43c](p1-43c-chain-ladders-and-ui.md) | š“ stub | P1 | p1-43 follow-ups ā chain ladder authoring, AI stack scoring, city UI upgrade surface, GUT bridge test | ā | š¢ |
| [p1-44](p1-44-buildings-as-producers.md) | š” partial | P1 | Buildings produce units, not the city center ā per-building production queues | ā | š¢ |
| [p1-44c](p1-44c-buildings-as-producers-followups.md) | š” partial | P1 | p1-44 follow-ups ā UI, AI per-building emission, themed roster, GUT, batch | ā | š¢ |
| [p1-46](p1-46-design-lab-terrain-dimensions.md) | ā
done | P1 | Terrain Dimensions Lab ā fix ridginess, bind 149 flora species, add Whittaker plot | [terraformer](../team-leads/terraformer.md) | š¢ |
@@ -309,10 +310,10 @@
| [p2-57](p2-57-production-chain-typed-resources.md) | š“ stub | P2 | Production-chain typed resources ā raw ā processed pipelines wired into mc-city | [unassigned](../team-leads/unassigned.md) | š¢ |
| [p2-57a](p2-57a-typed-resource-stockpile.md) | š” partial | P2 | Typed resource stockpile ā raw vs processed taxonomy | [unassigned](../team-leads/unassigned.md) | š¢ |
| [p2-57b](p2-57b-consume-produce-edges.md) | š“ stub | P2 | Building consume/produce edges ā stockpile coupled to unit quality | [unassigned](../team-leads/unassigned.md) | š p2-57a |
-| [p2-58](p2-58-ambient-encounter-rolls.md) | š” partial | P2 | Ambient encounter rolls per tile moved ā fauna_density Ć ecology_tier | [unassigned](../team-leads/unassigned.md) | š¢ |
+| [p2-58](p2-58-ambient-encounter-rolls.md) | ā
done | P2 | Ambient encounter rolls per tile moved ā fauna_density Ć ecology_tier | [unassigned](../team-leads/unassigned.md) | š¢ |
| [p2-58a](p2-58a-tilestate-fauna-fields.md) | ā
done | P2 | TileState fauna fields ā fauna_density + fauna_index for AmbientTileCtx | [game-systems](../team-leads/game-systems.md) | š¢ |
-| [p2-58b](p2-58b-ambient-encounter-hook.md) | š” partial | P2 | Ambient encounter hook ā mc-turn::movement calls roll_ambient_encounter per tile step | [unassigned](../team-leads/unassigned.md) | š¢ |
-| [p2-59](p2-59-pioneer-escort-mechanic.md) | š“ stub | P2 | Pioneer escort mechanic ā protection rules vs ambient encounters | [unassigned](../team-leads/unassigned.md) | š p2-58 |
+| [p2-58b](p2-58b-ambient-encounter-hook.md) | ā
done | P2 | Ambient encounter hook ā mc-turn::movement calls roll_ambient_encounter per tile step | [unassigned](../team-leads/unassigned.md) | š¢ |
+| [p2-59](p2-59-pioneer-escort-mechanic.md) | š“ stub | P2 | Pioneer escort mechanic ā protection rules vs ambient encounters | [unassigned](../team-leads/unassigned.md) | š¢ |
| [p2-60](p2-60-weather-lens-godot-ui.md) | š“ stub | P2 | Weather / observation lens switcher in the Godot HUD | [unassigned](../team-leads/unassigned.md) | š¢ |
| [p2-61](p2-61-observation-recording-gates-from-tech.md) | š“ stub | P2 | Bind mc-observation gate_bits to player tech state ā recording gates per-field | [unassigned](../team-leads/unassigned.md) | š¢ |
| [p2-62](p2-62-procedural-unit-and-building-renderer.md) | ā
done | P2 | Procedural unit/building renderer ā alpha-only visual substitute | [asset-sprite](../team-leads/asset-sprite.md) | š¢ |
diff --git a/.project/objectives/DASHBOARD_COMPLETED.md b/.project/objectives/DASHBOARD_COMPLETED.md
index 484bb951..9890bb71 100644
--- a/.project/objectives/DASHBOARD_COMPLETED.md
+++ b/.project/objectives/DASHBOARD_COMPLETED.md
@@ -173,7 +173,9 @@
| [p2-54d](p2-54d-ai-tech-priority-from-visibility.md) | AI tech-priority bias from visible-but-gated luxuries + indicator decorations | ā | [terraformer](../team-leads/terraformer.md) | 2026-05-01 |
| [p2-56a](p2-56a-worker-category-types.md) | Worker category types ā Sustenance / Construction / Wealth taxonomy | ā | [unassigned](../team-leads/unassigned.md) | 2026-05-04 |
| [p2-56b](p2-56b-expertise-tier-progression.md) | Expertise tier progression ā 5-tier specialist XP ladder | ā | [simulator-infra](../team-leads/simulator-infra.md) | 2026-05-04 |
+| [p2-58](p2-58-ambient-encounter-rolls.md) | Ambient encounter rolls per tile moved ā fauna_density Ć ecology_tier | ā | [unassigned](../team-leads/unassigned.md) | 2026-05-07 |
| [p2-58a](p2-58a-tilestate-fauna-fields.md) | TileState fauna fields ā fauna_density + fauna_index for AmbientTileCtx | ā | [game-systems](../team-leads/game-systems.md) | 2026-05-07 |
+| [p2-58b](p2-58b-ambient-encounter-hook.md) | Ambient encounter hook ā mc-turn::movement calls roll_ambient_encounter per tile step | ā | [unassigned](../team-leads/unassigned.md) | 2026-05-07 |
| [p2-62](p2-62-procedural-unit-and-building-renderer.md) | Procedural unit/building renderer ā alpha-only visual substitute | ā | [asset-sprite](../team-leads/asset-sprite.md) | 2026-05-04 |
## P3
diff --git a/.project/objectives/README.md b/.project/objectives/README.md
index 22ac14a1..afe077e2 100644
--- a/.project/objectives/README.md
+++ b/.project/objectives/README.md
@@ -15,10 +15,10 @@
| Priority | šµ | š” | š“ | ā | ā« | ā
| Total |
|---|---|---|---|---|---|---|---|
| **P0** | 0 | 0 | 0 | 0 | 0 | 44 | 44 |
-| **P1** | 1 | 14 | 1 | 5 | 1 | 52 | 74 |
-| **P2** | 0 | 11 | 11 | 0 | 6 | 63 | 91 |
+| **P1** | 1 | 14 | 2 | 5 | 1 | 52 | 75 |
+| **P2** | 0 | 9 | 11 | 0 | 6 | 65 | 91 |
| **P3 (oos)** | 0 | 9 | 8 | 0 | 21 | 5 | 43 |
-| **total** | **1** | **34** | **20** | **5** | **28** | **164** | **252** |
+| **total** | **1** | **32** | **21** | **5** | **28** | **166** | **253** |
@@ -26,7 +26,7 @@
| Team Lead | Remaining |
|---|---|
-| [unassigned](../team-leads/unassigned.md) | 26 |
+| [unassigned](../team-leads/unassigned.md) | 24 |
| [asset-sprite](../team-leads/asset-sprite.md) | 6 |
| [shipwright](../team-leads/shipwright.md) | 5 |
| [simulator-infra](../team-leads/simulator-infra.md) | 4 |
@@ -58,13 +58,14 @@
| [p1-29a](p1-29a-last-stand-defense.md) | š” partial | Last-stand defense ā combat-strength multiplier when defender is at last city | balance, combat, pacing | [combat-dev](../team-leads/combat-dev.md) | 2026-05-04 | š¢ unblocked |
| [p1-38](p1-38-biome-economy-coupling.md) | š” partial | Biome ā economy coupling ā population & luxury driven by live ecology | ā | [shipwright](../team-leads/shipwright.md) | 2026-05-04 | š¢ unblocked |
| [p1-42](p1-42-ai-full-building-catalog.md) | š” partial | AI must consider the full 155-building catalog, not the hardcoded 8-id ladder | ā | ā | 2026-05-04 | š¢ unblocked |
-| [p1-43](p1-43-building-stacking-upgrade.md) | š” partial | Building stacking ā per-category upgrade chains (military / science / culture / production / etc.) | ā | ā | 2026-05-05 | š¢ unblocked |
+| [p1-43](p1-43-building-stacking-upgrade.md) | š” partial | Building stacking ā per-category upgrade chains (military / science / culture / production / etc.) | ā | ā | 2026-05-07 | š¢ unblocked |
| [p1-44](p1-44-buildings-as-producers.md) | š” partial | Buildings produce units, not the city center ā per-building production queues | ā | ā | 2026-05-05 | š¢ unblocked |
| [p1-44c](p1-44c-buildings-as-producers-followups.md) | š” partial | p1-44 follow-ups ā UI, AI per-building emission, themed roster, GUT, batch | ā | ā | 2026-05-07 | š¢ unblocked |
| [p1-55](p1-55-tech-culture-domain-propagation.md) | š” partial | Tech & Culture domain field ā propagate categorization through Rust, Godot UI, and player analysis | ā | [simulator-infra](../team-leads/simulator-infra.md) | 2026-05-04 | š¢ unblocked |
| [p1-56](p1-56-civics-buildings-and-great-works.md) | š” partial | Civics buildings, Great Works, Specialists, Great People ā wire authored data into Rust + Godot | ā | [simulator-infra](../team-leads/simulator-infra.md) | 2026-05-04 | š¢ unblocked |
| [p1-58](p1-58-ecology-cognitive-system.md) | š” partial | Ecology cognition: terrain affinity, food web, grudge memory, apex tier-10 fauna/flora | ā | [simulator-infra](../team-leads/simulator-infra.md) | 2026-05-04 | š¢ unblocked |
| [p2-22](p2-22-sprite-generation-pipeline.md) | š” partial | Sprite generation pipeline ā runnable end-to-end | ā | [asset-sprite](../team-leads/asset-sprite.md) | 2026-04-25 | š¢ unblocked |
+| [p1-43c](p1-43c-chain-ladders-and-ui.md) | š“ stub | p1-43 follow-ups ā chain ladder authoring, AI stack scoring, city UI upgrade surface, GUT bridge test | ā | ā | 2026-05-07 | š¢ unblocked |
| [p1-57](p1-57-diplomacy-tribute-treaties.md) | š“ stub | Diplomacy: tribute, treaty lifecycle, magical-terrain episode gating | ā | [unassigned](../team-leads/unassigned.md) | 2026-05-03 | š¢ unblocked |
| [p2-23](p2-23-unit-sprites-dwarf-roster.md) | ā missing | Unit sprites ā Dwarf-racial roster (m/f variants) | ā | [asset-sprite](../team-leads/asset-sprite.md) | 2026-04-17 | š¢ unblocked |
| [p2-24](p2-24-unit-sprites-wild-creatures.md) | ā missing | Unit sprites ā wild creatures & fauna (generic, no race/sex) | ā | [asset-sprite](../team-leads/asset-sprite.md) | 2026-04-17 | š¢ unblocked |
@@ -84,8 +85,6 @@
| [p2-55](p2-55-civilian-capture-system.md) | š” partial | Civilian Capture / Destroy / Ransom | ā | ā | 2026-05-03 | š¢ unblocked |
| [p2-56c](p2-56c-master-grandmaster-auras.md) | š” partial | Master / Grandmaster auras ā adjacent-slot yield propagation | ā | [unassigned](../team-leads/unassigned.md) | 2026-05-04 | š¢ unblocked |
| [p2-57a](p2-57a-typed-resource-stockpile.md) | š” partial | Typed resource stockpile ā raw vs processed taxonomy | ā | [unassigned](../team-leads/unassigned.md) | 2026-05-04 | š¢ unblocked |
-| [p2-58](p2-58-ambient-encounter-rolls.md) | š” partial | Ambient encounter rolls per tile moved ā fauna_density Ć ecology_tier | ā | [unassigned](../team-leads/unassigned.md) | 2026-05-07 | š¢ unblocked |
-| [p2-58b](p2-58b-ambient-encounter-hook.md) | š” partial | Ambient encounter hook ā mc-turn::movement calls roll_ambient_encounter per tile step | ā | [unassigned](../team-leads/unassigned.md) | 2026-05-07 | š¢ unblocked |
| [p2-64](p2-64-apricot-async-batch-protocol.md) | š” partial | Apricot async batch protocol ā launch / status / fetch decoupling | ā | [simulator-infra](../team-leads/simulator-infra.md) | 2026-05-05 | š¢ unblocked |
| [p2-10k](p2-10k-gdlint-cleanup.md) | š“ stub | CI: fix 51 gdlint violations so Stage 3 is hard-green | ā | [testwright](../team-leads/testwright.md) | 2026-05-04 | š¢ unblocked |
| [p2-10l](p2-10l-gut-regression-triage.md) | š“ stub | CI: fix 15 GUT regressions so Stage 5 is hard-green | ā | [testwright](../team-leads/testwright.md) | 2026-05-04 | š¢ unblocked |
@@ -93,11 +92,11 @@
| [p2-55e](p2-55e-richer-ransom-events.md) | š“ stub | UnitRansomAccepted / UnitRansomExpired events on TurnResult | ā | ā | 2026-05-03 | š¢ unblocked |
| [p2-56](p2-56-worker-categories-and-expertise-tiers.md) | š“ stub | Worker categories (Sustenance/Construction/Wealth) + 5-tier expertise + Master/Grandmaster auras + idle decay | ā | [unassigned](../team-leads/unassigned.md) | 2026-05-03 | š¢ unblocked |
| [p2-57](p2-57-production-chain-typed-resources.md) | š“ stub | Production-chain typed resources ā raw ā processed pipelines wired into mc-city | ā | [unassigned](../team-leads/unassigned.md) | 2026-05-03 | š¢ unblocked |
+| [p2-59](p2-59-pioneer-escort-mechanic.md) | š“ stub | Pioneer escort mechanic ā protection rules vs ambient encounters | ā | [unassigned](../team-leads/unassigned.md) | 2026-05-03 | š¢ unblocked |
| [p2-60](p2-60-weather-lens-godot-ui.md) | š“ stub | Weather / observation lens switcher in the Godot HUD | ā | [unassigned](../team-leads/unassigned.md) | 2026-05-03 | š¢ unblocked |
| [p2-61](p2-61-observation-recording-gates-from-tech.md) | š“ stub | Bind mc-observation gate_bits to player tech state ā recording gates per-field | ā | [unassigned](../team-leads/unassigned.md) | 2026-05-03 | š¢ unblocked |
| [p2-63](p2-63-mc-flora-biome-substrate-migration.md) | š“ stub | mc-flora generation: migrate biome filter to substrate_climate-aware path | ā | [unassigned](../team-leads/unassigned.md) | 2026-05-04 | š¢ unblocked |
| [p2-57b](p2-57b-consume-produce-edges.md) | š“ stub | Building consume/produce edges ā stockpile coupled to unit quality | ā | [unassigned](../team-leads/unassigned.md) | 2026-05-03 | š p2-57a |
-| [p2-59](p2-59-pioneer-escort-mechanic.md) | š“ stub | Pioneer escort mechanic ā protection rules vs ambient encounters | ā | [unassigned](../team-leads/unassigned.md) | 2026-05-03 | š p2-58 |
## Out of Scope
diff --git a/.project/objectives/objectives.json b/.project/objectives/objectives.json
index 8bde6b80..d9fd2755 100644
--- a/.project/objectives/objectives.json
+++ b/.project/objectives/objectives.json
@@ -1,13 +1,13 @@
{
- "generated_at": "2026-05-07T06:11:33Z",
+ "generated_at": "2026-05-07T06:35:10Z",
"totals": {
- "done": 164,
+ "done": 166,
"in_progress": 1,
- "partial": 34,
- "stub": 20,
+ "partial": 32,
+ "stub": 21,
"missing": 5,
"oos": 28,
- "total": 252
+ "total": 253
},
"objectives": [
{
@@ -979,7 +979,7 @@
"priority": "p1",
"status": "partial",
"scope": "game1",
- "updated_at": "2026-05-05",
+ "updated_at": "2026-05-07",
"blocked_by": [],
"summary": "User direction (2026-04-29): \"all the buildings should be buildable and some buildings can be built on top of each other (double barracks - infantry) ... what about comboing other buildings ... science stack, culture stack\".\n\nToday every building is binary: a city either has it or doesn't. The mechanic the user wants: queueing a building on top of an existing one upgrades the slot in place ā `barracks` + another `barracks` build = `infantry` (a stronger military producer). The same primitive applies to every category: science stacks (library ā scriptorium ā academy), culture stacks (monument ā bardic_circle ā great_hall), production stacks (forge ā iron_forge ā grand_forge), etc. This is distinct from the BUILDINGS.md \"Hybrid Merged Structures\" mechanic (which combines TWO different buildings + Synthesis tech into a hybrid). Stacking is the simpler primitive: same-category Lv1 ā Lv2 ā Lv3 chains within one slot.\n\nThe existing data already implies category-tier chains via the `tier` + `category` fields:\n\n| Category | Lv1 (no tech) | Lv2 (mid tech) | Lv3+ (late tech) |\n|---|---|---|---|\n| Production | `forge` t1 | (gap ā `iron_forge` doesn't exist) | `dwarf_deep_forge` t3, `tempering_forge` t6, `steam_forge` t7, `adamantine_foundry` t10 |\n| Science | `library` t1 | `university` t3, `observatory` t3 | `academy_of_sciences` t5, `climate_institute` t9 |\n| Culture | `monument` t1 | `great_hall` t3, `gathering_hall` t2 | `ancestor_hall` t10 |\n| Military | `barracks` t1 | (gap ā `infantry` doesn't exist) | `armory` t3, `military_academy` t6, `command_citadel` t10 |\n| Food | `granary` t1 | `mill` t2, `brewery` t2, `watermill` t2 | `great_granary` t2 (wonder) |\n| Defense | `walls` t1 | `watchtower` t1 | `castle` t3 |\n| Wealth | `marketplace` t2, `market` t2 (DUPLICATE) | `guild_hall` t4 | (none) |\n| Religion | `temple` t2 | `temple_of_the_ancestor` t5 (wonder) | (none) |\n\nThe stacking schema makes these chains explicit and queryable. Where a Lv2 successor doesn't exist yet (e.g. `infantry`, `iron_forge`, `scriptorium`), this objective authors the missing intermediates.\n\nThree design questions need user sign-off before authoring:\n\n1. **Successor identity**: is `infantry` a NEW building (needs authoring) or an existing one (e.g. reuse `armory` as the \"barracks Lv2\" slot)?\n2. **Mechanic shape**:\n - **(a) Replacement**: building barracks twice consumes both, slot becomes `infantry`. Original gone.\n - **(b) Levelled**: building stays \"barracks\" but carries a `level: 2` field with stacked effects.\n - **(c) Per-tile**: two barracks on same tile merge (only relevant if `placement_tile_required: true`).\n3. **Schema**: declare on the lower tier (`barracks.json::stacks_into: \"infantry\"`) or on the upper (`infantry.json::requires_existing: \"barracks\"` + `consumes_existing: true`)? The latter keeps the relationship bidirectional readable.\n\nRecommendation: option **(a) Replacement** with declaration on the upper tier (`requires_existing` + `consumes_existing`). Matches civ-style upgrade slots, reads naturally in the city UI (\"Upgrade Barracks ā Infantry\"), avoids per-tile placement complexity for a v1."
},
@@ -995,6 +995,16 @@
],
"summary": "`p1-43a` (closed inline in `p1-43-building-stacking-upgrade.md` on 2026-05-05)\nshipped the engine + schema layer and **5 representative new high-tier\nbuildings** as a chain-extension proof:\n\n| ID | Tier | Chain |\n|---|---|---|\n| `hydroponic_farm` | 5 | food |\n| `bazaar` | 5 | wealth |\n| `grand_chronicle` | 7 | culture |\n| `gravity_press` | 9 | production |\n| `apothecarium` | 5 | medical |\n\nThis objective (`p1-43b`) covers the **remaining ~35 high-tier buildings**\nneeded to extend each short chain to ~8 tiers, per Q1's locked design."
},
+ {
+ "id": "p1-43c",
+ "title": "p1-43 follow-ups ā chain ladder authoring, AI stack scoring, city UI upgrade surface, GUT bridge test",
+ "priority": "p1",
+ "status": "stub",
+ "scope": "game1",
+ "updated_at": "2026-05-07",
+ "blocked_by": [],
+ "summary": "Remaining bullets from p1-43 that require either bulk data authoring (~21 producer\nbuilding `produces:` field fills across the 14 military + 7 civilian chains) or\ndepend on p1-42 (AI stack scoring) or need GDScript bridge wiring (UI upgrade\nsurface + GUT bridge test).\n\nAs of cycle 39: 209 building JSON files exist (206 known IDs in the validator).\n92 buildings carry a `produces:` field. The remaining ~117 producer buildings in\nthe 14 military + 7 civilian chains need their `produces:` arrays populated.\nEngine, schema, and Rust tests are fully done (see p1-43 evidence). Only data\nauthoring, AI scoring, UI surface, and GUT bridge test remain."
+ },
{
"id": "p1-44",
"title": "Buildings produce units, not the city center ā per-building production queues",
@@ -2197,7 +2207,7 @@
"id": "p2-58",
"title": "Ambient encounter rolls per tile moved ā fauna_density Ć ecology_tier",
"priority": "p2",
- "status": "partial",
+ "status": "done",
"scope": "game1",
"owner": "unassigned",
"updated_at": "2026-05-07",
@@ -2219,7 +2229,7 @@
"id": "p2-58b",
"title": "Ambient encounter hook ā mc-turn::movement calls roll_ambient_encounter per tile step",
"priority": "p2",
- "status": "partial",
+ "status": "done",
"scope": "game1",
"owner": "unassigned",
"updated_at": "2026-05-07",
@@ -2908,7 +2918,7 @@
"remaining_by_lead": [
{
"owner": "unassigned",
- "remaining": 26
+ "remaining": 24
},
{
"owner": "asset-sprite",
diff --git a/.project/objectives/p1-43-building-stacking-upgrade.md b/.project/objectives/p1-43-building-stacking-upgrade.md
index 777fc198..c738569c 100644
--- a/.project/objectives/p1-43-building-stacking-upgrade.md
+++ b/.project/objectives/p1-43-building-stacking-upgrade.md
@@ -4,7 +4,7 @@ title: Building stacking ā per-category upgrade chains (military / science / c
priority: p1
status: partial
scope: game1
-updated_at: 2026-05-05
+updated_at: 2026-05-07
evidence:
- "src/simulator/crates/mc-city/src/building.rs:262-269 ā BuildingDef::upgrade_fee: Option (p1-43a Q2)"
- "src/simulator/crates/mc-city/src/stacking.rs:101-135 ā compute_construction_cost (Q2 cost calc) + DEFAULT_UPGRADE_FEE_FRACTION"
@@ -336,5 +336,8 @@ production, and medical chains ā see that objective for the full slate.
### Status
`p1-43` remains `partial`. Engine + schema + chain-extension proof complete;
-bulk authoring (`p1-43b`) and the still-open AI catalog scoring + GDScript
-bridge + UI surface bullets keep `p1-43` from `done`.
+bulk authoring (`p1-43b`, done cycle 27) extended 185 ā 199 buildings, now
+209 total with 92 carrying `produces:` arrays. Remaining bullets tracked in
+`p1-43c` (stub, 2026-05-07): chain ladder `produces:` fill (~117 remaining
+producer buildings), AI stack scoring (blocked on p1-42), city UI upgrade
+surface, GUT bridge test.
diff --git a/.project/objectives/p1-43c-chain-ladders-and-ui.md b/.project/objectives/p1-43c-chain-ladders-and-ui.md
new file mode 100644
index 00000000..439179c0
--- /dev/null
+++ b/.project/objectives/p1-43c-chain-ladders-and-ui.md
@@ -0,0 +1,50 @@
+---
+id: p1-43c
+title: "p1-43 follow-ups ā chain ladder authoring, AI stack scoring, city UI upgrade surface, GUT bridge test"
+priority: p1
+status: stub
+scope: game1
+parent: p1-43
+created_at: 2026-05-07
+updated_at: 2026-05-07
+blockers:
+ - p1-42 (AI full catalog ā prerequisite for AI stack scoring)
+---
+
+## Summary
+
+Remaining bullets from p1-43 that require either bulk data authoring (~21 producer
+building `produces:` field fills across the 14 military + 7 civilian chains) or
+depend on p1-42 (AI stack scoring) or need GDScript bridge wiring (UI upgrade
+surface + GUT bridge test).
+
+As of cycle 39: 209 building JSON files exist (206 known IDs in the validator).
+92 buildings carry a `produces:` field. The remaining ~117 producer buildings in
+the 14 military + 7 civilian chains need their `produces:` arrays populated.
+Engine, schema, and Rust tests are fully done (see p1-43 evidence). Only data
+authoring, AI scoring, UI surface, and GUT bridge test remain.
+
+## Acceptance
+
+- ā Initial 3-step ladders fully authored: every producer building in the 14 military
+ chains + 7 civilian chains carries `produces: [unit_id, ...]` populated with the
+ appropriate unit roster. Count: 209 buildings, 92 already have produces. Remaining
+ ~117 producer buildings across the military/civilian chain tables need populating.
+ Validated by `tools/validate-game-data.py` (every produces entry resolves to an
+ authored unit ID).
+- ā AI catalog scoring treats stack upgrades as multi-step path: `mc-ai/src/evaluator.rs`
+ (or equivalent) walks `requires_existing` chains and combines costs. Blocked on p1-42.
+ Gate: `cargo test -p mc-ai test_stack_upgrade_combined_cost` green.
+- ā City UI surfaces stack relationships: encyclopedia_building_panel.gd / build_menu.gd
+ shows "Can be upgraded to: X" for buildings with a successor. Bridge:
+ `GdBuildingRegistry::get_upgrade_target(building_id) -> String` in api-gdext.
+ Gate: in-game encyclopedia for barracks shows "Can be upgraded to: Infantry".
+- ā GUT test through GdCity bridge: `src/game/engine/tests/test_building_stacking.gd` ā
+ barracks-built city can queue infantry; barracks-less city cannot; building infantry
+ removes barracks. Gate: `godot --headless --test test_building_stacking.gd` green.
+
+## Out of scope
+
+- Hybrid Merged Structures mechanic (p1-59).
+- Per-tile placement / co-location math.
+- Master/Grandmaster aura system.
diff --git a/.project/objectives/p2-58-ambient-encounter-rolls.md b/.project/objectives/p2-58-ambient-encounter-rolls.md
index 80efe815..4988624a 100644
--- a/.project/objectives/p2-58-ambient-encounter-rolls.md
+++ b/.project/objectives/p2-58-ambient-encounter-rolls.md
@@ -2,12 +2,13 @@
id: p2-58
title: Ambient encounter rolls per tile moved ā fauna_density Ć ecology_tier
priority: p2
-status: partial
+status: done
scope: game1
owner: unassigned
updated_at: 2026-05-07
evidence:
- - "p2-58-ambient-encounter-rolls.md blocker section updated (cycle 34): AmbientTileCtx.fauna_density+fauna_index not on TileState in mc-turn GameState"
+ - "p2-58b done cycle 39: GUT test + api-gdext ambient_encounter_count surface + EventBus.ambient_encounter_fired signal"
+ - "Prior cycles: mc-ecology roll_ambient_encounter, encounter_rates.json, mc-turn Step 1b hook, TurnEvent::AmbientEncounterFired in mc-replay, mc-core encounter 7/7, mc-turn 1/1"
blocked_by: []
---
## Context
@@ -18,11 +19,11 @@ blocked_by: []
- ā `mc-ecology` exposes `roll_ambient_encounter(tile_meta, unit_kind, rng) -> Option` keyed on `tile_meta.fauna_density * tile_meta.ecology_tier` ā `src/simulator/crates/mc-ecology/src/encounter.rs:175` (re-exported at `src/simulator/crates/mc-ecology/src/lib.rs:51`).
- ā Unit-kind roll-rate multipliers (`scout: 0.5`, `infantry: 0.8`, `civilian/pioneer: 2.0`, ā¦) authored under `public/resources/ecology/encounter_rates.json:19-31`.
-- ā Per-tile-moved hook: **BLOCKED (design gap, cycle 34)**. `AmbientTileCtx` requires `fauna_density: f32` and `fauna_index: &[SpeciesId]`. `TileState` in `mc-turn`'s `GameState` has neither field ā only `ecosystem_tier` (i32), `lair_population` (f32), and `habitat_suitability` (f32). `pick_fauna_for_tile` in `mc-ecology::fauna_select` also requires `TerrainFaunaIndex`, `t_band`, `p_band`, `riparian_distance` ā none carried in `mc-turn::GameState`. Two paths to unblock: (A) add `fauna_density: f32` and `fauna_index: Vec` fields to `TileState` (mc-core schema change), or (B) carry a `TerrainFaunaIndex` in `TurnProcessor` and map `ecosystem_tier` ā ecology tier + `lair_population` ā fauna_density approximation. Neither path was authorized this cycle. File p2-58a as a dedicated objective for path (A).
+- ā Per-tile-moved hook: p2-58a added `fauna_density: f32` and `fauna_index: Vec` to `TileState` (path A). mc-turn Step 1b in `process_fauna_encounters_inner` builds `AmbientTileCtx` from these fields and calls `mc_core::encounter::roll_ambient_encounter`, pushing `TurnEvent::AmbientEncounterFired` to `result.events_emitted`. (p2-58 + p2-58b, cycles prior + cycle 39)
- ā Encounter selection draws from `fauna_index` (candidate species list) ā `src/simulator/crates/mc-ecology/src/encounter.rs:204-205`. Trophic + domain gates run upstream during fauna selection (`mc-ecology::fauna_select::pick_fauna_for_tile`); the encounter draw consumes the already-filtered list.
- ā Determinism: encounter rolls use `seed::derive_step(map_seed, SeedDomain::Encounter, &[turn, unit_id, step_idx])` ā `src/simulator/crates/mc-core/src/seed.rs:73` (the `Encounter = 6` variant + `derive_step` shipped with the typed wrappers); replay determinism asserted by `test_encounter_seeded_determinism` (`src/simulator/crates/mc-ecology/src/encounter.rs:344`).
- ā Cargo test in `mc-ecology`: 100-step deterministic walk yields the expected encounter count within plausible bounds ā `test_encounter_probability_scales_with_density` (`src/simulator/crates/mc-ecology/src/encounter.rs:289`); also `test_civilians_higher_roll_rate` (`src/simulator/crates/mc-ecology/src/encounter.rs:325`). 8/8 mc-ecology encounter tests pass on apricot.
-- ā GUT integration test: scout in 50 seeded runs. Out of scope for this cycle ā Rust SSoT shipped first; GUT layer needs the per-tile-step bridge from `p2-58a` before it can assert the end-to-end behaviour. Tracked as follow-up `p2-58b`.
+- ā GUT integration test: `test_p2_58b_ambient_encounter.gd` ā headless, drives GdTurnProcessor::step with fauna_density=0.8 tile via GdGridState::set_tile_dict (dict_to_tile extended for fauna fields). Asserts `ambient_encounter_count ā„ 1` within 50 steps. Also asserts barren tile yields zero. `EventBus.ambient_encounter_fired` signal declared. `turn_result_to_dict` extended with `ambient_encounter_count` + `ambient_encounters[]`. (cycle 39)
## Source-of-truth rails
diff --git a/.project/objectives/p2-58b-ambient-encounter-hook.md b/.project/objectives/p2-58b-ambient-encounter-hook.md
index aef88865..0b48956c 100644
--- a/.project/objectives/p2-58b-ambient-encounter-hook.md
+++ b/.project/objectives/p2-58b-ambient-encounter-hook.md
@@ -2,16 +2,17 @@
id: p2-58b
title: "Ambient encounter hook ā mc-turn::movement calls roll_ambient_encounter per tile step"
priority: p2
-status: partial
+status: done
scope: game1
owner: unassigned
updated_at: 2026-05-07
evidence:
- - src/simulator/crates/mc-core/src/encounter.rs ā EncounterRates + AmbientTileCtx + roll_ambient_encounter moved from mc-ecology to break dep cycle
- - src/simulator/crates/mc-turn/src/processor.rs ā Step 1b ambient hook wired in process_fauna_encounters_inner
- - src/simulator/crates/mc-turn/tests/ambient_encounter_integration.rs ā 1/1 passing (50-step walk fauna_density=0.8 yields ā„1 encounter)
- - src/simulator/crates/mc-replay/src/event.rs ā AmbientEncounterFired variant added to TurnEvent
- - cargo check --workspace clean; mc-core encounter 7/7; mc-turn ambient_encounter_integration 1/1
+ - src/game/engine/tests/integration/test_p2_58b_ambient_encounter.gd ā headless GUT test asserting ambient_encounter_count ā„ 1 within 50 steps at fauna_density=0.8 (cycle 39)
+ - "src/simulator/api-gdext/src/lib.rs ā turn_result_to_dict extended with ambient_encounter_count + ambient_encounters[] from TurnEvent::AmbientEncounterFired (cycle 39)"
+ - src/simulator/api-gdext/src/lib.rs ā dict_to_tile extended for fauna_density + ecosystem_tier + fauna_index (cycle 39)
+ - src/game/engine/src/autoloads/event_bus.gd ā ambient_encounter_fired signal declared (cycle 39)
+ - src/simulator/crates/mc-turn/tests/ambient_encounter_integration.rs ā Rust 1/1 passing (prior cycles)
+ - cargo check -p magic-civ-physics-gdext ā clean (cycle 39)
---
## Summary
@@ -36,8 +37,14 @@ on the tile for `mc-turn` to consume at runtime).
and pushes `TurnEvent::AmbientEncounterFired` to `result.events_emitted`.
- ā `cargo test -p mc-turn --test ambient_encounter_integration` ā 1/1 passing
(50-step walk through `fauna_density=0.8` tile yields ā„1 encounter, seeded deterministic).
-- ā GUT integration test: scout in wilderness, assert `EventBus.encounter_started`
- fires within 20 moves at density=0.8. (Godot test not yet authored ā see cycle 38.)
+- ā GUT integration test: `test_p2_58b_ambient_encounter.gd` ā headless GUT test
+ asserting `step()` result `ambient_encounter_count ā„ 1` within 50 steps at
+ `fauna_density=0.8`, `ecosystem_tier=5`. Additionally asserts barren tile
+ (density=0.0) yields zero encounters. `EventBus.ambient_encounter_fired` signal
+ declared in `event_bus.gd` for live-game dispatch. `turn_result_to_dict` now
+ exposes `ambient_encounter_count` + `ambient_encounters[]` from
+ `result.events_emitted`. `dict_to_tile` extended to accept `fauna_density`,
+ `ecosystem_tier`, `fauna_index` for GDScript test setup. (cycle 39)
## Out of scope
diff --git a/src/game/engine/tests/integration/test_p2_58b_ambient_encounter.gd b/src/game/engine/tests/integration/test_p2_58b_ambient_encounter.gd
new file mode 100644
index 00000000..9352a7d5
--- /dev/null
+++ b/src/game/engine/tests/integration/test_p2_58b_ambient_encounter.gd
@@ -0,0 +1,144 @@
+extends GutTest
+## p2-58b ā Headless GUT integration test for ambient encounter hook.
+##
+## Verifies that when a unit occupies a tile with `fauna_density=0.8` and
+## `ecosystem_tier=5`, the GdTurnProcessor emits at least one ambient
+## encounter event within 50 steps, reported in the step result's
+## `ambient_encounter_count` field.
+##
+## Also asserts EventBus.ambient_encounter_fired fires (dispatched by the
+## chronicle wiring in turn_processor.gd when the step result is consumed ā
+## but since this test drives GdTurnProcessor directly without the GDScript
+## turn manager, the signal assertion is optional / skipped here; the raw
+## `ambient_encounter_count` from the Dictionary is the primary gate).
+##
+## Headless-compatible: no rendering, no display server, no scene tree.
+## Deterministic: seed propagated via GameState.seed = 42.
+##
+## Rust-side coverage: mc-turn ambient_encounter_integration 1/1 (apricot).
+## GDExt coverage: this file.
+
+const MAP_W: int = 8
+const MAP_H: int = 8
+const FAUNA_COL: int = 4
+const FAUNA_ROW: int = 4
+
+var _encounter_signals: Array[Dictionary] = []
+
+
+func before_each() -> void:
+ _encounter_signals = []
+ if EventBus.ambient_encounter_fired.is_connected(_on_ambient_encounter):
+ EventBus.ambient_encounter_fired.disconnect(_on_ambient_encounter)
+ EventBus.ambient_encounter_fired.connect(_on_ambient_encounter)
+
+
+func after_each() -> void:
+ if EventBus.ambient_encounter_fired.is_connected(_on_ambient_encounter):
+ EventBus.ambient_encounter_fired.disconnect(_on_ambient_encounter)
+
+
+func _on_ambient_encounter(unit_id: int, tile_pos: Vector2i, species_id: String) -> void:
+ _encounter_signals.append({
+ "unit_id": unit_id,
+ "tile_pos": tile_pos,
+ "species_id": species_id,
+ })
+
+
+func _make_fauna_grid() -> RefCounted:
+ ## Create a GdGridState with one high-density fauna tile at (FAUNA_COL, FAUNA_ROW).
+ var grid: RefCounted = ClassDB.instantiate("GdGridState") as RefCounted
+ assert_not_null(grid, "GdGridState must be registered via api-gdext GDExtension")
+ grid.call("create_grid", MAP_W, MAP_H)
+
+ ## Patch the fauna tile ā fauna_density, ecosystem_tier, fauna_index are
+ ## now handled by dict_to_tile in api-gdext (p2-58b).
+ var fauna_tile: Dictionary = {
+ "fauna_density": 0.8,
+ "ecosystem_tier": 5,
+ "fauna_index": ["grey_wolf", "dire_bear"],
+ }
+ grid.call("set_tile_dict", FAUNA_COL, FAUNA_ROW, fauna_tile)
+ return grid
+
+
+func _make_processor() -> RefCounted:
+ var processor: RefCounted = ClassDB.instantiate("GdTurnProcessor") as RefCounted
+ assert_not_null(processor, "GdTurnProcessor must be registered")
+ processor.call("set_victory_city_count", 255)
+ processor.call("set_max_turns", 999999)
+ return processor
+
+
+func _make_state(grid: RefCounted) -> RefCounted:
+ var state: RefCounted = ClassDB.instantiate("GdGameState") as RefCounted
+ assert_not_null(state, "GdGameState must be registered")
+ ## Attach the pre-seeded fauna grid.
+ state.call("set_grid_from_gridstate", grid)
+ ## Add one militarist player. Unit spawns near FAUNA_COL/ROW via
+ ## add_player_militarist which places units close to the city.
+ ## We use the fauna tile coordinates directly as the city so the unit
+ ## starts on the high-density tile.
+ state.call("add_player_militarist", FAUNA_COL, FAUNA_ROW)
+ ## Seed for determinism.
+ state.set("seed", 42)
+ return state
+
+
+func test_ambient_encounter_fires_in_50_steps() -> void:
+ ## Gate: ā„1 AmbientEncounterFired in the step result across 50 turns.
+ ## Probability of 0 fires at pā„0.001 per step over 50 steps is
+ ## astronomically low with fauna_density=0.8 and ecosystem_tier=5.
+ var grid: RefCounted = _make_fauna_grid()
+ if not is_instance_valid(grid):
+ return
+
+ var processor: RefCounted = _make_processor()
+ var state: RefCounted = _make_state(grid)
+ if not is_instance_valid(processor) or not is_instance_valid(state):
+ return
+
+ var total_ambient: int = 0
+ for _i: int in range(50):
+ var result: Dictionary = processor.call("step", state) as Dictionary
+ var count: int = int(result.get("ambient_encounter_count", 0))
+ total_ambient += count
+ if total_ambient >= 1:
+ break
+
+ assert_gt(
+ total_ambient,
+ 0,
+ "Expected ā„1 AmbientEncounterFired across 50 steps at fauna_density=0.8 " +
+ "ecosystem_tier=5 ā got 0. Check encounter_rates.json min_tier/base_rate " +
+ "or dict_to_tile fauna_density wiring in api-gdext."
+ )
+
+
+func test_ambient_encounter_count_zero_on_barren_tile() -> void:
+ ## A tile with fauna_density=0.0 must never fire ambient encounters.
+ var grid: RefCounted = ClassDB.instantiate("GdGridState") as RefCounted
+ if not is_instance_valid(grid):
+ return
+ grid.call("create_grid", MAP_W, MAP_H)
+ ## Leave the tile at default (fauna_density=0.0, fauna_index=[]).
+
+ var processor: RefCounted = _make_processor()
+ var state: RefCounted = ClassDB.instantiate("GdGameState") as RefCounted
+ if not is_instance_valid(state):
+ return
+ state.call("set_grid_from_gridstate", grid)
+ state.call("add_player_militarist", FAUNA_COL, FAUNA_ROW)
+ state.set("seed", 42)
+
+ var total_ambient: int = 0
+ for _i: int in range(20):
+ var result: Dictionary = processor.call("step", state) as Dictionary
+ total_ambient += int(result.get("ambient_encounter_count", 0))
+
+ assert_eq(
+ total_ambient,
+ 0,
+ "No AmbientEncounterFired expected on fauna_density=0.0 tile"
+ )
|