feat(@projects/@magic-civilization): 🗺️ p3-25 step 2 — real city territory in the bench sim (culture border expansion) + projected

Rail-1 city-model unification, step 2: give the headless/bench simulation real,
growing territory so view_json carries it (toward "simulator provides everything").

- mc_city::CityState gains owned_tiles: Vec<(i32,i32)> (serde default; backward-compat).
- mc-turn::process_culture: the culture-ready list from CulturePool::tick_all was
  previously DROPPED (let _ready = ...). Now each ready city claims one contiguous,
  in-bounds frontier tile per turn into owned_tiles — real border expansion in Rust.
  Deterministic pick (lowest col,row among the unclaimed frontier); city centre owned
  implicitly via city_positions, materialised on first expansion; consume_expansion
  advances the threshold. Grid dims read before the &mut player borrow.
- mc-player-api projection: CityView.owned_tiles (schema field that existed but was
  stubbed Vec::new()) now projects CityState.owned_tiles, with a centre fallback so
  every city reports at least the tile it sits on.
- Fixed a pre-existing broken test (serde_roundtrip HappinessInput literal missing the
  building_happiness_effects/happiness_per_city_effects fields p3-24 added).

Verified: cargo test mc-city + mc-turn + mc-player-api 725/0, incl. new
culture_expansion_claims_frontier_tiles + projection_surfaces_city_owned_tiles. Rust-only
headless-path change; live game (presentation_cities) unaffected. Unblocks step 4
(trade sourcing from owned-tile resources). p3-25 steps 1-2 done; 3-6 remain.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
This commit is contained in:
Natalie 2026-06-26 01:18:24 -04:00
parent 922c18fb0c
commit 37fbb6153d
6 changed files with 170 additions and 17 deletions

View file

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

View file

@ -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": [

View file

@ -173,6 +173,15 @@ pub struct CityState {
/// per-slot in `City`, this widens to `(SpecialistId, slot)`.
#[serde(default)]
pub worker_expertise: BTreeMap<WorkerCategory, WorkerExpertise>,
/// 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(),
}
}
}

View file

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

View file

@ -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(&centre) = 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;

View file

@ -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(),
};