bevy_society 0.9.0

Living societies for Bevy: personalities, moods, needs, memories, relationships, opinions, rumors, reputation and factions; utility-AI daily lives; Rhai triggers; dialogue through bevy_speech_bubbles.
Documentation
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
266
267
268
269
270
271
272
273
274
275
276
277
278
279
280
281
282
283
284
285
286
287
288
289
290
291
292
293
294
295
296
297
298
299
300
301
302
303
304
305
306
307
308
309
310
311
312
313
314
315
316
317
318
319
320
321
322
323
324
325
326
327
328
329
330
331
332
333
334
335
336
337
338
339
340
341
342
343
344
345
346
347
348
349
350
351
352
353
354
355
356
357
358
359
360
361
362
363
364
365
366
367
368
369
370
371
372
373
374
375
376
377
378
379
380
381
382
383
384
385
386
387
388
389
390
391
392
393
394
395
396
397
398
399
400
401
402
403
404
405
406
407
408
409
410
411
412
413
414
415
416
417
418
419
420
421
422
423
424
425
426
427
428
429
430
431
432
433
434
435
436
437
438
439
440
441
442
443
444
445
446
447
448
449
450
451
452
453
454
455
456
457
458
459
460
461
462
463
464
465
466
467
468
469
470
471
472
473
474
475
476
477
478
479
480
481
482
483
484
485
486
487
488
489
490
491
492
493
494
495
496
497
498
499
500
501
502
503
504
505
506
507
508
509
510
511
512
513
514
515
516
517
518
519
520
521
522
523
524
525
526
527
528
529
530
531
532
533
534
535
536
537
538
539
540
541
542
543
544
545
546
547
548
549
550
551
552
553
554
555
556
557
558
559
560
561
562
563
564
565
566
567
568
569
570
571
572
573
574
575
576
577
578
579
580
581
582
583
584
585
586
587
588
589
590
591
592
593
594
595
596
597
598
599
600
601
602
603
604
605
606
607
608
609
610
611
612
613
614
615
616
617
618
619
620
621
622
623
624
625
626
627
628
629
630
631
632
633
634
635
636
637
638
639
640
641
642
643
644
645
646
647
648
649
650
651
652
653
654
655
656
657
658
659
660
661
662
663
664
665
666
667
668
669
670
671
672
673
674
675
676
677
678
679
680
681
682
683
684
685
686
687
688
689
690
691
692
693
694
695
696
697
698
699
700
701
702
703
704
705
706
707
708
709
710
711
712
713
714
715
716
717
718
719
720
721
722
723
724
725
726
727
728
729
730
731
732
733
734
735
736
737
738
739
740
741
742
743
744
745
746
747
748
749
750
751
752
753
754
755
756
757
758
759
760
761
762
763
764
765
766
767
768
769
770
771
772
773
774
775
776
777
778
779
780
781
782
783
784
785
786
787
788
789
790
791
792
793
794
795
796
797
798
799
800
801
802
803
804
805
806
807
808
809
810
811
812
813
814
815
816
817
818
819
820
821
822
823
824
825
826
827
828
829
830
831
832
833
834
835
836
837
838
839
840
841
842
843
844
845
846
847
848
849
850
851
852
853
854
855
856
857
858
859
860
861
862
863
864
865
866
867
868
869
870
871
872
873
874
875
876
877
878
879
880
881
882
883
884
885
886
887
888
889
890
891
892
893
894
895
896
897
898
899
900
901
902
903
904
905
906
907
908
909
910
911
912
913
914
915
916
917
918
919
920
921
922
923
924
925
926
927
928
929
930
931
932
933
934
935
936
937
938
939
940
941
942
943
944
945
946
947
948
949
950
951
952
953
954
955
956
957
958
959
960
961
962
963
964
965
966
967
968
969
970
971
972
973
//! Personality, mood, emotions, needs and memories (plan D3). Pure data plus the update rules that
//! run on them; the systems that call these live in `MindPlugin`.

use bevy::ecs::component::Component;
use bevy::reflect::Reflect;
use bevy_emerge_rng::DetRng;
use serde::{Deserialize, Serialize};

use crate::core::{
    Perception, Person, PersonIndex, SocietyClock, SocietySettings, SocietySystems, require_core,
};
use crate::defs::SocietyDefs;
use crate::defs::{DeedId, InteractionId};
use crate::model::pad;
use crate::model::recall;
use crate::relationships::Ties;
use crate::rumor::RumorId;
use bevy::app::{App, FixedUpdate, Plugin};
use bevy::ecs::entity::Entity;
use bevy::ecs::lifecycle::Add;
use bevy::ecs::message::Message;
use bevy::ecs::message::MessageReader;
use bevy::ecs::observer::On;
use bevy::ecs::schedule::IntoScheduleConfigs;
use bevy::ecs::system::{Query, Res};

/// One of the five personality traits (McCrae & John 1992, DOI 10.1111/j.1467-6494.1992.tb00970.x).
#[derive(Copy, Clone, Debug, PartialEq, Eq, Serialize, Deserialize, Reflect)]
pub enum Trait {
    /// Openness to experience.
    Openness,
    /// Conscientiousness.
    Conscientiousness,
    /// Extraversion.
    Extraversion,
    /// Agreeableness.
    Agreeableness,
    /// Neuroticism (emotional instability is its negation).
    Neuroticism,
}

