feat(@projects/@magic-civilization): 🏗️ p3-26 B3 (2/4) — improvement subsystem logic (build-tick + dispatch + yields)

De-stubs the headless improvement pipeline:
- improvement_phase::process_improvement_build_phase — ticks pending_improvements down,
  completes at 0 → appends the improvement id to the owning city's city_improvements (so it
  yields). Registered in END_OF_TURN_PHASES (ecology, healing, improvement_build).
- dispatch::build_improvement — replaces the stub: validates via the action gate, finds the
  worker's owning city (CityState.owned_tiles, else first city), queues a PendingImprovement
  with turns_remaining from improvement_defs[id].build_turns. (improvement_id, previously
  dropped by the dispatcher, is now plumbed through.)
- apply_end_turn repopulates the fresh processor's improvement_yield_table from
  state.improvement_defs each turn, so process_improvement_yields actually folds food/
  production into cities (was a no-op — table never loaded in real play).

Tests: build-tick (3), dispatch queue (1), registry order. mc-turn 279/0, mc-player-api 136/0.
Remaining (3/4): FFI + harness to boot improvement_defs from public/resources/improvements.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
This commit is contained in:
Natalie 2026-06-26 19:40:08 -04:00
parent cb451832e0
commit f4e9d02115
4 changed files with 195 additions and 8 deletions

View file

