feat(@projects/@magic-civilization): ⚒️ p3-26 B6a (1/2) — resource-refinement (recipe) tick in the headless turn

Wires the previously-unwired mc_city::recipes system into the turn:
- GameState += recipes_json (#[serde(skip)] boot) + load_recipes_json (mc-state holds the raw
  bundle JSON; no mc-city dep).
- recipe_phase::process_recipe_phase — per player, loads strategic_ledger into a
  ResourceStockpile, runs tick_recipes over the player's processing buildings (consume raw →
  produce refined), writes the transformed ledger back. Registered in END_OF_TURN_PHASES
  (ecology, healing, improvement_build, recipe_refine).

Refined resources land in strategic_ledger (which trade/economy already use), so it's a real
economic transform, not inert. Tests: refines, idle-on-shortage, no-op-without-recipes.
mc-turn recipe_phase 3/3. Remaining (2/2): FFI + harness to boot recipes.json.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
This commit is contained in:
Natalie 2026-06-26 20:49:04 -04:00
parent 45d52c8ce6
commit dd1a537ab5
4 changed files with 118 additions and 1 deletions

View file

@ -439,6 +439,12 @@ pub struct GameState {
/// no improvements build or yield (safe no-op).
#[serde(skip)]
pub improvement_defs: BTreeMap<String, ImprovementDef>,
/// 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

View file

@ -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")]

View file

@ -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<BuildingId> = 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, &registry, &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));
}
}

View file

@ -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]