impl Trait {
    /// Parses a trait from its lowercase script name (`"openness"` … `"neuroticism"`).
    pub fn from_name(name: &str) -> Option<Self> {
        match name {
            "openness" => Some(Self::Openness),
            "conscientiousness" => Some(Self::Conscientiousness),
            "extraversion" => Some(Self::Extraversion),
            "agreeableness" => Some(Self::Agreeableness),
            "neuroticism" => Some(Self::Neuroticism),
            _ => None,
        }
    }
}

/// The five traits, each in `[0, 1]`; default 0.5 on every trait.
#[derive(Component, Clone, Copy, Debug, PartialEq, Reflect)]
#[cfg_attr(feature = "serde", derive(serde::Serialize, serde::Deserialize))]
pub struct Personality {
    /// Openness to experience, in `[0, 1]`.
    pub openness: f32,
    /// Conscientiousness, in `[0, 1]`.
    pub conscientiousness: f32,
    /// Extraversion, in `[0, 1]`.
    pub extraversion: f32,
    /// Agreeableness, in `[0, 1]`.
    pub agreeableness: f32,
    /// Neuroticism, in `[0, 1]`.
    pub neuroticism: f32,
}

impl Default for Personality {
    fn default() -> Self {
        Self {
            openness: 0.5,
            conscientiousness: 0.5,
            extraversion: 0.5,
            agreeableness: 0.5,
            neuroticism: 0.5,
        }
    }
}

impl Personality {
    /// The traits in the order `[O, C, E, A, N]` used by `model::pad`.
    pub fn as_array(&self) -> [f32; 5] {
        [
            self.openness,
            self.conscientiousness,
            self.extraversion,
            self.agreeableness,
            self.neuroticism,
        ]
    }

    /// The value of one trait.
    pub fn value(&self, which: Trait) -> f32 {
        match which {
            Trait::Openness => self.openness,
            Trait::Conscientiousness => self.conscientiousness,
            Trait::Extraversion => self.extraversion,
            Trait::Agreeableness => self.agreeableness,
            Trait::Neuroticism => self.neuroticism,
        }
    }

    /// Samples each trait as the mean of three uniform draws, which centres the population on 0.5
    /// and makes extremes rare (McCrae & John 1992, DOI 10.1111/j.1467-6494.1992.tb00970.x;
    /// Goldberg 1990, DOI 10.1037/0022-3514.59.6.1216).
    pub fn sample<R: DetRng>(rng: &mut R) -> Self {
        let mut mean3 = || (rng.unit() + rng.unit() + rng.unit()) as f32 / 3.0;
        Self {
            openness: mean3(),
            conscientiousness: mean3(),
            extraversion: mean3(),
            agreeableness: mean3(),
            neuroticism: mean3(),
        }
    }

    /// The baseline mood this personality settles back to (Mehrabian 1996, DOI
    /// 10.1080/00049539608259510; Gebhard 2005, DOI 10.1145/1082473.1082478).
    pub fn baseline_mood(&self) -> Mood {
        Mood::from_pad(pad::baseline(self.as_array()))
    }
}

/// How far a person's body clock runs behind the wall clock, in hours: positive is an owl, negative a lark,
/// `0.0` (the default) a person whose body keeps the village's time.
///
/// Chronotype is the phase at which a person's circadian clock entrains to the day, measured by the middle of
/// their sleep on free days (Roenneberg et al. 2007, DOI `10.1016/j.smrv.2007.07.005`). Across a population
/// it is near-normal at every age (Fischer et al. 2017, DOI `10.1371/journal.pone.0178782`, n = 53 689), and
/// it is unrelated to how long a person sleeps, so an offset moves a person's night and does not change its
/// length. The pack's sleep window `Hours(22, 6)` is centred on 02:00, so the offset is a person's mid-sleep
/// minus 02:00: `+2.0` falls asleep at 00:00 and wakes at 08:00.
///
/// **Two clocks.** [`Input::Hour`](crate::Input::Hour) is the *social* clock (shop hours, the baker's dawn,
/// the working day) and stays the same for everybody; [`Input::BodyHour`](crate::Input::BodyHour) is this
/// person's own. The gap between them is what Wittmann et al. 2006 (DOI `10.1080/07420520500545979`) call
/// social jetlag, and an author who wants an owl to open the workshop late simply writes the activity's
/// window on `Hour`.
///
/// The crate never draws one: a host writes it as it writes a [`Personality`], with
/// [`Chronotype::sample`] or from its own data (`PersonDef::chronotype_hours`). A person without one has
/// offset `0` and is exactly the person the crate had before this component existed.
#[derive(Component, Clone, Copy, Debug, Default, PartialEq, Reflect)]
#[cfg_attr(feature = "serde", derive(serde::Serialize, serde::Deserialize))]
pub struct Chronotype(pub f32);

impl Chronotype {
    /// The spread of a population's offsets, in hours (one standard deviation).
    ///
    /// **A design default**, chosen inside the published range: Fischer et al. 2017 give an interquartile
    /// range of 02:24-04:15 (a normal fit has a standard deviation of 1.37 h) and 1.57-2.65 h by age and sex
    /// once diary recall noise is in; Reiter et al. 2021 (DOI `10.3390/clockssleep3020021`) 1.18 h; Sogawa et
    /// al. 2023 (DOI `10.5334/jcr.225`) 1.6 h.
    pub const POPULATION_SD_HOURS: f32 = 1.5;

