//! Pure crafting and construction rules, independent of the ECS so they are easy to test. use game_core::{ GameData, components::{Inventory, Skills}, data::{Ingredient, SkillRequirement}, }; /// Result of a successful craft, applied by the caller. #[derive(Debug, PartialEq, Eq)] pub struct Crafted { pub level_up: Option<(String, u32)>, pub on_craft: Option, } pub fn item_name<'a>(data: &'a GameData, id: &'a str) -> &'a str { data.items.get(id).map_or(id, |i| i.name.as_str()) } fn check_requirements( data: &GameData, inventory: &Inventory, skills: &Skills, ingredients: &[Ingredient], tools: &[String], skill: Option<&SkillRequirement>, ) -> Result<(), String> { if let Some(req) = skill { let level = skills.level(&req.skill); if level < req.level { let name = data .skills .get(&req.skill) .map_or(req.skill.as_str(), |s| &s.name); return Err(format!( "Requires {name} level {} (you have {level}).", req.level )); } } let missing_tools: Vec<&str> = tools .iter() .filter(|t| !inventory.has(t, 1)) .map(|t| item_name(data, t)) .collect(); if !missing_tools.is_empty() { return Err(format!("You need: {}.", missing_tools.join(", "))); } let missing: Vec = ingredients .iter() .filter(|i| !inventory.has(&i.item, i.count)) .map(|i| { format!( "{} {} (have {})", i.count, item_name(data, &i.item), inventory.count(&i.item) ) }) .collect(); if !missing.is_empty() { return Err(format!("Missing materials: {}.", missing.join(", "))); } Ok(()) } /// Crafts `recipe` with the inventory. `stations` are the station tags available nearby. /// On failure nothing is changed and a player-facing reason is returned. pub fn try_craft( data: &GameData, recipe: &str, stations: &[String], inventory: &mut Inventory, skills: &mut Skills, ) -> Result { let recipe = data .recipes .get(recipe) .ok_or_else(|| format!("Unknown recipe `{recipe}`."))?; if let Some(station) = &recipe.station && !stations.contains(station) { return Err(format!("You need to stand next to a {station}.")); } check_requirements( data, inventory, skills, &recipe.inputs, &recipe.tools, recipe.skill.as_ref(), )?; // Verify outputs fit before consuming anything. let mut trial = inventory.clone(); for input in &recipe.inputs { trial.remove(&input.item, input.count); } for output in &recipe.outputs { if trial.add(&output.item, output.count, data) > 0 { return Err("Your inventory is full.".into()); } } *inventory = trial; let level_up = recipe.skill.as_ref().and_then(|req| { skills .add_xp(&req.skill, recipe.xp) .map(|level| (req.skill.clone(), level)) }); Ok(Crafted { level_up, on_craft: recipe.on_craft.clone(), }) } /// Consumes construction materials for `building`. Returns the xp level-up, if any. pub fn try_construct( data: &GameData, building: &str, inventory: &mut Inventory, skills: &mut Skills, ) -> Result, String> { let def = data .buildings .get(building) .ok_or_else(|| format!("Unknown building `{building}`."))?; let construction = def .construction .as_ref() .ok_or_else(|| format!("{} can not be built.", def.name))?; check_requirements( data, inventory, skills, &construction.materials, &construction.tools, construction.skill.as_ref(), )?; for m in &construction.materials { inventory.remove(&m.item, m.count); } Ok(construction.skill.as_ref().and_then(|req| { skills .add_xp(&req.skill, construction.xp) .map(|level| (req.skill.clone(), level)) })) } #[cfg(test)] mod tests { use super::*; fn data() -> GameData { GameData::load_dir(&game_core::asset_path("data")).unwrap() } #[test] fn craft_planks_needs_station_and_materials() { let data = data(); let mut inv = Inventory::default(); let mut skills = Skills::default(); let bench = vec!["workbench".to_owned()]; let err = try_craft(&data, "saw_planks", &[], &mut inv, &mut skills).unwrap_err(); assert!(err.contains("workbench"), "{err}"); let err = try_craft(&data, "saw_planks", &bench, &mut inv, &mut skills).unwrap_err(); assert!(err.contains("Missing"), "{err}"); inv.add("log", 2, &data); try_craft(&data, "saw_planks", &bench, &mut inv, &mut skills).unwrap(); assert_eq!(inv.count("log"), 1); assert_eq!(inv.count("plank"), 4); assert_eq!(skills.skills["carpentry"].xp, 5); } #[test] fn craft_requires_tools_and_skill() { let data = data(); let mut inv = Inventory::default(); let mut skills = Skills::default(); let bench = vec!["workbench".to_owned()]; inv.add("iron_plate", 2, &data); inv.add("plank", 1, &data); let err = try_craft(&data, "make_pickaxe", &bench, &mut inv, &mut skills).unwrap_err(); assert!(err.contains("Wrench"), "{err}"); inv.add("wrench", 1, &data); try_craft(&data, "make_pickaxe", &bench, &mut inv, &mut skills).unwrap(); assert!(inv.has("wrench", 1) && inv.has("pickaxe", 1)); inv.add("iron_plate", 1, &data); inv.add("apple", 2, &data); let err = try_craft(&data, "make_medkit", &bench, &mut inv, &mut skills).unwrap_err(); assert!(err.contains("level 2"), "{err}"); skills.add_xp("medicine", 50); let crafted = try_craft(&data, "make_medkit", &bench, &mut inv, &mut skills).unwrap(); assert_eq!(crafted.on_craft, None); assert!(inv.has("medkit", 1)); } #[test] fn full_inventory_is_not_consumed() { let data = data(); let mut inv = Inventory::default(); let mut skills = Skills::default(); let bench = vec!["workbench".to_owned()]; // Logs stay in their slot (2 - 1 = 1 left), so the planks need a free slot. inv.add("log", 2, &data); for _ in 1..game_core::components::INVENTORY_SLOTS { inv.add("pickaxe", 1, &data); } let before = inv.clone(); let err = try_craft(&data, "saw_planks", &bench, &mut inv, &mut skills).unwrap_err(); assert!(err.contains("full"), "{err}"); assert_eq!(inv, before); assert!(skills.skills.is_empty()); // Consuming a whole stack frees its slot for the output. inv.remove("log", 1); try_craft(&data, "saw_planks", &bench, &mut inv, &mut skills).unwrap(); assert!(inv.has("plank", 4)); } #[test] fn construct_consumes_materials() { let data = data(); let mut inv = Inventory::default(); let mut skills = Skills::default(); assert!(try_construct(&data, "tree", &mut inv, &mut skills).is_err()); assert!(try_construct(&data, "table", &mut inv, &mut skills).is_err()); inv.add("plank", 3, &data); try_construct(&data, "table", &mut inv, &mut skills).unwrap(); assert_eq!(inv.count("plank"), 1); } }