veloren_rtsim/data/
sentiment.rs

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