    /// The hour on this person's own clock, in `[0, 24)`, when the wall clock reads `clock_hour`.
    pub fn body_hour(self, clock_hour: f32) -> f32 {
        (clock_hour - self.0).rem_euclid(24.0)
    }

    /// One person's offset drawn from a normal population of standard deviation `sd_hours`, centred on the
    /// pack's own night and truncated at three standard deviations (the near-normal distribution of Fischer
    /// et al. 2017 has no use for the tails a game would have to reconcile).
    ///
    /// The normal comes from twelve uniform draws summed (Irwin-Hall: mean 6, variance 1), which needs no
    /// transcendental function and takes exactly twelve numbers from `rng`, so the stream a host reads is
    /// the same on every platform. Pass [`Chronotype::POPULATION_SD_HOURS`] for a population like the ones
    /// measured; `0.0` gives everyone the village's clock.
    pub fn sample<R: DetRng>(rng: &mut R, sd_hours: f32) -> Self {
        let sd = if sd_hours.is_finite() {
            sd_hours.abs()
        } else {
            0.0
        };
        let normal = (0..12).map(|_| rng.unit()).sum::<f64>() as f32 - 6.0;
        Self((normal * sd).clamp(-3.0 * sd, 3.0 * sd))
    }
}

/// Pleasure, arousal and dominance, each in `[-1, 1]` (Russell 1980, DOI 10.1037/h0077714; the
/// Mehrabian 1996 mapping this crate uses is DOI 10.1080/00049539608259510, the one `model::pad`
/// implements).
#[derive(Component, Clone, Copy, Debug, Default, PartialEq, Reflect)]
#[cfg_attr(feature = "serde", derive(serde::Serialize, serde::Deserialize))]
pub struct Mood {
    /// Pleasure (valence).
    pub pleasure: f32,
    /// Arousal.
    pub arousal: f32,
    /// Dominance.
    pub dominance: f32,
}

/// Rates of the mood update, per game-day (plan D3 defaults).
#[derive(Clone, Copy, Debug, PartialEq, Reflect)]
pub struct MoodRates {
    /// How fast mood is pulled to the emotion centre (`mood_pull_per_day`, 4.0).
    pub pull_per_day: f32,
    /// How fast mood returns to baseline (`mood_return_per_day`, 1.0).
    pub return_per_day: f32,
    /// Emotional contagion rate (`contagion_per_day`, 0.3; Hatfield et al. 1993, DOI
    /// 10.1111/1467-8721.ep10770953).
    pub contagion_per_day: f32,
    /// Pleasure's resting point above the personality baseline (`positivity_offset`, 0.1). See
    /// `Tuning::positivity_offset` for the mechanism and the source.
    pub positivity_offset: f32,
}

impl Default for MoodRates {
    fn default() -> Self {
        Self {
            pull_per_day: 4.0,
            return_per_day: 1.0,
            contagion_per_day: 0.3,
            positivity_offset: 0.1,
        }
    }
}

impl Mood {
    /// Builds a mood from a PAD triple, clamping each axis to `[-1, 1]`.
    pub fn from_pad(pad: [f32; 3]) -> Self {
        Self {
            pleasure: pad[0].clamp(-1.0, 1.0),
            arousal: pad[1].clamp(-1.0, 1.0),
            dominance: pad[2].clamp(-1.0, 1.0),
        }
    }

    /// The PAD triple `[P, A, D]`.
    pub fn as_array(&self) -> [f32; 3] {
        [self.pleasure, self.arousal, self.dominance]
    }

    /// One mood step over `dt_days` (plan D3). `centre` is the intensity-weighted PAD of the active
    /// emotions and `intensity` their capped total `I`. `contagion` is `(contacts, weight)`: the
    /// affinity-weighted mean pleasure of the person's contacts and the contagion weight `E` (the
    /// caller passes extraversion); `None` when no contact has positive weight.
    ///
    /// Pull and return are both ALMA's mood update (Gebhard 2005, DOI 10.1145/1082473.1082478); the
    /// baseline returned to is the Mehrabian 1996 personality mapping (DOI
    /// 10.1080/00049539608259510); contagion is Hatfield et al. 1993 (DOI
    /// 10.1111/1467-8721.ep10770953).
    pub fn step(
        &mut self,
        baseline: Mood,
        centre: [f32; 3],
        intensity: f32,
        contagion: Option<(f32, f32)>,
        dt_days: f32,
        rates: &MoodRates,
    ) {
        let pull = (rates.pull_per_day * dt_days).min(1.0) * intensity;
        self.pleasure += (centre[0] - self.pleasure) * pull;
        self.arousal += (centre[1] - self.arousal) * pull;
        self.dominance += (centre[2] - self.dominance) * pull;

        // The resting point is the personality baseline **plus the positivity offset**. Cacioppo et al.
        // 2013 put the offset on the *intercept* of the positive activation function — the approach
        // motivation at zero input — which is exactly the point this return step pulls toward. Without
        // it the intercept is the baseline alone, which is zero for a sampled personality, so pleasure
        // has no way above zero and a village is never pleased.
        let resting = baseline.pleasure + rates.positivity_offset;
        let back = (rates.return_per_day * dt_days).min(1.0);
        self.pleasure += (resting - self.pleasure) * back;
        self.arousal += (baseline.arousal - self.arousal) * back;
        self.dominance += (baseline.dominance - self.dominance) * back;

        if let Some((contacts, weight)) = contagion {
            self.pleasure +=
                rates.contagion_per_day * weight * dt_days * (contacts - self.pleasure);
        }
        *self = Self::from_pad(self.as_array());
    }
}

