From f4e9d02115bd0777954ae6110527745e346362f9 Mon Sep 17 00:00:00 2001 From: Natalie Date: Fri, 26 Jun 2026 19:40:08 -0400 Subject: [PATCH] =?UTF-8?q?feat(@projects/@magic-civilization):=20?= =?UTF-8?q?=F0=9F=8F=97=EF=B8=8F=20p3-26=20B3=20(2/4)=20=E2=80=94=20improv?= =?UTF-8?q?ement=20subsystem=20logic=20(build-tick=20+=20dispatch=20+=20yi?= =?UTF-8?q?elds)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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) --- .../crates/mc-player-api/src/dispatch.rs | 100 ++++++++++++++++-- .../crates/mc-turn/src/improvement_phase.rs | 98 +++++++++++++++++ src/simulator/crates/mc-turn/src/lib.rs | 2 + .../crates/mc-turn/src/sim_phases.rs | 3 +- 4 files changed, 195 insertions(+), 8 deletions(-) create mode 100644 src/simulator/crates/mc-turn/src/improvement_phase.rs diff --git a/src/simulator/crates/mc-player-api/src/dispatch.rs b/src/simulator/crates/mc-player-api/src/dispatch.rs index b6b5fb13..7d422ccc 100644 --- a/src/simulator/crates/mc-player-api/src/dispatch.rs +++ b/src/simulator/crates/mc-player-api/src/dispatch.rs @@ -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, // 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, 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)]) } diff --git a/src/simulator/crates/mc-turn/src/improvement_phase.rs b/src/simulator/crates/mc-turn/src/improvement_phase.rs new file mode 100644 index 00000000..e23f09e3 --- /dev/null +++ b/src/simulator/crates/mc-turn/src/improvement_phase.rs @@ -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 = 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()]); + } +} diff --git a/src/simulator/crates/mc-turn/src/lib.rs b/src/simulator/crates/mc-turn/src/lib.rs index 9240a57b..91b2ea77 100644 --- a/src/simulator/crates/mc-turn/src/lib.rs +++ b/src/simulator/crates/mc-turn/src/lib.rs @@ -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")] diff --git a/src/simulator/crates/mc-turn/src/sim_phases.rs b/src/simulator/crates/mc-turn/src/sim_phases.rs index 6cc88c82..4cb67a57 100644 --- a/src/simulator/crates/mc-turn/src/sim_phases.rs +++ b/src/simulator/crates/mc-turn/src/sim_phases.rs @@ -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]