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:
parent
11beef576e
commit
7274858ce3
2 changed files with 113 additions and 11 deletions
|
|
@ -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", ®istry, &mut stockpile, &techs).unwrap();
|
||||
let resources: HashSet<String> = 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);
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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", ®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();
|
||||
|
|
|
|||
Loading…
Add table
Reference in a new issue