/// The OCC emotion subset (Ortony, Clore & Collins; via Gratch & Marsella 2004, DOI
/// 10.1016/j.cogsys.2004.02.002, and Dias & Paiva 2005, DOI 10.1007/11595014_13).
#[derive(Copy, Clone, Debug, PartialEq, Eq, Serialize, Deserialize, Reflect)]
pub enum EmotionKind {
    /// Joy.
    Joy,
    /// Distress.
    Distress,
    /// Hope.
    Hope,
    /// Fear.
    Fear,
    /// Pride.
    Pride,
    /// Shame.
    Shame,
    /// Admiration.
    Admiration,
    /// Reproach.
    Reproach,
    /// Gratitude.
    Gratitude,
    /// Anger.
    Anger,
    /// Liking.
    Liking,
    /// Disliking.
    Disliking,
}

impl EmotionKind {
    /// Parses an emotion from its lowercase script name (`"joy"` … `"disliking"`), mirroring
    /// [`Trait::from_name`]. Scripts and definitions name emotions in lowercase.
    pub fn from_name(name: &str) -> Option<Self> {
        match name {
            "joy" => Some(Self::Joy),
            "distress" => Some(Self::Distress),
            "hope" => Some(Self::Hope),
            "fear" => Some(Self::Fear),
            "pride" => Some(Self::Pride),
            "shame" => Some(Self::Shame),
            "admiration" => Some(Self::Admiration),
            "reproach" => Some(Self::Reproach),
            "gratitude" => Some(Self::Gratitude),
            "anger" => Some(Self::Anger),
            "liking" => Some(Self::Liking),
            "disliking" => Some(Self::Disliking),
            _ => None,
        }
    }

    /// The PAD vector of this emotion (ALMA's table, Gebhard 2005, DOI 10.1145/1082473.1082478).
    pub fn pad(self) -> [f32; 3] {
        match self {
            Self::Joy => [0.40, 0.20, 0.10],
            Self::Distress => [-0.40, -0.20, -0.50],
            Self::Hope => [0.20, 0.20, -0.10],
            Self::Fear => [-0.64, 0.60, -0.43],
            Self::Pride => [0.40, 0.30, 0.30],
            Self::Shame => [-0.30, 0.10, -0.60],
            Self::Admiration => [0.50, 0.30, -0.20],
            Self::Reproach => [-0.30, -0.10, 0.40],
            Self::Gratitude => [0.40, 0.20, -0.30],
            Self::Anger => [-0.51, 0.59, 0.25],
            Self::Liking => [0.40, 0.16, -0.24],
            Self::Disliking => [-0.40, 0.20, 0.10],
        }
    }
}

/// One active emotion. `toward` names the person it is about, when there is one.
#[derive(Clone, Copy, Debug, PartialEq, Reflect)]
pub struct ActiveEmotion {
    /// Which emotion.
    pub kind: EmotionKind,
    /// Intensity in `[0, 1]`.
    pub intensity: f32,
    /// The person the emotion is about.
    pub toward: Option<Entity>,
}

/// The emotions a person currently feels.
#[derive(Component, Clone, Debug, Default, PartialEq, Reflect)]
pub struct Emotions(pub Vec<ActiveEmotion>);

impl Emotions {
    /// Adds `intensity` to an active emotion with the same kind and target (capped at 1.0), or pushes
    /// a new one.
    pub fn appraise(&mut self, kind: EmotionKind, intensity: f32, toward: Option<Entity>) {
        if let Some(e) = self
            .0
            .iter_mut()
            .find(|e| e.kind == kind && e.toward == toward)
        {
            e.intensity = (e.intensity + intensity).min(1.0);
        } else {
            self.0.push(ActiveEmotion {
                kind,
                intensity: intensity.clamp(0.0, 1.0),
                toward,
            });
        }
    }

    /// Raises the active emotion with this kind and target to at least `intensity` (capped at 1.0),
    /// or pushes a new one. Unlike [`appraise`](Self::appraise) it never adds, so calling it every tick
    /// holds a level instead of building one.
    pub fn raise_to(&mut self, kind: EmotionKind, intensity: f32, toward: Option<Entity>) {
        let intensity = intensity.clamp(0.0, 1.0);
        if let Some(e) = self
            .0
            .iter_mut()
            .find(|e| e.kind == kind && e.toward == toward)
        {
            e.intensity = e.intensity.max(intensity);
        } else {
            self.0.push(ActiveEmotion {
                kind,
                intensity,
                toward,
            });
        }
    }

    /// Halves every intensity per `half_life_days` and drops those below 0.05 (plan D3, half-life 0.1
    /// game-day).
    pub fn decay(&mut self, dt_days: f32, half_life_days: f32) {
        let factor = 0.5f32.powf(dt_days / half_life_days);
        for e in &mut self.0 {
            e.intensity *= factor;
        }
        self.0.retain(|e| e.intensity >= 0.05);
    }

    /// The intensity-weighted PAD centre `V` and the capped total intensity `I = min(1, Σ intensity)`.
    pub fn virtual_centre(&self) -> ([f32; 3], f32) {
        let total: f32 = self.0.iter().map(|e| e.intensity).sum();
        if total <= 0.0 {
            return ([0.0; 3], 0.0);
        }
        let mut centre = [0.0f32; 3];
        for e in &self.0 {
            let pad = e.kind.pad();
            for axis in 0..3 {
                centre[axis] += pad[axis] * e.intensity;
            }
        }
        for c in &mut centre {
            *c /= total;
        }
        (centre, total.min(1.0))
    }

