diff --git a/blastmud_game/src/language.rs b/blastmud_game/src/language.rs index f9693d2..392427d 100644 --- a/blastmud_game/src/language.rs +++ b/blastmud_game/src/language.rs @@ -184,6 +184,11 @@ pub fn pluralise(orig_input: &str) -> String { drop: 0, append_suffix: "es", }, + PluralRule { + match_suffix: "ox", + drop: 0, + append_suffix: "es", + }, ] }); diff --git a/blastmud_game/src/message_handler/user_commands.rs b/blastmud_game/src/message_handler/user_commands.rs index c6a51c9..aee175b 100644 --- a/blastmud_game/src/message_handler/user_commands.rs +++ b/blastmud_game/src/message_handler/user_commands.rs @@ -46,6 +46,7 @@ pub mod movement; pub mod open; mod page; pub mod parsing; +pub mod put; mod quit; pub mod register; pub mod remove; @@ -74,7 +75,7 @@ pub enum CommandHandlingError { UserError(String), SystemError(Box), } -use CommandHandlingError::*; +pub use CommandHandlingError::*; #[async_trait] pub trait UserVerb { @@ -196,6 +197,8 @@ static REGISTERED_COMMANDS: UserVerbRegistry = phf_map! { "repl" => page::VERB, "reply" => page::VERB, + "put" => put::VERB, + "remove" => remove::VERB, "rent" => rent::VERB, diff --git a/blastmud_game/src/message_handler/user_commands/buy.rs b/blastmud_game/src/message_handler/user_commands/buy.rs index fdd66ff..063a8c0 100644 --- a/blastmud_game/src/message_handler/user_commands/buy.rs +++ b/blastmud_game/src/message_handler/user_commands/buy.rs @@ -106,7 +106,21 @@ impl UserVerb for Verb { location: loc.to_owned(), ..stock.possession_type.clone().into() }; + ctx.trans.create_item(&new_item).await?; + + if let Some(container_data) = possession_type.container_data.as_ref() { + for sub_possession_type in &container_data.default_contents { + let sub_item_code = ctx.trans.alloc_item_code().await?; + let new_sub_item = Item { + item_code: format!("{}", sub_item_code), + location: new_item.refstr(), + ..sub_possession_type.clone().into() + }; + ctx.trans.create_item(&new_sub_item).await?; + } + } + ctx.trans .queue_for_session( &ctx.session, diff --git a/blastmud_game/src/message_handler/user_commands/list.rs b/blastmud_game/src/message_handler/user_commands/list.rs index 1f484ca..05a58f2 100644 --- a/blastmud_game/src/message_handler/user_commands/list.rs +++ b/blastmud_game/src/message_handler/user_commands/list.rs @@ -36,7 +36,7 @@ impl UserVerb for Verb { let mut msg = String::new(); msg.push_str(&format!( - ansi!("| {:20} | {:15} |\n"), + ansi!("| {:40} | {:15} |\n"), ansi!("Item"), ansi!("Price") )); @@ -52,7 +52,7 @@ impl UserVerb for Verb { &possession_type.display }; msg.push_str(&format!( - "| {:20} | {:15.2} |\n", + "| {:40} | {:15.2} |\n", &language::caps_first(&display), &stock.list_price )) diff --git a/blastmud_game/src/message_handler/user_commands/put.rs b/blastmud_game/src/message_handler/user_commands/put.rs new file mode 100644 index 0000000..70f4428 --- /dev/null +++ b/blastmud_game/src/message_handler/user_commands/put.rs @@ -0,0 +1,282 @@ +use super::{ + get_player_item_or_fail, parsing::parse_count, search_item_for_user, search_items_for_user, + user_error, ItemSearchParams, UResult, UserError, UserVerb, UserVerbRef, VerbContext, +}; +use crate::{ + models::item::LocationActionType, + regular_tasks::queued_command::{ + queue_command, QueueCommand, QueueCommandHandler, QueuedCommandContext, + }, + services::{ + capacity::{check_item_capacity, recalculate_container_weight, CapacityLevel}, + comms::broadcast_to_room, + }, + static_content::possession_type::possession_data, +}; +use ansi::ansi; +use async_trait::async_trait; +use std::time; + +pub struct QueueHandler; +#[async_trait] +impl QueueCommandHandler for QueueHandler { + async fn start_command(&self, ctx: &mut QueuedCommandContext<'_>) -> UResult { + if ctx.item.death_data.is_some() { + user_error( + "You try to put it in, but your ghostly hands slip through it uselessly".to_owned(), + )?; + } + let (container_code, item_code) = match ctx.command { + QueueCommand::Put { + container_possession_id, + target_possession_id, + } => (container_possession_id, target_possession_id), + _ => user_error("Expected Put command".to_owned())?, + }; + + let container = ctx + .trans + .find_item_by_type_code("possession", &container_code) + .await? + .ok_or_else(|| UserError("Item to put in not found".to_owned()))?; + if container.location != ctx.item.location && container.location != ctx.item.refstr() { + user_error(format!( + "You try to put something in {} but realise {} is no longer there", + container.display_for_sentence(ctx.explicit().await?, 1, false), + container.display_for_sentence(ctx.explicit().await?, 1, false), + ))? + } + let item = ctx + .trans + .find_item_by_type_code("possession", &item_code) + .await? + .ok_or_else(|| UserError("Item to place not found".to_owned()))?; + if item.location != ctx.item.refstr() { + user_error(format!( + "You try to put {} in {}, but realise you no longer have it", + item.display_for_sentence(ctx.explicit().await?, 1, false), + container.display_for_sentence(ctx.explicit().await?, 1, false), + ))? + } + let msg_exp = format!( + "{} fumbles around trying to put {} in {}.\n", + &ctx.item.display_for_sentence(true, 1, true), + &item.display_for_sentence(true, 1, false), + &container.display_for_sentence(true, 1, false) + ); + let msg_nonexp = format!( + "{} fumbles around trying to put {} in {}.\n", + &ctx.item.display_for_sentence(false, 1, true), + &item.display_for_sentence(false, 1, false), + &container.display_for_sentence(false, 1, false) + ); + broadcast_to_room( + ctx.trans, + &ctx.item.location, + None, + &msg_exp, + Some(&msg_nonexp), + ) + .await?; + Ok(time::Duration::from_secs(1)) + } + + #[allow(unreachable_patterns)] + async fn finish_command(&self, ctx: &mut QueuedCommandContext<'_>) -> UResult<()> { + if ctx.item.death_data.is_some() { + user_error( + "You try to put it in, but your ghostly hands slip through it uselessly".to_owned(), + )?; + } + let (container_code, item_code) = match ctx.command { + QueueCommand::Put { + container_possession_id, + target_possession_id, + } => (container_possession_id, target_possession_id), + _ => user_error("Expected Put command".to_owned())?, + }; + + let is_explicit = ctx.explicit().await?; + let container = ctx + .trans + .find_item_by_type_code("possession", &container_code) + .await? + .ok_or_else(|| UserError("Item to put in not found".to_owned()))?; + if container.location != ctx.item.location && container.location != ctx.item.refstr() { + user_error(format!( + "You try to put something in {} but realise {} is no longer there", + container.display_for_sentence(is_explicit, 1, false), + container.display_for_sentence(is_explicit, 1, false), + ))? + } + let item = ctx + .trans + .find_item_by_type_code("possession", &item_code) + .await? + .ok_or_else(|| UserError("Item to place not found".to_owned()))?; + if item.location != ctx.item.refstr() { + user_error(format!( + "You try to put {} in {}, but realise you no longer have it", + item.display_for_sentence(is_explicit, 1, false), + container.display_for_sentence(is_explicit, 1, false), + ))? + } + + let container_data = container + .possession_type + .as_ref() + .and_then(|pt| { + possession_data() + .get(pt) + .and_then(|pd| pd.container_data.as_ref()) + }) + .ok_or_else(|| { + UserError(format!( + "You try to put {} in {}, but can't find out a way to get anything in it", + item.display_for_sentence(is_explicit, 1, false), + container.display_for_sentence(is_explicit, 1, false), + )) + })?; + container_data.checker.check_place(&container, &item)?; + + let msg_exp = format!( + "{} puts {} in {}.\n", + &ctx.item.display_for_sentence(true, 1, true), + &item.display_for_sentence(true, 1, false), + &container.display_for_sentence(true, 1, false) + ); + let msg_nonexp = format!( + "{} puts {} in {}.\n", + &ctx.item.display_for_sentence(false, 1, true), + &item.display_for_sentence(false, 1, false), + &container.display_for_sentence(false, 1, false) + ); + broadcast_to_room( + ctx.trans, + &ctx.item.location, + None, + &msg_exp, + Some(&msg_nonexp), + ) + .await?; + + let possession_data = match item + .possession_type + .as_ref() + .and_then(|pt| possession_data().get(&pt)) + { + None => { + user_error("That item no longer exists in the game so can't be handled".to_owned())? + } + Some(pd) => pd, + }; + + match check_item_capacity(ctx.trans, &container, possession_data.weight).await? { + CapacityLevel::AboveItemLimit => user_error(format!( + "{} just can't hold that many things!", + container.display_for_sentence(is_explicit, 1, true), + ))?, + CapacityLevel::OverBurdened => user_error(format!( + "{} You can't place {} because it is too heavy!", + if is_explicit { "Fuck!" } else { "Rats!" }, + &ctx.item.display_for_sentence(is_explicit, 1, false) + ))?, + _ => (), + } + + let mut item_mut = (*item).clone(); + item_mut.location = container.refstr(); + item_mut.action_type = LocationActionType::Normal; + ctx.trans.save_item_model(&item_mut).await?; + + recalculate_container_weight(&ctx.trans, &container).await?; + + Ok(()) + } +} + +pub struct Verb; +#[async_trait] +impl UserVerb for Verb { + async fn handle( + self: &Self, + ctx: &mut VerbContext, + _verb: &str, + mut remaining: &str, + ) -> UResult<()> { + let player_item = get_player_item_or_fail(ctx).await?; + + let mut get_limit = Some(1); + if remaining == "all" || remaining.starts_with("all ") { + remaining = remaining[3..].trim(); + get_limit = None; + } else if let (Some(n), remaining2) = parse_count(remaining) { + get_limit = Some(n); + remaining = remaining2; + } + + let (search_what, for_what) = match remaining.split_once(" in ") { + None => { + user_error(ansi!("Try put item in container").to_owned())? + } + Some((item_str_raw, container_str_raw)) => { + let container = search_item_for_user( + ctx, + &ItemSearchParams { + include_loc_contents: true, + include_contents: true, + item_type_only: Some("possession"), + ..ItemSearchParams::base(&player_item, container_str_raw.trim()) + }, + ) + .await?; + (container, item_str_raw.trim()) + } + }; + + let targets = search_items_for_user( + ctx, + &ItemSearchParams { + include_contents: true, + item_type_only: Some("possession"), + limit: get_limit.unwrap_or(100), + ..ItemSearchParams::base(&player_item, for_what) + }, + ) + .await?; + if player_item.death_data.is_some() { + user_error( + "You try to put it in, but your ghostly hands slip through it uselessly".to_owned(), + )?; + } + + let mut did_anything: bool = false; + let mut player_item_mut = (*player_item).clone(); + for target in targets + .iter() + .filter(|t| t.action_type.is_visible_in_look()) + { + if target.item_type != "possession" { + user_error("You can't put that in something!".to_owned())?; + } + did_anything = true; + queue_command( + ctx, + &mut player_item_mut, + &QueueCommand::Put { + container_possession_id: search_what.item_code.clone(), + target_possession_id: target.item_code.clone(), + }, + ) + .await?; + } + if !did_anything { + user_error("I didn't find anything matching.".to_owned())?; + } else { + ctx.trans.save_item_model(&player_item_mut).await?; + } + Ok(()) + } +} +static VERB_INT: Verb = Verb; +pub static VERB: UserVerbRef = &VERB_INT as UserVerbRef; diff --git a/blastmud_game/src/models/item.rs b/blastmud_game/src/models/item.rs index 4ea936a..1918ebd 100644 --- a/blastmud_game/src/models/item.rs +++ b/blastmud_game/src/models/item.rs @@ -1,7 +1,9 @@ use super::session::Session; use crate::{ - language, regular_tasks::queued_command::QueueCommand, - static_content::possession_type::PossessionType, static_content::room::Direction, + language, + regular_tasks::queued_command::QueueCommand, + static_content::possession_type::{possession_data, PossessionType}, + static_content::room::Direction, static_content::species::SpeciesType, }; use chrono::{DateTime, Utc}; @@ -449,7 +451,20 @@ impl Item { } pub fn max_carry(&self) -> u64 { - (50.0 * 2.0_f64.powf(*self.total_stats.get(&StatType::Brawn).unwrap_or(&0.0))).ceil() as u64 + if self.item_type == "possession" { + if let Some(container_data) = self.possession_type.as_ref().and_then(|pt| { + possession_data() + .get(pt) + .and_then(|pd| pd.container_data.as_ref()) + }) { + container_data.max_weight + } else { + 0 + } + } else { + (50.0 * 2.0_f64.powf(*self.total_stats.get(&StatType::Brawn).unwrap_or(&0.0))).ceil() + as u64 + } } } diff --git a/blastmud_game/src/regular_tasks/queued_command.rs b/blastmud_game/src/regular_tasks/queued_command.rs index 6880460..e6b2872 100644 --- a/blastmud_game/src/regular_tasks/queued_command.rs +++ b/blastmud_game/src/regular_tasks/queued_command.rs @@ -2,8 +2,8 @@ use super::{TaskHandler, TaskRunContext}; #[double] use crate::db::DBTrans; use crate::message_handler::user_commands::{ - close, cut, drop, get, improvise, movement, open, remove, use_cmd, user_error, wear, wield, - CommandHandlingError, UResult, VerbContext, + close, cut, drop, get, improvise, movement, open, put, remove, use_cmd, user_error, wear, + wield, CommandHandlingError, UResult, VerbContext, }; use crate::message_handler::ListenerSession; use crate::models::session::Session; @@ -74,6 +74,10 @@ pub enum QueueCommand { OpenDoor { direction: Direction, }, + Put { + container_possession_id: String, + target_possession_id: String, + }, Remove { possession_id: String, }, @@ -107,6 +111,7 @@ impl QueueCommand { GetFromContainer { .. } => "GetFromContainer", Movement { .. } => "Movement", OpenDoor { .. } => "OpenDoor", + Put { .. } => "Put", Remove { .. } => "Remove", Use { .. } => "Use", Wear { .. } => "Wear", @@ -184,6 +189,10 @@ fn queue_command_registry( "OpenDoor", &open::QueueHandler as &(dyn QueueCommandHandler + Sync + Send), ), + ( + "Put", + &put::QueueHandler as &(dyn QueueCommandHandler + Sync + Send), + ), ( "Remove", &remove::QueueHandler as &(dyn QueueCommandHandler + Sync + Send), diff --git a/blastmud_game/src/services/capacity.rs b/blastmud_game/src/services/capacity.rs index 1f4f2a7..4724454 100644 --- a/blastmud_game/src/services/capacity.rs +++ b/blastmud_game/src/services/capacity.rs @@ -1,6 +1,7 @@ #[double] use crate::db::DBTrans; -use crate::{models::item::Item, DResult}; +use crate::{models::item::Item, static_content::possession_type::possession_data, DResult}; + use mockall_double::double; #[derive(Debug, PartialEq)] @@ -18,7 +19,7 @@ pub async fn check_item_ref_capacity( proposed_weight: u64, ) -> DResult { if let Some((item_type, item_code)) = container.split_once("/") { - if item_type != "player" && item_type != "npc" { + if item_type != "player" && item_type != "npc" && item_type != "possession" { // Fast path... let stats = trans.get_location_stats(&container).await?; if stats.total_count >= 50 || proposed_weight > 0 && stats.total_count >= 49 { @@ -47,7 +48,10 @@ pub async fn check_item_capacity( if stats.total_count >= 50 || proposed_weight > 0 && stats.total_count >= 49 { return Ok(CapacityLevel::AboveItemLimit); } - if container.item_type == "player" || container.item_type == "npc" { + if container.item_type == "player" + || container.item_type == "npc" + || container.item_type == "possession" + { let max_weight = container.max_carry(); let new_weight = stats.total_weight + proposed_weight; if new_weight >= max_weight { @@ -61,6 +65,24 @@ pub async fn check_item_capacity( Ok(CapacityLevel::Unburdened) } +pub async fn recalculate_container_weight(trans: &DBTrans, container: &Item) -> DResult<()> { + if let Some(container_data) = container.possession_type.as_ref().and_then(|pt| { + possession_data() + .get(pt) + .and_then(|pd| pd.container_data.as_ref()) + }) { + let stats = trans.get_location_stats(&container.refstr()).await?; + let new_weight = container_data.base_weight + + (((stats.total_weight as f64) * container_data.compression_ratio).ceil() as u64); + if new_weight != container.weight { + let mut container_mut = container.clone(); + container_mut.weight = new_weight; + trans.save_item_model(&container_mut).await?; + } + } + Ok(()) +} + #[cfg(test)] mod test { use super::*; diff --git a/blastmud_game/src/static_content/possession_type.rs b/blastmud_game/src/static_content/possession_type.rs index 87093c3..efc41e8 100644 --- a/blastmud_game/src/static_content/possession_type.rs +++ b/blastmud_game/src/static_content/possession_type.rs @@ -244,6 +244,38 @@ pub trait InstallHandler { ) -> UResult<()>; } +pub trait ContainerCheck { + fn check_place(&self, container: &Item, item: &Item) -> UResult<()>; +} + +pub struct PermissiveContainerCheck; +static PERMISSIVE_CONTAINER_CHECK: PermissiveContainerCheck = PermissiveContainerCheck; +impl ContainerCheck for PermissiveContainerCheck { + fn check_place(&self, _container: &Item, _item: &Item) -> UResult<()> { + Ok(()) + } +} + +pub struct ContainerData { + pub max_weight: u64, + pub base_weight: u64, + pub compression_ratio: f64, + pub checker: &'static (dyn ContainerCheck + Sync + Send), + pub default_contents: Vec, +} + +impl Default for ContainerData { + fn default() -> Self { + Self { + max_weight: 10000, + base_weight: 500, + compression_ratio: 1.0, + checker: &PERMISSIVE_CONTAINER_CHECK, + default_contents: vec![], + } + } +} + pub struct PossessionData { pub weapon_data: Option, pub display: &'static str, @@ -262,6 +294,7 @@ pub struct PossessionData { pub write_handler: Option<&'static (dyn WriteHandler + Sync + Send)>, pub can_butcher: bool, pub wear_data: Option, + pub container_data: Option, } impl Default for PossessionData { @@ -284,6 +317,7 @@ impl Default for PossessionData { write_handler: None, can_butcher: false, wear_data: None, + container_data: None, } } } @@ -341,6 +375,7 @@ pub enum PossessionType { SeveredHead, // Recipes CulinaryEssentials, + GrilledSteakRecipe, } impl Into for PossessionType { @@ -588,4 +623,21 @@ mod tests { Vec::<&'static PossessionType>::new() ); } + + #[test] + fn container_weight_should_match_calculated_weight() { + for (_pt, pd) in possession_data().iter() { + if let Some(container_data) = pd.container_data.as_ref() { + let tot: u64 = container_data.base_weight + + ((container_data + .default_contents + .iter() + .map(|pt| possession_data().get(pt).map(|pd| pd.weight).unwrap_or(0) as f64) + .sum::() + * container_data.compression_ratio) + .ceil() as u64); + assert!(tot as u64 == pd.weight); + } + } + } } diff --git a/blastmud_game/src/static_content/possession_type/books.rs b/blastmud_game/src/static_content/possession_type/books.rs index 407fe98..11112c3 100644 --- a/blastmud_game/src/static_content/possession_type/books.rs +++ b/blastmud_game/src/static_content/possession_type/books.rs @@ -1,5 +1,35 @@ use super::{PossessionData, PossessionType}; +use crate::{ + message_handler::user_commands::{UResult, UserError}, + models::item::Item, + static_content::possession_type::{ContainerCheck, ContainerData}, +}; use once_cell::sync::OnceCell; +use std::collections::BTreeSet; + +pub fn recipe_set() -> &'static BTreeSet { + static SET: OnceCell> = OnceCell::new(); + &SET.get_or_init(|| { + vec![PossessionType::GrilledSteakRecipe] + .into_iter() + .collect() + }) +} + +struct RecipesOnlyChecker; +impl ContainerCheck for RecipesOnlyChecker { + fn check_place(&self, _container: &Item, item: &Item) -> UResult<()> { + item.possession_type + .as_ref() + .and_then(|pt| recipe_set().get(pt)) + .ok_or_else(|| { + UserError("You don't find a sensible place for that in a recipe book.".to_owned()) + })?; + Ok(()) + } +} + +static RECIPES_ONLY_CHECKER: RecipesOnlyChecker = RecipesOnlyChecker; pub fn data() -> &'static Vec<(PossessionType, PossessionData)> { static D: OnceCell> = OnceCell::new(); @@ -10,6 +40,22 @@ pub fn data() -> &'static Vec<(PossessionType, PossessionData)> { aliases: vec!["book", "cookbook"], details: "A weathered cookbook filled with essential recipes for survival in the post-apocalyptic world. Its pages are yellowed and marked with stains, a testament to its extensive use by those seeking sustenance and comfort amidst scarcity.", weight: 300, + container_data: Some(ContainerData { + base_weight: 290, + default_contents: vec![ + PossessionType::GrilledSteakRecipe + ], + checker: &RECIPES_ONLY_CHECKER, + ..Default::default() + }), + ..Default::default() + }), + (PossessionType::GrilledSteakRecipe, + PossessionData { + display: "recipe for Grilled Steak", + aliases: vec!["grilled steak"], + details: "Instructions for how to make a basic but mouthwatering steak", + weight: 10, ..Default::default() }), )) diff --git a/blastmud_game/src/static_content/room/melbs.rs b/blastmud_game/src/static_content/room/melbs.rs index 6b3b7e6..1c6777c 100644 --- a/blastmud_game/src/static_content/room/melbs.rs +++ b/blastmud_game/src/static_content/room/melbs.rs @@ -2169,7 +2169,7 @@ pub fn room_list() -> Vec { code: "melbs_dustypages", name: "The Dusty Pages", short: ansi!("DP"), - description: "Beneath a large hand-carved wooden sign reading \"The Dusty Pages\" lies a small oasis of knowledge. The room is dimly lit, with flickering candles and shafts of sunlight piercing through cracked windows. The air is heavy with the scent of decaying books and the lingering memories of a bygone era.\n\nShelves made of salvaged wood stand defiantly against the crumbling walls, bearing the weight of books that have miraculously survived the ravages of time and nuclear fallout. The covers are worn and the pages yellowed, but the knowledge contained within remains invaluable.\n\nThe inhabitants of this forsaken land gather here, seeking solace and hope within the forgotten stories and practical guides that line the shelves.\n\nThe Dusty Pages stands as a beacon of intellectual survival, a sanctuary where survivors can momentarily escape the harsh realities of their existence.", + description: "Beneath a large hand-carved wooden sign reading \"The Dusty Pages\" lies a small oasis of knowledge. The room is dimly lit, with flickering candles and shafts of sunlight piercing through cracked windows. The air is heavy with the scent of decaying books and the lingering memories of a bygone era.\n\nShelves made of salvaged wood stand defiantly against the crumbling walls, bearing the weight of books that have miraculously survived the ravages of time and nuclear fallout. The covers are worn and the pages yellowed, but the knowledge contained within remains invaluable.\n\nThe inhabitants of this forsaken land gather here, seeking solace and hope within the forgotten stories and practical guides that line the shelves.\n\nThe Dusty Pages stands as a beacon of intellectual survival, a sanctuary where survivors can momentarily escape the harsh realities of their existence", description_less_explicit: None, grid_coords: GridCoords { x: 6, y: 4, z: 0 }, exits: vec!(