station/crates/game_server/src/crafting.rs

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);
}
}