    /// The strongest active emotion, the first one on a tie.
    pub fn strongest(&self) -> Option<ActiveEmotion> {
        let mut best: Option<ActiveEmotion> = None;
        for e in &self.0 {
            if best.is_none_or(|b| e.intensity > b.intensity) {
                best = Some(*e);
            }
        }
        best
    }

    /// The summed intensity of one emotion kind, over all targets.
    pub fn intensity_of(&self, kind: EmotionKind) -> f32 {
        self.0
            .iter()
            .filter(|e| e.kind == kind)
            .map(|e| e.intensity)
            .sum::<f32>()
            .min(1.0)
    }
}

/// Need levels, indexed by `NeedId`. Level 1 is satisfied, 0 is desperate.
#[derive(Component, Clone, Debug, Default, PartialEq, Reflect)]
#[cfg_attr(feature = "serde", derive(serde::Serialize, serde::Deserialize))]
pub struct Needs(pub Vec<f32>);

impl Needs {
    /// The level of need `index`, or 0 when out of range.
    pub fn level(&self, index: usize) -> f32 {
        self.0.get(index).copied().unwrap_or(0.0)
    }

    /// Adds `delta` to need `index`, clamped to `[0, 1]`. Out-of-range indices are ignored.
    pub fn add(&mut self, index: usize, delta: f32) {
        if let Some(level) = self.0.get_mut(index) {
            *level = (*level + delta).clamp(0.0, 1.0);
        }
    }

    /// Decays every need by its `decay_per_day · dt_days`, never below 0 (Maslow 1943, DOI
    /// 10.1037/h0054346; Baumeister & Leary 1995, DOI 10.1037/0033-2909.117.3.497).
    pub fn decay(&mut self, decay_per_day: &[f32], dt_days: f32) {
        for (level, rate) in self.0.iter_mut().zip(decay_per_day) {
            *level = (*level - rate * dt_days).max(0.0);
        }
    }
}

/// Host-written signal values, indexed by [`SignalId`](crate::SignalId). The crate only reads them;
/// a value the host never wrote is `0.0`.
#[derive(Component, Clone, Debug, Default, PartialEq, Reflect)]
#[cfg_attr(feature = "serde", derive(serde::Serialize, serde::Deserialize))]
pub struct Signals(pub Vec<f32>);

impl Signals {
    /// The value of signal `id`, or `0.0` when the vector does not reach it.
    pub fn get(&self, id: crate::defs::SignalId) -> f32 {
        self.0.get(usize::from(id.0)).copied().unwrap_or(0.0)
    }

    /// Writes `value` for signal `id`, growing the vector to fit. A non-finite value is stored as
    /// `0.0`, so a consideration never sees a NaN.
    pub fn set_id(&mut self, id: crate::defs::SignalId, value: f32) {
        let index = usize::from(id.0);
        if self.0.len() <= index {
            self.0.resize(index + 1, 0.0);
        }
        if let Some(slot) = self.0.get_mut(index) {
            *slot = if value.is_finite() { value } else { 0.0 };
        }
    }

    /// Writes `value` for the signal named `name`. An unknown name is
    /// [`DefsError::UnknownId`](crate::DefsError::UnknownId).
    pub fn set(
        &mut self,
        defs: &SocietyDefs,
        name: &str,
        value: f32,
    ) -> Result<(), crate::defs::DefsError> {
        let id = defs
            .signal_id(name)
            .ok_or_else(|| crate::defs::DefsError::UnknownId {
                kind: "signal",
                id: name.to_owned(),
                owner: "person signals".to_owned(),
            })?;
        self.set_id(id, value);
        Ok(())
    }
}

/// What a memory is about.
#[derive(Clone, Debug, PartialEq, Reflect)]
pub enum MemoryKind {
    /// A social exchange.
    Interaction {
        /// The interaction definition.
        id: InteractionId,
        /// Whether it was accepted.
        accepted: bool,
        /// Whether this person started it.
        initiated: bool,
    },
    /// A deed seen by the person.
    Witnessed {
        /// The deed definition.
        deed: DeedId,
        /// Who did it.
        actor: Entity,
    },
    /// A deed done to this person: the victim's side of what a bystander keeps as `Witnessed`. A
    /// host-reported deed leaves it, so [`Memories::grudge`] toward the actor is not left at zero.
    Suffered {
        /// The deed definition.
        deed: DeedId,
        /// Who did it.
        actor: Entity,
    },
    /// A rumor heard from someone.
    HeardRumor(RumorId),
    /// A conversation with the player.
    Talked,
    /// A free-form memory from a script.
    Custom(String),
}

/// One remembered event.
#[derive(Clone, Debug, PartialEq, Reflect)]
pub struct Memory {
    /// The game-day it happened.
    pub day: f32,
    /// What happened.
    pub kind: MemoryKind,
    /// The other person involved, if any.
    pub other: Option<Entity>,
    /// Good (+) or bad (−) for the rememberer, in `[-1, 1]`.
    pub valence: f32,
    /// How much the memory matters, in `[0, 1]`.
    pub importance: f32,
}

