diff --git a/.project/objectives/p3-25-rail1-city-model-unify-headless-view-completeness.md b/.project/objectives/p3-25-rail1-city-model-unify-headless-view-completeness.md index 709e8531..b5eb720d 100644 --- a/.project/objectives/p3-25-rail1-city-model-unify-headless-view-completeness.md +++ b/.project/objectives/p3-25-rail1-city-model-unify-headless-view-completeness.md @@ -50,16 +50,20 @@ to `state.trade_ledger`, (d) projecting it all. ## Acceptance (sequenced; each step keeps cargo + GUT green) -- [~] **Step 1 — de-stub the projection for data already in bench state.** +- [x] **Step 1 — de-stub the projection for data already in bench state.** `DiplomacyView.{open_borders,shared_map,agreements_active}` now read the real OpenBorders/SharedMap entries from `state.trade_ledger` (no fabrication). **Done - 2026-06-26** (`projection_surfaces_open_borders_from_ledger`; mc-player-api projection - 32/0). `CityView.owned_tiles` left honestly TODO (center-only would mislead; fills in - Step 2 when expansion territory lands). -- [ ] **Step 2 — territory into the bench state.** Add a parallel per-city `owned_tiles` - store to `GameState`/`PlayerState` (mirroring `city_positions`), seed it at headless - `found_city` (canonical rule: center tile, per `City::found`), port border expansion - into the bench turn so it grows. Project `CityView.owned_tiles` from it. + 2026-06-26** (`projection_surfaces_open_borders_from_ledger`). +- [x] **Step 2 — territory into the bench state.** `mc_city::CityState` gains + `owned_tiles: Vec<(i32,i32)>` (serde default). `mc-turn::process_culture` now drives + **real border expansion** — the culture-ready list (previously dropped) claims one + contiguous, in-bounds frontier tile per ready city per turn into `owned_tiles` + (deterministic lowest-`(col,row)` pick; centre owned implicitly from `city_positions`, + materialised on first expansion). `projection.rs` projects `CityView.owned_tiles` from + it with a centre fallback. **Done 2026-06-26** — `culture_expansion_claims_frontier_tiles` + (mc-turn) + `projection_surfaces_city_owned_tiles` (mc-player-api); fixed a pre-existing + broken `serde_roundtrip` HappinessInput literal in passing. cargo mc-city+mc-turn+ + mc-player-api **725/0**. - [ ] **Step 3 — resource-category catalog in Rust.** Load luxury/strategic categories (currently GDScript-only via DataLoader) into Rust via a `set_resources_catalog_json` + storage, so the sim can classify owned-tile resources. diff --git a/public/games/age-of-dwarves/data/objectives.json b/public/games/age-of-dwarves/data/objectives.json index cdd63a99..5236ff87 100644 --- a/public/games/age-of-dwarves/data/objectives.json +++ b/public/games/age-of-dwarves/data/objectives.json @@ -1,12 +1,12 @@ { - "generated_at": "2026-06-26T04:43:46Z", + "generated_at": "2026-06-26T05:18:24Z", "totals": { + "done": 296, "in_progress": 0, + "oos": 31, + "partial": 2, "stub": 0, "missing": 0, - "partial": 2, - "done": 296, - "oos": 31, "total": 329 }, "objectives": [ diff --git a/src/simulator/crates/mc-city/src/lib.rs b/src/simulator/crates/mc-city/src/lib.rs index 8a4d18e0..1e43ccac 100644 --- a/src/simulator/crates/mc-city/src/lib.rs +++ b/src/simulator/crates/mc-city/src/lib.rs @@ -173,6 +173,15 @@ pub struct CityState { /// per-slot in `City`, this widens to `(SpecialistId, slot)`. #[serde(default)] pub worker_expertise: BTreeMap, + + /// p3-25: tiles this city controls, as offset `(col, row)`. Empty on a fresh + /// bench city — the city centre is derived from `PlayerState.city_positions` + /// until the first culture-driven border expansion claims a frontier tile + /// (mc-turn `process_culture`). Projected as `CityView.owned_tiles` so the + /// headless view carries real territory. `#[serde(default)]` keeps older + /// saves loadable. + #[serde(default)] + pub owned_tiles: Vec<(i32, i32)>, } fn default_population() -> u32 { @@ -200,6 +209,7 @@ impl CityState { hp: default_city_hp(), max_hp: default_city_hp(), worker_expertise: BTreeMap::new(), + owned_tiles: Vec::new(), } } } diff --git a/src/simulator/crates/mc-player-api/src/projection.rs b/src/simulator/crates/mc-player-api/src/projection.rs index 6c709f82..d619b7c7 100644 --- a/src/simulator/crates/mc-player-api/src/projection.rs +++ b/src/simulator/crates/mc-player-api/src/projection.rs @@ -282,10 +282,15 @@ fn project_cities( food_growth_threshold: 0.0, production_queue, buildings, - // TRACKED: per-city owned_tiles list. mc-city's CityState doesn't - // carry it on the bench struct — comes with city.rs full City when - // the bench grows tile ownership. - owned_tiles: Vec::new(), + // p3-25: real territory. CityState.owned_tiles is empty on a fresh + // city (centre owned implicitly) and grows via culture border + // expansion in mc-turn::process_culture; fall back to the centre so + // every city reports at least the tile it sits on. + owned_tiles: if city.owned_tiles.is_empty() { + vec![position] + } else { + city.owned_tiles.iter().map(|&(c, r)| [c, r]).collect() + }, yields, hp: 100, max_hp: 100, @@ -2002,6 +2007,38 @@ mod tests { assert!(view.diplomacy[0].pending_envelopes.is_empty()); } + /// p3-25 step 2: CityView.owned_tiles carries real territory from + /// CityState.owned_tiles, falling back to the city centre when the store is + /// empty (fresh city before its first culture border expansion). + #[test] + fn projection_surfaces_city_owned_tiles() { + let mut state = GameState::default(); + state.turn = 1; + state.grid = Some(mc_core::grid::GridState::new(20, 20)); + let mut a = PlayerState::default(); + a.player_index = 0; + a.city_positions = vec![(3, 4), (7, 8)]; + let mut c0 = mc_city::CityState::starter(); + c0.owned_tiles = vec![(3, 4), (3, 5)]; + let c1 = mc_city::CityState::starter(); + a.cities = vec![c0, c1]; + state.players.push(a); + + let view = project_view(&state, 0, /*omniscient=*/ true); + assert_eq!(view.cities.len(), 2); + assert!( + view.cities[0].owned_tiles.contains(&[3, 4]) + && view.cities[0].owned_tiles.contains(&[3, 5]), + "explicit owned tiles must surface: {:?}", + view.cities[0].owned_tiles + ); + assert_eq!( + view.cities[1].owned_tiles, + vec![[7, 8]], + "empty store falls back to the city centre" + ); + } + /// p3-24 rail-1: the diplomacy projection reads real OpenBorders/SharedMap /// agreement state from `state.trade_ledger` (replacing the former hardcoded /// `false`/empty stubs). diff --git a/src/simulator/crates/mc-turn/src/processor.rs b/src/simulator/crates/mc-turn/src/processor.rs index 7173c212..b55bfe75 100644 --- a/src/simulator/crates/mc-turn/src/processor.rs +++ b/src/simulator/crates/mc-turn/src/processor.rs @@ -920,6 +920,13 @@ impl TurnProcessor { /// The live game calls `mc_culture::CulturePool` via `GdCulture` and reacts /// to the ready list on the GDScript side. fn process_culture(&self, state: &mut GameState, pi: usize) { + // Grid dims read before the &mut player borrow (needed for in-bounds + // candidate filtering during border expansion). + let (gw, gh) = state + .grid + .as_ref() + .map(|g| (g.width, g.height)) + .unwrap_or((0, 0)); let player = &mut state.players[pi]; let culture_axis = *player.strategic_axes.get("culture").unwrap_or(&2); let per_city_yield = culture_axis as f64 * Self::BENCH_CULTURE_PER_AXIS_POINT; @@ -933,7 +940,57 @@ impl TurnProcessor { } // Single accumulation call site across the Rust workspace. - let _ready_for_expansion = player.culture_pool.tick_all(); + let ready_for_expansion = player.culture_pool.tick_all(); + + // p3-25: drive real border expansion. Each culture-ready city claims one + // contiguous, in-bounds frontier tile into its `owned_tiles` so the + // headless view carries growing territory (previously the ready list was + // dropped). The bench owns the city centre implicitly (from + // `city_positions`); the first expansion materialises `[centre, frontier]`. + // Deterministic pick = lowest `(col,row)` among the unclaimed frontier + // (reproducible; not yield-scored like the live game — bench is reduced). + if gw > 0 && gh > 0 && !ready_for_expansion.is_empty() { + use std::collections::BTreeSet; + let mut claimed: BTreeSet<(i32, i32)> = BTreeSet::new(); + for (ci, city) in player.cities.iter().enumerate() { + if city.owned_tiles.is_empty() { + if let Some(¢re) = player.city_positions.get(ci) { + claimed.insert(centre); + } + } else { + claimed.extend(city.owned_tiles.iter().copied()); + } + } + for &ci in &ready_for_expansion { + let centre = match player.city_positions.get(ci) { + Some(&c) => c, + None => continue, + }; + let owned: Vec<(i32, i32)> = if player.cities[ci].owned_tiles.is_empty() { + vec![centre] + } else { + player.cities[ci].owned_tiles.clone() + }; + let mut candidates: BTreeSet<(i32, i32)> = BTreeSet::new(); + for &(col, row) in &owned { + for n in mc_core::algorithms::hex::offset_neighbors(col, row, gw, gh) { + if !claimed.contains(&n) { + candidates.insert(n); + } + } + } + if let Some(&pick) = candidates.iter().next() { + let ot = &mut player.cities[ci].owned_tiles; + if ot.is_empty() { + ot.push(centre); + } + ot.push(pick); + claimed.insert(pick); + player.culture_pool.consume_expansion(ci); + } + } + } + player.culture_total = player.culture_pool.culture_total; } @@ -5489,6 +5546,49 @@ mod tests { assert!(cfg.encounter_radius(7) <= cfg.encounter_radius(10)); } + #[test] + fn culture_expansion_claims_frontier_tiles() { + // p3-25: culture-ready cities claim contiguous frontier tiles into + // CityState.owned_tiles (previously the ready list was dropped). + use mc_core::grid::GridState; + let mut state = GameState::default(); + state.grid = Some(GridState::new(12, 12)); + let mut p = crate::game_state::PlayerState::default(); + p.player_index = 0; + p.cities = vec![CityState::starter()]; + p.city_positions = vec![(5, 5)]; + p.strategic_axes.insert("culture".to_string(), 9u8); + state.players.push(p); + + let processor = TurnProcessor::new(1); + for _ in 0..80 { + processor.process_culture(&mut state, 0); + } + + let owned = &state.players[0].cities[0].owned_tiles; + assert!( + owned.contains(&(5, 5)), + "centre must be materialised once an expansion fires" + ); + assert!( + owned.len() >= 2, + "culture should claim ≥1 frontier tile beyond centre (got {owned:?})" + ); + // No duplicate claims. + let mut sorted = owned.clone(); + sorted.sort(); + sorted.dedup(); + assert_eq!(sorted.len(), owned.len(), "owned tiles must be unique"); + // Every claimed tile is in-bounds. + for &(c, r) in owned { + assert!( + (0..12).contains(&c) && (0..12).contains(&r), + "claimed tile {:?} out of bounds", + (c, r) + ); + } + } + #[test] fn processor_is_deterministic() { use mc_core::grid::GridState; diff --git a/src/simulator/crates/mc-turn/tests/serde_roundtrip.rs b/src/simulator/crates/mc-turn/tests/serde_roundtrip.rs index 6fc32c96..6b5a344e 100644 --- a/src/simulator/crates/mc-turn/tests/serde_roundtrip.rs +++ b/src/simulator/crates/mc-turn/tests/serde_roundtrip.rs @@ -303,6 +303,8 @@ fn happiness_pool_json_roundtrip_is_stable() { total_citizens: 18, units_in_enemy_territory: 2, building_happiness: 9, + building_happiness_effects: vec![2, 3], + happiness_per_city_effects: vec![1], owned_luxuries, growth_tier: "balanced".to_string(), };