diff --git a/CHANGELOG.md b/CHANGELOG.md index 449768251d..e6a3dac1e5 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -26,6 +26,7 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 - RTsim travellers now follow paths between towns - "Poise" renamed to "Stun resilience" - Stun resilience stat display +- Villagers and guards now spawn with potions, and know how to use them. ### Changed diff --git a/common/src/comp/inventory/loadout_builder.rs b/common/src/comp/inventory/loadout_builder.rs index ff995f1c88..987e3d58b0 100644 --- a/common/src/comp/inventory/loadout_builder.rs +++ b/common/src/comp/inventory/loadout_builder.rs @@ -13,6 +13,7 @@ use crate::{ }; use rand::Rng; use serde::{Deserialize, Serialize}; +use tracing::warn; /// Builder for character Loadouts, containing weapon and armour items belonging /// to a character, along with some helper methods for loading Items and @@ -55,6 +56,18 @@ pub enum LoadoutConfig { Warlock, } +pub fn make_potion_bag(quantity: u32) -> Item { + let mut bag = Item::new_from_asset_expect("common.items.armor.misc.bag.tiny_leather_pouch"); + if let Some(i) = bag.slots_mut().iter_mut().next() { + let mut potions = Item::new_from_asset_expect("common.items.consumable.potion_big"); + if let Err(e) = potions.set_amount(quantity) { + warn!("Failed to set potion quantity: {:?}", e); + } + *i = Some(potions); + } + bag +} + impl LoadoutBuilder { #[allow(clippy::new_without_default)] // TODO: Pending review in #587 pub fn new() -> Self { Self(Loadout::new_empty()) } @@ -551,6 +564,7 @@ impl LoadoutBuilder { 0 => Some(Item::new_from_asset_expect("common.items.lantern.black_0")), _ => None, }) + .bag(ArmorSlot::Bag1, Some(make_potion_bag(25))) .build(), Merchant => { let mut backpack = @@ -934,6 +948,7 @@ impl LoadoutBuilder { _ => "common.items.armor.misc.foot.sandals", }, ))) + .bag(ArmorSlot::Bag1, Some(make_potion_bag(10))) .build(), } } else { diff --git a/server/src/rtsim/entity.rs b/server/src/rtsim/entity.rs index 8110498fd7..8d265b929d 100644 --- a/server/src/rtsim/entity.rs +++ b/server/src/rtsim/entity.rs @@ -131,6 +131,10 @@ impl Entity { .chest(chest) .pants(pants) .shoulder(shoulder) + .bag( + comp::inventory::slot::ArmorSlot::Bag1, + Some(comp::inventory::loadout_builder::make_potion_bag(100)), + ) .build() } diff --git a/server/src/sys/agent.rs b/server/src/sys/agent.rs index 2298a08112..f5560290ef 100644 --- a/server/src/sys/agent.rs +++ b/server/src/sys/agent.rs @@ -11,13 +11,15 @@ use common::{ invite::{InviteKind, InviteResponse}, item::{ tool::{ToolKind, UniqueKind}, - ItemDesc, ItemKind, + Item, ItemDesc, ItemKind, }, skills::{AxeSkill, BowSkill, HammerSkill, Skill, StaffSkill, SwordSkill}, Agent, Alignment, BehaviorCapability, BehaviorState, Body, CharacterState, ControlAction, - ControlEvent, Controller, Energy, Health, InputKind, Inventory, LightEmitter, MountState, - Ori, PhysicsState, Pos, Scale, Stats, UnresolvedChatMsg, Vel, + ControlEvent, Controller, Energy, Health, HealthChange, InputKind, Inventory, + InventoryAction, LightEmitter, MountState, Ori, PhysicsState, Pos, Scale, Stats, + UnresolvedChatMsg, Vel, }, + effect::{BuffEffect, Effect}, event::{Emitter, EventBus, ServerEvent}, path::TraversalConfig, resources::{DeltaTime, Time, TimeOfDay}, @@ -109,6 +111,7 @@ const SIGHT_DIST: f32 = 80.0; const SNEAK_COEFFICIENT: f32 = 0.25; const AVG_FOLLOW_DIST: f32 = 6.0; const RETARGETING_THRESHOLD_SECONDS: f64 = 10.0; +const HEALING_ITEM_THRESHOLD: f32 = 0.5; /// This system will allow NPCs to modify their controller #[derive(Default)] @@ -576,6 +579,11 @@ impl<'a> AgentData<'a> { read_data: &ReadData, event_emitter: &mut Emitter<'_, ServerEvent>, ) { + if self.damage < HEALING_ITEM_THRESHOLD && self.heal_self(agent, controller) { + agent.action_timer = 0.01; + return; + } + if let Some(Target { target, selected_at, @@ -619,12 +627,14 @@ impl<'a> AgentData<'a> { event_emitter .emit(ServerEvent::Chat(UnresolvedChatMsg::npc(*self.uid, msg))); } - // Choose a new target every 10 seconds + // Choose a new target every 10 seconds, but only for + // enemies // TODO: This should be more principled. Consider factoring - // health, combat rating, wielded - // weapon, etc, into the decision to change - // target. - } else if read_data.time.0 - selected_at > RETARGETING_THRESHOLD_SECONDS { + // health, combat rating, wielded weapon, etc, into the + // decision to change target. + } else if read_data.time.0 - selected_at > RETARGETING_THRESHOLD_SECONDS + && matches!(self.alignment, Some(Alignment::Enemy)) + { self.choose_target(agent, controller, &read_data, event_emitter); } else if dist_sqrd < SIGHT_DIST.powi(2) { self.attack( @@ -690,6 +700,11 @@ impl<'a> AgentData<'a> { } }; + if self.damage < HEALING_ITEM_THRESHOLD && self.heal_self(agent, controller) { + agent.action_timer = 0.01; + return; + } + agent.action_timer = 0.0; if let Some((travel_to, _destination)) = &agent.rtsim_controller.travel_to { // if it has an rtsim destination and can fly then it should @@ -1289,6 +1304,61 @@ impl<'a> AgentData<'a> { agent.action_timer += dt.0; } + /// Attempt to consume a healing item, and return whether any healing items + /// were queued. Callers should use this to implement a delay so that + /// the healing isn't interrupted. + fn heal_self(&self, _agent: &mut Agent, controller: &mut Controller) -> bool { + let healing_value = |item: &Item| { + let mut value = 0; + #[allow(clippy::single_match)] + match item.kind() { + ItemKind::Consumable { effect, .. } => { + for e in effect.iter() { + use BuffKind::*; + match e { + Effect::Health(HealthChange { amount, .. }) => { + value += *amount; + }, + Effect::Buff(BuffEffect { kind, data, .. }) + if matches!(kind, Regeneration | Saturation | Potion) => + { + value += (data.strength + * data.duration.map_or(0.0, |d| d.as_secs() as f32)) + as i32; + } + _ => {}, + } + } + }, + _ => {}, + } + value + }; + + let mut consumables: Vec<_> = self + .inventory + .slots_with_id() + .filter_map(|(id, slot)| match slot { + Some(item) if healing_value(item) > 0 => Some((id, item)), + _ => None, + }) + .collect(); + + consumables.sort_by_key(|(_, item)| healing_value(item)); + + if let Some((id, _)) = consumables.last() { + use comp::inventory::slot::Slot; + controller + .actions + .push(ControlAction::InventoryAction(InventoryAction::Use( + Slot::Inventory(*id), + ))); + true + } else { + false + } + } + fn choose_target( &self, agent: &mut Agent,