/// A bounded list of memories, at most `memory_capacity` (default 64).
///
/// Bounded retention follows Park et al. 2023 (DOI 10.48550/arXiv.2304.03442) in keeping a finite
/// store and evicting the least useful entry; the number 64 is a design default, not theirs.
/// Recency here is measured from **creation**, where Park et al. measure from the last retrieval —
/// a documented departure, because a society's memories are not replayed.
#[derive(Component, Clone, Debug, Default, PartialEq, Reflect)]
pub struct Memories {
    /// The memories, in the order recorded.
    pub entries: Vec<Memory>,
}

impl Memories {
    /// The retention score `r + importance (+ 1 when about the given person)`, where `r` is the
    /// recency of the memory measured **from its creation**. Park et al. 2023 (DOI
    /// 10.48550/arXiv.2304.03442) measure from the last retrieval; this crate does not, and the
    /// departure is deliberate.
    fn score(memory: &Memory, now_days: f64, about: Option<Entity>) -> f32 {
        let hours = ((now_days - memory.day as f64) * 24.0).max(0.0) as f32;
        let about_bonus = match (about, memory.other) {
            (Some(a), Some(o)) if a == o => 1.0,
            _ => 0.0,
        };
        recall::recency(hours) + memory.importance + about_bonus
    }

    /// Records a memory. When full, the lowest-scoring memory is evicted first (score without any
    /// "about" bonus).
    pub fn record(&mut self, memory: Memory, now_days: f64, capacity: usize) {
        if capacity == 0 {
            return;
        }
        while self.entries.len() >= capacity {
            let worst = self
                .entries
                .iter()
                .enumerate()
                .map(|(i, m)| (i, Self::score(m, now_days, None)))
                .fold(None::<(usize, f32)>, |acc, (i, s)| match acc {
                    Some((_, best)) if best <= s => acc,
                    _ => Some((i, s)),
                });
            match worst {
                Some((i, _)) => {
                    self.entries.remove(i);
                }
                None => break,
            }
        }
        self.entries.push(memory);
    }

    /// The `n` best memories by score, highest first. Ties keep recording order.
    pub fn recall(&self, now_days: f64, about: Option<Entity>, n: usize) -> Vec<&Memory> {
        let mut scored: Vec<(f32, &Memory)> = self
            .entries
            .iter()
            .map(|m| (Self::score(m, now_days, about), m))
            .collect();
        scored.sort_by(|a, b| b.0.total_cmp(&a.0));
        scored.into_iter().take(n).map(|(_, m)| m).collect()
    }

    /// Grudge against `other`: the **maximum** of `max(0, −valence) · r` over memories about them,
    /// clamped to `[0, 1]`. Recency is `recall::recency`, an exponential decay (Park et al. 2023, DOI
    /// 10.48550/arXiv.2304.03442).
    ///
    /// A **design default**, and it used to be a sum. Summing made the grudge depend on
    /// `memory_capacity`: four retained trivial slights saturated it exactly as one betrayal did, so a
    /// person's opinion of another was set by how many small entries the buffer happened to hold. In a
    /// cluster of twelve that pinned `snub`'s only consideration at 1.0 permanently — `snub` satisfies no
    /// need, so nothing pushed back. Measured on `rumor-crosses-a-cluster` (seed 11): `snub` was **50.3%**
    /// of activity starts with sums and **26.0%** with the maximum, the friends:rivals balance went 4:9 →
    /// **6:5**, and rumor reach doubled **0.167 → 0.333**. `village-evening` held (travelling 58.9% →
    /// 59.0%, need mean 0.545 → 0.542).
    pub fn grudge(&self, other: Entity, now_days: f64) -> f32 {
        self.grudge_at_pace(other, now_days, 1.0)
    }

    /// [`Self::grudge`] with each memory's age multiplied by `pace` before recency reads it, so a grievance
    /// fades `pace` times as fast. Pace `1.0` is [`Self::grudge`] exactly. The pace `ctx.grudge` uses is
    /// [`recall::forgiving_pace`] of the holder's trust in `other` ([`crate::Tuning::forgiveness`]).
    pub fn grudge_at_pace(&self, other: Entity, now_days: f64, pace: f32) -> f32 {
        recall::grudge(
            self.entries
                .iter()
                .filter(|m| m.other == Some(other))
                .map(|m| {
                    let hours = ((now_days - m.day as f64) * 24.0).max(0.0) as f32;
                    (m.valence, recall::recency(hours * pace))
                }),
        )
    }
}

/// Asks for an emotion to be felt by `person`: the game, a deed, an interaction or a rumor writes
/// it and the mind plugin merges it into the person's [`Emotions`]. Appraisals are applied on every
/// fixed step, not only on society ticks, so a message is never dropped between ticks.
#[derive(Message, Clone, Copy, Debug, PartialEq)]
pub struct Appraise {
    /// The person who feels the emotion.
    pub person: Entity,
    /// The emotion kind (OCC subset).
    pub kind: EmotionKind,
    /// Intensity added to the emotion, in [0, 1].
    pub intensity: f32,
    /// The person or thing the emotion is about, if any.
    pub toward: Option<Entity>,
}

/// Runs the mind: applies [`Appraise`] messages on every fixed step, then, on society ticks, decays
/// emotions, moves moods (with emotional contagion) and decays needs. Requires [`crate::SocietyCorePlugin`].
pub struct MindPlugin;

