236 lines
7.5 KiB
Rust
236 lines
7.5 KiB
Rust
//! 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<String>,
|
|
}
|
|
|
|
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<String> = 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<Crafted, String> {
|
|
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<Option<(String, u32)>, 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);
|
|
}
|
|
}
|