feat(@projects/@magic-civilization): add ambient encounter roll completion status

Co-Authored-By: Lilith Autocommit <noreply@atlilith.com>
This commit is contained in:
Natalie 2026-05-06 23:37:16 -07:00
parent a0ab149d10
commit 4203a301cf
9 changed files with 252 additions and 35 deletions

View file

@ -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) | 🟢 |

View file

@ -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

View file

@ -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** |
</td><td valign='top' style='padding-left:2em'>
@ -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

View file

@ -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",

View file

@ -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<u32> (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.

View file

@ -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.

View file

@ -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<EncounterSpec>` 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<SpeciesId>` 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<SpeciesId>` 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

View file

@ -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

View file

@ -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"
)