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}