From 08338436ea671602be28f7a22659b0594f04dcc6 Mon Sep 17 00:00:00 2001 From: Joshua Barretto Date: Thu, 6 Apr 2023 12:29:20 +0100 Subject: [PATCH] Added sentiments --- common/src/character.rs | 3 +- rtsim/src/data/mod.rs | 1 + rtsim/src/data/npc.rs | 10 +- rtsim/src/data/sentiment.rs | 192 ++++++++++++++++++ server/src/persistence/character.rs | 89 ++++---- .../src/persistence/character/conversions.rs | 6 +- server/src/persistence/character_loader.rs | 2 +- server/src/persistence/character_updater.rs | 4 +- 8 files changed, 259 insertions(+), 48 deletions(-) create mode 100644 rtsim/src/data/sentiment.rs diff --git a/common/src/character.rs b/common/src/character.rs index 92401c3fe1..3f6ae248f6 100644 --- a/common/src/character.rs +++ b/common/src/character.rs @@ -5,7 +5,8 @@ use serde::{Deserialize, Serialize}; /// The limit on how many characters that a player can have pub const MAX_CHARACTERS_PER_PLAYER: usize = 8; -pub type CharacterId = i64; +#[derive(Copy, Clone, Debug, Serialize, Deserialize, PartialEq, Eq, Hash)] +pub struct CharacterId(pub i64); pub const MAX_NAME_LENGTH: usize = 20; diff --git a/rtsim/src/data/mod.rs b/rtsim/src/data/mod.rs index e470dc9d7c..e06588aeb9 100644 --- a/rtsim/src/data/mod.rs +++ b/rtsim/src/data/mod.rs @@ -1,6 +1,7 @@ pub mod faction; pub mod nature; pub mod npc; +pub mod sentiment; pub mod site; pub use self::{ diff --git a/rtsim/src/data/npc.rs b/rtsim/src/data/npc.rs index c71d1c06f0..c867ec7e94 100644 --- a/rtsim/src/data/npc.rs +++ b/rtsim/src/data/npc.rs @@ -1,6 +1,7 @@ -use crate::{ai::Action, gen::name}; +use crate::{ai::Action, data::sentiment::Sentiments, gen::name}; pub use common::rtsim::{NpcId, Profession}; use common::{ + character::CharacterId, comp, grid::Grid, rtsim::{ @@ -91,7 +92,10 @@ pub struct Npc { pub faction: Option, pub riding: Option, + #[serde(default)] pub personality: Personality, + #[serde(default)] + pub sentiments: Sentiments, // Unpersisted state #[serde(skip)] @@ -124,6 +128,7 @@ impl Clone for Npc { riding: self.riding.clone(), body: self.body, personality: self.personality, + sentiments: self.sentiments.clone(), // Not persisted chunk_pos: None, current_site: Default::default(), @@ -144,6 +149,7 @@ impl Npc { wpos, body, personality: Personality::default(), + sentiments: Sentiments::default(), profession: None, home: None, faction: None, @@ -274,7 +280,7 @@ pub struct Npcs { #[serde(skip, default = "construct_npc_grid")] pub npc_grid: Grid, #[serde(skip)] - pub character_map: HashMap, Vec<(common::character::CharacterId, Vec3)>>, + pub character_map: HashMap, Vec<(CharacterId, Vec3)>>, } impl Default for Npcs { diff --git a/rtsim/src/data/sentiment.rs b/rtsim/src/data/sentiment.rs new file mode 100644 index 0000000000..718d9d7f84 --- /dev/null +++ b/rtsim/src/data/sentiment.rs @@ -0,0 +1,192 @@ +use common::{ + character::CharacterId, + rtsim::{Actor, FactionId, NpcId}, +}; +use rand::prelude::*; +use serde::{Deserialize, Serialize}; +use std::collections::HashMap; + +// Factions have a larger 'social memory' than individual NPCs and so we allow +// them to have more sentiments +pub const FACTION_MAX_SENTIMENTS: usize = 1024; +pub const NPC_MAX_SENTIMENTS: usize = 128; + +/// The target that a sentiment is felt toward. +// NOTE: More could be added to this! For example: +// - Animal species (dislikes spiders?) +// - Kind of food (likes meat?) +// - Occupations (hatred of hunters or chefs?) +// - Ideologies (dislikes democracy, likes monarchy?) +// - etc. +#[derive(Copy, Clone, Hash, PartialEq, Eq, Serialize, Deserialize)] +pub enum Target { + Character(CharacterId), + Npc(NpcId), + Faction(FactionId), +} + +impl From for Target { + fn from(npc: NpcId) -> Self { Self::Npc(npc) } +} +impl From for Target { + fn from(faction: FactionId) -> Self { Self::Faction(faction) } +} +impl From for Target { + fn from(character: CharacterId) -> Self { Self::Character(character) } +} +impl From for Target { + fn from(actor: Actor) -> Self { + match actor { + Actor::Character(character) => Self::Character(character), + Actor::Npc(npc) => Self::Npc(npc), + } + } +} + +#[derive(Clone, Default, Serialize, Deserialize)] +pub struct Sentiments { + #[serde(rename = "m")] + map: HashMap, +} + +impl Sentiments { + /// Return the sentiment that is felt toward the given target. + pub fn toward(&self, target: impl Into) -> Sentiment { + self.map.get(&target.into()).copied().unwrap_or_default() + } + + pub fn change_by(&mut self, target: impl Into, change: f32) { + let target = target.into(); + self.map.entry(target).or_default().change_by(change); + } + + /// Progressively decay the sentiment back to a neutral sentiment. + /// + /// Note that sentiment get decay gets slower the harsher the sentiment is. + /// You can calculate the **average** number of ticks required for a + /// sentiment to decay with the following formula: + /// + /// ``` + /// ticks_until_neutrality = ((sentiment_value * 127 * 32) ^ 2) / 2 + /// ``` + /// + /// For example, a positive (see [`Sentiment::POSITIVE`]) sentiment has a + /// value of `0.2`, so we get + /// + /// ``` + /// ticks_until_neutrality = ((0.1 * 127 * 32) ^ 2) / 2 = ~82,580 ticks + /// ``` + /// + /// Assuming a TPS of 30, that's ~46 minutes. + /// + /// Some 'common' sentiment decay times are as follows: + /// + /// - `POSITIVE`/`NEGATIVE`: ~46 minutes + /// - `ALLY`/`RIVAL`: ~6.9 hours + /// - `FRIEND`/`ENEMY`: ~27.5 hours + /// - `HERO`/`VILLAIN`: ~48.9 hours + pub fn decay(&mut self, rng: &mut impl Rng) { + self.map.retain(|_, sentiment| { + sentiment.decay(rng); + // We can eliminate redundant sentiments that don't need remembering + !sentiment.is_redundant() + }); + } + + /// Clean up sentiments to avoid them growing too large + pub fn cleanup(&mut self, max_sentiments: usize) { + if self.map.len() > max_sentiments { + let mut sentiments = self.map + .iter() + // For each sentiment, calculate how valuable it is for us to remember. + // For now, we just use the absolute value of the sentiment but later on we might want to favour + // sentiments toward factions and other 'larger' groups over, say, sentiments toward players/other NPCs + .map(|(tgt, sentiment)| (*tgt, sentiment.positivity.unsigned_abs())) + .collect::>(); + sentiments.sort_unstable_by_key(|(_, value)| *value); + + // Remove the superfluous sentiments + for (tgt, _) in &sentiments[0..self.map.len() - max_sentiments] { + self.map.remove(tgt); + } + } + } +} + +#[derive(Copy, Clone, Default, Serialize, Deserialize)] +pub struct Sentiment { + /// How positive the sentiment is. + /// + /// Using i8 to reduce on-disk memory footprint. + /// Semantically, this value is -1 <= x <= 1. + #[serde(rename = "p")] + positivity: i8, +} + +impl Sentiment { + /// Substantial positive sentiments: NPC may go out of their way to help + /// actors associated with the target, greet them, etc. + pub const ALLY: f32 = 0.3; + /// Very negative sentiments: NPC may confront the actor, get aggressive + /// with them, or even use force against them. + pub const ENEMY: f32 = -0.6; + /// Very positive sentiments: NPC may join the actor as a companion, + /// encourage them to join their faction, etc. + pub const FRIEND: f32 = 0.6; + /// Extremely positive sentiments: NPC may switch sides to join the actor's + /// faction, protect them at all costs, turn against friends for them, + /// etc. Verging on cult-like behaviour. + pub const HERO: f32 = 0.8; + /// Minor negative sentiments: NPC might be less willing to provide + /// information, give worse trade deals, etc. + pub const NEGATIVE: f32 = -0.1; + /// Minor positive sentiments: NPC might be more willing to provide + /// information, give better trade deals, etc. + pub const POSITIVE: f32 = 0.1; + /// Substantial positive sentiments: NPC may reject attempts to trade or + /// avoid actors associated with the target, insult them, but will not + /// use physical force. + pub const RIVAL: f32 = -0.3; + /// Extremely negative sentiments: NPC may aggressively persue or hunt down + /// the actor, organise others around them to do the same, and will + /// generally try to harm the actor in any way they can. + pub const VILLAIN: f32 = -0.8; + + fn value(&self) -> f32 { self.positivity as f32 / 127.0 } + + fn change_by(&mut self, change: f32) { + // There's a bit of ceremony here for two reasons: + // 1) Very small changes should not be rounded to 0 + // 2) Sentiment should never (over/under)flow + if change != 0.0 { + let abs = (change * 127.0).abs().clamp(1.0, 127.0) as i8; + self.positivity = if change > 0.0 { + self.positivity.saturating_add(abs) + } else { + self.positivity.saturating_sub(abs) + }; + } + } + + fn decay(&mut self, rng: &mut impl Rng) { + if self.positivity != 0 { + // TODO: Make dt-independent so we can slow tick rates + if rng.gen_range(0..self.positivity.unsigned_abs() as u32 * 1024) == 0 { + self.positivity -= self.positivity.signum(); + } + } + } + + /// Return `true` if the sentiment can be forgotten without changing + /// anything (i.e: is entirely neutral, the default stance). + fn is_redundant(&self) -> bool { self.positivity == 0 } + + /// Returns `true` if the sentiment has reached the given threshold. + pub fn is(&self, val: f32) -> bool { + if val > 0.0 { + self.value() >= val + } else { + self.value() <= val + } + } +} diff --git a/server/src/persistence/character.rs b/server/src/persistence/character.rs index 5a1b99bc1a..19a51b00f1 100644 --- a/server/src/persistence/character.rs +++ b/server/src/persistence/character.rs @@ -139,7 +139,7 @@ pub fn load_character_data( )?; let (body_data, character_data) = stmt.query_row( - &[requesting_player_uuid.clone(), char_id.to_string()], + &[requesting_player_uuid.clone(), char_id.0.to_string()], |row| { let character_data = Character { character_id: row.get(0)?, @@ -168,7 +168,7 @@ pub fn load_character_data( warn!( "Error reading waypoint from database for character ID {}, error: {}", - char_id, e + char_id.0, e ); (None, None) }, @@ -187,9 +187,9 @@ pub fn load_character_data( )?; let skill_group_data = stmt - .query_map(&[char_id], |row| { + .query_map(&[char_id.0], |row| { Ok(SkillGroup { - entity_id: char_id, + entity_id: char_id.0, skill_group_kind: row.get(0)?, earned_exp: row.get(1)?, spent_exp: row.get(2)?, @@ -212,7 +212,7 @@ pub fn load_character_data( )?; let db_pets = stmt - .query_map(&[char_id], |row| { + .query_map(&[char_id.0], |row| { Ok(Pet { database_id: row.get(0)?, name: row.get(1)?, @@ -240,7 +240,7 @@ pub fn load_character_data( } else { warn!( "Failed to deserialize pet_id: {} for character_id {}", - db_pet.database_id, char_id + db_pet.database_id, char_id.0 ); None } @@ -254,9 +254,9 @@ pub fn load_character_data( WHERE entity_id = ?1", )?; - let ability_set_data = stmt.query_row(&[char_id], |row| { + let ability_set_data = stmt.query_row(&[char_id.0], |row| { Ok(AbilitySets { - entity_id: char_id, + entity_id: char_id.0, ability_sets: row.get(0)?, }) })?; @@ -329,7 +329,7 @@ pub fn load_character_list(player_uuid_: &str, connection: &Connection) -> Chara FROM body WHERE body_id = ?1", )?; - let db_body = stmt.query_row(&[char.id], |row| { + let db_body = stmt.query_row(&[char.id.map(|c| c.0)], |row| { Ok(Body { body_id: row.get(0)?, variant: row.get(1)?, @@ -342,7 +342,7 @@ pub fn load_character_list(player_uuid_: &str, connection: &Connection) -> Chara let loadout_container_id = get_pseudo_container_id( connection, - character_data.character_id, + CharacterId(character_data.character_id), LOADOUT_PSEUDO_CONTAINER_POSITION, )?; @@ -470,7 +470,8 @@ pub fn create_character( ])?; drop(stmt); - let db_skill_groups = convert_skill_groups_to_database(character_id, skill_set.skill_groups()); + let db_skill_groups = + convert_skill_groups_to_database(CharacterId(character_id), skill_set.skill_groups()); let mut stmt = transaction.prepare_cached( " @@ -495,7 +496,8 @@ pub fn create_character( } drop(stmt); - let ability_sets = convert_active_abilities_to_database(character_id, &active_abilities); + let ability_sets = + convert_active_abilities_to_database(CharacterId(character_id), &active_abilities); let mut stmt = transaction.prepare_cached( " @@ -547,7 +549,7 @@ pub fn create_character( } drop(stmt); - load_character_list(uuid, transaction).map(|list| (character_id, list)) + load_character_list(uuid, transaction).map(|list| (CharacterId(character_id), list)) } pub fn edit_character( @@ -570,7 +572,7 @@ pub fn edit_character( warn!( "Character edit rejected due to failed validation - Character ID: {} \ Alias: {}", - character_id, character_alias + character_id.0, character_alias ); return Err(PersistenceError::CharacterDataError); } else { @@ -587,14 +589,14 @@ pub fn edit_character( stmt.execute(&[ &body_variant.to_string(), &body_data, - &character_id as &dyn ToSql, + &character_id.0 as &dyn ToSql, ])?; drop(stmt); let mut stmt = transaction.prepare_cached("UPDATE character SET alias = ?1 WHERE character_id = ?2")?; - stmt.execute(&[&character_alias, &character_id as &dyn ToSql])?; + stmt.execute(&[&character_alias, &character_id.0 as &dyn ToSql])?; drop(stmt); char_list.map(|list| (character_id, list)) @@ -616,10 +618,13 @@ pub fn delete_character( AND player_uuid = ?2", )?; - let result = stmt.query_row(&[&char_id as &dyn ToSql, &requesting_player_uuid], |row| { - let y: i64 = row.get(0)?; - Ok(y) - })?; + let result = stmt.query_row( + &[&char_id.0 as &dyn ToSql, &requesting_player_uuid], + |row| { + let y: i64 = row.get(0)?; + Ok(y) + }, + )?; drop(stmt); if result != 1 { @@ -636,7 +641,7 @@ pub fn delete_character( WHERE entity_id = ?1", )?; - stmt.execute(&[&char_id])?; + stmt.execute(&[&char_id.0])?; drop(stmt); let pet_ids = get_pet_ids(char_id, transaction)? @@ -655,7 +660,7 @@ pub fn delete_character( WHERE entity_id = ?1", )?; - stmt.execute(&[&char_id])?; + stmt.execute(&[&char_id.0])?; drop(stmt); // Delete character @@ -666,7 +671,7 @@ pub fn delete_character( WHERE character_id = ?1", )?; - stmt.execute(&[&char_id])?; + stmt.execute(&[&char_id.0])?; drop(stmt); // Delete body @@ -677,7 +682,7 @@ pub fn delete_character( WHERE body_id = ?1", )?; - stmt.execute(&[&char_id])?; + stmt.execute(&[&char_id.0])?; drop(stmt); // Delete all items, recursively walking all containers starting from the @@ -701,14 +706,14 @@ pub fn delete_character( WHERE EXISTS (SELECT 1 FROM parents WHERE parents.item_id = item.item_id)", )?; - let deleted_item_count = stmt.execute(&[&char_id])?; + let deleted_item_count = stmt.execute(&[&char_id.0])?; drop(stmt); if deleted_item_count < 3 { return Err(PersistenceError::OtherError(format!( "Error deleting from item table for char_id {} (expected at least 3 deletions, found \ {})", - char_id, deleted_item_count + char_id.0, deleted_item_count ))); } @@ -822,7 +827,7 @@ fn get_pseudo_container_id( #[allow(clippy::needless_question_mark)] let res = stmt.query_row( &[ - character_id.to_string(), + character_id.0.to_string(), pseudo_container_position.to_string(), ], |row| Ok(row.get(0)?), @@ -850,7 +855,7 @@ fn update_pets( pets: Vec, transaction: &mut Transaction, ) -> Result<(), PersistenceError> { - debug!("Updating {} pets for character {}", pets.len(), char_id); + debug!("Updating {} pets for character {}", pets.len(), char_id.0); let db_pets = get_pet_ids(char_id, transaction)?; if !db_pets.is_empty() { @@ -907,7 +912,7 @@ fn update_pets( VALUES (?1, ?2, ?3)", )?; - stmt.execute(&[&pet_entity_id as &dyn ToSql, &char_id, &stats.name])?; + stmt.execute(&[&pet_entity_id as &dyn ToSql, &char_id.0, &stats.name])?; drop(stmt); pet.get_database_id() @@ -917,7 +922,10 @@ fn update_pets( Ok(()) } -fn get_pet_ids(char_id: i64, transaction: &mut Transaction) -> Result, PersistenceError> { +fn get_pet_ids( + char_id: CharacterId, + transaction: &mut Transaction, +) -> Result, PersistenceError> { #[rustfmt::skip] let mut stmt = transaction.prepare_cached(" SELECT pet_id @@ -927,7 +935,7 @@ fn get_pet_ids(char_id: i64, transaction: &mut Transaction) -> Result, #[allow(clippy::needless_question_mark)] let db_pets = stmt - .query_map(&[&char_id], |row| Ok(row.get(0)?))? + .query_map(&[&char_id.0], |row| Ok(row.get(0)?))? .map(|x| x.unwrap()) .collect::>(); drop(stmt); @@ -948,7 +956,10 @@ fn delete_pets( let delete_count = stmt.execute(&[&pet_ids])?; drop(stmt); - debug!("Deleted {} pets for character id {}", delete_count, char_id); + debug!( + "Deleted {} pets for character id {}", + delete_count, char_id.0 + ); #[rustfmt::skip] let mut stmt = transaction.prepare_cached(" @@ -960,7 +971,7 @@ fn delete_pets( let delete_count = stmt.execute(&[&pet_ids])?; debug!( "Deleted {} pet bodies for character id {}", - delete_count, char_id + delete_count, char_id.0 ); Ok(()) @@ -996,7 +1007,7 @@ pub fn update( })?; // Next, delete any slots we aren't upserting. - trace!("Deleting items for character_id {}", char_id); + trace!("Deleting items for character_id {}", char_id.0); let mut existing_item_ids: Vec<_> = vec![ Value::from(pseudo_containers.inventory_container_id), Value::from(pseudo_containers.loadout_container_id), @@ -1040,7 +1051,7 @@ pub fn update( trace!( "Upserting items {:?} for character_id {}", upserted_items, - char_id + char_id.0 ); // When moving inventory items around, foreign key constraints on @@ -1111,12 +1122,12 @@ pub fn update( ", )?; - let waypoint_count = stmt.execute(&[&db_waypoint as &dyn ToSql, &char_id])?; + let waypoint_count = stmt.execute(&[&db_waypoint as &dyn ToSql, &char_id.0])?; if waypoint_count != 1 { return Err(PersistenceError::OtherError(format!( "Error updating character table for char_id {}", - char_id + char_id.0 ))); } @@ -1132,13 +1143,13 @@ pub fn update( let ability_sets_count = stmt.execute(&[ &ability_sets.ability_sets as &dyn ToSql, - &char_id as &dyn ToSql, + &char_id.0 as &dyn ToSql, ])?; if ability_sets_count != 1 { return Err(PersistenceError::OtherError(format!( "Error updating ability_set table for char_id {}", - char_id, + char_id.0, ))); } diff --git a/server/src/persistence/character/conversions.rs b/server/src/persistence/character/conversions.rs index 61a7549cb9..6629cd7a9a 100644 --- a/server/src/persistence/character/conversions.rs +++ b/server/src/persistence/character/conversions.rs @@ -608,7 +608,7 @@ pub fn convert_body_from_database( pub fn convert_character_from_database(character: &Character) -> common::character::Character { common::character::Character { - id: Some(character.character_id), + id: Some(CharacterId(character.character_id)), alias: String::from(&character.alias), } } @@ -704,7 +704,7 @@ pub fn convert_skill_groups_to_database<'a, I: Iterator AbilitySets { let ability_sets = json_models::active_abilities_to_db_model(active_abilities); AbilitySets { - entity_id, + entity_id: entity_id.0, ability_sets: serde_json::to_string(&ability_sets).unwrap_or_default(), } } diff --git a/server/src/persistence/character_loader.rs b/server/src/persistence/character_loader.rs index 0420cb9caf..4b1419767d 100644 --- a/server/src/persistence/character_loader.rs +++ b/server/src/persistence/character_loader.rs @@ -139,7 +139,7 @@ impl CharacterLoader { if result.is_err() { error!( ?result, - "Error loading character data for character_id: {}", character_id + "Error loading character data for character_id: {}", character_id.0 ); } CharacterScreenResponseKind::CharacterData(Box::new(result)) diff --git a/server/src/persistence/character_updater.rs b/server/src/persistence/character_updater.rs index 13d5ab76f4..29289e117d 100644 --- a/server/src/persistence/character_updater.rs +++ b/server/src/persistence/character_updater.rs @@ -242,7 +242,7 @@ impl CharacterUpdater { warn!( "Ignoring request to add pending logout update for character ID {} as there is a \ disconnection of all clients in progress", - update_data.0 + update_data.0.0 ); return; } @@ -251,7 +251,7 @@ impl CharacterUpdater { warn!( "Ignoring request to add pending logout update for character ID {} as there is \ already a pending delete for this character", - update_data.0 + update_data.0.0 ); return; }