diff --git a/src/simulator/crates/mc-state/src/game_state.rs b/src/simulator/crates/mc-state/src/game_state.rs index e3d30a73..ccfc1771 100644 --- a/src/simulator/crates/mc-state/src/game_state.rs +++ b/src/simulator/crates/mc-state/src/game_state.rs @@ -439,6 +439,12 @@ pub struct GameState { /// no improvements build or yield (safe no-op). #[serde(skip)] pub improvement_defs: BTreeMap, + /// p3-26 B6a: raw recipe-bundle JSON (`{"recipes":[{building_id,consumes,produces}]}`) + /// from `public/resources/recipes/recipes.json`, boot-loaded. `#[serde(skip)]` static + /// content; the recipe phase parses it into a `RecipeRegistry` each turn (so mc-state + /// needs no mc-city dependency). Empty → no resource refinement runs (safe no-op). + #[serde(skip)] + pub recipes_json: String, /// p2-71: tactical-AI view of the producible-unit catalog. Mirrors /// `TacticalState::unit_catalog` and is populated once at harness boot /// by `GdPlayerApi::set_units_catalog_json` (or directly in Rust tests). @@ -776,6 +782,13 @@ impl GameState { n } + /// p3-26 B6a: store the raw recipe-bundle JSON (parsed into a RecipeRegistry by the + /// recipe phase each turn). Returns the byte length stored (0 if empty). Called at boot. + pub fn load_recipes_json(&mut self, json: &str) -> usize { + self.recipes_json = json.to_string(); + self.recipes_json.len() + } + /// p2-65 Phase7 test helper: construct a GameState whose combat_balance /// (and future SimConfig fields) are pre-populated without touching the /// global RwLock singleton. Callers that need isolated config for diff --git a/src/simulator/crates/mc-turn/src/lib.rs b/src/simulator/crates/mc-turn/src/lib.rs index 91b2ea77..18aa9b67 100644 --- a/src/simulator/crates/mc-turn/src/lib.rs +++ b/src/simulator/crates/mc-turn/src/lib.rs @@ -51,6 +51,8 @@ pub mod healing; pub mod ecology_phase; /// p3-26 B3 — tile-improvement build-tick. pub mod improvement_phase; +/// p3-26 B6a — resource-refinement (recipe) tick. +pub mod recipe_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/recipe_phase.rs b/src/simulator/crates/mc-turn/src/recipe_phase.rs new file mode 100644 index 00000000..1e8396af --- /dev/null +++ b/src/simulator/crates/mc-turn/src/recipe_phase.rs @@ -0,0 +1,98 @@ +//! p3-26 B6a — resource-refinement (recipe) tick. +//! +//! Wires `mc_city::recipes` into the headless turn: each player's processing +//! buildings consume raw resources and produce refined ones, transforming the +//! player's `strategic_ledger`. The recipe registry is parsed from the +//! boot-loaded `recipes_json` each turn (mc-state holds it as raw JSON so it +//! needs no mc-city dependency). No-op when no recipes are booted or the player +//! has no buildings. +//! +//! Registered in [`crate::sim_phases::END_OF_TURN_PHASES`]. The player's +//! `strategic_ledger` is loaded into a `ResourceStockpile` the recipes operate +//! on, then written back (capturing both consumed and produced deltas). + +use mc_city::recipes::{tick_recipes, RecipeRegistry}; +use mc_core::{BuildingId, ResourceId, ResourceStockpile}; +use mc_state::game_state::GameState; + +/// Run every player's processing-building recipes once. No-op without recipes. +pub fn process_recipe_phase(state: &mut GameState) { + if state.recipes_json.is_empty() { + return; + } + let registry = match RecipeRegistry::from_json(&state.recipes_json) { + Ok(r) if !r.is_empty() => r, + _ => return, + }; + for player in &mut state.players { + // Every processing building the player owns, across all cities. + let buildings: Vec = player + .city_buildings + .iter() + .flatten() + .map(|b| BuildingId::new(b.clone())) + .collect(); + if buildings.is_empty() { + continue; + } + // Load the player's resources into the stockpile the recipes operate on. + let mut stock = ResourceStockpile::new(); + for (id, qty) in &player.strategic_ledger { + stock.add(ResourceId::new(id.clone()), *qty); + } + let _outcomes = tick_recipes(&buildings, ®istry, &mut stock); + // Write the refined ledger back (captures produced + consumed deltas). + let mut ledger = std::collections::BTreeMap::new(); + for (rid, qty) in stock.entries() { + if *qty > 0 { + ledger.insert(rid.as_str().to_string(), *qty); + } + } + player.strategic_ledger = ledger; + } +} + +#[cfg(test)] +mod tests { + use super::*; + use mc_state::game_state::{GameState, PlayerState}; + + const RECIPES: &str = r#"{"recipes":[{"building_id":"smelter","consumes":[{"resource":"iron_ore","qty_per_turn":2}],"produces":[{"resource":"iron_ingot","qty_per_turn":1}]}]}"#; + + fn player_with(ledger: &[(&str, u32)], buildings: &[&str]) -> PlayerState { + let mut p = PlayerState::default(); + p.strategic_ledger = ledger.iter().map(|(k, v)| (k.to_string(), *v)).collect(); + p.city_buildings = vec![buildings.iter().map(|b| b.to_string()).collect()]; + p + } + + #[test] + fn recipe_phase_refines_resources() { + let mut state = GameState::default(); + state.recipes_json = RECIPES.to_string(); + state.players.push(player_with(&[("iron_ore", 10)], &["smelter"])); + process_recipe_phase(&mut state); + let led = &state.players[0].strategic_ledger; + assert_eq!(led.get("iron_ore").copied().unwrap_or(0), 8, "consumed 2 ore"); + assert_eq!(led.get("iron_ingot").copied().unwrap_or(0), 1, "produced 1 ingot"); + } + + #[test] + fn recipe_phase_idle_without_enough_input() { + let mut state = GameState::default(); + state.recipes_json = RECIPES.to_string(); + state.players.push(player_with(&[("iron_ore", 1)], &["smelter"])); // need 2 + process_recipe_phase(&mut state); + let led = &state.players[0].strategic_ledger; + assert_eq!(led.get("iron_ore").copied().unwrap_or(0), 1, "insufficient input → idle"); + assert_eq!(led.get("iron_ingot").copied().unwrap_or(0), 0, "nothing produced"); + } + + #[test] + fn recipe_phase_noop_without_recipes() { + let mut state = GameState::default(); + state.players.push(player_with(&[("iron_ore", 5)], &["smelter"])); + process_recipe_phase(&mut state); + assert_eq!(state.players[0].strategic_ledger.get("iron_ore").copied(), Some(5)); + } +} diff --git a/src/simulator/crates/mc-turn/src/sim_phases.rs b/src/simulator/crates/mc-turn/src/sim_phases.rs index 4cb67a57..34e7ec3e 100644 --- a/src/simulator/crates/mc-turn/src/sim_phases.rs +++ b/src/simulator/crates/mc-turn/src/sim_phases.rs @@ -25,6 +25,7 @@ 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), + ("recipe_refine", crate::recipe_phase::process_recipe_phase), ]; /// Run every registered end-of-turn phase in order. @@ -41,7 +42,10 @@ 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", "improvement_build"]); + assert_eq!( + names, + vec!["ecology", "healing", "improvement_build", "recipe_refine"] + ); } #[test]