feat(view): project CityView.culture_stored from the per-city CulturePool (Rail-1 Phase 0)

The live city_screen reads a per-city culture meter off the GDScript CityScript;
view_json had no equivalent, so the UI-pure-view migration couldn't render it from
getState(). project_cities now surfaces culture_stored from
PlayerState.culture_pool.city(c_idx) (the same accumulator mc-turn::process_culture
ticks for border expansion); 0.0 for a city with no pool entry.

Closes the last genuine Phase-0 projection gap (UnitView equipped/experience/movement/
posture + ResourceView golden_age were already projected — design-doc table was stale).

Test: projection_surfaces_city_culture_stored. mc-player-api lib 142/0.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
This commit is contained in:
Natalie 2026-06-27 15:05:34 -04:00
parent 0c50c04b4c
commit 04763a3870
2 changed files with 46 additions and 0 deletions

View file

@ -286,6 +286,15 @@ fn project_cities(
// and adapters can call view() across turns to observe progress.
// TRACKED: surface the canonical threshold via mc-city.
food_growth_threshold: 0.0,
// p3-25/Phase-0: real per-city culture accumulator from the
// player's CulturePool (the same value border expansion ticks
// in mc-turn::process_culture). 0.0 when the city has no pool
// entry yet (registered lazily on first culture yield).
culture_stored: player
.culture_pool
.city(c_idx)
.map(|cc| cc.culture_stored as f32)
.unwrap_or(0.0),
production_queue,
buildings,
// p3-25: real territory. CityState.owned_tiles is empty on a fresh
@ -2142,6 +2151,38 @@ mod tests {
);
}
/// p3-25 Phase-0: `CityView.culture_stored` surfaces the per-city culture
/// accumulator from the player's `CulturePool` (the same meter the live
/// `city_screen` reads + border expansion ticks). A city with no pool entry
/// projects `0.0`.
#[test]
fn projection_surfaces_city_culture_stored() {
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)];
a.cities = vec![mc_city::CityState::starter(), mc_city::CityState::starter()];
// City 0 accrues culture via its pool; city 1 has no pool entry.
a.culture_pool.register_city(0, 5.0);
a.culture_pool.tick_all();
a.culture_pool.tick_all();
state.players.push(a);
let view = project_view(&state, 0, /*omniscient=*/ true);
assert_eq!(view.cities.len(), 2);
assert!(
(view.cities[0].culture_stored - 10.0).abs() < 1e-3,
"registered city's stored culture must surface: {}",
view.cities[0].culture_stored
);
assert_eq!(
view.cities[1].culture_stored, 0.0,
"city with no pool entry projects 0.0"
);
}
/// 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

@ -142,6 +142,11 @@ pub struct CityView {
pub food_stored: f32,
/// Food needed for the next growth event.
pub food_growth_threshold: f32,
/// Culture accumulated toward the next border expansion (mirrors the live
/// `CityScript` culture meter the `city_screen` reads). Sourced from the
/// player's `CulturePool` per-city state; `0.0` for a city with no pool entry.
#[serde(default)]
pub culture_stored: f32,
/// Current production queue.
pub production_queue: Vec<ProductionQueueEntry>,
/// Buildings completed in this city.