impl Plugin for MindPlugin {
    fn build(&self, app: &mut App) {
        app.register_type::<Personality>()
            .register_type::<Mood>()
            .register_type::<MoodRates>()
            .register_type::<EmotionKind>()
            .register_type::<ActiveEmotion>()
            .register_type::<Emotions>()
            .register_type::<Needs>()
            .register_type::<Signals>()
            .register_type::<Memory>()
            .register_type::<MemoryKind>()
            .register_type::<Memories>()
            .add_message::<Appraise>()
            .add_observer(set_baseline_mood)
            .add_systems(
                FixedUpdate,
                crate::absence::announce_returns
                    .in_set(SocietySystems::Clock)
                    .before(apply_appraisals),
            )
            .add_systems(FixedUpdate, apply_appraisals.in_set(SocietySystems::Clock))
            .add_systems(
                FixedUpdate,
                (
                    crate::absence::miss_the_away.after(update_minds),
                    update_minds,
                    decay_needs,
                )
                    .in_set(SocietySystems::Mind),
            );
        crate::absence::build(app);
    }

    fn finish(&self, app: &mut App) {
        require_core(app, "MindPlugin");
    }
}

/// Starts each new person at the mood its personality implies (Mehrabian 1996, DOI
/// 10.1080/00049539608259510).
fn set_baseline_mood(add: On<Add<Person>>, mut people: Query<(&Personality, &mut Mood)>) {
    if let Ok((personality, mut mood)) = people.get_mut(add.entity) {
        *mood = personality.baseline_mood();
    }
}

/// Merges every pending [`Appraise`] into its person's [`Emotions`]. Runs in the ungated clock set,
/// so a message written between ticks is not lost.
pub(crate) fn apply_appraisals(
    mut reader: MessageReader<Appraise>,
    mut emotions: Query<&mut Emotions>,
) {
    for msg in reader.read() {
        if let Ok(mut e) = emotions.get_mut(msg.person) {
            e.appraise(msg.kind, msg.intensity, msg.toward);
        }
    }
}

/// Decays emotions and moves each person's mood toward its virtual emotion centre, its personality
/// baseline, and (Hatfield et al. 1993, DOI 10.1111/1467-8721.ep10770953) the pleasure of its close
/// contacts, weighted by affinity and extraversion. Contacts are read from a snapshot taken before
/// any update, so the result does not depend on iteration order.
fn update_minds(
    settings: Res<SocietySettings>,
    clock: Res<SocietyClock>,
    index: Res<PersonIndex>,
    perception: Res<Perception>,
    mut people: Query<(&Personality, &mut Emotions, &mut Mood, &Ties)>,
) {
    let tuning = &settings.tuning;
    let rates = MoodRates {
        pull_per_day: tuning.mood_pull_per_day,
        return_per_day: tuning.mood_return_per_day,
        contagion_per_day: tuning.contagion_per_day,
        positivity_offset: tuning.positivity_offset,
    };
    let pleasure: Vec<f32> = index
        .sorted
        .iter()
        .map(|(_, e)| people.get(*e).map_or(0.0, |(_, _, m, _)| m.pleasure))
        .collect();
    for (i, (_, entity)) in index.sorted.iter().enumerate() {
        let Ok((personality, mut emotions, mut mood, ties)) = people.get_mut(*entity) else {
            continue;
        };
        emotions.decay(clock.dt_days, tuning.emotion_half_life_days);
        let (centre, intensity) = emotions.virtual_centre();
        let contacts: &[u32] = match perception.contact.get(i) {
            Some(list) => list,
            None => &[],
        };
        let mut sum_w = 0.0_f32;
        let mut sum_wp = 0.0_f32;
        for &j in contacts {
            let Some((_, other)) = index.sorted.get(j as usize) else {
                continue;
            };
            let Some(tie) = ties.get(*other) else {
                continue;
            };
            let w = tie.affinity.max(0.0);
            if w > 0.0 {
                sum_w += w;
                sum_wp += w * pleasure.get(j as usize).copied().unwrap_or(0.0);
            }
        }
        let contagion = if sum_w > 0.0 {
            Some((sum_wp / sum_w, personality.extraversion))
        } else {
            None
        };
        mood.step(
            personality.baseline_mood(),
            centre,
            intensity,
            contagion,
            clock.dt_days,
            &rates,
        );
    }
}

/// Decays every person's needs at the rates their definitions declare (Maslow 1943; Baumeister &
/// Leary 1995, DOI 10.1037/0033-2909.117.3.497).
fn decay_needs(
    clock: Res<SocietyClock>,
    index: Res<PersonIndex>,
    defs: Res<SocietyDefs>,
    mut needs: Query<&mut Needs>,
) {
    let rates: Vec<f32> = defs.needs.iter().map(|n| n.decay_per_day).collect();
    for (_, entity) in &index.sorted {
        if let Ok(mut n) = needs.get_mut(*entity) {
            n.decay(&rates, clock.dt_days);
        }
    }
}

#[cfg(test)]
mod tests {
    use super::*;
    use bevy_emerge_rng::seeded;

    fn ent(bits: u64) -> Entity {
        Entity::from_bits(bits)
    }

    #[test]
    fn personality_sample_is_deterministic_and_in_range() {
        let mut a = seeded(3);
        let mut b = seeded(3);
        let pa = Personality::sample(&mut a);
        let pb = Personality::sample(&mut b);
        assert_eq!(pa, pb);
        for v in pa.as_array() {
            assert!((0.0..=1.0).contains(&v));
        }
    }

