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:
parent
922c18fb0c
commit
37fbb6153d
6 changed files with 170 additions and 17 deletions
|
|
@ -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.
|
||||
|
|
|
|||
|
|
@ -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": [
|
||||
|
|
|
|||
|
|
@ -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(),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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).
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
|
|
|
|||
|
|
@ -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(),
|
||||
};
|
||||
|
|
|
|||
Loading…
Add table
Reference in a new issue