diff --git a/.project/objectives/p2-71b-militarist-starter-widening.md b/.project/objectives/p2-71b-militarist-starter-widening.md new file mode 100644 index 00000000..22410513 --- /dev/null +++ b/.project/objectives/p2-71b-militarist-starter-widening.md @@ -0,0 +1,84 @@ +--- +id: p2-71b +title: "Militarist starter widening — add a settler/founder unit so FoundCity fires" +priority: p2 +status: open +scope: game1 +category: simulation +owner: simulator-infra +created: 2026-05-12 +updated_at: 2026-05-12 +blocked_by: [] +follow_ups: [p2-67, p2-68, p2-71] +--- + +## Context + +`p2-71` (bench projector enrichment) shipped catalog/personality flow end-to-end and proved the AI emits one `EnqueueBuild` per AI slot on turn 1. Turns 2-5 emit zero because: + +1. Single-slot queue saturates (warrior already queued from turn 1; `pick_for_city` skips non-empty queues). +2. **Militarist starter has no settler/founder** — `Action::FoundCity` never fires. +3. Idle warriors find no enemy/resource within scout range → no `MoveUnit` emitted. +4. `decide_tactical_actions` does not emit fallback `Fortify` for stationary military. + +This objective addresses (2): widen the militarist starter so `FoundCity` becomes a legal action immediately. Cleanest fix to AI inertness past turn 0. + +## Source-of-truth rails + +- **Rust crate**: `mc-turn::game_state` (where `add_player_militarist` lives — verified via grep). +- **JSON path**: `public/games/age-of-dwarves/data/units/dwarf_settler.json` (canonical settler/founder unit; verify name). +- **GDScript**: harness wiring only — no GDScript changes. + +## Surface + +### 1. Add settler to `add_player_militarist` + +```rust +// Existing: spawns 3 dwarf_warriors at (capital_col, capital_row). +// New: also spawn 1 dwarf_settler (or whatever the canonical founder is) +// at the same location with movement_remaining = base_moves. +``` + +Settler unit-id resolved from data — do NOT hardcode the string in Rust beyond a single resolution call (`mc_units::UnitsCatalog::find_founder()` if such a helper exists, otherwise use a known id with a comment citing the JSON file). + +### 2. Verify FoundCity threshold in mc-ai + +`mc-ai/src/tactical/...` — confirm `decide_tactical_actions` emits `Action::FoundCity` when a founder unit exists and is on a foundable tile. If not, document why and file the gap as a follow-up. + +### 3. Re-run smoke + +`scripts/claude-smoke-5endturn.sh` after the change. Acceptance: AI slots emit `actions_applied > 0` on at least 3 of turns 1-5. Per personality, slot 1 (blackhammer) vs slot 2 (deepforge) should produce different action chains. + +## Acceptance + +- ☐ `add_player_militarist` spawns a settler/founder alongside the warriors. +- ☐ Settler unit-id resolved from data, not hardcoded beyond a documented citation. +- ☐ 5-EndTurn smoke: `actions_applied > 0` on at least 3 of turns 1-5 for both AI slots. +- ☐ AI chains differ by personality across the 5-turn span. +- ☐ Unit test: `add_player_militarist` post-state has 4 units (3 warriors + 1 settler), settler on the capital hex. +- ☐ `cargo check --workspace && cargo test --workspace` green. +- ☐ p2-68 acceptance bullet "smoke-non-trivial-AI-chains" flips ⚠ → ✓. +- ☐ p2-71 acceptance bullet "5-EndTurn smoke" flips ⚠ → ✓ (or moves to p2-71a as the remaining gap). + +## Why this size + +- Settler spawn: ~30 min. +- `find_founder` resolver (if needed): ~30 min. +- Smoke verification + test authoring: ~1 hr. +- Cargo gates + objective updates: ~30 min. + +**Total: ~2-3 hours.** + +## Unblocks + +- p2-71 acceptance bullet — smoke turns 2-5 emit. +- p2-68 final acceptance bullet flips ✓ → status `done`. +- p2-67 Phase 13 has real multi-turn AI behaviour to capture screenshots of. + +## References + +- `src/simulator/crates/mc-turn/src/game_state.rs::add_player_militarist` — call site to widen. +- `src/simulator/crates/mc-units/src/catalog.rs` — catalog API. +- `public/games/age-of-dwarves/data/units/*.json` — settler/founder unit definitions. +- `scripts/claude-smoke-5endturn.sh` — verification smoke. +- `.project/objectives/p2-71-bench-projector-enrichment.md` (findings section). diff --git a/src/simulator/api-gdext/src/lib.rs b/src/simulator/api-gdext/src/lib.rs index 67182c00..c5f3561b 100644 --- a/src/simulator/api-gdext/src/lib.rs +++ b/src/simulator/api-gdext/src/lib.rs @@ -2980,7 +2980,9 @@ impl GdGameState { /// Add a player with the militarist strategic profile (expansion=2, /// production=5, wealth=2, culture=2) and one starter city at /// `(city_col, city_row)`. The player starts with 3 dwarf_warrior - /// units adjacent to the city. Returns the new player's index. + /// units adjacent to the capital and 1 dwarf_founder co-located with + /// it so `Action::FoundCity` becomes a legal turn-1 AI action + /// (p2-71b). Returns the new player's index. #[func] fn add_player_militarist(&mut self, city_col: i64, city_row: i64) -> i64 { let pi = self.inner.players.len() as u8; @@ -2992,7 +2994,7 @@ impl GdGameState { let city_col = city_col as i32; let city_row = city_row as i32; - let units: Vec = (0..3) + let mut units: Vec = (0..3) .map(|i| { let unit_u32 = self.inner.next_unit_id; self.inner.next_unit_id = self.inner.next_unit_id.saturating_add(1); @@ -3013,6 +3015,31 @@ impl GdGameState { }) .collect(); + // p2-71b — spawn a founder co-located with the capital so the AI's + // settle dispatch sees a `can_found_city` unit on turn 1. + // Unit id matches `public/resources/units/dwarf_founder.json` (the + // canonical Game-1 founder). Cited rather than catalog-resolved + // because the bench harness loads `ai_unit_catalog` (TacticalUnitSpec) + // but not the runtime `UnitsCatalog` (UnitStats); the id is the only + // founder Game-1 ships. + let founder_id = self.inner.next_unit_id; + self.inner.next_unit_id = self.inner.next_unit_id.saturating_add(1); + units.push(mc_turn::MapUnit { + id: founder_id, + col: city_col, + row: city_row, + hp: 30, + max_hp: 30, + attack: 0, + defense: 0, + is_fortified: false, + unit_id: "dwarf_founder".to_string(), + held_resources: Vec::new(), + patrol_order: None, + auto_join: false, + ..Default::default() + }); + self.inner.players.push(mc_turn::PlayerState { player_index: pi, gold: 60, diff --git a/src/simulator/crates/mc-player-api/src/projection.rs b/src/simulator/crates/mc-player-api/src/projection.rs index 8747bd63..a1790661 100644 --- a/src/simulator/crates/mc-player-api/src/projection.rs +++ b/src/simulator/crates/mc-player-api/src/projection.rs @@ -610,7 +610,14 @@ fn project_tactical_player( // can plan a single move per turn. moves_left: 2, fortified: u.is_fortified, - can_found_city: false, + // p2-71b — flip the founder flag for the canonical Game-1 + // founder so `TacticalUnit::is_founder()` returns true and the + // settle dispatch emits `Action::FoundCity`. Sourced from + // `public/resources/units/dwarf_founder.json::can_found_city`. + // The bench harness does not load the runtime `UnitsCatalog`, + // so this is the single citation site rather than a generic + // catalog lookup. Add new founder ids here as Game-1 grows. + can_found_city: is_founder_unit_id(&u.unit_id), patrol_order: None, is_siege: false, is_deployed: u.is_deployed, @@ -698,6 +705,17 @@ fn project_tactical_player( /// Render a `Queueable` as its id string (matches the AI's /// `production_queue: Vec` shape). +/// p2-71b — recognise canonical Game-1 founder unit ids. Drives +/// `TacticalUnit::can_found_city` projection. The bench / claude-player +/// harness does not load the runtime `UnitsCatalog`, so we keep an +/// explicit allow-list here citing `public/resources/units/*.json`. +/// Add new founder ids here as Game-1 grows. The downstream +/// `TacticalUnit::is_founder()` also falls back to matching the generic +/// `"settler"` / `"founder"` kind strings used by Rust test fixtures. +fn is_founder_unit_id(unit_id: &str) -> bool { + matches!(unit_id, "dwarf_founder") +} + fn queueable_id(q: &mc_city::production::Queueable) -> String { use mc_city::production::Queueable; match q { @@ -1049,6 +1067,47 @@ mod tests { assert!((t.difficulty_threshold_mult - 1.5).abs() < 1e-6); } + #[test] + fn tactical_dwarf_founder_projects_can_found_city_true() { + // p2-71b — `add_player_militarist` spawns a `dwarf_founder` alongside + // 3 `dwarf_warrior` units. The tactical projection must mark the + // founder with `can_found_city: true` so `TacticalUnit::is_founder()` + // returns true and `mc_ai::tactical::settle::decide_settle` can emit + // `Action::FoundCity`. + let mut state = GameState::default(); + let mut ps = PlayerState::default(); + ps.player_index = 0; + let mut warrior = MapUnit::default(); + warrior.id = 1; + warrior.unit_id = "dwarf_warrior".into(); + warrior.hp = 60; + warrior.max_hp = 60; + ps.units.push(warrior); + let mut founder = MapUnit::default(); + founder.id = 2; + founder.unit_id = "dwarf_founder".into(); + founder.hp = 30; + founder.max_hp = 30; + ps.units.push(founder); + state.players.push(ps); + + let t = project_tactical(&state, 0); + let warrior_t = t.players[0].units.iter().find(|u| u.id == 1).unwrap(); + let founder_t = t.players[0].units.iter().find(|u| u.id == 2).unwrap(); + assert!( + !warrior_t.can_found_city, + "warrior must not be tagged as a founder" + ); + assert!( + founder_t.can_found_city, + "dwarf_founder must project can_found_city = true" + ); + assert!( + founder_t.is_founder(), + "TacticalUnit::is_founder() must pick up the projected flag" + ); + } + #[test] fn tactical_round_trips_through_json() { // serde shape parity: projector output must survive a JSON