Allow putting things in containers.

Includes rules on what you can place, and weight / capacity limits.
This commit is contained in:
Condorra 2023-07-16 13:37:29 +10:00
parent b3cbc9f544
commit bfc1d4d4b5
11 changed files with 460 additions and 12 deletions

View File

@ -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",
},
]
});

View File

@ -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<dyn std::error::Error + Send + Sync>),
}
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,

View File

@ -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,

View File

@ -36,7 +36,7 @@ impl UserVerb for Verb {
let mut msg = String::new();
msg.push_str(&format!(
ansi!("<bold><bgblue><white>| {:20} | {:15} |<reset>\n"),
ansi!("<bold><bgblue><white>| {:40} | {:15} |<reset>\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
))

View File

@ -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<time::Duration> {
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 <bold>put<reset> item <bold>in<reset> 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;

View File

@ -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
}
}
}

View File

@ -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),

View File

@ -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<CapacityLevel> {
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::*;

View File

@ -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<PossessionType>,
}
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<WeaponData>,
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<WearData>,
pub container_data: Option<ContainerData>,
}
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<Item> 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::<f64>()
* container_data.compression_ratio)
.ceil() as u64);
assert!(tot as u64 == pd.weight);
}
}
}
}

View File

@ -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<PossessionType> {
static SET: OnceCell<BTreeSet<PossessionType>> = 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<Vec<(PossessionType, PossessionData)>> = 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()
}),
))

View File

@ -2169,7 +2169,7 @@ pub fn room_list() -> Vec<Room> {
code: "melbs_dustypages",
name: "The Dusty Pages",
short: ansi!("<bgblue><yellow>DP<reset>"),
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!(