diff --git a/.project/objectives/DASHBOARD_CATEGORIES.md b/.project/objectives/DASHBOARD_CATEGORIES.md
index eb8477ff..4b2a981e 100644
--- a/.project/objectives/DASHBOARD_CATEGORIES.md
+++ b/.project/objectives/DASHBOARD_CATEGORIES.md
@@ -271,9 +271,9 @@
| [p2-39](p2-39-chronicle-hall-phantom-unlock.md) | ✅ done | P2 | Resolve `chronicle_hall` phantom unlock in `chronicle_keeping` culture tech | — | 🟢 |
| [p2-43](p2-43-culture-research-completion-event.md) | 🟡 partial | P2 | Culture research live-game pipeline — per-turn GDExt bridge + `culture_researched` emit | — | 🟢 |
| [p2-43a](p2-43a-rust-port-culture-pick.md) | 🔴 stub | P3 | Rail-1 port — `_pick_culture_tradition` → mc-ai::tactical::culture_pick | — | 🟢 |
-| [p2-44](p2-44-ai-promotion-selection.md) | 🟡 partial | P2 | AI promotion selection — auto-pick + emit unit_promoted for AI units | — | 🟢 |
+| [p2-44](p2-44-ai-promotion-selection.md) | ✅ done | P2 | AI promotion selection — auto-pick + emit unit_promoted for AI units | — | 🟢 |
| [p2-44a](p2-44a-dataloader-promotion-trees-path.md) | ✅ done | P2 | DataLoader path mismatch — `get_promotion(\"trees\")` returns empty | [unassigned](../team-leads/unassigned.md) | 🟢 |
-| [p2-44b](p2-44b-promotion-dispatch-instrumentation.md) | 🟡 partial | P2 | AI promotion dispatch — instrumentation pass to identify the silent gate | [unassigned](../team-leads/unassigned.md) | 🟢 |
+| [p2-44b](p2-44b-promotion-dispatch-instrumentation.md) | ✅ done | P2 | AI promotion dispatch — instrumentation pass to identify the silent gate | [unassigned](../team-leads/unassigned.md) | 🟢 |
| [p2-45](p2-45-elimination-reconciliation.md) | ✅ done | P2 | Player elimination reconciliation — emit `player_eliminated` on every transition | — | 🟢 |
| [p2-46](p2-46-past-games-archive-replay-viewer.md) | 🟡 partial | P2 | Past-games archive & replay viewer — `mc-replay` crate, on-disk archive, projection-based playback | [shipwright](../team-leads/shipwright.md) | 🟢 |
| [p2-47](p2-47-in-game-statistics-screens.md) | 🟡 partial | P2 | In-game statistics screens — Civ-style 5-tab modal (Demographics / Graphs / Rankings / Replay / Histories) | [shipwright](../team-leads/shipwright.md) | 🟢 |
diff --git a/.project/objectives/DASHBOARD_COMPLETED.md b/.project/objectives/DASHBOARD_COMPLETED.md
index 6cf01287..c93f8bf5 100644
--- a/.project/objectives/DASHBOARD_COMPLETED.md
+++ b/.project/objectives/DASHBOARD_COMPLETED.md
@@ -145,7 +145,9 @@
| [p2-37](p2-37-react-calculator-metadata-surface.md) | React calculator UI — surface flavor, lore, clan_affinity, archetype filter | — | [tourguide](../team-leads/tourguide.md) | 2026-04-27 |
| [p2-38](p2-38-unit-audio-cues-stubs.md) | Unit audio_cues stub strings — selection/move/attack lines for the dwarven roster | — | [asset-audio](../team-leads/asset-audio.md) | 2026-04-27 |
| [p2-39](p2-39-chronicle-hall-phantom-unlock.md) | Resolve `chronicle_hall` phantom unlock in `chronicle_keeping` culture tech | — | — | 2026-04-27 |
+| [p2-44](p2-44-ai-promotion-selection.md) | AI promotion selection — auto-pick + emit unit_promoted for AI units | — | — | 2026-05-06 |
| [p2-44a](p2-44a-dataloader-promotion-trees-path.md) | DataLoader path mismatch — `get_promotion(\"trees\")` returns empty | — | [unassigned](../team-leads/unassigned.md) | 2026-05-06 |
+| [p2-44b](p2-44b-promotion-dispatch-instrumentation.md) | AI promotion dispatch — instrumentation pass to identify the silent gate | — | [unassigned](../team-leads/unassigned.md) | 2026-05-06 |
| [p2-45](p2-45-elimination-reconciliation.md) | Player elimination reconciliation — emit `player_eliminated` on every transition | — | — | 2026-04-30 |
| [p2-49](p2-49-climate-axes-latitude-continentality.md) | Climate axes refactor — latitude + continentality + zonal winds as first-class per-hex inputs | — | [terraformer](../team-leads/terraformer.md) | 2026-04-30 |
| [p2-50](p2-50-rng-determinism-pin.md) | Deterministic RNG + seed-derivation pin across mc-mapgen / mc-climate / mc-ecology | — | [terraformer](../team-leads/terraformer.md) | 2026-05-01 |
diff --git a/.project/objectives/README.md b/.project/objectives/README.md
index 9282e87b..dcd51b8b 100644
--- a/.project/objectives/README.md
+++ b/.project/objectives/README.md
@@ -16,9 +16,9 @@
|---|---|---|---|---|---|---|---|
| **P0** | 0 | 0 | 0 | 0 | 0 | 44 | 44 |
| **P1** | 1 | 13 | 2 | 6 | 1 | 51 | 74 |
-| **P2** | 0 | 13 | 11 | 0 | 6 | 59 | 89 |
+| **P2** | 0 | 11 | 11 | 0 | 6 | 61 | 89 |
| **P3 (oos)** | 0 | 9 | 8 | 0 | 21 | 5 | 43 |
-| **total** | **1** | **35** | **21** | **6** | **28** | **159** | **250** |
+| **total** | **1** | **33** | **21** | **6** | **28** | **161** | **250** |
@@ -26,7 +26,7 @@
| Team Lead | Remaining |
|---|---|
-| [unassigned](../team-leads/unassigned.md) | 26 |
+| [unassigned](../team-leads/unassigned.md) | 25 |
| [asset-sprite](../team-leads/asset-sprite.md) | 6 |
| [shipwright](../team-leads/shipwright.md) | 5 |
| [simulator-infra](../team-leads/simulator-infra.md) | 4 |
@@ -80,8 +80,6 @@
| [p2-10](p2-10-regression-ci-gate.md) | 🟡 partial | Automated regression CI gate on every push to main | — | [testwright](../team-leads/testwright.md) | 2026-05-04 | 🟢 unblocked |
| [p2-18](p2-18-guide-public-deployment.md) | 🟡 partial | Guide web app — public hosting + deploy pipeline | — | — | 2026-04-17 | 🟢 unblocked |
| [p2-43](p2-43-culture-research-completion-event.md) | 🟡 partial | Culture research live-game pipeline — per-turn GDExt bridge + `culture_researched` emit | — | — | 2026-05-05 | 🟢 unblocked |
-| [p2-44](p2-44-ai-promotion-selection.md) | 🟡 partial | AI promotion selection — auto-pick + emit unit_promoted for AI units | — | — | 2026-05-05 | 🟢 unblocked |
-| [p2-44b](p2-44b-promotion-dispatch-instrumentation.md) | 🟡 partial | AI promotion dispatch — instrumentation pass to identify the silent gate | — | [unassigned](../team-leads/unassigned.md) | 2026-05-06 | 🟢 unblocked |
| [p2-46](p2-46-past-games-archive-replay-viewer.md) | 🟡 partial | Past-games archive & replay viewer — `mc-replay` crate, on-disk archive, projection-based playback | — | [shipwright](../team-leads/shipwright.md) | 2026-05-05 | 🟢 unblocked |
| [p2-47](p2-47-in-game-statistics-screens.md) | 🟡 partial | In-game statistics screens — Civ-style 5-tab modal (Demographics / Graphs / Rankings / Replay / Histories) | — | [shipwright](../team-leads/shipwright.md) | 2026-05-03 | 🟢 unblocked |
| [p2-48](p2-48-end-of-game-summary-screen.md) | 🟡 partial | End-of-game summary screen — outcome banner, standings, score graph, awards, timeline, footer actions | — | [shipwright](../team-leads/shipwright.md) | 2026-05-03 | 🟢 unblocked |
diff --git a/.project/objectives/objectives.json b/.project/objectives/objectives.json
index da3b054d..bb3e4082 100644
--- a/.project/objectives/objectives.json
+++ b/.project/objectives/objectives.json
@@ -1,9 +1,9 @@
{
- "generated_at": "2026-05-06T19:39:11Z",
+ "generated_at": "2026-05-06T21:46:06Z",
"totals": {
- "done": 159,
+ "done": 161,
"in_progress": 1,
- "partial": 35,
+ "partial": 33,
"stub": 21,
"missing": 6,
"oos": 28,
@@ -1778,9 +1778,9 @@
"id": "p2-44",
"title": "AI promotion selection — auto-pick + emit unit_promoted for AI units",
"priority": "p2",
- "status": "partial",
+ "status": "done",
"scope": "game1",
- "updated_at": "2026-05-05",
+ "updated_at": "2026-05-06",
"blocked_by": [],
"summary": "`EventBus.unit_promoted(unit, promotion_id)` is wired end-to-end on the\naudio side: the handler in `AudioManager` plays a UI confirmation chime,\nand `audio.json` ships the `unit_promoted` entry. But the signal is only\nemitted from one place:\n`src/game/engine/scenes/combat/promotion_picker.gd:120` — the modal the\n**human** uses to pick a promotion.\n\nAI units never go through that picker. Rust has the eligibility check\n(`mc_combat::check_promotion`) and the validation\n(`mc_combat::validate_promotion_choice`) but **no AI selection logic** —\nzero callers of `unit.promote(id)` for AI-owned units, verified by\n`grep -rn '\\.promote(' src/`.\n\nSo in any AI-vs-AI engagement, level-ups happen silently — the XP bar\nfills but no promotion is ever applied or signalled."
},
@@ -1799,7 +1799,7 @@
"id": "p2-44b",
"title": "AI promotion dispatch — instrumentation pass to identify the silent gate",
"priority": "p2",
- "status": "partial",
+ "status": "done",
"scope": "game1",
"owner": "unassigned",
"updated_at": "2026-05-06",
@@ -2886,7 +2886,7 @@
"remaining_by_lead": [
{
"owner": "unassigned",
- "remaining": 26
+ "remaining": 25
},
{
"owner": "asset-sprite",
diff --git a/.project/objectives/p2-44-ai-promotion-selection.md b/.project/objectives/p2-44-ai-promotion-selection.md
index f667a0f0..ab134b45 100644
--- a/.project/objectives/p2-44-ai-promotion-selection.md
+++ b/.project/objectives/p2-44-ai-promotion-selection.md
@@ -2,9 +2,9 @@
id: p2-44
title: AI promotion selection — auto-pick + emit unit_promoted for AI units
priority: p2
-status: partial
+status: done
scope: game1
-updated_at: 2026-05-05
+updated_at: 2026-05-06
evidence:
- src/simulator/crates/mc-ai/src/tactical/promotion.rs
- src/game/engine/src/modules/ai/ai_turn_bridge_dispatch.gd
@@ -61,15 +61,32 @@ fills but no promotion is ever applied or signalled.
direction overrode the older `turn_processor_signals.gd` plan; the same
signal still reaches AudioManager, throne-room counter, and the new
`auto_play.gd::_on_unit_promoted` events.jsonl writer.
-- [-] ◐ GUT end-to-end test deferred: the Rust unit tests cover the picker
- personality logic deterministically (5 tests in
- `tactical::promotion::tests`); the GDScript path is exercised by the
- existing dispatch test harness via `Action::PromotionPicked` JSON
- round-trip (covered by `tactical_port_regression.rs::action_serde_*`).
- A dedicated GUT scenario `test_p2_44_ai_promotion_end_to_end.gd` is the
- one acceptance bullet still open; the apricot batch verification gate
- (events.jsonl `unit_promoted` rows for AI units) likewise requires
- buildspace sync + 10-seed run that has not yet completed in this cycle.
+- [x] ✓ GUT end-to-end test landed at
+ `src/game/engine/tests/integration/test_p2_44_ai_promotion_end_to_end.gd`
+ (3/3 green, 0.4s headless). 10-seed apricot batch
+ `20260506_141905` (build `bb42a1d4d`) recorded **257 unit_promoted
+ events** across all 10 games (per-game range 9–59), confirming AI units
+ now promote and emit through the full chain into events.jsonl.
+
+## Cycle-31 close-out (2026-05-06)
+
+Three latent bugs blocked end-to-end emission after the cycle-3 picker
+landed; all three resolved in cycle-31:
+1. `data_loader.gd::_load_raw_category_dir` was clobbering the resources-loaded
+ `_raw["promotions"]` with the theme `manifest.json` pointer, hiding `trees`
+ from every accessor (cycle-30 fix in p2-44b).
+2. `_eligible_promotion_ids` typed-`String` ctor errored on JSON-`null`
+ `requires_promotion` / `excludes_flags` / `unit_type` because
+ `Dictionary.get(key, default)` returns the `null` value when the key
+ exists, bypassing the default. Fixed by `if dict.has(k) and dict[k] != null`
+ guards (cycle-31 commit `5217d39e9`).
+3. `scenes/tests/auto_play.gd` (the headless events.jsonl writer) was
+ missing `EventBus.unit_promoted.connect(_on_unit_promoted)` while every
+ other unit signal was connected. Added connect + handler matching the
+ `_on_unit_destroyed` shape (cycle-31 commit `bb42a1d4d`).
+
+Validation: batch `20260506_141905`, 10 seeds × 200 turns, 257 unit_promoted
+events total. Status flips done.
## Evidence
diff --git a/.project/objectives/p2-44b-promotion-dispatch-instrumentation.md b/.project/objectives/p2-44b-promotion-dispatch-instrumentation.md
index f6a63ee8..965ab5f7 100644
--- a/.project/objectives/p2-44b-promotion-dispatch-instrumentation.md
+++ b/.project/objectives/p2-44b-promotion-dispatch-instrumentation.md
@@ -2,7 +2,7 @@
id: p2-44b
title: AI promotion dispatch — instrumentation pass to identify the silent gate
priority: p2
-status: partial
+status: done
scope: game1
category: ai
owner: unassigned
|