diff --git a/blastmud_game/src/db.rs b/blastmud_game/src/db.rs index d2c9f7b..638559c 100644 --- a/blastmud_game/src/db.rs +++ b/blastmud_game/src/db.rs @@ -742,7 +742,7 @@ impl DBTrans { .pg_trans()? .query( "SELECT details FROM items WHERE details->>'location' = $1 \ - ORDER BY details->>'display' + ORDER BY details->>'display' ASC, item_id ASC LIMIT 100", &[&location], ) @@ -2047,9 +2047,12 @@ impl DBTrans { Ok(self .pg_trans()? .query( - "SELECT details FROM users WHERE idle_park_time < NOW() \ - AND details->>'location' NOT LIKE 'room/holding%' \ - AND details->>'location' NOT IN ('room/repro_xv_chargen', \ + "SELECT u.details FROM users u JOIN items i ON \ + i.details->>'item_type' = 'player' AND \ + i.details->>'item_code' = u.username \ + WHERE u.idle_park_time < NOW() \ + AND i.details->>'location' NOT LIKE 'room/holding%' \ + AND i.details->>'location' NOT IN ('room/repro_xv_chargen', \ 'room/repro_xv_respawn') \ LIMIT 100", &[], diff --git a/blastmud_game/src/message_handler/user_commands.rs b/blastmud_game/src/message_handler/user_commands.rs index a52b552..cd2edf9 100644 --- a/blastmud_game/src/message_handler/user_commands.rs +++ b/blastmud_game/src/message_handler/user_commands.rs @@ -21,7 +21,7 @@ mod agree; mod allow; mod attack; mod butcher; -mod buy; +pub mod buy; mod c; pub mod close; pub mod corp; diff --git a/blastmud_game/src/message_handler/user_commands/buy.rs b/blastmud_game/src/message_handler/user_commands/buy.rs index f151b30..81fcb75 100644 --- a/blastmud_game/src/message_handler/user_commands/buy.rs +++ b/blastmud_game/src/message_handler/user_commands/buy.rs @@ -2,6 +2,8 @@ use super::{ get_player_item_or_fail, parsing::parse_offset, user_error, UResult, UserVerb, UserVerbRef, VerbContext, }; +#[double] +use crate::db::DBTrans; use crate::{ models::item::Item, services::{ @@ -10,9 +12,11 @@ use crate::{ }, static_content::possession_type::possession_data, static_content::room, + DResult, }; use ansi::ansi; use async_trait::async_trait; +use mockall_double::double; pub struct Verb; #[async_trait] @@ -140,18 +144,7 @@ impl UserVerb for Verb { }; 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?; - } - } + create_item_default_contents(&ctx.trans, &new_item).await?; ctx.trans .queue_for_session( @@ -173,5 +166,26 @@ impl UserVerb for Verb { user_error(ansi!("That doesn't seem to be for sale. Try list").to_owned()) } } + +pub async fn create_item_default_contents(trans: &DBTrans, item: &Item) -> DResult<()> { + if let Some(container_data) = item + .possession_type + .as_ref() + .and_then(|pt| possession_data().get(pt)) + .and_then(|pt| pt.container_data.as_ref()) + { + for sub_possession_type in &container_data.default_contents { + let sub_item_code = trans.alloc_item_code().await?; + let new_sub_item = Item { + item_code: format!("{}", sub_item_code), + location: item.refstr(), + ..sub_possession_type.clone().into() + }; + trans.create_item(&new_sub_item).await?; + } + } + Ok(()) +} + static VERB_INT: Verb = Verb; pub static VERB: UserVerbRef = &VERB_INT as UserVerbRef; diff --git a/blastmud_game/src/message_handler/user_commands/wield.rs b/blastmud_game/src/message_handler/user_commands/wield.rs index e6a8e16..34374ad 100644 --- a/blastmud_game/src/message_handler/user_commands/wield.rs +++ b/blastmud_game/src/message_handler/user_commands/wield.rs @@ -34,7 +34,7 @@ impl QueueCommandHandler for QueueHandler { None => user_error("Item not found".to_owned())?, Some(it) => it, }; - if item.location != format!("player/{}", ctx.item.item_code) { + if item.location != ctx.item.refstr() { user_error("You try to wield it but realise you no longer have it".to_owned())? } let msg = format!( @@ -89,7 +89,7 @@ impl QueueCommandHandler for QueueHandler { None => user_error("Item not found".to_owned())?, Some(it) => it, }; - if item.location != format!("player/{}", ctx.item.item_code) { + if item.location != ctx.item.refstr() { user_error("You try to wield it but realise you no longer have it".to_owned())? } let msg = format!( diff --git a/blastmud_game/src/models/item.rs b/blastmud_game/src/models/item.rs index 67acc1c..cb655eb 100644 --- a/blastmud_game/src/models/item.rs +++ b/blastmud_game/src/models/item.rs @@ -343,6 +343,7 @@ pub enum ItemFlag { Invincible, NoIdlePark, DontCounterattack, + EnableCombatAi, } #[derive(Serialize, Deserialize, Clone, Debug, Eq, PartialEq, Ord, PartialOrd)] diff --git a/blastmud_game/src/services.rs b/blastmud_game/src/services.rs index e5574ae..03f3342 100644 --- a/blastmud_game/src/services.rs +++ b/blastmud_game/src/services.rs @@ -24,6 +24,7 @@ pub mod display; pub mod effect; pub mod environment; pub mod idlepark; +pub mod npc_ai; pub mod room_effects; pub mod sharing; pub mod skills; diff --git a/blastmud_game/src/services/combat.rs b/blastmud_game/src/services/combat.rs index 9c910bd..ce1afe0 100644 --- a/blastmud_game/src/services/combat.rs +++ b/blastmud_game/src/services/combat.rs @@ -39,10 +39,11 @@ use chrono::{TimeDelta, Utc}; use mockall_double::double; use rand::{prelude::IteratorRandom, thread_rng, Rng}; use rand_distr::{Distribution, Normal}; -use std::{sync::Arc, time}; +use std::{collections::BTreeSet, sync::Arc, time}; use super::{ effect::{cancel_effect, default_effects_for_type, run_effects}, + npc_ai::npc_combat_ai_attacking, sharing::stop_conversation_mut, }; @@ -63,6 +64,8 @@ pub async fn soak_damage( .collect(); clothes.sort_unstable_by(|c1, c2| c2.action_type_started.cmp(&c1.action_type_started)); + let mut damaged_clothes: BTreeSet = BTreeSet::new(); + let mut total_damage = 0.0; for (damage_type, mut damage_amount) in &damage_by_type { @@ -102,6 +105,7 @@ pub async fn soak_damage( )), ) .await?; + damaged_clothes.insert(clothing.item_code.clone()); } } } @@ -111,6 +115,9 @@ pub async fn soak_damage( } for clothing in &clothes { + if !damaged_clothes.contains(&clothing.item_code) { + continue; + } if clothing.health <= 0 { trans .delete_item(&clothing.item_type, &clothing.item_code) @@ -130,6 +137,8 @@ pub async fn soak_damage( .await?; } } + } else { + trans.save_item_model(&clothing).await?; } } @@ -513,6 +522,10 @@ impl TaskHandler for AttackTaskHandler { let mut attacker_item_mut = (*attacker_item).clone(); + if ctype == "npc" { + npc_combat_ai_attacking(ctx, &mut attacker_item_mut, &victim_item).await?; + } + if attacker_item_mut .active_effects .iter() diff --git a/blastmud_game/src/services/npc_ai.rs b/blastmud_game/src/services/npc_ai.rs new file mode 100644 index 0000000..dd0fa56 --- /dev/null +++ b/blastmud_game/src/services/npc_ai.rs @@ -0,0 +1,313 @@ +use std::collections::BTreeMap; + +use crate::{ + models::item::{Item, ItemFlag, LocationActionType}, + regular_tasks::{ + queued_command::{queue_command_for_npc, QueueCommand}, + TaskRunContext, + }, + static_content::possession_type::{ + possession_data, ContainerFlag, PossessionData, PossessionType, + }, + DResult, +}; + +pub async fn npc_combat_ai_attacking( + ctx: &mut TaskRunContext<'_>, + npc: &mut Item, + _opponent: &Item, +) -> DResult<()> { + if !npc.flags.contains(&ItemFlag::EnableCombatAi) { + return Ok(()); + } + + // Look through the inventory and find the weapons. + let possessed_items = ctx.trans.find_items_by_location(&npc.refstr()).await?; + + // We always truncate the queue to 1 and re-insert actions... + npc.queue.truncate(1); + + let all_weapons = rank_weapons_for_ai(&possessed_items); + let mut ammo_loaded_cache: BTreeMap = BTreeMap::new(); + queue_ai_wield_weapon( + &all_weapons, + ctx, + &mut ammo_loaded_cache, + &possessed_items, + npc, + ) + .await?; + + queue_weapon_reloads_for_ai(&possessed_items, all_weapons, ammo_loaded_cache, ctx, npc).await?; + + Ok(()) +} + +async fn queue_weapon_reloads_for_ai( + possessed_items: &Vec>, + all_weapons: Vec<(String, &PossessionData)>, + mut ammo_loaded_cache: BTreeMap, + ctx: &mut TaskRunContext<'_>, + npc: &mut Item, +) -> DResult<()> { + let direct_ammo: Vec<(String, PossessionType)> = find_direct_ammo_for_ai(&possessed_items); + let clips: Vec<(String, PossessionType)> = find_clips_for_ai(possessed_items); + for (poss_id, poss_data) in &all_weapons { + match poss_data.container_data.as_ref() { + None => break, + Some(cont_data) => { + // If a weapon is loaded sufficiently, don't use more ammo... + let ammolevel = match ammo_loaded_cache.get(poss_id.as_str()) { + Some(v) => *v, + None => { + let loaded_ammo = ctx + .trans + .find_items_by_location(&format!("possession/{}", &poss_id)) + .await?; + let ammolevel = loaded_ammo.len(); + ammo_loaded_cache.insert(poss_id.clone(), ammolevel); + ammolevel + } + }; + if ammolevel > 3 { + break; + } + let mut to_load = 3 - ammolevel; + + // Check for an ammo item in inventory directly. + queue_direct_load_ammo_for_ai( + &direct_ammo, + cont_data, + ammolevel, + &mut to_load, + npc, + ctx, + poss_id, + ) + .await?; + + // We might need to load some ammo from a clip... + queue_load_ammo_from_clip_for_ai(&clips, to_load, cont_data, ctx, npc, poss_id) + .await?; + } + } + } + Ok(()) +} + +async fn queue_load_ammo_from_clip_for_ai( + clips: &Vec<(String, PossessionType)>, + mut to_load: usize, + cont_data: &crate::static_content::possession_type::ContainerData, + ctx: &mut TaskRunContext<'_>, + npc: &mut Item, + poss_id: &String, +) -> DResult<()> { + for (clip_id, ammo_type) in clips { + if to_load == 0 { + break; + } + if !cont_data.checker.check_place_type(&Some(ammo_type.clone())) { + continue; + } + let clip_contents = ctx + .trans + .find_items_by_location(&format!("possession/{}", &clip_id)) + .await?; + for ammo_it in &clip_contents { + if to_load == 0 { + return Ok(()); + } + if ammo_it.possession_type.as_ref() != Some(ammo_type) { + continue; + } + to_load -= 1; + queue_command_for_npc( + &ctx.trans, + npc, + &QueueCommand::GetFromContainer { + from_item_id: format!("possession/{}", clip_id), + get_possession_id: ammo_it.item_code.clone(), + }, + ) + .await?; + queue_command_for_npc( + &ctx.trans, + npc, + &QueueCommand::Put { + container_possession_id: poss_id.clone(), + target_possession_id: ammo_it.item_code.clone(), + }, + ) + .await?; + } + } + Ok(()) +} + +async fn queue_direct_load_ammo_for_ai( + direct_ammo: &Vec<(String, PossessionType)>, + cont_data: &crate::static_content::possession_type::ContainerData, + ammolevel: usize, + to_load: &mut usize, + npc: &mut Item, + ctx: &mut TaskRunContext<'_>, + poss_id: &String, +) -> DResult<()> { + let load_ammo: Vec = direct_ammo + .iter() + .filter(|(_, pt)| cont_data.checker.check_place_type(&Some(pt.clone()))) + .map(|(it_id, _)| it_id.clone()) + .take(ammolevel) + .collect(); + *to_load -= load_ammo.len(); + for load_id in load_ammo { + match npc.queue.front() { + Some(QueueCommand::Put { + target_possession_id, + .. + }) if target_possession_id == &load_id => break, + _ => {} + } + queue_command_for_npc( + &ctx.trans, + npc, + &QueueCommand::Put { + container_possession_id: poss_id.clone(), + target_possession_id: load_id.clone(), + }, + ) + .await?; + } + Ok(()) +} + +fn find_clips_for_ai(possessed_items: &Vec>) -> Vec<(String, PossessionType)> { + possessed_items + .iter() + .filter_map(|it| { + it.possession_type + .as_ref() + .and_then(|pt| possession_data().get(pt)) + .and_then(|pd| { + pd.container_data.as_ref().and_then(|cd| { + if cd.container_flags.contains(&ContainerFlag::AmmoClip) { + cd.default_contents + .first() + .map(|pt_in| (it.item_code.clone(), pt_in.clone())) + } else { + None + } + }) + }) + }) + .collect() +} + +fn find_direct_ammo_for_ai( + possessed_items: &Vec>, +) -> Vec<(String, PossessionType)> { + possessed_items + .iter() + .filter_map(|it| { + match it + .possession_type + .as_ref() + .and_then(|pt| possession_data().get(pt).map(|pd| (pt, pd))) + { + Some((pt, pd)) if pd.ammo_data.is_some() => { + Some((it.item_code.clone(), pt.clone())) + } + _ => None, + } + }) + .collect() +} + +async fn queue_ai_wield_weapon( + all_weapons: &Vec<(String, &PossessionData)>, + ctx: &mut TaskRunContext<'_>, + ammo_loaded_cache: &mut BTreeMap, + possessed_items: &Vec>, + npc: &mut Item, +) -> DResult<()> { + let mut best_ready_weapon: Option = None; + for (poss_code, poss_data) in all_weapons { + if poss_data.container_data.is_none() { + best_ready_weapon = Some(poss_code.clone()); + break; + } + // Check loaded status... + let loaded_ammo = ctx + .trans + .find_items_by_location(&format!("possession/{}", &poss_code)) + .await?; + let ammolevel = loaded_ammo.len(); + ammo_loaded_cache.insert(poss_code.clone(), ammolevel); + if ammolevel > 0 { + best_ready_weapon = Some(poss_code.clone()); + break; + } + } + let current_wield = possessed_items + .iter() + .find(|it| it.action_type == LocationActionType::Wielded) + .map(|it| it.item_code.clone()); + let pending_wield = npc.queue.front().and_then(|comm| match comm { + QueueCommand::Wield { possession_id } => Some(possession_id.clone()), + _ => None, + }); + let eventual_wield = pending_wield.or(current_wield); + Ok(match (eventual_wield, best_ready_weapon) { + (_, None) => {} // Do nothing if nothing possible. + (Some(wielded), Some(to_wield)) if wielded == to_wield => {} + (_, Some(to_wield)) => { + queue_command_for_npc( + &ctx.trans, + npc, + &QueueCommand::Wield { + possession_id: to_wield.clone(), + }, + ) + .await? + } + }) +} + +fn rank_weapons_for_ai( + possessed_items: &Vec>, +) -> Vec<(String, &PossessionData)> { + let mut all_weapons: Vec<(String, &'static PossessionData)> = possessed_items + .iter() + .filter_map(|it| { + match it + .possession_type + .as_ref() + .and_then(|pt| possession_data().get(pt)) + { + None => None, + Some(pd) if pd.weapon_data.is_none() => None, + Some(pd) => Some((it.item_code.clone(), *pd)), + } + }) + .collect(); + + all_weapons.sort_by(|(_, pd1), (_, pd2)| { + pd1.container_data + .is_some() + .cmp(&pd2.container_data.is_some()) + .then( + pd1.weapon_data + .as_ref() + .map_or(0.0, |wd| wd.normal_attack.mean_damage) + .partial_cmp( + &pd2.weapon_data + .as_ref() + .map_or(0.0, |wd| wd.normal_attack.mean_damage), + ) + .unwrap_or(std::cmp::Ordering::Equal), + ) + .reverse() + }); + all_weapons +} diff --git a/blastmud_game/src/static_content.rs b/blastmud_game/src/static_content.rs index d4ac8ab..7da5d50 100644 --- a/blastmud_game/src/static_content.rs +++ b/blastmud_game/src/static_content.rs @@ -85,6 +85,8 @@ fn static_task_registry() -> Vec> { #[cfg(not(test))] async fn refresh_static_items(pool: &DBPool) -> DResult<()> { + use crate::message_handler::user_commands::buy::create_item_default_contents; + let registry = static_item_registry(); let expected_type: BTreeSet = @@ -118,6 +120,7 @@ async fn refresh_static_items(pool: &DBPool) -> DResult<()> { for mut item in (expected_item.extra_items_on_create)(&new_item) { item.item_code = format!("{}", tx.alloc_item_code().await?); tx.create_item(&item).await?; + create_item_default_contents(&tx, &item).await?; } } for existing_item_code in expected_set.intersection(&existing_items) { diff --git a/blastmud_game/src/static_content/npc.rs b/blastmud_game/src/static_content/npc.rs index 1c6719a..a6c6967 100644 --- a/blastmud_game/src/static_content/npc.rs +++ b/blastmud_game/src/static_content/npc.rs @@ -8,7 +8,10 @@ use super::{ use crate::db::DBTrans; use crate::{ message_handler::{ - user_commands::{say::say_to_room, CommandHandlingError, UResult, VerbContext}, + user_commands::{ + buy::create_item_default_contents, say::say_to_room, CommandHandlingError, UResult, + VerbContext, + }, ListenerSession, }, models::{ @@ -163,7 +166,7 @@ impl Default for NPC { ) }) .collect(), - total_stats: vec![].into_iter().collect(), + total_stats: vec![(StatType::Brawn, 8.0)].into_iter().collect(), aggression: 0, aggro_pc_only: false, max_health: 24, @@ -271,6 +274,7 @@ pub fn npc_static_items() -> Box> { pos_it.action_type_started = Some( Utc::now() - chrono::TimeDelta::try_seconds(spawn_item.wear_layer).unwrap(), ); + pos_it })) }), @@ -648,6 +652,7 @@ impl TaskHandler for NPCRecloneTaskHandler { item.item_code = format!("{}", ctx.trans.alloc_item_code().await?); item.action_type = spawn_item.action_type.clone(); ctx.trans.create_item(&item).await?; + create_item_default_contents(&ctx.trans, &item).await?; } return Ok(None); diff --git a/blastmud_game/src/static_content/npc/ronalds_house.rs b/blastmud_game/src/static_content/npc/ronalds_house.rs index ec7e44a..623e883 100644 --- a/blastmud_game/src/static_content/npc/ronalds_house.rs +++ b/blastmud_game/src/static_content/npc/ronalds_house.rs @@ -1,10 +1,10 @@ use crate::{ models::{ consent::ConsentType, - item::{Pronouns, SkillType}, + item::{ItemFlag, LocationActionType, Pronouns, SkillType}, }, static_content::{ - npc::{NPCSayInfo, NPCSayType}, + npc::{NPCSayInfo, NPCSayType, NPCSpawnPossession}, possession_type::PossessionType, species::SpeciesType, }, @@ -28,7 +28,7 @@ pub fn npc_list() -> Vec { "I once had a private army, now I must rely on my wits, my gun and my blade. They have never failed me.", "You dare threaten me, the Praefect of a fallen empire? I'll show you what true power is.", "Your intrusion is an affront to my dignity. Prepare to face my wrath.", - "I once commanded entire planets, now I must defend myself and my the green code share against pathetic invaders like you.", + "I once commanded an entire praefect, now I must defend myself and my green code share against pathetic invaders like you.", "The late emperor trusted me with the green code share, and I will not let it fall into the wrong hands.", "My people used to cower in fear of me. Now they dare to trespass on my property.", "I once ruled an empire, now I must defend my home and my code against intruders like you.", @@ -51,6 +51,7 @@ pub fn npc_list() -> Vec { NPC { code: "ronalds_house_taipan".to_owned(), name: "Brown-headed taipan".to_owned(), + pronouns: Pronouns::default_inanimate(), description: "A large olive coloured snake, its head a darker brown. Its head tracks you as it tastes the air with its forked tongue. As it flicks its tongue, you see a glimpse of two needle-sharp fangs in the front of its mouth".to_owned(), spawn_location: "room/ronalds_snakepit".to_owned(), aliases: vec!["snake".to_owned(), "taipan".to_owned()], @@ -82,7 +83,28 @@ pub fn npc_list() -> Vec { pronouns: Pronouns::default_male(), description: "A tall man, probably in his fifties, but still looking fit. His hair shows tinges of gray, while his rugged face and piercing blue eyes give the impression this is a man who would make a heartless decision in an instant".to_owned(), spawn_location: "room/ronalds_study".to_owned(), - spawn_possessions: vec![], + spawn_possessions: vec![ + NPCSpawnPossession { + what: PossessionType::Z3000Pistol, + action_type: LocationActionType::Normal, + wear_layer: 0, + }, + NPCSpawnPossession { + what: PossessionType::NinemilFiftyBox, + action_type: LocationActionType::Normal, + wear_layer: 0, + }, + NPCSpawnPossession { + what: PossessionType::NinemilFiftyBox, + action_type: LocationActionType::Normal, + wear_layer: 0, + }, + NPCSpawnPossession { + what: PossessionType::Electroblade, + action_type: LocationActionType::Wielded, + wear_layer: 0, + }, + ], says: ronald_says, aggression: 21, aggro_pc_only: true, @@ -95,13 +117,15 @@ pub fn npc_list() -> Vec { sk.clone(), match sk { SkillType::Dodge => 14.0, - SkillType::Fists => 18.0, + SkillType::Blades => 16.0, + SkillType::Pistols => 18.0, _ => 8.0 } ) }).collect(), species: SpeciesType::Human, player_consents: vec!(ConsentType::Fight), + extra_flags: vec![ItemFlag::EnableCombatAi], ..Default::default() } ] diff --git a/blastmud_game/src/static_content/possession_type.rs b/blastmud_game/src/static_content/possession_type.rs index fa15555..9a912be 100644 --- a/blastmud_game/src/static_content/possession_type.rs +++ b/blastmud_game/src/static_content/possession_type.rs @@ -264,28 +264,9 @@ pub trait TurnToggleHandler { } 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 AllowlistContainerCheck { - allowed_types: Vec, -} -impl ContainerCheck for AllowlistContainerCheck { + // The full check that needs item data. fn check_place(&self, container: &Item, item: &Item) -> UResult<()> { - if item - .possession_type - .as_ref() - .map(|pt| !self.allowed_types.contains(pt)) - .unwrap_or(true) - { + if !self.check_place_type(&item.possession_type) { user_error(format!( "You realise {} wasn't designed to hold that.", container.display_for_sentence(1, false), @@ -294,6 +275,29 @@ impl ContainerCheck for AllowlistContainerCheck { Ok(()) } } + // A lighter check that only factors in the content type. May pass in some cases when + // check_place will fail. None is for non-possessions. + fn check_place_type(&self, item_type: &Option) -> bool; +} + +pub struct PermissiveContainerCheck; +static PERMISSIVE_CONTAINER_CHECK: PermissiveContainerCheck = PermissiveContainerCheck; +impl ContainerCheck for PermissiveContainerCheck { + fn check_place_type(&self, _item_type: &Option) -> bool { + true + } +} + +pub struct AllowlistContainerCheck { + allowed_types: Vec, +} +impl ContainerCheck for AllowlistContainerCheck { + fn check_place_type(&self, item_type: &Option) -> bool { + item_type + .as_ref() + .map(|pt| self.allowed_types.contains(pt)) + .unwrap_or(false) + } } #[async_trait] @@ -448,10 +452,14 @@ pub enum PossessionType { // Armour / Clothes RustyMetalPot, HockeyMask, + CombatHelmet, + CombatBoots, Shirt, LeatherJacket, + ShieldWeaveJacket, Jeans, LeatherPants, + ShieldWeavePants, RadSuit, // Weapons: Whips AntennaWhip, @@ -884,7 +892,13 @@ mod tests { .sum::() * container_data.compression_ratio) .ceil() as u64); - assert!(tot as u64 == pd.weight); + assert!( + tot as u64 == pd.weight, + "Starting weight for {}, {} didn't match calculated weight, {}", + pd.display, + pd.weight, + tot + ); } } } diff --git a/blastmud_game/src/static_content/possession_type/books.rs b/blastmud_game/src/static_content/possession_type/books.rs index ac45188..280a0cc 100644 --- a/blastmud_game/src/static_content/possession_type/books.rs +++ b/blastmud_game/src/static_content/possession_type/books.rs @@ -19,13 +19,18 @@ pub fn recipe_set() -> &'static BTreeSet { struct RecipesOnlyChecker; impl ContainerCheck for RecipesOnlyChecker { fn check_place(&self, _container: &Item, item: &Item) -> UResult<()> { - item.possession_type + if !self.check_place_type(&item.possession_type) { + Err(UserError( + "You don't find a sensible place for that in a recipe book.".to_owned(), + )) + } else { + Ok(()) + } + } + fn check_place_type(&self, item_type: &Option) -> bool { + item_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(()) + .map_or(false, |pt| recipe_set().contains(pt)) } } diff --git a/blastmud_game/src/static_content/possession_type/gun.rs b/blastmud_game/src/static_content/possession_type/gun.rs index 4e950b1..358b624 100644 --- a/blastmud_game/src/static_content/possession_type/gun.rs +++ b/blastmud_game/src/static_content/possession_type/gun.rs @@ -64,6 +64,7 @@ pub fn data() -> &'static Vec<(PossessionType, PossessionData)> { display: "box of airsoft rounds", details: "A box of airsoft rounds, each a soft plastic spherical projectile", aliases: vec!["air-soft round box", "air soft round box", "ammo"], + weight: 361, container_data: Some(ContainerData { max_weight: 400, base_weight: 1, @@ -111,6 +112,7 @@ pub fn data() -> &'static Vec<(PossessionType, PossessionData)> { power_attack: None } ), + weight: 500, container_data: Some(ContainerData { max_weight: 96, base_weight: 500, @@ -165,6 +167,7 @@ pub fn data() -> &'static Vec<(PossessionType, PossessionData)> { display: "box of 9mm solid bullets", details: "A box of nine millimetre bullets, each consisting of casing and a solid-tipped projectile", aliases: vec!["bullet", "nine-millimetre bullet", "ammo"], + weight: 361, container_data: Some(ContainerData { max_weight: 400, base_weight: 1, @@ -182,6 +185,7 @@ pub fn data() -> &'static Vec<(PossessionType, PossessionData)> { display: "Z3000 pistol", details: "A black metal pistol, featuring a cylindrical barrel, with a rectangular body and butt", aliases: vec!["gun", "pistol"], + weight: 500, weapon_data: Some( WeaponData { uses_skill: SkillType::Pistols, diff --git a/blastmud_game/src/static_content/possession_type/head_armour.rs b/blastmud_game/src/static_content/possession_type/head_armour.rs index c9b3600..f8d95e1 100644 --- a/blastmud_game/src/static_content/possession_type/head_armour.rs +++ b/blastmud_game/src/static_content/possession_type/head_armour.rs @@ -80,6 +80,51 @@ pub fn data() -> &'static Vec<(PossessionType, PossessionData)> { }), ..Default::default() } - ) + ), + ( + PossessionType::CombatHelmet, + PossessionData { + display: "combat helmet", + details: "A military grade combat helmet, featuring a visor. It looks like it would provide full protection for the head or face against many threats a soldier might face on the battlefield", + aliases: vec!("helmet"), + weight: 500, + wear_data: Some(WearData { + covers_parts: vec!(BodyPart::Face, BodyPart::Head), + thickness: 6.0, + dodge_penalty: 0.4, + soaks: vec!( + (DamageType::Beat, + SoakData { + min_soak: 3.0, + max_soak: 4.0, + damage_probability_per_soak: 0.1 + } + ), + (DamageType::Slash, + SoakData { + min_soak: 2.0, + max_soak: 3.0, + damage_probability_per_soak: 0.1 + } + ), + (DamageType::Pierce, + SoakData { + min_soak: 2.0, + max_soak: 3.0, + damage_probability_per_soak: 0.1 + } + ), + (DamageType::Bullet, + SoakData { + min_soak: 60.0, + max_soak: 70.0, + damage_probability_per_soak: 0.1 + } + ), + ).into_iter().collect() + }), + ..Default::default() + } + ), )) } diff --git a/blastmud_game/src/static_content/possession_type/lower_armour.rs b/blastmud_game/src/static_content/possession_type/lower_armour.rs index ef7c9cf..7ecad70 100644 --- a/blastmud_game/src/static_content/possession_type/lower_armour.rs +++ b/blastmud_game/src/static_content/possession_type/lower_armour.rs @@ -62,5 +62,88 @@ pub fn data() -> &'static Vec<(PossessionType, PossessionData)> { ..Default::default() } ), + ( + PossessionType::ShieldWeavePants, + PossessionData { + display: "pair of ShieldWeave pants", + details: "Black pants that look like they could be formal business pants, except they are made out of some kind of light but extremely strong fibre. A label inside says they are ShieldWeave bullet-resistant pants, offering stylish protection against fire from pistols, as well as offering a degree of stab and slash protection", + aliases: vec!("shield weave pants", "shieldweave pants", "pants"), + weight: 300, + wear_data: Some(WearData { + covers_parts: vec!(BodyPart::Groin, BodyPart::Legs), + thickness: 4.0, + dodge_penalty: 0.25, + soaks: vec!( + (DamageType::Bullet, + SoakData { + min_soak: 60.0, + max_soak: 70.0, + damage_probability_per_soak: 0.1 + } + ), + (DamageType::Slash, + SoakData { + min_soak: 2.0, + max_soak: 3.0, + damage_probability_per_soak: 0.1 + } + ), + (DamageType::Pierce, + SoakData { + min_soak: 2.0, + max_soak: 3.0, + damage_probability_per_soak: 0.1 + } + ), + ).into_iter().collect(), + }), + ..Default::default() + } + ), + ( + PossessionType::CombatBoots, + PossessionData { + display: "pair of combat boots", + details: "A pair of calf-high rough tan-leather combat boots. They look like they would protect the feet", + aliases: vec!("boots", "footwear", "combat boots"), + weight: 300, + wear_data: Some(WearData { + covers_parts: vec!(BodyPart::Feet), + thickness: 4.0, + dodge_penalty: 0.25, + soaks: vec!( + (DamageType::Beat, + SoakData { + min_soak: 4.0, + max_soak: 5.0, + damage_probability_per_soak: 0.1 + } + ), + (DamageType::Slash, + SoakData { + min_soak: 3.0, + max_soak: 4.0, + damage_probability_per_soak: 0.1 + } + ), + (DamageType::Pierce, + SoakData { + min_soak: 3.0, + max_soak: 4.0, + damage_probability_per_soak: 0.1 + } + ), + (DamageType::Bullet, + SoakData { + min_soak: 40.0, + max_soak: 50.0, + damage_probability_per_soak: 0.1 + } + ), + ).into_iter().collect(), + }), + ..Default::default() + } + ), )) } diff --git a/blastmud_game/src/static_content/possession_type/torso_armour.rs b/blastmud_game/src/static_content/possession_type/torso_armour.rs index c16ff9e..5cecb8a 100644 --- a/blastmud_game/src/static_content/possession_type/torso_armour.rs +++ b/blastmud_game/src/static_content/possession_type/torso_armour.rs @@ -99,5 +99,45 @@ pub fn data() -> &'static Vec<(PossessionType, PossessionData)> { ..Default::default() } ), + ( + PossessionType::ShieldWeaveJacket, + PossessionData { + display: "ShieldWeave jacket", + details: "A fancy-looking black jacket that could be formal business wear, except it is made out of some kind of light but extremely strong fibre. A label inside says it is a ShieldWeave bullet-resistant business jacket, offering stylish protection against fire from pistols, as well as offering a degree of stab and slash protection", + aliases: vec!("shield weave jacket", "jacket", "shieldweave jacket"), + weight: 300, + wear_data: Some(WearData { + covers_parts: vec!(BodyPart::Arms, + BodyPart::Chest, + BodyPart::Back), + thickness: 4.0, + dodge_penalty: 0.25, + soaks: vec!( + (DamageType::Bullet, + SoakData { + min_soak: 60.0, + max_soak: 70.0, + damage_probability_per_soak: 0.1 + } + ), + (DamageType::Slash, + SoakData { + min_soak: 2.0, + max_soak: 3.0, + damage_probability_per_soak: 0.1 + } + ), + (DamageType::Pierce, + SoakData { + min_soak: 2.0, + max_soak: 3.0, + damage_probability_per_soak: 0.1 + } + ), + ).into_iter().collect(), + }), + ..Default::default() + } + ), )) } diff --git a/blastmud_game/src/static_content/room/melbs.yaml b/blastmud_game/src/static_content/room/melbs.yaml index 5e8cd27..f655a01 100644 --- a/blastmud_game/src/static_content/room/melbs.yaml +++ b/blastmud_game/src/static_content/room/melbs.yaml @@ -1796,6 +1796,15 @@ - possession_type: LeatherPants list_price: 500 poverty_discount: false + - possession_type: CombatHelmet + list_price: 15000 + poverty_discount: false + - possession_type: ShieldWeavePants + list_price: 35000 + poverty_discount: false + - possession_type: ShieldWeaveJacket + list_price: 40000 + poverty_discount: false - zone: melbs code: melbs_bourkest_200 name: Bourke St - 200 block @@ -1854,6 +1863,9 @@ - possession_type: ElectricLantern list_price: 500 poverty_discount: false + - possession_type: CombatBoots + list_price: 3000 + poverty_discount: false scavtable: CityStreet - zone: melbs code: melbs_bourkest_190 diff --git a/blastmud_game/src/static_content/room/ronalds_house.yaml b/blastmud_game/src/static_content/room/ronalds_house.yaml index 5e44077..380c576 100644 --- a/blastmud_game/src/static_content/room/ronalds_house.yaml +++ b/blastmud_game/src/static_content/room/ronalds_house.yaml @@ -52,8 +52,8 @@ z: 0 description: A section of hallway that branches off to several rooms of the house. On a pillar hangs a framed but faded picture of two men, each with an evil smile, one recognisable as the former emperor, and the other clad in the nanoweave smartwear of the fallen empire's most trusted elite exits: - - direction: south - - direction: east + - direction: north + - direction: west - zone: ronalds_house code: ronalds_study name: Study @@ -65,4 +65,4 @@ z: 0 description: A spacious study, the walls lined with massive electronic ink displays. An extremely fancy leather chair, featuring electronic controls, has prime place in the middle exits: - - direction: west + - direction: east diff --git a/docs/dbfixes.md b/docs/dbfixes.md index 03c964a..765806a 100644 --- a/docs/dbfixes.md +++ b/docs/dbfixes.md @@ -19,7 +19,7 @@ This is expected to be 0 - haven't seen any instances of it deviating, so any bu ## NPC combat non-symmetrical attacking / attacked_by -select i1.details->>'item_code' as i1_code, i1.details->>'active_combat' as i1_combat, i1.details->>'location' as i1_loc, i2.details->>'item_code' as i2_code, i2.details->>'active_combat' as i2_combat, i2.details->>'location' as i2_loc from items i1 join items i2 on i2.details->>'item_type' = (regexp_split_to_array(i1.details->'active_combat'->>'attacking', '/'))[1] and i2.details->>'item_code' = (regexp_split_to_array(i1.details->'active_combat'->>'attacking', '/'))[2] where not exists (select 1 from jsonb_array_elements_text(i2.details->'active_combat'->'attacked_by') e where e = ((i1.details->>'item_type') || '/' || (i1.details->>'item_code'))); +`select i1.details->>'item_code' as i1_code, i1.details->>'active_combat' as i1_combat, i1.details->>'location' as i1_loc, i2.details->>'item_code' as i2_code, i2.details->>'active_combat' as i2_combat, i2.details->>'location' as i2_loc from items i1 join items i2 on i2.details->>'item_type' = (regexp_split_to_array(i1.details->'active_combat'->>'attacking', '/'))[1] and i2.details->>'item_code' = (regexp_split_to_array(i1.details->'active_combat'->>'attacking', '/'))[2] where not exists (select 1 from jsonb_array_elements_text(i2.details->'active_combat'->'attacked_by') e where e = ((i1.details->>'item_type') || '/' || (i1.details->>'item_code')));` This should be empty, but there has been a bug breaking this. @@ -27,4 +27,10 @@ This should be empty, but there has been a bug breaking this. ## Rename a skill Something like this: -with fexdet as (select item_id, jsonb_strip_nulls(jsonb_set(details, '{total_skills,Fuck}', 'null')) as details, details->'total_skills'->'Fuck' as f from items), amenddet as (select jsonb_set(details :: jsonb, '{total_skills,Share}', f :: jsonb) as details, item_id from fexdet where f is not null) update items i set details = a.details from amenddet a where i.item_id = a.item_id; +`with fexdet as (select item_id, jsonb_strip_nulls(jsonb_set(details, '{total_skills,Fuck}', 'null')) as details, details->'total_skills'->'Fuck' as f from items), amenddet as (select jsonb_set(details :: jsonb, '{total_skills,Share}', f :: jsonb) as details, item_id from fexdet where f is not null) update items i set details = a.details from amenddet a where i.item_id = a.item_id;` + +# Advanced admin via the database to help debugging / admin +## Immediately reclone a dead NPC + +Set task_code to the NPC's ID as appropriate. `next_scheduled` can be any time in the past. +`update tasks set details=jsonb_set(details, '{next_scheduled}', '"2024-01-01T00:00:00Z"') where details->>'task_type' = 'RecloneNPC' and details->>'task_code' = 'ronalds_house_ronald';`