    #[test]
    fn appraise_merges_same_kind_and_target_and_caps() {
        let mut e = Emotions::default();
        let who = Some(ent(1));
        e.appraise(EmotionKind::Anger, 0.7, who);
        e.appraise(EmotionKind::Anger, 0.7, who);
        e.appraise(EmotionKind::Anger, 0.2, None);
        assert_eq!(e.0.len(), 2);
        assert_eq!(e.0[0].intensity, 1.0);
    }

    #[test]
    fn emotions_decay_below_threshold_is_dropped() {
        let mut e = Emotions::default();
        e.appraise(EmotionKind::Joy, 0.5, None);
        e.decay(0.1, 0.1);
        assert!((e.0[0].intensity - 0.25).abs() < 1e-6);
        e.decay(0.5, 0.1);
        assert!(e.0.is_empty());
    }

    #[test]
    fn centre_is_weighted_mean_and_intensity_capped() {
        let mut e = Emotions::default();
        e.appraise(EmotionKind::Joy, 0.5, None);
        e.appraise(EmotionKind::Distress, 0.5, None);
        let (centre, total) = e.virtual_centre();
        assert!((total - 1.0).abs() < 1e-6);
        assert!((centre[0] - 0.0).abs() < 1e-6);
    }

    #[test]
    fn needs_clamp_and_decay_floor_at_zero() {
        let mut n = Needs(vec![0.5, 0.2]);
        n.add(0, 1.0);
        assert_eq!(n.level(0), 1.0);
        n.decay(&[2.0, 0.1], 1.0);
        assert_eq!(n.level(0), 0.0);
        assert!((n.level(1) - 0.1).abs() < 1e-6);
    }

    #[test]
    fn mood_relaxes_to_baseline_plus_the_positivity_offset() {
        let base = Mood::from_pad([0.3, -0.2, 0.1]);
        let mut m = Mood::from_pad([-0.9, 0.9, -0.9]);
        let rates = MoodRates::default();
        for _ in 0..200 {
            m.step(base, [0.0; 3], 0.0, None, 0.05, &rates);
        }
        // Pleasure returns to the baseline **plus** the positivity offset (Cacioppo et al. 2013: the
        // positive activation function's intercept is above the negative one's). Arousal and dominance
        // have no such offset and return to the baseline itself.
        assert!((m.pleasure - (base.pleasure + rates.positivity_offset)).abs() < 1e-3);
        assert!((m.arousal - base.arousal).abs() < 1e-3);
    }

    #[test]
    fn a_zero_offset_returns_pleasure_to_the_bare_baseline() {
        let base = Mood::from_pad([0.3, -0.2, 0.1]);
        let mut m = Mood::from_pad([-0.9, 0.9, -0.9]);
        let rates = MoodRates {
            positivity_offset: 0.0,
            ..MoodRates::default()
        };
        for _ in 0..200 {
            m.step(base, [0.0; 3], 0.0, None, 0.05, &rates);
        }
        assert!((m.pleasure - base.pleasure).abs() < 1e-3);
    }

    fn mem(day: f32, other: Option<Entity>, valence: f32, importance: f32) -> Memory {
        Memory {
            day,
            kind: MemoryKind::Talked,
            other,
            valence,
            importance,
        }
    }

    #[test]
    fn recall_prefers_about_then_recency() {
        let mut m = Memories::default();
        let a = ent(10);
        let b = ent(11);
        m.record(mem(0.0, Some(b), 0.0, 0.1), 0.0, 8);
        m.record(mem(0.0, Some(a), 0.0, 0.1), 0.0, 8);
        let top = m.recall(0.0, Some(a), 1);
        assert_eq!(top[0].other, Some(a));
    }

    #[test]
    fn record_evicts_lowest_score_when_full() {
        let mut m = Memories::default();
        m.record(mem(0.0, None, 0.0, 0.0), 0.0, 2);
        m.record(mem(0.0, None, 0.0, 0.9), 0.0, 2);
        m.record(mem(0.0, None, 0.0, 0.5), 0.0, 2);
        assert_eq!(m.entries.len(), 2);
        let importances: Vec<f32> = m.entries.iter().map(|e| e.importance).collect();
        assert!(!importances.contains(&0.0));
    }

    #[test]
    fn grudge_is_the_worst_offence_not_the_count_of_them() {
        let mut m = Memories::default();
        let a = ent(20);
        m.record(mem(0.0, Some(a), -0.6, 0.5), 0.0, 8);
        m.record(mem(0.0, Some(a), -0.6, 0.5), 0.0, 8);
        // Four trivial slights do not equal one betrayal. While this summed, the value depended on
        // `memory_capacity`: keeping four small entries instead of two turned 0.6 into a saturated 1.0.
        assert!((m.grudge(a, 0.0) - 0.6).abs() < 1e-6);
        assert_eq!(m.grudge(ent(21), 0.0), 0.0);
        let mut positive = Memories::default();
        positive.record(mem(0.0, Some(a), 0.8, 0.5), 0.0, 8);
        assert_eq!(positive.grudge(a, 0.0), 0.0);
        // A deeper offence still dominates, and recency still discounts it.
        let mut mixed = Memories::default();
        mixed.record(mem(0.0, Some(a), -0.9, 0.5), 0.0, 8);
        mixed.record(mem(0.0, Some(a), -0.2, 0.5), 0.0, 8);
        assert!((mixed.grudge(a, 0.0) - 0.9).abs() < 1e-6);
    }
}