Skip to main content

veloren_rtsim/data/
sentiment.rs

1use common::rtsim::{ActorId, FactionId};
2use hashbrown::HashMap;
3use rand::prelude::*;
4use serde::{Deserialize, Serialize};
5use std::collections::BinaryHeap;
6
7// Factions have a larger 'social memory' than individual NPCs and so we allow
8// them to have more sentiments
9pub const FACTION_MAX_SENTIMENTS: usize = 1024;
10pub const NPC_MAX_SENTIMENTS: usize = 128;
11
12/// Magic factor used to control sentiment decay speed (note: higher = slower
13/// decay, for implementation reasons).
14const DECAY_TIME_FACTOR: f32 = 2500.0;
15
16/// The target that a sentiment is felt toward.
17// NOTE: More could be added to this! For example:
18// - Animal species (dislikes spiders?)
19// - Kind of food (likes meat?)
20// - Occupations (hatred of hunters or chefs?)
21// - Ideologies (dislikes democracy, likes monarchy?)
22// - etc.
23#[derive(Copy, Clone, Hash, PartialEq, Eq, Serialize, Deserialize, PartialOrd, Ord)]
24pub enum Target {
25    Actor(ActorId),
26    Faction(FactionId),
27}
28
29impl From<ActorId> for Target {
30    fn from(actor_id: ActorId) -> Self { Self::Actor(actor_id) }
31}
32impl From<FactionId> for Target {
33    fn from(faction: FactionId) -> Self { Self::Faction(faction) }
34}
35
36#[derive(Clone, Default, Serialize, Deserialize)]
37pub struct Sentiments {
38    #[serde(rename = "m")]
39    map: HashMap<Target, Sentiment>,
40}
41
42impl Sentiments {
43    /// Return the sentiment that is felt toward the given target.
44    pub fn toward(&self, target: impl Into<Target>) -> &Sentiment {
45        self.map.get(&target.into()).unwrap_or(&Sentiment::DEFAULT)
46    }
47
48    /// Return the sentiment that is felt toward the given target.
49    pub fn toward_mut(&mut self, target: impl Into<Target>) -> &mut Sentiment {
50        self.map.entry(target.into()).or_default()
51    }
52
53    /// Progressively decay the sentiment back to a neutral sentiment.
54    ///
55    /// Note that sentiment get decay gets slower the harsher the sentiment is.
56    /// You can calculate the **average** number of seconds required for a
57    /// sentiment to neutral decay with the following rough formula:
58    ///
59    /// ```ignore
60    /// seconds_until_neutrality = (sentiment_value^2 * 24 + 1) / 25 * DECAY_TIME_FACTOR * sentiment_value * 128
61    /// ```
62    ///
63    /// Some 'common' sentiment decay times are as follows:
64    ///
65    /// - `POSITIVE`/`NEGATIVE`: ~26 minutes
66    /// - `ALLY`/`RIVAL`: ~3.4 hours
67    /// - `FRIEND`/`ENEMY`: ~21 hours
68    /// - `HERO`/`VILLAIN`: ~47 hours
69    pub fn decay(&mut self, rng: &mut impl Rng, dt: f32) {
70        self.map.retain(|_, sentiment| {
71            sentiment.decay(rng, dt);
72            // We can eliminate redundant sentiments that don't need remembering
73            !sentiment.is_redundant()
74        });
75    }
76
77    /// Clean up sentiments to avoid them growing too large
78    pub fn cleanup(&mut self, max_sentiments: usize) {
79        if self.map.len() > max_sentiments {
80            let mut sentiments = self.map
81                .iter()
82                // For each sentiment, calculate how valuable it is for us to remember.
83                // For now, we just use the absolute value of the sentiment but later on we might want to favour
84                // sentiments toward factions and other 'larger' groups over, say, sentiments toward players/other NPCs
85                .map(|(tgt, sentiment)| (sentiment.positivity.unsigned_abs(), *tgt))
86                .collect::<BinaryHeap<_>>();
87
88            // Remove the superfluous sentiments
89            for (_, tgt) in sentiments
90                .drain_sorted()
91                .take(self.map.len() - max_sentiments)
92            {
93                self.map.remove(&tgt);
94            }
95        }
96    }
97}
98
99#[derive(Copy, Clone, Debug, Default, Serialize, Deserialize)]
100pub struct Sentiment {
101    /// How positive the sentiment is.
102    ///
103    /// Using i8 to reduce on-disk memory footprint.
104    /// Semantically, this value is -1 <= x <= 1.
105    #[serde(rename = "p")]
106    positivity: i8,
107}
108
109impl Sentiment {
110    /// Substantial positive sentiments: NPC may go out of their way to help
111    /// actors associated with the target, greet them, etc.
112    pub const ALLY: f32 = 0.3;
113    const DEFAULT: Self = Self { positivity: 0 };
114    /// Very negative sentiments: NPC may confront the actor, get aggressive
115    /// with them, or even use force against them.
116    pub const ENEMY: f32 = -0.6;
117    /// Very positive sentiments: NPC may join the actor as a companion,
118    /// encourage them to join their faction, etc.
119    pub const FRIEND: f32 = 0.6;
120    /// Extremely positive sentiments: NPC may switch sides to join the actor's
121    /// faction, protect them at all costs, turn against friends for them,
122    /// etc. Verging on cult-like behaviour.
123    pub const HERO: f32 = 0.8;
124    /// Minor negative sentiments: NPC might be less willing to provide
125    /// information, give worse trade deals, etc.
126    pub const NEGATIVE: f32 = -0.1;
127    /// Minor positive sentiments: NPC might be more willing to provide
128    /// information, give better trade deals, etc.
129    pub const POSITIVE: f32 = 0.1;
130    /// Substantial negative sentiments: NPC may reject attempts to trade or
131    /// avoid actors associated with the target, insult them, but will not
132    /// use physical force.
133    pub const RIVAL: f32 = -0.3;
134    /// Extremely negative sentiments: NPC may aggressively persue or hunt down
135    /// the actor, organise others around them to do the same, and will
136    /// generally try to harm the actor in any way they can.
137    pub const VILLAIN: f32 = -0.8;
138
139    fn value(&self) -> f32 { self.positivity as f32 * (1.0 / 126.0) }
140
141    /// Change the sentiment toward the given target by the given amount,
142    /// capping out at the given value.
143    pub fn change_by(&mut self, change: f32, cap: f32) {
144        // There's a bit of ceremony here for two reasons:
145        // 1) Very small changes should not be rounded to 0
146        // 2) Sentiment should never (over/under)flow
147        if change != 0.0 {
148            let abs = (change * 126.0).abs().clamp(1.0, 126.0) as i8;
149            let cap = (cap.abs().min(1.0) * 126.0) as i8;
150            self.positivity = if change > 0.0 {
151                self.positivity.saturating_add(abs).min(cap)
152            } else {
153                self.positivity.saturating_sub(abs).max(-cap)
154            };
155        }
156    }
157
158    /// Limit the sentiment to the given value, either positive or negative. The
159    /// resulting sentiment is guaranteed to be less than the cap (at least,
160    /// as judged by [`Sentiment::is`]).
161    pub fn limit_below(&mut self, cap: f32) {
162        if cap > 0.0 {
163            self.positivity = self
164                .positivity
165                .min(((cap.min(1.0) * 126.0) as i8 - 1).max(0));
166        } else {
167            self.positivity = self
168                .positivity
169                .max(((-cap.max(-1.0) * 126.0) as i8 + 1).min(0));
170        }
171    }
172
173    fn decay(&mut self, rng: &mut impl Rng, dt: f32) {
174        if self.positivity != 0 {
175            // TODO: Find a slightly nicer way to have sentiment decay, perhaps even by
176            // remembering the last interaction instead of constant updates.
177            let chance = (1.0
178                / ((self.value().powi(2) * 0.24 + 1.0) * (1.0 / 25.0) * DECAY_TIME_FACTOR * dt))
179                .min(1.0) as f64;
180
181            // For some reason, RNG doesn't work with small chances (possibly due to impl
182            // limits), so use two bools
183            if rng.random_bool(chance.sqrt()) && rng.random_bool(chance.sqrt()) {
184                self.positivity -= self.positivity.signum();
185            }
186        }
187    }
188
189    /// Return `true` if the sentiment can be forgotten without changing
190    /// anything (i.e: is entirely neutral, the default stance).
191    fn is_redundant(&self) -> bool { self.positivity == 0 }
192
193    /// Returns `true` if the sentiment has reached the given threshold.
194    pub fn is(&self, val: f32) -> bool {
195        if val > 0.0 {
196            self.value() >= val
197        } else {
198            self.value() <= val
199        }
200    }
201}