feat(mc-city): Introduce Rust-based City and Production simulation modules with core urban/industrial logic

Co-Authored-By: Lilith Autocommit <noreply@atlilith.com>
This commit is contained in:
autocommit 2026-04-15 20:42:54 -07:00
parent 11beef576e
commit 7274858ce3
2 changed files with 113 additions and 11 deletions

View file

@ -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<String>,
available_resources: &HashSet<String>,
) -> 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<String> = ["bronze_working"].iter().map(|s| s.to_string()).collect();
city.enqueue_item("iron_axe", &registry, &mut stockpile, &techs).unwrap();
let resources: HashSet<String> = HashSet::new();
city.enqueue_item("iron_axe", &registry, &mut stockpile, &techs, &resources).unwrap();
let done = city.tick_building("smithy", 30).unwrap();
assert_eq!(done.len(), 1);
}

View file

@ -29,6 +29,10 @@ pub struct ItemDef {
pub secondary_building: Option<String>,
#[serde(default)]
pub requires_tech: Option<String>,
/// 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<String>,
#[serde(default)]
pub materials: Vec<MaterialCost>,
}
@ -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<String> {
ids.iter().map(|s| s.to_string()).collect()
}
fn no_res() -> HashSet<String> {
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", &registry(), &mut stockpile, &techs(&["bronze_working"]))
.enqueue_item("iron_axe", &registry(), &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 {
&registry(),
&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", &registry(), &mut stockpile, &techs(&[]))
.enqueue_item("iron_axe", &registry(), &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 {
&registry(),
&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", &registry(), &mut stockpile, &techs(&[]))
.enqueue_item("ghost_blade", &registry(), &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", &registry(), &mut stockpile, &techs(&["bronze_working"]))
city.enqueue_item("iron_axe", &registry(), &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", &registry(), &mut stockpile, &techs(&["bronze_working"]))
city.enqueue_item("iron_axe", &registry(), &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();