diff --git a/CHANGELOG.md b/CHANGELOG.md index 9eaa784d17..b5f75b66f1 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -16,6 +16,7 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 - Furniture and waypoints in site2 towns - text input for trading - Themed Site CliffTown, hoodoo/arabic inspired stone structures inhabited by mountaineer NPCs. +- NPCs now have rudimentary personalities ### Changed diff --git a/Cargo.lock b/Cargo.lock index 618578d8f5..f9fdcad81b 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -6703,6 +6703,7 @@ dependencies = [ "chrono", "chrono-tz", "crossbeam-channel", + "enumset", "futures-util", "hashbrown 0.11.2", "humantime", diff --git a/assets/voxygen/i18n/en/npc.ron b/assets/voxygen/i18n/en/npc.ron index 1b508fc50a..19b25c2558 100644 --- a/assets/voxygen/i18n/en/npc.ron +++ b/assets/voxygen/i18n/en/npc.ron @@ -7,40 +7,88 @@ vector_map: { "npc.speech.villager": [ - "Isn't it such a lovely day?", - "How are you today?", - "Top of the morning to you!", + "I love cheese.", + ], + "npc.speech.villager_open": [ "I wonder what the Catoblepas thinks when it eats grass.", - "What do you think about this weather?", - "Thinking about those dungeons makes me scared. I hope someone will clear them out.", - "I'd like to go spelunking in a cave when I'm stronger.", - "Have you seen my cat?", + "What do you suppose makes Glowing Remains glow?", "Have you ever heard of the ferocious Land Sharks? I hear they live in deserts.", + "I wonder what is on the other side of the mountains.", + "I left some cheese with my sibling. Now I don't know if it exists or not. I call it Schrödinger's cheese.", + "Have you ever caught a firefly?", "They say shiny gems of all kinds can be found in caves.", - "I'm just crackers about cheese!", - "Won't you come in? We were just about to have some cheese!", + "I just can't understand where those Sauroks keep coming from.", + ], + "npc.speech.villager_adventurous": [ + "I hope to make my own glider someday.", + "I'd like to go spelunking in a cave when I'm stronger.", + ], + "npc.speech.villager_closed": [ + "You're not from around here are you?", + "Don't you think our village is the best?", "They say mushrooms are good for your health. Never eat them myself.", + "To be, or not to be? I think I'll be a farmer.", + ], + "npc.speech.villager_conscientious": [ + "I keep busy. There's always something to do.", + "I hope it rains soon. Would be good for the crops.", + ], + "npc.speech.villager_busybody": [ + "People should talk less and work more.", + ], + "npc.speech.villager_unconscientious": [ + "I think it's time for second breakfast!", + "I wish was my house wasn't such a mess. But then I'd have to tidy up! Haha!", + "Now where did I leave that thing...", + ], + "npc.speech.villager_extroverted": [ + "You won't believe what I did this weekend!", + "Top of the morning to you!", + "What do you think about this weather?", + "I'm just crackers about cheese!", "Don't forget the crackers!", "I simply adore dwarven cheese. I wish I could make it.", - "I wonder what is on the other side of the mountains.", - "I hope to make my own glider someday.", - "Would you like to see my garden? Okay, maybe some other time.", - "Lovely day for a stroll in the woods!", - "To be, or not to be? I think I'll be a farmer.", - "Don't you think our village is the best?", - "What do you suppose makes Glowing Remains glow?", - "I think it's time for second breakfast!", - "Have you ever caught a firefly?", - "I just can't understand where those Sauroks keep coming from.", - "I wish someone would keep the wolves away from the village.", "I had a wonderful dream about cheese last night. What does it mean?", - "I left some cheese with my brother. Now I don't know if it exists or not. I call it Schrödinger's cheese.", - "I left some cheese with my sister. Now I don't know if it exists or not. I call it Schrödinger's cheese.", - "Someone should do something about those cultists. Preferably not me.", - "I hope it rains soon. Would be good for the crops.", "I love honey! And I hate bees.", + ], + "npc.speech.villager_sociable": [ + "Won't you come in? We were just about to have some cheese!", + "Would you like to see my garden? Okay, maybe some other time.", + ], + "npc.speech.villager_introverted": [ + "Hi.", + "Oh me? I'm nothing special.", + ], + "npc.speech.villager_agreeable": [ + "How are you today?", + "Just tell me if you need anything.", + "Have you seen my cat?", + ], + "npc.speech.villager_worried": [ + "Be careful, alright? There are so many dangers out there.", + ], + "npc.speech.villager_disagreeable": [ + "I say it like it is. If people don't like that, too bad.", + "People are too easily offended.", + ], + "npc.speech.villager_neurotic": [ + "Thinking about those dungeons makes me scared. I hope someone will clear them out.", + "Someone should do something about those cultists. Preferably not me.", + "I have the feeling something bad will happen.", + "I wish someone would keep the wolves away from the village.", + ], + "npc.speech.villager_sad_loner": [ + "I'm so lonely.", + "... Sorry about this awkward silence. I'm not so good with people.", + ], + "npc.speech.villager_seeker": [ "I want to see the world one day. There's got to be more to life than this village.", ], + "npc.speech.villager_stable": [ + "Isn't it such a lovely day?", + "Life's not too bad.", + "Lovely day for a stroll in the woods!", + ], "npc.speech.villager_decline_trade": [ "Sorry, I don't have anything to trade.", "Trade? Like I got anything that may interest you.", @@ -52,13 +100,15 @@ "I have plenty of goods, Do you want to take a look?" ], "npc.speech.merchant_busy": [ - "Hey, wait your turn.", "Please wait, I'm only one person.", - "Do you see the other person in front of you?", "Just a moment, let me finish.", - "No cutting in line.", "I'm busy, come back later." ], + "npc.speech.merchant_busy_rude": [ + "Hey, wait your turn.", + "Do you see the other person in front of you?", + "No cutting in line.", + ], "npc.speech.merchant_trade_successful": [ "Thank you for trading with me!", "Thank you!", diff --git a/server/Cargo.toml b/server/Cargo.toml index bd27849e5d..d2179bcf90 100644 --- a/server/Cargo.toml +++ b/server/Cargo.toml @@ -57,6 +57,7 @@ portpicker = { git = "https://github.com/xMAC94x/portpicker-rs", rev = "df6b3787 authc = { git = "https://gitlab.com/veloren/auth.git", rev = "fb3dcbc4962b367253f8f2f92760ef44d2679c9a" } slab = "0.4" rand_distr = "0.4.0" +enumset = "1.0.8" rusqlite = { version = "0.24.2", features = ["array", "vtab", "bundled", "trace"] } refinery = { git = "https://gitlab.com/veloren/refinery.git", rev = "8ecf4b4772d791e6c8c0a3f9b66a7530fad1af3e", features = ["rusqlite"] } diff --git a/server/src/rtsim/entity.rs b/server/src/rtsim/entity.rs index 0601216893..0cdf0b45e8 100644 --- a/server/src/rtsim/entity.rs +++ b/server/src/rtsim/entity.rs @@ -6,6 +6,7 @@ use common::{ terrain::TerrainGrid, trade, LoadoutBuilder, }; +use enumset::*; use rand_distr::{Distribution, Normal}; use std::f32::consts::PI; use tracing::warn; @@ -647,7 +648,7 @@ impl Entity { } #[derive(Clone, Debug)] -enum Travel { +pub enum Travel { // The initial state all entities start in, and a fallback for when a state has stopped making // sense. Non humanoids will always revert to this state after reaching their goal since the // current site they are in doesn't change their behavior. @@ -689,31 +690,157 @@ enum Travel { Idle, } -impl Default for Travel { - fn default() -> Self { Self::Lost } +// Based on https://en.wikipedia.org/wiki/Big_Five_personality_traits +pub struct PersonalityBase { + openness: u8, + conscientiousness: u8, + extraversion: u8, + agreeableness: u8, + neuroticism: u8, +} + +impl PersonalityBase { + /* All thresholds here are arbitrary "seems right" values. The goal is for + * most NPCs to have some kind of distinguishing trait - something + * interesting about them. We want to avoid Joe Averages. But we also + * don't want everyone to be completely weird. + */ + pub fn to_personality(&self) -> Personality { + let mut chat_traits: EnumSet = EnumSet::new(); + if self.openness > Personality::HIGH_THRESHOLD { + chat_traits.insert(PersonalityTrait::Open); + if self.neuroticism < Personality::MID { + chat_traits.insert(PersonalityTrait::Adventurous); + } + } else if self.openness < Personality::LOW_THRESHOLD { + chat_traits.insert(PersonalityTrait::Closed); + } + if self.conscientiousness > Personality::HIGH_THRESHOLD { + chat_traits.insert(PersonalityTrait::Conscientious); + if self.agreeableness < Personality::LOW_THRESHOLD { + chat_traits.insert(PersonalityTrait::Busybody); + } + } else if self.conscientiousness < Personality::LOW_THRESHOLD { + chat_traits.insert(PersonalityTrait::Unconscientious); + } + if self.extraversion > Personality::HIGH_THRESHOLD { + chat_traits.insert(PersonalityTrait::Extroverted); + } else if self.extraversion < Personality::LOW_THRESHOLD { + chat_traits.insert(PersonalityTrait::Introverted); + } + if self.agreeableness > Personality::HIGH_THRESHOLD { + chat_traits.insert(PersonalityTrait::Agreeable); + if self.extraversion > Personality::MID { + chat_traits.insert(PersonalityTrait::Sociable); + } + } else if self.agreeableness < Personality::LOW_THRESHOLD { + chat_traits.insert(PersonalityTrait::Disagreeable); + } + if self.neuroticism > Personality::HIGH_THRESHOLD { + chat_traits.insert(PersonalityTrait::Neurotic); + if self.openness > Personality::LITTLE_HIGH { + chat_traits.insert(PersonalityTrait::Seeker); + } + if self.agreeableness > Personality::LITTLE_HIGH { + chat_traits.insert(PersonalityTrait::Worried); + } + if self.extraversion < Personality::LITTLE_LOW { + chat_traits.insert(PersonalityTrait::SadLoner); + } + } else if self.neuroticism < Personality::LOW_THRESHOLD { + chat_traits.insert(PersonalityTrait::Stable); + } + Personality { + personality_traits: chat_traits, + } + } +} + +pub struct Personality { + pub personality_traits: EnumSet, +} + +#[derive(EnumSetType)] +pub enum PersonalityTrait { + Open, + Adventurous, + Closed, + Conscientious, + Busybody, + Unconscientious, + Extroverted, + Introverted, + Agreeable, + Sociable, + Disagreeable, + Neurotic, + Seeker, + Worried, + SadLoner, + Stable, +} + +impl Personality { + pub const HIGH_THRESHOLD: u8 = Self::MAX - Self::LOW_THRESHOLD; + pub const LITTLE_HIGH: u8 = Self::MID + (Self::MAX - Self::MIN) / 20; + pub const LITTLE_LOW: u8 = Self::MID - (Self::MAX - Self::MIN) / 20; + pub const LOW_THRESHOLD: u8 = (Self::MAX - Self::MIN) / 5 * 2 + Self::MIN; + const MAX: u8 = 100; + pub const MID: u8 = (Self::MAX - Self::MIN) / 2; + const MIN: u8 = 0; + + pub fn random_chat_trait(&self, rng: &mut impl Rng) -> Option { + self.personality_traits.into_iter().choose(rng) + } + + pub fn random_trait_value_bounded(rng: &mut impl Rng, min: u8, max: u8) -> u8 { + let max_third = max / 3; + let min_third = min / 3; + rng.gen_range(min_third..=max_third) + + rng.gen_range(min_third..=max_third) + + rng.gen_range((min - 2 * min_third)..=(max - 2 * max_third)) + } + + pub fn random_trait_value(rng: &mut impl Rng) -> u8 { + Self::random_trait_value_bounded(rng, Self::MIN, Self::MAX) + } + + pub fn random(rng: &mut impl Rng) -> Personality { + let mut random_value = + || rng.gen_range(0..=33) + rng.gen_range(0..=34) + rng.gen_range(0..=33); + let base = PersonalityBase { + openness: random_value(), + conscientiousness: random_value(), + extraversion: random_value(), + agreeableness: random_value(), + neuroticism: random_value(), + }; + base.to_personality() + } } -#[derive(Default)] pub struct Brain { - begin: Option>, - tgt: Option>, - route: Travel, - last_visited: Option>, - memories: Vec, + pub begin: Option>, + pub tgt: Option>, + pub route: Travel, + pub last_visited: Option>, + pub memories: Vec, + pub personality: Personality, } impl Brain { - pub fn idle() -> Self { + pub fn idle(rng: &mut impl Rng) -> Self { Self { begin: None, tgt: None, route: Travel::Idle, last_visited: None, memories: Vec::new(), + personality: Personality::random(rng), } } - pub fn raid(home_id: Id, target_id: Id) -> Self { + pub fn raid(home_id: Id, target_id: Id, rng: &mut impl Rng) -> Self { Self { begin: None, tgt: None, @@ -725,36 +852,54 @@ impl Brain { }, last_visited: None, memories: Vec::new(), + personality: Personality::random(rng), } } - pub fn villager(home_id: Id) -> Self { + pub fn villager(home_id: Id, rng: &mut impl Rng) -> Self { Self { begin: Some(home_id), tgt: None, route: Travel::Idle, last_visited: None, memories: Vec::new(), + personality: Personality::random(rng), } } - pub fn merchant(home_id: Id) -> Self { + pub fn merchant(home_id: Id, rng: &mut impl Rng) -> Self { + // Merchants are generally extraverted and agreeable + let extraversion_bias = (Personality::MAX - Personality::MIN) / 10 * 3; + let extraversion = + Personality::random_trait_value_bounded(rng, extraversion_bias, Personality::MAX); + let agreeableness_bias = extraversion_bias / 2; + let agreeableness = + Personality::random_trait_value_bounded(rng, agreeableness_bias, Personality::MAX); + let personality_base = PersonalityBase { + openness: Personality::random_trait_value(rng), + conscientiousness: Personality::random_trait_value(rng), + extraversion, + agreeableness, + neuroticism: Personality::random_trait_value(rng), + }; Self { begin: Some(home_id), tgt: None, route: Travel::Idle, last_visited: None, memories: Vec::new(), + personality: personality_base.to_personality(), } } - pub fn town_guard(home_id: Id) -> Self { + pub fn town_guard(home_id: Id, rng: &mut impl Rng) -> Self { Self { begin: Some(home_id), tgt: None, route: Travel::Idle, last_visited: None, memories: Vec::new(), + personality: Personality::random(rng), } } diff --git a/server/src/rtsim/mod.rs b/server/src/rtsim/mod.rs index c9d15da28b..292a416f5f 100644 --- a/server/src/rtsim/mod.rs +++ b/server/src/rtsim/mod.rs @@ -1,11 +1,13 @@ #![allow(dead_code)] // TODO: Remove this when rtsim is fleshed out mod chunks; -mod entity; +pub(crate) mod entity; mod load_chunks; mod tick; mod unload_chunks; +use crate::rtsim::entity::{Personality, Travel}; + use self::chunks::Chunks; use common::{ comp, @@ -134,7 +136,14 @@ pub fn init( controller: RtSimController::default(), last_time_ticked: 0.0, kind: RtSimEntityKind::Wanderer, - brain: Default::default(), + brain: Brain { + begin: None, + tgt: None, + route: Travel::Lost, + last_visited: None, + memories: Vec::new(), + personality: Personality::random(&mut thread_rng()), + }, }); } for (site_id, site) in world @@ -190,7 +199,7 @@ pub fn init( controller: RtSimController::default(), last_time_ticked: 0.0, kind: RtSimEntityKind::Cultist, - brain: Brain::raid(site_id, nearest_village), + brain: Brain::raid(site_id, nearest_village, &mut thread_rng()), }); } } @@ -214,7 +223,7 @@ pub fn init( controller: RtSimController::default(), last_time_ticked: 0.0, kind: RtSimEntityKind::Villager, - brain: Brain::villager(site_id), + brain: Brain::villager(site_id, &mut thread_rng()), }); } @@ -238,7 +247,7 @@ pub fn init( controller: RtSimController::default(), last_time_ticked: 0.0, kind: RtSimEntityKind::TownGuard, - brain: Brain::town_guard(site_id), + brain: Brain::town_guard(site_id, &mut thread_rng()), }); } @@ -262,7 +271,7 @@ pub fn init( controller: RtSimController::default(), last_time_ticked: 0.0, kind: RtSimEntityKind::Merchant, - brain: Brain::merchant(site_id), + brain: Brain::merchant(site_id, &mut thread_rng()), }); } }, @@ -287,7 +296,7 @@ pub fn init( controller: RtSimController::default(), last_time_ticked: 0.0, kind: RtSimEntityKind::Merchant, - brain: Brain::merchant(site_id), + brain: Brain::merchant(site_id, &mut thread_rng()), }); } }, diff --git a/server/src/sys/agent.rs b/server/src/sys/agent.rs index 5750cfe8a9..3758840a38 100644 --- a/server/src/sys/agent.rs +++ b/server/src/sys/agent.rs @@ -4,7 +4,7 @@ pub mod data; pub mod util; use crate::{ - rtsim::RtSim, + rtsim::{entity::PersonalityTrait, RtSim}, sys::agent::{ consts::{ AVG_FOLLOW_DIST, DAMAGE_MEMORY_DURATION, DEFAULT_ATTACK_RANGE, FLEE_DURATION, @@ -1010,6 +1010,25 @@ impl<'a> AgentData<'a> { Some(rtsim_entity), ) = (&agent.rtsim_controller.travel_to, &self.rtsim_entity) { + let personality = &rtsim_entity.brain.personality; + let standard_response_msg = || -> String { + if personality + .personality_traits + .contains(PersonalityTrait::Extroverted) + { + format!( + "I'm heading to {}! Want to come along?", + destination_name + ) + } else if personality + .personality_traits + .contains(PersonalityTrait::Disagreeable) + { + "Hrm.".to_string() + } else { + "Hello!".to_string() + } + }; let msg = if let Some(tgt_stats) = read_data.stats.get(target) { agent.rtsim_controller.events.push( @@ -1024,23 +1043,33 @@ impl<'a> AgentData<'a> { .brain .remembers_character(&tgt_stats.name) { - format!( - "Greetings fair {}! It has been far too \ - long since last I saw you. I'm going to \ - {} right now.", - &tgt_stats.name, destination_name - ) + if personality + .personality_traits + .contains(PersonalityTrait::Extroverted) + { + format!( + "Greetings fair {}! It has been far \ + too long since last I saw you. I'm \ + going to {} right now.", + &tgt_stats.name, destination_name + ) + } else if personality + .personality_traits + .contains(PersonalityTrait::Disagreeable) + { + "Oh. It's you again.".to_string() + } else { + format!( + "Hi again {}! Unfortunately I'm in a \ + hurry right now. See you!", + &tgt_stats.name + ) + } } else { - format!( - "I'm heading to {}! Want to come along?", - destination_name - ) + standard_response_msg() } } else { - format!( - "I'm heading to {}! Want to come along?", - destination_name - ) + standard_response_msg() }; self.chat_npc(msg, event_emitter); } else if agent.behavior.can_trade() { @@ -1051,13 +1080,81 @@ impl<'a> AgentData<'a> { event_emitter, ); } else { - self.chat_npc( - "npc.speech.merchant_busy", - event_emitter, - ); + let default_msg = "npc.speech.merchant_busy"; + let msg = self.rtsim_entity.map_or(default_msg, |e| { + if e.brain + .personality + .personality_traits + .contains(PersonalityTrait::Disagreeable) + { + "npc.speech.merchant_busy_rude" + } else { + default_msg + } + }); + self.chat_npc(msg, event_emitter); } } else { - self.chat_npc("npc.speech.villager", event_emitter); + let mut rng = rand::thread_rng(); + if let Some(extreme_trait) = + self.rtsim_entity.and_then(|e| { + e.brain.personality.random_chat_trait(&mut rng) + }) + { + let msg = match extreme_trait { + PersonalityTrait::Open => { + "npc.speech.villager_open" + }, + PersonalityTrait::Adventurous => { + "npc.speech.villager_adventurous" + }, + PersonalityTrait::Closed => { + "npc.speech.villager_closed" + }, + PersonalityTrait::Conscientious => { + "npc.speech.villager_conscientious" + }, + PersonalityTrait::Busybody => { + "npc.speech.villager_busybody" + }, + PersonalityTrait::Unconscientious => { + "npc.speech.villager_unconscientious" + }, + PersonalityTrait::Extroverted => { + "npc.speech.villager_extroverted" + }, + PersonalityTrait::Introverted => { + "npc.speech.villager_introverted" + }, + PersonalityTrait::Agreeable => { + "npc.speech.villager_agreeable" + }, + PersonalityTrait::Sociable => { + "npc.speech.villager_sociable" + }, + PersonalityTrait::Disagreeable => { + "npc.speech.villager_disagreeable" + }, + PersonalityTrait::Neurotic => { + "npc.speech.villager_neurotic" + }, + PersonalityTrait::Seeker => { + "npc.speech.villager_seeker" + }, + PersonalityTrait::SadLoner => { + "npc.speech.villager_sad_loner" + }, + PersonalityTrait::Worried => { + "npc.speech.villager_worried" + }, + PersonalityTrait::Stable => { + "npc.speech.villager_stable" + }, + }; + self.chat_npc(msg, event_emitter); + } else { + self.chat_npc("npc.speech.villager", event_emitter); + } } }, Subject::Trade => {