src/social.rs 11 K · 349 lines · raw · history

1 use std::collections::HashMap;
2
3 use bevy::prelude::*;
4 use serde::{Deserialize, Serialize};
5
6 use crate::{
7 colonist::{Colonist, Path},
8 family::Partner,
9 jobs::CurrentJob,
10 mood::Mood,
11 needs::is_sleeping,
12 population::ColonyLog,
13 rng::SimRng,
14 sim::{SimSystems, SimTick, TICKS_PER_HOUR},
15 world::TilePos,
16 worldgen::WorldGenerated,
17 };
18
19 pub struct SocialPlugin;
20
21 impl Plugin for SocialPlugin {
22 fn build(&self, app: &mut App) {
23 app.init_resource::<Relations>()
24 .add_systems(Update, clear_relations.run_if(on_message::<WorldGenerated>))
25 .add_systems(
26 FixedUpdate,
27 (mourn, socialize).chain().in_set(SimSystems::Act),
28 );
29 }
30 }
31
32 pub const SOCIAL_RANGE: i32 = 4;
33 pub const FRIEND: f32 = 0.5;
34 pub const RIVAL: f32 = -0.5;
35 pub const FRIEND_LOST: f32 = 0.3;
36 pub const RIVAL_LOST: f32 = -0.3;
37 const CHAT_GAIN: f32 = 0.1;
38 const ARGUMENT_LOSS: f32 = 0.12;
39 const CHAT_CHANCE: f32 = 0.35;
40 const ARGUMENT_CHANCE: f32 = 0.12;
41 const GRUMPY_MOOD: f32 = 0.3;
42 const FADE_PER_HOUR: f32 = 0.998;
43 pub const MOURNING_TICKS: u64 = 2 * 24 * TICKS_PER_HOUR;
44
45 #[derive(Component, Debug, Clone, Copy, PartialEq, Serialize, Deserialize)]
46 pub struct Personality {
47 pub warmth: f32,
48 }
49
50 impl Default for Personality {
51 fn default() -> Self {
52 Self { warmth: 0.5 }
53 }
54 }
55
56 #[derive(Component, Debug, Clone, Copy, PartialEq)]
57 pub struct Mourning {
58 pub until: u64,
59 }
60
61 #[derive(Resource, Default, Debug, Clone)]
62 pub struct Relations(
63 pub HashMap<(Entity, Entity), f32>,
64 pub HashMap<(Entity, Entity), Bond>,
65 );
66
67 #[derive(Debug, Clone, Copy, PartialEq, Eq)]
68 pub enum Bond {
69 Friends,
70 Rivals,
71 }
72
73 pub fn bond_after(bond: Option<Bond>, opinion: f32) -> Option<Bond> {
74 match bond {
75 Some(Bond::Friends) if opinion >= FRIEND_LOST => Some(Bond::Friends),
76 Some(Bond::Rivals) if opinion <= RIVAL_LOST => Some(Bond::Rivals),
77 _ if opinion >= FRIEND => Some(Bond::Friends),
78 _ if opinion <= RIVAL => Some(Bond::Rivals),
79 _ => None,
80 }
81 }
82
83 fn key(a: Entity, b: Entity) -> (Entity, Entity) {
84 (a.min(b), a.max(b))
85 }
86
87 impl Relations {
88 pub fn opinion(&self, a: Entity, b: Entity) -> f32 {
89 self.0.get(&key(a, b)).copied().unwrap_or(0.0)
90 }
91
92 pub fn change(&mut self, a: Entity, b: Entity, delta: f32) -> (f32, f32) {
93 let value = self.0.entry(key(a, b)).or_insert(0.0);
94 let before = *value;
95 *value = (*value + delta).clamp(-1.0, 1.0);
96 (before, *value)
97 }
98
99 pub fn set(&mut self, a: Entity, b: Entity, value: f32) {
100 self.0.insert(key(a, b), value.clamp(-1.0, 1.0));
101 }
102
103 pub fn of(&self, who: Entity) -> impl Iterator<Item = (Entity, f32)> + '_ {
104 self.0.iter().filter_map(move |(&(a, b), &v)| {
105 (a == who)
106 .then_some((b, v))
107 .or((b == who).then_some((a, v)))
108 })
109 }
110
111 pub fn bond(&self, a: Entity, b: Entity) -> Option<Bond> {
112 self.1.get(&key(a, b)).copied()
113 }
114
115 pub fn bonded(&self, who: Entity, bond: Bond) -> impl Iterator<Item = Entity> + '_ {
116 self.1.iter().filter_map(move |(&(a, b), &kind)| {
117 (kind == bond && (a == who || b == who)).then_some(if a == who { b } else { a })
118 })
119 }
120
121 pub fn friends(&self, who: Entity) -> usize {
122 self.bonded(who, Bond::Friends).count()
123 }
124
125 pub fn rivals(&self, who: Entity) -> usize {
126 self.bonded(who, Bond::Rivals).count()
127 }
128
129 pub fn update_bond(&mut self, a: Entity, b: Entity) -> Option<Bond> {
130 let k = key(a, b);
131 let before = self.1.get(&k).copied();
132 let after = bond_after(before, self.opinion(a, b));
133 match after {
134 Some(bond) => self.1.insert(k, bond),
135 None => self.1.remove(&k),
136 };
137 (after != before).then_some(after).flatten()
138 }
139
140 pub fn forget(&mut self, who: Entity) {
141 self.0.retain(|&(a, b), _| a != who && b != who);
142 self.1.retain(|&(a, b), _| a != who && b != who);
143 }
144 }
145
146 fn clear_relations(mut relations: ResMut<Relations>) {
147 relations.0.clear();
148 }
149
150 pub fn chat_chance(warmth: f32) -> f32 {
151 CHAT_CHANCE * (0.5 + warmth)
152 }
153
154 pub fn argument_chance(warmth: f32, grumpy: bool) -> f32 {
155 ARGUMENT_CHANCE * (1.5 - warmth) * if grumpy { 2.0 } else { 1.0 }
156 }
157
158 #[allow(clippy::type_complexity)]
159 fn socialize(
160 tick: Res<SimTick>,
161 mut rng: ResMut<SimRng>,
162 mut relations: ResMut<Relations>,
163 mut log: ResMut<ColonyLog>,
164 colonists: Query<(
165 Entity,
166 &Colonist,
167 &TilePos,
168 &CurrentJob,
169 &Path,
170 Option<&Personality>,
171 Option<&Mood>,
172 )>,
173 ) {
174 if !tick.0.is_multiple_of(TICKS_PER_HOUR) {
175 return;
176 }
177 for value in relations.0.values_mut() {
178 *value *= FADE_PER_HOUR;
179 }
180 let pairs: Vec<(Entity, Entity)> = relations.1.keys().copied().collect();
181 for (a, b) in pairs {
182 relations.update_bond(a, b);
183 }
184 let awake: Vec<(Entity, &str, UVec2, f32, bool)> = colonists
185 .iter()
186 .filter(|(_, _, tile, job, path, ..)| !is_sleeping(job, tile, path))
187 .map(|(e, c, tile, _, _, personality, mood)| {
188 let warmth = personality.copied().unwrap_or_default().warmth;
189 let grumpy = mood.is_some_and(|m| m.0 < GRUMPY_MOOD);
190 (e, c.name.as_str(), tile.0, warmth, grumpy)
191 })
192 .collect();
193 for (i, a) in awake.iter().enumerate() {
194 for b in &awake[i + 1..] {
195 let near = (a.2.as_ivec2() - b.2.as_ivec2()).abs().max_element() <= SOCIAL_RANGE;
196 if !near {
197 continue;
198 }
199 let warmth = (a.3 + b.3) / 2.0;
200 let roll = rng.range_f32(0.0..1.0);
201 let chat = chat_chance(warmth);
202 let delta = if roll < chat {
203 CHAT_GAIN
204 } else if roll < chat + argument_chance(warmth, a.4 || b.4) {
205 -ARGUMENT_LOSS
206 } else {
207 continue;
208 };
209 relations.change(a.0, b.0, delta);
210 let event = match relations.update_bond(a.0, b.0) {
211 Some(Bond::Friends) => "became friends",
212 Some(Bond::Rivals) => "became rivals",
213 None => continue,
214 };
215 log.push(*tick, format!("{} and {} {event}", a.1, b.1));
216 }
217 }
218 }
219
220 fn mourn(
221 mut commands: Commands,
222 tick: Res<SimTick>,
223 mut relations: ResMut<Relations>,
224 mut removed: RemovedComponents<Colonist>,
225 colonists: Query<(Entity, Option<&Partner>), With<Colonist>>,
226 mourning: Query<(Entity, &Mourning)>,
227 ) {
228 for (entity, grief) in &mourning {
229 if tick.0 >= grief.until {
230 commands.entity(entity).remove::<Mourning>();
231 }
232 }
233 for gone in removed.read() {
234 for (entity, partner) in &colonists {
235 let close = relations.bond(entity, gone) == Some(Bond::Friends)
236 || partner.is_some_and(|p| p.0 == gone);
237 if close {
238 commands.entity(entity).insert(Mourning {
239 until: tick.0 + MOURNING_TICKS,
240 });
241 }
242 }
243 relations.forget(gone);
244 }
245 }
246
247 #[cfg(test)]
248 mod tests {
249 use super::*;
250
251 fn e(i: u32) -> Entity {
252 Entity::from_raw_u32(i).unwrap()
253 }
254
255 #[test]
256 fn opinions_are_shared_and_cross_into_friendship_or_rivalry() {
257 let mut relations = Relations::default();
258 relations.change(e(1), e(2), 0.3);
259 assert_eq!(relations.opinion(e(2), e(1)), 0.3);
260 relations.set(e(1), e(3), FRIEND);
261 relations.set(e(1), e(4), RIVAL);
262 assert_eq!(relations.update_bond(e(1), e(3)), Some(Bond::Friends));
263 assert_eq!(relations.update_bond(e(1), e(4)), Some(Bond::Rivals));
264 assert_eq!((relations.friends(e(1)), relations.rivals(e(1))), (1, 1));
265 relations.forget(e(1));
266 assert!(relations.0.is_empty());
267 }
268
269 // Warm people chat more and argue less; unhappy people pick fights.
270 #[test]
271 fn warmth_and_mood_steer_conversations() {
272 assert!(chat_chance(0.9) > chat_chance(0.1));
273 assert!(argument_chance(0.1, false) > argument_chance(0.9, false));
274 assert!(argument_chance(0.5, true) > argument_chance(0.5, false));
275 }
276
277 // Colonists who spend their days side by side grow close (or come to loathe each other);
278 // the colony log notes when a friendship forms.
279 #[test]
280 fn colonists_near_each_other_form_opinions() {
281 let mut app = App::new();
282 app.init_resource::<SimTick>()
283 .insert_resource(SimRng::new(5))
284 .init_resource::<Relations>()
285 .init_resource::<ColonyLog>()
286 .add_systems(Update, socialize);
287 let spawn = |app: &mut App, name: &str, x: u32| {
288 app.world_mut()
289 .spawn((
290 Colonist { name: name.into() },
291 TilePos(UVec2::new(x, 0)),
292 Personality { warmth: 1.0 },
293 ))
294 .id()
295 };
296 let ada = spawn(&mut app, "Ada", 0);
297 let bram = spawn(&mut app, "Bram", 2);
298 let far = spawn(&mut app, "Cleo", 30);
299 for hour in 0..100 {
300 app.world_mut().resource_mut::<SimTick>().0 = hour * TICKS_PER_HOUR;
301 app.update();
302 }
303 let relations = app.world().resource::<Relations>();
304 assert!(relations.opinion(ada, bram) >= FRIEND);
305 assert_eq!(relations.opinion(ada, far), 0.0);
306 let log = app.world().resource::<ColonyLog>();
307 assert!(
308 log.0
309 .iter()
310 .any(|l| l.ends_with("Ada and Bram became friends"))
311 );
312 }
313
314 // Losing a friend or partner hurts more than distant grief: they mourn for two days.
315 #[test]
316 fn friends_mourn_the_dead() {
317 let mut app = App::new();
318 app.init_resource::<SimTick>()
319 .init_resource::<Relations>()
320 .add_systems(Update, mourn);
321 let spawn =
322 |app: &mut App, name: &str| app.world_mut().spawn(Colonist { name: name.into() }).id();
323 let (ada, bram, cleo) = (
324 spawn(&mut app, "Ada"),
325 spawn(&mut app, "Bram"),
326 spawn(&mut app, "Cleo"),
327 );
328 let mut relations = app.world_mut().resource_mut::<Relations>();
329 relations.set(ada, bram, 0.8);
330 relations.update_bond(ada, bram);
331 app.update();
332 app.world_mut().despawn(bram);
333 app.update();
334 assert!(app.world().get::<Mourning>(ada).is_some());
335 assert!(app.world().get::<Mourning>(cleo).is_none());
336 assert!(app.world().resource::<Relations>().1.is_empty());
337 }
338
339 // Friendships and rivalries need a clear swing to end, so an opinion hovering at the line
340 // doesn't flip them back and forth (and flood the log).
341 #[test]
342 fn bonds_have_hysteresis() {
343 assert_eq!(bond_after(None, 0.49), None);
344 assert_eq!(bond_after(None, 0.5), Some(Bond::Friends));
345 assert_eq!(bond_after(Some(Bond::Friends), 0.35), Some(Bond::Friends));
346 assert_eq!(bond_after(Some(Bond::Friends), 0.2), None);
347 assert_eq!(bond_after(Some(Bond::Rivals), -0.35), Some(Bond::Rivals));
348 }
349 }