diff --git a/src/simulator/crates/mc-city/src/city.rs b/src/simulator/crates/mc-city/src/city.rs index 5754af3b..f3f71a4f 100644 --- a/src/simulator/crates/mc-city/src/city.rs +++ b/src/simulator/crates/mc-city/src/city.rs @@ -497,12 +497,18 @@ impl City { // ── Production queue (carried from production.rs, unchanged) ── /// Validate, charge, and enqueue an item into its producer building's queue. + /// + /// `available_resources` is the set of strategic-resource ids the owning + /// player currently controls (via worked city tiles). If the item's + /// `requires_resource` is set and missing from this set, enqueue fails + /// with `QueueError::MissingResource` and nothing is consumed. pub fn enqueue_item( &mut self, item_id: &str, registry: &ItemRegistry, stockpile: &mut Stockpile, researched_techs: &HashSet, + available_resources: &HashSet, ) -> Result<(), QueueError> { let def = registry .get(item_id) @@ -535,6 +541,15 @@ impl City { } } + if let Some(resource) = &def.requires_resource { + if !available_resources.contains(resource) { + return Err(QueueError::MissingResource { + item_id: def.id.clone(), + resource: resource.clone(), + }); + } + } + // Atomic material check for cost in &def.materials { if !stockpile.has(&cost.resource, cost.amount) { @@ -825,10 +840,12 @@ mod tests { building: "smithy".into(), secondary_building: None, requires_tech: Some("bronze_working".into()), + requires_resource: None, materials: vec![MaterialCost { resource: "iron_ore".into(), amount: 2 }], }); let techs: HashSet = ["bronze_working"].iter().map(|s| s.to_string()).collect(); - city.enqueue_item("iron_axe", ®istry, &mut stockpile, &techs).unwrap(); + let resources: HashSet = HashSet::new(); + city.enqueue_item("iron_axe", ®istry, &mut stockpile, &techs, &resources).unwrap(); let done = city.tick_building("smithy", 30).unwrap(); assert_eq!(done.len(), 1); } diff --git a/src/simulator/crates/mc-city/src/production.rs b/src/simulator/crates/mc-city/src/production.rs index 8b8465e1..d9ed68c9 100644 --- a/src/simulator/crates/mc-city/src/production.rs +++ b/src/simulator/crates/mc-city/src/production.rs @@ -29,6 +29,10 @@ pub struct ItemDef { pub secondary_building: Option, #[serde(default)] pub requires_tech: Option, + /// Strategic resource that the owning player must control (on any of + /// their worked tiles) for this item to be queueable. `None` = no gate. + #[serde(default)] + pub requires_resource: Option, #[serde(default)] pub materials: Vec, } @@ -168,6 +172,8 @@ pub enum QueueError { MissingSecondary { item_id: String, building: String }, /// The item's required tech has not been researched yet. TechLocked { item_id: String, tech: String }, + /// The item needs a strategic resource the owning player does not control. + MissingResource { item_id: String, resource: String }, /// Stockpile lacks the materials. Wraps the underlying StockpileError. InsufficientMaterials { item_id: String, source: StockpileError }, /// Caller asked to enqueue into a building this city does not have. @@ -193,6 +199,11 @@ impl std::fmt::Display for QueueError { "cannot craft {}: tech {} not yet researched", item_id, tech ), + Self::MissingResource { item_id, resource } => write!( + f, + "cannot craft {}: requires strategic resource {}", + item_id, resource + ), Self::InsufficientMaterials { item_id, source } => { write!(f, "cannot craft {}: {}", item_id, source) } @@ -222,6 +233,7 @@ mod tests { building: "smithy".into(), secondary_building: None, requires_tech: Some("bronze_working".into()), + requires_resource: None, materials: vec![MaterialCost { resource: "iron_ore".into(), amount: 2, @@ -236,6 +248,7 @@ mod tests { building: "forge".into(), secondary_building: Some("tannery".into()), requires_tech: Some("steelworking".into()), + requires_resource: None, materials: vec![ MaterialCost { resource: "iron_ore".into(), @@ -260,6 +273,14 @@ mod tests { ids.iter().map(|s| s.to_string()).collect() } + fn resources(ids: &[&str]) -> HashSet { + ids.iter().map(|s| s.to_string()).collect() + } + + fn no_res() -> HashSet { + HashSet::new() + } + #[test] fn happy_path_enqueue_then_tick_to_completion() { let mut city = City::with_buildings("khazad", vec!["smithy".into()]); @@ -267,7 +288,7 @@ mod tests { stockpile.add("iron_ore", 2); let r = registry(); - city.enqueue_item("iron_axe", &r, &mut stockpile, &techs(&["bronze_working"])) + city.enqueue_item("iron_axe", &r, &mut stockpile, &techs(&["bronze_working"]), &no_res()) .unwrap(); // Materials consumed up-front. assert_eq!(stockpile.available("iron_ore"), 0); @@ -291,7 +312,7 @@ mod tests { let mut stockpile = Stockpile::new(); stockpile.add("iron_ore", 2); let err = city - .enqueue_item("iron_axe", ®istry(), &mut stockpile, &techs(&["bronze_working"])) + .enqueue_item("iron_axe", ®istry(), &mut stockpile, &techs(&["bronze_working"]), &no_res()) .unwrap_err(); assert!(matches!(err, QueueError::MissingProducer { .. })); // No materials consumed on failure. @@ -310,6 +331,7 @@ mod tests { ®istry(), &mut stockpile, &techs(&["steelworking"]), + &no_res(), ) .unwrap_err(); assert!(matches!(err, QueueError::MissingSecondary { .. })); @@ -324,7 +346,7 @@ mod tests { let mut stockpile = Stockpile::new(); stockpile.add("iron_ore", 2); let err = city - .enqueue_item("iron_axe", ®istry(), &mut stockpile, &techs(&[])) + .enqueue_item("iron_axe", ®istry(), &mut stockpile, &techs(&[]), &no_res()) .unwrap_err(); assert!(matches!(err, QueueError::TechLocked { .. })); assert_eq!(stockpile.available("iron_ore"), 2); @@ -342,6 +364,7 @@ mod tests { ®istry(), &mut stockpile, &techs(&["steelworking"]), + &no_res(), ) .unwrap_err(); match err { @@ -360,7 +383,7 @@ mod tests { let mut city = City::with_buildings("khazad", vec!["smithy".into()]); let mut stockpile = Stockpile::new(); let err = city - .enqueue_item("ghost_blade", ®istry(), &mut stockpile, &techs(&[])) + .enqueue_item("ghost_blade", ®istry(), &mut stockpile, &techs(&[]), &no_res()) .unwrap_err(); assert!(matches!(err, QueueError::UnknownItem { .. })); } @@ -370,7 +393,7 @@ mod tests { let mut city = City::with_buildings("khazad", vec!["smithy".into()]); let mut stockpile = Stockpile::new(); stockpile.add("iron_ore", 2); - city.enqueue_item("iron_axe", ®istry(), &mut stockpile, &techs(&["bronze_working"])) + city.enqueue_item("iron_axe", ®istry(), &mut stockpile, &techs(&["bronze_working"]), &no_res()) .unwrap(); // 10 production → not done. @@ -393,9 +416,9 @@ mod tests { let mut stockpile = Stockpile::new(); stockpile.add("iron_ore", 4); // enough for two axes let r = registry(); - city.enqueue_item("iron_axe", &r, &mut stockpile, &techs(&["bronze_working"])) + city.enqueue_item("iron_axe", &r, &mut stockpile, &techs(&["bronze_working"]), &no_res()) .unwrap(); - city.enqueue_item("iron_axe", &r, &mut stockpile, &techs(&["bronze_working"])) + city.enqueue_item("iron_axe", &r, &mut stockpile, &techs(&["bronze_working"]), &no_res()) .unwrap(); // Two entries x 30 production each = 60 total. let done = city.tick_building("smithy", 60).unwrap(); @@ -423,8 +446,8 @@ mod tests { let r = registry(); let techs = techs(&["bronze_working", "steelworking"]); - city.enqueue_item("iron_axe", &r, &mut stockpile, &techs).unwrap(); - city.enqueue_item("dwarven_plate", &r, &mut stockpile, &techs).unwrap(); + city.enqueue_item("iron_axe", &r, &mut stockpile, &techs, &no_res()).unwrap(); + city.enqueue_item("dwarven_plate", &r, &mut stockpile, &techs, &no_res()).unwrap(); // Tick smithy to completion, forge only partway. let done_axe = city.tick_building("smithy", 30).unwrap(); @@ -437,12 +460,74 @@ mod tests { ); } + fn cavalry_axe() -> ItemDef { + // Resource-gated variant of iron_axe: requires a `horses` tile. + ItemDef { + id: "cavalry_axe".into(), + production_cost: 30, + building: "smithy".into(), + secondary_building: None, + requires_tech: Some("bronze_working".into()), + requires_resource: Some("horses".into()), + materials: vec![MaterialCost { + resource: "iron_ore".into(), + amount: 2, + }], + } + } + + fn resource_gated_registry() -> ItemRegistry { + let mut r = ItemRegistry::new(); + r.insert(cavalry_axe()); + r + } + + #[test] + fn production_rejects_resource_gated_without_resource() { + let mut city = City::with_buildings("khazad", vec!["smithy".into()]); + let mut stockpile = Stockpile::new(); + stockpile.add("iron_ore", 4); + let r = resource_gated_registry(); + + // No resources available → rejection, atomic (nothing consumed, empty queue). + let err = city + .enqueue_item( + "cavalry_axe", + &r, + &mut stockpile, + &techs(&["bronze_working"]), + &no_res(), + ) + .unwrap_err(); + match err { + QueueError::MissingResource { item_id, resource } => { + assert_eq!(item_id, "cavalry_axe"); + assert_eq!(resource, "horses"); + } + other => panic!("expected MissingResource, got {:?}", other), + } + assert_eq!(stockpile.available("iron_ore"), 4); + assert!(city.queue_for("smithy").map(|q| q.is_empty()).unwrap_or(true)); + + // With the resource controlled → enqueue succeeds and materials consumed. + city.enqueue_item( + "cavalry_axe", + &r, + &mut stockpile, + &techs(&["bronze_working"]), + &resources(&["horses"]), + ) + .unwrap(); + assert_eq!(stockpile.available("iron_ore"), 2); + assert_eq!(city.queue_for("smithy").unwrap().len(), 1); + } + #[test] fn json_roundtrip_preserves_queues() { let mut city = City::with_buildings("khazad", vec!["smithy".into()]); let mut stockpile = Stockpile::new(); stockpile.add("iron_ore", 2); - city.enqueue_item("iron_axe", ®istry(), &mut stockpile, &techs(&["bronze_working"])) + city.enqueue_item("iron_axe", ®istry(), &mut stockpile, &techs(&["bronze_working"]), &no_res()) .unwrap(); let json = serde_json::to_string(&city).unwrap(); let back: City = serde_json::from_str(&json).unwrap();