diff --git a/.project/objectives/DASHBOARD_CATEGORIES.md b/.project/objectives/DASHBOARD_CATEGORIES.md
index 34f07e56..2b53cf76 100644
--- a/.project/objectives/DASHBOARD_CATEGORIES.md
+++ b/.project/objectives/DASHBOARD_CATEGORIES.md
@@ -308,6 +308,7 @@
| [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) | 🔴 stub | P2 | Procedural unit/building renderer — alpha-only visual substitute | [unassigned](../team-leads/unassigned.md) | 🟢 |
+| [p2-63](p2-63-mc-flora-biome-substrate-migration.md) | 🔴 stub | P2 | mc-flora generation: migrate biome filter to substrate_climate-aware path | [unassigned](../team-leads/unassigned.md) | 🟢 |
| [p3-01](p3-01-courier-diplomacy.md) | ✅ done | P3 | Courier-gated diplomacy — open borders + shared maps via tech-tiered courier units | [envoy](../team-leads/envoy.md) | 🟢 |
| [p3-02](p3-02-hybrid-merged-structures.md) | ❌ missing | P3 | Hybrid merged structures — war_academy, assault_citadel, cavalry_corps, gunnery_corps | — | 🟢 |
| [p3-03](p3-03-courier-route-resolver.md) | ✅ done | P3 | Courier route resolver — real hex pathfinding, per-tier movement, severable infrastructure | [envoy](../team-leads/envoy.md) | 🟢 |
diff --git a/.project/objectives/README.md b/.project/objectives/README.md
index 269052c4..b2a75be3 100644
--- a/.project/objectives/README.md
+++ b/.project/objectives/README.md
@@ -16,9 +16,9 @@
|---|---|---|---|---|---|---|---|
| **P0** | 43 | 0 | 0 | 0 | 0 | 0 | 43 |
| **P1** | 47 | 1 | 13 | 3 | 7 | 1 | 72 |
-| **P2** | 52 | 0 | 7 | 14 | 3 | 6 | 82 |
+| **P2** | 52 | 0 | 7 | 15 | 3 | 6 | 83 |
| **P3 (oos)** | 3 | 0 | 0 | 18 | 1 | 21 | 43 |
-| **total** | **145** | **1** | **20** | **35** | **11** | **28** | **240** |
+| **total** | **145** | **1** | **20** | **36** | **11** | **28** | **241** |
@@ -26,7 +26,7 @@
| Team Lead | Remaining |
|---|---|
-| [unassigned](../team-leads/unassigned.md) | 30 |
+| [unassigned](../team-leads/unassigned.md) | 31 |
| [warcouncil](../team-leads/warcouncil.md) | 6 |
| [asset-sprite](../team-leads/asset-sprite.md) | 6 |
| [shipwright](../team-leads/shipwright.md) | 5 |
@@ -241,6 +241,7 @@
| [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 |
| [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 |
| [p2-62](p2-62-procedural-unit-and-building-renderer.md) | 🔴 stub | "Procedural unit/building renderer — alpha-only visual substitute" | [unassigned](../team-leads/unassigned.md) | 2026-05-03 |
+| [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 |
## Out of Scope (Game 2 / Game 3)
diff --git a/.project/objectives/objectives.json b/.project/objectives/objectives.json
index 9995077d..7cf055e9 100644
--- a/.project/objectives/objectives.json
+++ b/.project/objectives/objectives.json
@@ -1,13 +1,13 @@
{
- "generated_at": "2026-05-04T04:50:40Z",
+ "generated_at": "2026-05-04T05:13:02Z",
"totals": {
"done": 146,
"in_progress": 1,
"partial": 20,
- "stub": 35,
+ "stub": 36,
"missing": 11,
"oos": 28,
- "total": 241
+ "total": 242
},
"objectives": [
{
@@ -2176,6 +2176,17 @@
"blocked_by": [],
"summary": ""
},
+ {
+ "id": "p2-63",
+ "title": "mc-flora generation: migrate biome filter to substrate_climate-aware path",
+ "priority": "p2",
+ "status": "stub",
+ "scope": "game1",
+ "owner": "unassigned",
+ "updated_at": "2026-05-04",
+ "blocked_by": [],
+ "summary": "Authored flora JSON files have migrated from a top-level `biomes: [...]`\narray to the `substrate_climate` ontology\n(see `p2-52-substrate-flora-cover-ontology-split.md`). The biome-filter\nloop in `mc-flora/src/generation.rs` was not updated, so the\n`AuthoredSpeciesFile.biomes` field is now empty for every authored file\nand the candidate-pool query returns nothing.\n\nTwo pre-existing regression tests in `mc-flora` document the gap:\n\n- `load_authored_returns_species_for_known_biome` (generation.rs:645)\n- `generate_flora_for_biome_more_species_with_authored_files`\n (generation.rs:695)\n\nBoth fail because the biome filter at `generation.rs:508-510` still\nchecks `raw.biomes.iter().any(|b| b == biome_id)` while the JSON now\nencodes biome eligibility through `substrate_climate` blocks the loader\ndoes not currently inspect."
+ },
{
"id": "g2-01",
"title": "Ley lines — Game 2 (Age of Kzzykt)",
@@ -2798,7 +2809,7 @@
"remaining_by_lead": [
{
"owner": "unassigned",
- "remaining": 30
+ "remaining": 31
},
{
"owner": "asset-sprite",
diff --git a/.project/objectives/p1-55-tech-culture-domain-propagation.md b/.project/objectives/p1-55-tech-culture-domain-propagation.md
index f772968a..1550e3b6 100644
--- a/.project/objectives/p1-55-tech-culture-domain-propagation.md
+++ b/.project/objectives/p1-55-tech-culture-domain-propagation.md
@@ -11,6 +11,12 @@ evidence:
- "src/simulator/crates/mc-tech/src/web.rs:74"
- "src/simulator/crates/mc-tech/src/web.rs:248"
- "src/simulator/crates/mc-tech/src/web.rs:399"
+ - "src/simulator/api-gdext/src/lib.rs:4953 (GdTechWeb::domains/techs_by_domain/research_history_by_domain)"
+ - "src/game/engine/src/modules/tech/tech_web.gd:88 (get_domains/get_techs_by_domain/get_research_history_by_domain)"
+ - "src/game/engine/scenes/knowledge_tree/knowledge_tree.gd:96 (tab axis hooks + tab bar + float-to-top sort)"
+ - "src/game/engine/scenes/tech_tree/tech_tree.gd:36 (TechTree overrides _get_tab_axis_values/_node_in_tab)"
+ - "src/game/engine/tests/unit/test_knowledge_tree_domain_tabs.gd (3/3 GUT pass on apricot, headless)"
+ - "tools/validate-game-data.py:178 (validate_tech_domains: 10-value enum membership check)"
assigned_by: simulator-infra
---
## Summary
@@ -54,15 +60,39 @@ node.
for culture data, matching the design note that culture uses `pillar`
as its tab axis.)
-- [ ] **GDExtension** — `GdTechWeb` exposes `domains() ->
- PackedStringArray` and `techs_by_domain(domain) -> Array`.
- `tech_data_json(id)` includes `domain` in returned dict. Build clean:
- `bash src/simulator/build-gdext.sh`.
+- [x] **GDExtension** — `GdTechWeb` exposes `domains() ->
+ PackedStringArray` (returns the canonical 10 `TechDomain::ALL`
+ variants regardless of loaded data) and `techs_by_domain(domain) ->
+ PackedStringArray` (parses the PascalCase string back to the typed
+ enum, then delegates to `mc_tech::TechWeb::techs_by_domain`).
+ `tech_data_json(id)` already includes `domain` for free via serde
+ (proven by the round-trip test in `mc-tech/src/web.rs:399`).
+ `research_history_by_domain(researched_techs) -> Dictionary` added
+ for the player-analysis bullet (Dictionary keyed by all 10 domain
+ strings, values are `PackedStringArray` of completed tech ids).
+ Built clean on apricot (`bash build-gdext.sh`, release profile, 17
+ pre-existing doc warnings unrelated to this change). Files:
+ `src/simulator/api-gdext/src/lib.rs:4953-5031`.
-- [ ] **GDScript** — `tech_web.gd` exposes `get_domains()` and
- `get_nodes_by_domain(domain)`. `knowledge_tree.gd` renders a domain
- tab bar above the existing pillar columns. Active tab causes matching
- nodes to float to the top within each era column.
+- [x] **GDScript** — `tech_web.gd` exposes `get_domains()`,
+ `get_techs_by_domain(domain)`, and
+ `get_research_history_by_domain(player_index)` — all pass-through
+ wrappers that call directly into the GDExtension bridge. No GDScript-
+ side enum or string list. `knowledge_tree.gd` gains an opt-in tab-axis
+ hook (`_get_tab_axis_values()` / `_node_in_tab()`), a tab bar above
+ the body, and float-to-top sort within each pillar column when an
+ explicit tab is active. The "All" sentinel preserves original order.
+ `tech_tree.gd` overrides the hooks to surface the 10 `TechDomain`
+ values from `TechWeb.get_domains()`. Culture trees opt out by leaving
+ the default empty hook; the tab bar hides itself when no axis is
+ declared. Files: `src/game/engine/src/modules/tech/tech_web.gd:88`,
+ `src/game/engine/scenes/knowledge_tree/knowledge_tree.gd:96`,
+ `src/game/engine/scenes/tech_tree/tech_tree.gd:36`. Headless GUT
+ proof: `test_knowledge_tree_domain_tabs.gd` (3/3 pass on apricot,
+ 108 asserts) — covers tab-bar rendering of all 10 domains + "All"
+ sentinel, float-to-top partition order with the Science tab active,
+ and round-trip equivalence of `get_domains()` with
+ `TechDomain::ALL` order.
- [ ] **Gameplay proof** — `tech_tree_proof.tscn` captures a screenshot
showing the domain tab bar, with one tab (e.g. "Science") active and
@@ -78,9 +108,17 @@ node.
in-game stats screen renders a domain-binned breakdown of a player's
research history. Driven by deterministic replay fixture data.
-- [ ] **Cross-reference validation** — `tools/` validator script (or new
- one) confirms every tech in `public/resources/techs/*.json` has a
- `domain` field set to one of the 10 canonical values.
+- [x] **Cross-reference validation** — `tools/validate-game-data.py`
+ gained a `validate_tech_domains()` pass that walks
+ `public/resources/techs/*.json` and asserts every entry's `domain`
+ field exists and ∈ the canonical 10-value enum (mirrored from
+ `mc_core::TechDomain::ALL` as a class constant — update both sites
+ if the enum ever changes). Verified clean across the 60+ shipped
+ techs (`tech domain enum membership (10 canonical values)` section
+ in the validator output is failure-free). The legacy
+ `game_data/techs/` fallback was intentionally dropped from this
+ pass per post-p1-40 SSoT (single source at `resources/techs/`).
+ Files: `tools/validate-game-data.py:178`.
- [ ] **Phase-gate proof screenshot** — captured per
`phase-gate-protocol.md`. Archived under `.project/screenshots/`.
diff --git a/.project/objectives/p1-56-civics-buildings-and-great-works.md b/.project/objectives/p1-56-civics-buildings-and-great-works.md
index 209f35dc..38324f33 100644
--- a/.project/objectives/p1-56-civics-buildings-and-great-works.md
+++ b/.project/objectives/p1-56-civics-buildings-and-great-works.md
@@ -7,12 +7,11 @@ scope: game1
owner: simulator-infra
updated_at: 2026-05-04
evidence:
- - "src/simulator/crates/mc-core/src/ids.rs:1 — typed BuildingId/SpecialistId/GreatPersonClass/HarvestPolicyId newtypes (transparent serde, mc-core test suite green)"
- - "src/simulator/crates/mc-core/src/gpp.rs:1 — closed GppType + GreatWorkType enums with effect-key mapping (snake_case round-trip)"
- - "src/simulator/crates/mc-city/src/building.rs:42 — typed BuildingEffect enum covering gpp_* and great_work_slots_* with Other catch-all; specialist_slots: Vec; requires_buildings_all_cities: Vec"
- - "src/simulator/crates/mc-city/src/building.rs:567 — test_building_deserialises_new_fields green for saga_arena + saga_chronicle"
- - "src/simulator/crates/mc-city/src/building.rs:625 — test_all_authored_buildings_deserialize green over 178 building JSONs"
- - "public/games/age-of-dwarves/docs/BUILDING_SCHEMA.md:99 — Civics extensions section documents new effect-array variants and typed wrappers"
+ - "src/simulator/crates/mc-city/src/great_works.rs:1 — static GreatWorkRegistry with 3/3 tests green over the 4 authored category JSONs (≥30 works)"
+ - "src/simulator/api-gdext/src/civics.rs:1 — GdSpecialistRegistry / GdHarvestPolicyRegistry / GdGreatWorkRegistry / GdBuildingCivics; cargo build -p magic-civ-physics-gdext clean on apricot"
+ - "src/game/engine/scenes/city/specialists_panel.gd:1 — VBoxContainer panel rendering specialist slots, 7-channel GPP/turn sums, 4-category great-work slot capacity from typed Rust accessors"
+ - src/game/engine/tests/unit/test_city_screen_specialist_slots.gd + test_city_screen_gpp.gd — 11 new tests; headless GUT 25/25 city_screen tests passing on apricot
+ - "src/simulator/crates/mc-city/src/harvest_policy.rs:91 — HarvestPolicyRegistry::all() accessor for UI dropdown enumeration"
assigned_by: simulator-infra
---
## Summary
@@ -232,6 +231,7 @@ Unit fields added (additive, optional):
- Dependencies: GPP bullet.
- Acceptance gate: `cargo test -p mc-city test_great_work_slot_assignment` green; removal on building destruction tested.
- SOLID/DRY/SSoT rails: registry typed; effect chains in `mc-city`.
+- **Sub-bullet (closed 2026-05-04 — registry only)** ✓ Static `GreatWorkRegistry` lives at `src/simulator/crates/mc-city/src/great_works.rs:1` with `from_json` / `from_json_files` / `by_type` / `get`, mirrors `SpecialistRegistry` shape. `mc-city` re-exports `GreatWork` + `GreatWorkRegistry` (`src/simulator/crates/mc-city/src/lib.rs:35`). Tests `great_works::tests::great_work_round_trips_via_json`, `all_authored_great_works_deserialize` (loads all 4 authored JSONs, ≥30 works), `by_type_filters_correctly` green (`cargo test -p mc-city --lib great_works` 3/3). Per-city occupation (which work fills which slot, removal on building destruction, GP-spawn assignment) NOT yet implemented — depends on per-city runtime state still missing in `mc-city`.
### Bullet: `mc-turn::process_buildings` calls per-turn GPP/specialist/harvest/great-work ticks
@@ -246,6 +246,13 @@ Unit fields added (additive, optional):
- Dependencies: Rust bullets above.
- Acceptance gate: `bash src/simulator/build-gdext.sh` clean; smoke probe in proof scene.
- SOLID/DRY/SSoT rails: bridge marshals JSON; tier-cap enforced server-side in Rust.
+- **Sub-bullet (closed 2026-05-04 — static-registry surface only)** ✓ `src/simulator/api-gdext/src/civics.rs:1` registers four `RefCounted` GDExt classes:
+ - `GdSpecialistRegistry::from_json` / `get_specialist` / `for_building` / `count` — wraps `mc_city::SpecialistRegistry`.
+ - `GdHarvestPolicyRegistry::from_json` / `get_policy` / `ids` / `count` — wraps `mc_city::HarvestPolicyRegistry`. New `HarvestPolicyRegistry::all` accessor at `src/simulator/crates/mc-city/src/harvest_policy.rs:91`.
+ - `GdGreatWorkRegistry::from_json_files` / `get_great_work` / `by_type` / `count` — wraps `mc_city::GreatWorkRegistry`.
+ - `GdBuildingCivics::from_defs_json` / `insert_def_json` / `specialist_slots` / `gpp_yield` / `great_work_slots` / `sum_gpp_yield` / `sum_great_work_slots` — exposes the cycle-1 typed `BuildingDef` accessors per building and as city-wide sums for UI.
+ - Verified by `cargo build -p magic-civ-physics-gdext` clean (Finished `dev` in 23.18s on apricot, 17 doc warnings, 0 errors).
+ - NOT bridged this cycle: `GdGreatPersonAction.activate(...)` (no Rust runtime), `set_tile_policy` (no `Tile.harvest_policy` field), per-city GPP accumulator readback (no per-city state). Tier-cap enforcement, GP spawn dispatch, and great-work occupation depend on the Rust runtime bullets above and stay ❌.
### Bullet: Godot UI — specialist drag, harvest dropdown, great-person spawn modal, throne-room layer slots
@@ -254,11 +261,19 @@ Unit fields added (additive, optional):
- Dependencies: GDExtension bullet.
- Acceptance gate: gdlint clean; manual smoke + proof scene.
- SOLID/DRY/SSoT rails: presentation only; no game rules in GDScript.
+- **Sub-bullet (closed 2026-05-04 — capacity panel only)** ✓ `src/game/engine/scenes/city/specialists_panel.gd:1` — VBoxContainer panel renders three sections:
+ - Specialist slots — one row per (building_id, specialist_id) sourced from `GdBuildingCivics.specialist_slots(bid)`. Specialist name resolved via `GdSpecialistRegistry.get_specialist(sid)`.
+ - GPP per turn — all 7 GPP types (`writing`, `music`, `art`, `statuary`, `scholarship`, `trade`, `engineering`) summed across the city's built buildings via `GdBuildingCivics.sum_gpp_yield`.
+ - Great-Work slot capacity — all 4 work types summed via `GdBuildingCivics.sum_great_work_slots`, rendered as `0 / cap` (occupants placeholder — runtime occupation not yet bridged).
+ - Vocab keys added at `public/games/age-of-dwarves/vocabulary.json:584` (`city_screen_section_specialists`, `city_screen_section_gpp`, `city_screen_section_great_works`, `city_screen_no_specialist_slots`).
+ - Pure presentation — no game rules in GDScript; `setup(specialist_registry, great_work_registry, building_civics)` accepts null registries and degrades gracefully (renders empty rows) when the GDExtension isn't loaded.
+ - Drag-to-employ, harvest-policy dropdown on tile rows, great-person spawn modal, and throne-room layer slot rendering remain ❌ — they require runtime employ/unemploy/policy-set/spawn APIs in mc-city.
### Bullet: GUT tests + proof screenshot
- Files to touch: `src/game/engine/src/tests/test_specialist_slots.gd`, `test_gpp_accumulation.gd`, `test_great_person_spawn.gd`, `test_harvest_policy_yield.gd`, `test_great_work_slot_assignment.gd`, `test_national_wonder_requirement.gd`; proof scene `scenes/tests/proof_civics_buildings.tscn`.
- Dependencies: all above.
- Acceptance gate: `godot --headless --test ...` green; screenshot approved per `phase-gate-protocol.md`.
+- **Sub-bullet (closed 2026-05-04 — UI-side tests for capacity panel)** ✓ `src/game/engine/tests/unit/test_city_screen_specialist_slots.gd` (6 tests, 6 passing) and `test_city_screen_gpp.gd` (5 tests, 5 passing) verify aggregation contract against mock GdBuildingCivics + GdSpecialistRegistry surfaces. Headless GUT run on apricot via `flatpak run org.godotengine.Godot --headless --script gut_cmdln.gd -gprefix=test_city_screen_`: `25/25 passed` across 4 city-screen test files (the 2 new + 2 pre-existing). Includes negative test `test_panel_does_not_render_runtime_progress` enforcing Rail-1: no GDScript shadow accumulator. Proof screenshot + per-system Rust tests (`test_specialist_yields_and_population`, `test_gpp_accumulation_and_spawn`, `test_great_person_spawn`, `test_harvest_policy_remove_chop_oneshot`, `test_great_work_slot_assignment`, `test_national_wonder_requirement`) remain ❌ — depend on Rust runtime bullets above.
-Bullets remaining: 10 (deserialise bullet closed 2026-05-04).
+Bullets remaining: 10 parents still ❌; this cycle landed three closed sub-bullets (great-works static registry, GDExt static-registry surface, capacity-only specialists panel + GUT tests). Next cycle: Rust runtime (Specialist yields, GPP accumulator, Tile.harvest_policy, national-wonder gates).
diff --git a/public/games/age-of-dwarves/data/objectives.json b/public/games/age-of-dwarves/data/objectives.json
index c1409365..3e35d886 100644
--- a/public/games/age-of-dwarves/data/objectives.json
+++ b/public/games/age-of-dwarves/data/objectives.json
@@ -1,13 +1,13 @@
{
- "generated_at": "2026-05-04T04:53:17Z",
+ "generated_at": "2026-05-04T05:14:49Z",
"totals": {
- "in_progress": 1,
- "missing": 11,
- "done": 145,
- "oos": 28,
"partial": 20,
- "stub": 35,
- "total": 240
+ "oos": 28,
+ "in_progress": 1,
+ "done": 145,
+ "missing": 11,
+ "stub": 36,
+ "total": 241
},
"objectives": [
{
@@ -1980,6 +1980,16 @@
"updated_at": "2026-05-03",
"summary": ""
},
+ {
+ "id": "p2-63",
+ "title": "\"mc-flora generation: migrate biome filter to substrate_climate-aware path\"",
+ "priority": "p2",
+ "status": "stub",
+ "scope": "game1",
+ "owner": "unassigned",
+ "updated_at": "2026-05-04",
+ "summary": "Authored flora JSON files have migrated from a top-level `biomes: [...]`\narray to the `substrate_climate` ontology\n(see `p2-52-substrate-flora-cover-ontology-split.md`). The biome-filter\nloop in `mc-flora/src/generation.rs` was not updated, so the\n`AuthoredSpeciesFile.biomes` field is now empty for every authored file\nand the candidate-pool query returns nothing.\n\nTwo pre-existing regression tests in `mc-flora` document the gap:\n\n- `load_authored_returns_species_for_known_biome` (generation.rs:645)\n- `generate_flora_for_biome_more_species_with_authored_files`\n (generation.rs:695)\n\nBoth fail because the biome filter at `generation.rs:508-510` still\nchecks `raw.biomes.iter().any(|b| b == biome_id)` while the JSON now\nencodes biome eligibility through `substrate_climate` blocks the loader\ndoes not currently inspect."
+ },
{
"id": "g2-01",
"title": "Ley lines — Game 2 (Age of Kzzykt)",
diff --git a/src/game/engine/scenes/tech_tree/tech_tree.gd b/src/game/engine/scenes/tech_tree/tech_tree.gd
index 2d59505b..3cd69b20 100644
--- a/src/game/engine/scenes/tech_tree/tech_tree.gd
+++ b/src/game/engine/scenes/tech_tree/tech_tree.gd
@@ -49,4 +49,5 @@ func _node_in_tab(node_id: String, tab: String) -> bool:
if tab.is_empty() or _web == null:
return true
var data: Dictionary = (_web as TechWeb).get_node_data(node_id)
- return String(data.get("domain", "")) == tab
+ var domain_val: String = str(data.get("domain", ""))
+ return domain_val == tab
diff --git a/src/simulator/crates/mc-combat/tests/golden.rs b/src/simulator/crates/mc-combat/tests/golden.rs
index f451c1ca..815b6145 100644
--- a/src/simulator/crates/mc-combat/tests/golden.rs
+++ b/src/simulator/crates/mc-combat/tests/golden.rs
@@ -173,6 +173,7 @@ fn outcome_str(o: CombatOutcome) -> &'static str {
CombatOutcome::Captured => "captured",
CombatOutcome::RansomOffered => "ransom_offered",
CombatOutcome::Destroyed => "destroyed",
+ CombatOutcome::Devastated => "devastated",
}
}
|