@ -125,13 +125,10 @@ pub fn apply_action(
PlayerAction::FoundCity { unit_id } => {
invoke_unit_action(state, unit_id, ActionKind::FoundCity)
}
PlayerAction::BuildImprovement { unit_id, .. } => {
// Improvement id is captured on the wire but the underlying
// `invoke()` handler reads it from per-unit context; passing
// the action kind is sufficient at this layer. TRACKED: p2-67
// Phase 1 follow-up will plumb the improvement_id through.
invoke_unit_action(state, unit_id, ActionKind::BuildImprovement)
}
PlayerAction::BuildImprovement {
unit_id,
improvement_id,
} => build_improvement(state, unit_id, &improvement_id.0),
PlayerAction::PillageFriendly { unit_id } => {
invoke_unit_action(state, unit_id, ActionKind::PillageFriendly)
}
@ -409,6 +406,18 @@ fn apply_end_turn(state: &mut GameState, player: PlayerId) -> Result<Vec<Event>,
// single authored source; `load_authored_encounter_rates` bakes a
// build-time copy for this headless path (no GDScript DataLoader).
processor.load_authored_encounter_rates();
// p3-26 B3: the fresh-per-turn processor starts with an empty improvement
// yield table; repopulate it from the boot-loaded defs so completed
// improvements fold food/production into their owning cities.
for (id, def) in &state.improvement_defs {
processor.improvement_yield_table.insert(
id.clone(),
mc_turn::processor::ImprovementYieldEntry {
food: def.food,
production: def.production,
},
);
}
// Load the boot-loaded TechWeb so `process_science` auto-advances
// research (topological order) each turn. Without this the fresh
// per-turn processor has `tech_web_parsed: None` and research is frozen
@ -1478,6 +1487,57 @@ fn find_unit_indices(
})
}
/// p3-26 B3: begin a tile improvement on the worker's hex. Validates via the
/// action-handler gate (worker-keyword / terrain), then queues a
/// `PendingImprovement` credited to the owning city (the player's city whose
/// `owned_tiles` contains the worker's hex, else the first city). The build-tick
/// phase completes it after `build_turns` and folds the yields in. No-op if the
/// player has no city to credit or the improvement id is unknown (build_turns
/// defaults to 1).
fn build_improvement(
state: &mut GameState,
unit_id: &str,
improvement_id: &str,
) -> Result<Vec<Event>, ActionError> {
let unit_u32 = parse_unit_id(unit_id)?;
let (player_idx, unit_idx) = find_unit_indices(state, unit_u32)?;
action_handlers::invoke(state, player_idx, unit_idx, ActionKind::BuildImprovement).map_err(
|e| ActionError::IllegalAction {
message: format!("{e}"),
},
)?;
let (col, row) = {
let u = &state.players[player_idx].units[unit_idx];
(u.col, u.row)
};
let turns = state
.improvement_defs
.get(improvement_id)
.map(|d| d.build_turns)
.unwrap_or(1)
.max(1) as i32;
let player = &mut state.players[player_idx];
let city_idx = match player
.cities
.iter()
.position(|c| c.owned_tiles.contains(&(col, row)))
{
Some(i) => i,
None if !player.cities.is_empty() => 0,
None => return Ok(Vec::new()),
};
player
.pending_improvements
.push(mc_state::game_state::PendingImprovement {
col,
row,
improvement_id: improvement_id.to_string(),
city_idx,
turns_remaining: turns,
});
Ok(Vec::new())
}
fn invoke_unit_action(
state: &mut GameState,
unit_id: &str,
@ -2188,6 +2248,32 @@ mod tests {
state
}
#[test]
fn build_improvement_queues_pending_on_owning_city() {
use mc_city::CityState;
use mc_state::game_state::ImprovementDef;
let mut state = make_state_with_units(vec![(0, 1, 5, 5)]);
let mut city = CityState::default();
city.owned_tiles = vec![(5, 5)];
state.players[0].cities.push(city);
state.players[0].city_improvements = vec![vec![]];
state.improvement_defs.insert(
"farm".into(),
ImprovementDef {
build_turns: 3,
food: 2,
production: 0,
},
);
build_improvement(&mut state, "1", "farm").expect("build queues a pending improvement");
let pending = &state.players[0].pending_improvements;
assert_eq!(pending.len(), 1, "one pending improvement queued");
assert_eq!(pending[0].improvement_id, "farm");
assert_eq!(pending[0].city_idx, 0, "credited to the owning city");
assert_eq!(pending[0].turns_remaining, 3, "turns_remaining from build_turns def");
}
fn empty_state_with_one_unit(unit_id: u32) -> GameState {
make_state_with_units(vec![(0, unit_id, 0, 0)])
}

View file

@ -0,0 +1,98 @@
//! p3-26 B3 — tile-improvement build-tick.
//!
//! Mirrors the live `TurnProcessorHelpersScript.process_improvements`: each
//! `pending_improvement` ticks down `turns_remaining`; at 0 the improvement id is
//! appended to its owning city's `city_improvements` list, so from the next turn
//! `process_improvement_yields` folds its food/production into the city.
//!
//! Registered in [`crate::sim_phases::END_OF_TURN_PHASES`]. Runs end-of-turn, so
//! an improvement completed this turn begins yielding next turn (the yield phase
//! runs early in the per-player loop) — matching the live ordering.
use mc_state::game_state::GameState;
/// Advance every player's in-progress improvements by one turn and complete any
/// that reach zero turns remaining. No-op when nothing is under construction.
pub fn process_improvement_build_phase(state: &mut GameState) {
for player in &mut state.players {
if player.pending_improvements.is_empty() {
continue;
}
// Decrement, collecting completed indices to remove afterward.
let mut completed: Vec<usize> = Vec::new();
for (i, pending) in player.pending_improvements.iter_mut().enumerate() {
pending.turns_remaining -= 1;
if pending.turns_remaining <= 0 {
completed.push(i);
}
}
// Remove high-index-first so earlier indices stay valid.
for &i in completed.iter().rev() {
let done = player.pending_improvements.remove(i);
if done.city_idx >= player.city_improvements.len() {
player
.city_improvements
.resize(done.city_idx + 1, Vec::new());
}
player.city_improvements[done.city_idx].push(done.improvement_id);
}
}
}
#[cfg(test)]
mod tests {
use super::*;
use mc_state::game_state::{PendingImprovement, PlayerState};
fn player_with_pending(turns: i32, city_idx: usize) -> PlayerState {
PlayerState {
city_improvements: vec![vec![]],
pending_improvements: vec![PendingImprovement {
col: 2,
row: 3,
improvement_id: "farm".into(),
city_idx,
turns_remaining: turns,
}],
..PlayerState::default()
}
}
fn state_with(player: PlayerState) -> GameState {
let mut s = GameState::default();
s.players.push(player);
s
}
#[test]
fn build_ticks_down_without_completing() {
let mut s = state_with(player_with_pending(3, 0));
process_improvement_build_phase(&mut s);
assert_eq!(s.players[0].pending_improvements.len(), 1, "still building");
assert_eq!(s.players[0].pending_improvements[0].turns_remaining, 2);
assert!(s.players[0].city_improvements[0].is_empty(), "not yet placed");
}
#[test]
fn build_completes_and_places_on_city() {
let mut s = state_with(player_with_pending(1, 0));
process_improvement_build_phase(&mut s);
assert!(s.players[0].pending_improvements.is_empty(), "completed + removed");
assert_eq!(
s.players[0].city_improvements[0],
vec!["farm".to_string()],
"improvement appended to owning city"
);
}
#[test]
fn completion_resizes_city_improvements_if_needed() {
// city_idx 2 but city_improvements starts with 1 slot → resize.
let mut p = player_with_pending(1, 2);
p.city_improvements = vec![vec![]];
let mut s = state_with(p);
process_improvement_build_phase(&mut s);
assert_eq!(s.players[0].city_improvements.len(), 3);
assert_eq!(s.players[0].city_improvements[2], vec!["farm".to_string()]);
}
}

View file

@ -49,6 +49,8 @@ pub mod happiness_phase;
pub mod healing;
/// p3-27 — Per-turn ecology (fauna populations + flora succession) tick.
pub mod ecology_phase;
/// p3-26 B3 — tile-improvement build-tick.
pub mod improvement_phase;
/// End-of-turn world-simulation phase registry (ecology, healing, …).
pub mod sim_phases;
#[cfg(feature = "gpu")]

View file

@ -24,6 +24,7 @@ pub type SimPhaseFn = fn(&mut GameState);
pub const END_OF_TURN_PHASES: &[(&str, SimPhaseFn)] = &[
("ecology", crate::ecology_phase::process_ecology_phase),
("healing", crate::healing::process_healing_phase),
("improvement_build", crate::improvement_phase::process_improvement_build_phase),
];
/// Run every registered end-of-turn phase in order.
@ -40,7 +41,7 @@ mod tests {
#[test]
fn registry_lists_phases_in_documented_order() {
let names: Vec<&str> = END_OF_TURN_PHASES.iter().map(|(n, _)| *n).collect();
assert_eq!(names, vec!["ecology", "healing"]);
assert_eq!(names, vec!["ecology", "healing", "improvement_build"]);
}
#[test]