Skip to main content

encounter/
types.rs

1//! Core data types shared across the crate.
2
3use serde::{Deserialize, Serialize};
4
5/// An effect produced by a social interaction beat.
6///
7/// Effects are tagged with `kind` in serialized form and categorized into
8/// aggregate buckets on [`EncounterResult`] by [`EncounterResult::push_beat`].
9#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
10#[serde(tag = "kind", rename_all = "snake_case")]
11pub enum Effect {
12    /// One character conveys a belief to another.
13    KnowledgeTransfer {
14        /// The character transmitting the claim.
15        from: String,
16        /// The character receiving the claim.
17        to: String,
18        /// The belief being transferred.
19        claim: String,
20        /// Where the belief originated.
21        provenance: Option<String>,
22        /// Starting confidence level (0.0–1.0).
23        initial_confidence: Option<f64>,
24    },
25    /// A shift along a relationship axis between two characters.
26    RelationshipDelta {
27        /// The relationship axis being modified (e.g. "trust").
28        axis: String,
29        /// The source character.
30        from: String,
31        /// The target character.
32        to: String,
33        /// Signed magnitude of the change.
34        delta: f64,
35    },
36    /// An emotional response triggered in a character.
37    EmotionalEvent {
38        /// The character experiencing the emotion.
39        target: String,
40        /// The emotion label (e.g. "joy", "anger").
41        emotion: String,
42        /// How strongly the emotion is felt (0.0–1.0).
43        intensity: f64,
44    },
45    /// A sustained shift in a character's mood along some axis.
46    MoodShift {
47        /// The character whose mood shifts.
48        target: String,
49        /// The mood axis being modified.
50        axis: String,
51        /// Signed magnitude of the shift.
52        delta: f64,
53    },
54    /// Partial or full satisfaction of a character's need.
55    NeedSatisfaction {
56        /// The character whose need is satisfied.
57        target: String,
58        /// The need being addressed (e.g. "belonging").
59        need: String,
60        /// Amount of satisfaction granted.
61        amount: f64,
62    },
63    /// A nudge to a character's value system.
64    ValueShift {
65        /// The character whose values shift.
66        target: String,
67        /// The value being modified (e.g. "honesty").
68        value: String,
69        /// Signed magnitude of the shift.
70        delta: f64,
71    },
72    /// An actor leaves the current practice.
73    PracticeExit {
74        /// The character exiting.
75        actor: String,
76        /// Why they are leaving.
77        reason: Option<String>,
78    },
79}
80
81/// A drive that motivates a character's participation in an encounter.
82#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
83pub struct DriveAlignment {
84    /// The drive category (e.g. "autonomy", "belonging").
85    pub kind: String,
86    /// How strongly this drive aligns with the action (0.0–1.0).
87    pub strength: f64,
88}
89
90/// A single consideration in a utility-scoring curve set.
91#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
92pub struct ConsiderationSpec {
93    /// Unique identifier for this consideration.
94    pub id: String,
95    /// The curve type to apply (e.g. "linear", "logistic").
96    pub curve: String,
97    /// Weight applied to this consideration's score.
98    pub weight: f64,
99    /// Minimum score threshold; consideration is ignored below this value.
100    #[serde(default)]
101    pub threshold: Option<f64>,
102}
103
104/// A single resolved action exchange within an encounter.
105///
106/// Beats are constructed by resolution protocols rather than deserialized from
107/// external data, so they only implement `Serialize`.
108#[derive(Debug, Clone, PartialEq, Serialize)]
109pub struct Beat {
110    /// The character performing the action.
111    pub actor: String,
112    /// The action identifier.
113    pub action: String,
114    /// Whether the action was accepted by the target.
115    pub accepted: bool,
116    /// Effects that fire as a result of this beat.
117    pub effects: Vec<Effect>,
118}
119
120/// Aggregated output of a resolved encounter.
121///
122/// Each `Effect` variant has a corresponding aggregate bucket; consumers may
123/// either walk `beats[*].effects` for fully ordered context, or read the
124/// per-variant buckets when only one effect kind is needed.
125#[derive(Debug, Clone, PartialEq, Serialize)]
126pub struct EncounterResult {
127    /// Characters who participated.
128    pub participants: Vec<String>,
129    /// The practice that framed this encounter, if any.
130    pub practice: Option<String>,
131    /// Ordered sequence of beats that occurred.
132    pub beats: Vec<Beat>,
133    /// All [`Effect::RelationshipDelta`] effects from all beats.
134    pub relationship_deltas: Vec<Effect>,
135    /// All [`Effect::KnowledgeTransfer`] effects from all beats.
136    pub knowledge_transfers: Vec<Effect>,
137    /// All [`Effect::EmotionalEvent`] effects from all beats.
138    pub emotional_events: Vec<Effect>,
139    /// All [`Effect::MoodShift`] effects from all beats.
140    pub mood_shifts: Vec<Effect>,
141    /// All [`Effect::NeedSatisfaction`] effects from all beats.
142    pub need_satisfactions: Vec<Effect>,
143    /// All [`Effect::ValueShift`] effects from all beats.
144    pub value_shifts: Vec<Effect>,
145    /// All [`Effect::PracticeExit`] effects from all beats.
146    pub practice_exits: Vec<Effect>,
147    /// Whether any participant requested escalation.
148    pub escalation_requested: bool,
149    /// Escalation requests emitted during the encounter.
150    pub escalation_requests: Vec<crate::escalation::EscalationRequest>,
151}
152
153impl EncounterResult {
154    /// Create a new, empty result for the given participants and optional practice.
155    pub fn new(participants: Vec<String>, practice: Option<String>) -> Self {
156        Self {
157            participants,
158            practice,
159            beats: Vec::new(),
160            relationship_deltas: Vec::new(),
161            knowledge_transfers: Vec::new(),
162            emotional_events: Vec::new(),
163            mood_shifts: Vec::new(),
164            need_satisfactions: Vec::new(),
165            value_shifts: Vec::new(),
166            practice_exits: Vec::new(),
167            escalation_requested: false,
168            escalation_requests: Vec::new(),
169        }
170    }
171
172    /// Append a beat and categorize each effect into its corresponding bucket.
173    ///
174    /// Every `Effect` variant is mirrored into a typed aggregate field; the
175    /// beat's full `effects` vec is also preserved on `beats`.
176    pub fn push_beat(&mut self, beat: Beat) {
177        for effect in &beat.effects {
178            match effect {
179                Effect::RelationshipDelta { .. } => {
180                    self.relationship_deltas.push(effect.clone());
181                }
182                Effect::KnowledgeTransfer { .. } => {
183                    self.knowledge_transfers.push(effect.clone());
184                }
185                Effect::EmotionalEvent { .. } => {
186                    self.emotional_events.push(effect.clone());
187                }
188                Effect::MoodShift { .. } => {
189                    self.mood_shifts.push(effect.clone());
190                }
191                Effect::NeedSatisfaction { .. } => {
192                    self.need_satisfactions.push(effect.clone());
193                }
194                Effect::ValueShift { .. } => {
195                    self.value_shifts.push(effect.clone());
196                }
197                Effect::PracticeExit { .. } => {
198                    self.practice_exits.push(effect.clone());
199                }
200            }
201        }
202        self.beats.push(beat);
203    }
204}
205
206#[cfg(test)]
207mod tests {
208    use super::*;
209
210    #[test]
211    fn effect_deserializes_from_toml() {
212        let s = r#"
213            kind = "relationship_delta"
214            axis = "trust"
215            from = "alice"
216            to = "bob"
217            delta = 0.25
218        "#;
219        let effect: Effect = toml::from_str(s).expect("should deserialize");
220        match effect {
221            Effect::RelationshipDelta { delta, .. } => {
222                assert!((delta - 0.25).abs() < f64::EPSILON);
223            }
224            other => panic!("unexpected variant: {other:?}"),
225        }
226    }
227
228    #[test]
229    fn knowledge_transfer_deserializes_with_optional_fields() {
230        let s = r#"
231            kind = "knowledge_transfer"
232            from = "alice"
233            to = "bob"
234            claim = "the vault is open"
235        "#;
236        let effect: Effect = toml::from_str(s).expect("should deserialize");
237        match effect {
238            Effect::KnowledgeTransfer {
239                provenance,
240                initial_confidence,
241                ..
242            } => {
243                assert!(provenance.is_none());
244                assert!(initial_confidence.is_none());
245            }
246            other => panic!("unexpected variant: {other:?}"),
247        }
248    }
249
250    #[test]
251    fn drive_alignment_deserializes() {
252        let s = r#"
253            kind = "belonging"
254            strength = 0.8
255        "#;
256        let da: DriveAlignment = toml::from_str(s).expect("should deserialize");
257        assert_eq!(da.kind, "belonging");
258        assert!((da.strength - 0.8).abs() < f64::EPSILON);
259    }
260
261    #[test]
262    fn encounter_result_categorizes_effects() {
263        let mut result = EncounterResult::new(
264            vec!["alice".to_string(), "bob".to_string()],
265            Some("negotiation".to_string()),
266        );
267
268        let beat = Beat {
269            actor: "alice".to_string(),
270            action: "share_secret".to_string(),
271            accepted: true,
272            effects: vec![
273                Effect::KnowledgeTransfer {
274                    from: "alice".to_string(),
275                    to: "bob".to_string(),
276                    claim: "the vault is open".to_string(),
277                    provenance: None,
278                    initial_confidence: None,
279                },
280                Effect::RelationshipDelta {
281                    axis: "trust".to_string(),
282                    from: "bob".to_string(),
283                    to: "alice".to_string(),
284                    delta: 0.1,
285                },
286            ],
287        };
288
289        result.push_beat(beat);
290
291        assert_eq!(result.beats.len(), 1);
292        assert_eq!(result.knowledge_transfers.len(), 1);
293        assert_eq!(result.relationship_deltas.len(), 1);
294        assert_eq!(result.emotional_events.len(), 0);
295        assert_eq!(result.value_shifts.len(), 0);
296    }
297
298    #[test]
299    fn encounter_result_categorizes_all_seven_variants() {
300        let mut result = EncounterResult::new(vec!["alice".into(), "bob".into()], None);
301
302        let beat = Beat {
303            actor: "alice".into(),
304            action: "complex_action".into(),
305            accepted: true,
306            effects: vec![
307                Effect::RelationshipDelta {
308                    axis: "trust".into(),
309                    from: "alice".into(),
310                    to: "bob".into(),
311                    delta: 0.1,
312                },
313                Effect::KnowledgeTransfer {
314                    from: "alice".into(),
315                    to: "bob".into(),
316                    claim: "test".into(),
317                    provenance: None,
318                    initial_confidence: None,
319                },
320                Effect::EmotionalEvent {
321                    target: "bob".into(),
322                    emotion: "joy".into(),
323                    intensity: 0.5,
324                },
325                Effect::MoodShift {
326                    target: "bob".into(),
327                    axis: "calm".into(),
328                    delta: 0.2,
329                },
330                Effect::NeedSatisfaction {
331                    target: "bob".into(),
332                    need: "belonging".into(),
333                    amount: 0.3,
334                },
335                Effect::ValueShift {
336                    target: "bob".into(),
337                    value: "honesty".into(),
338                    delta: 0.05,
339                },
340                Effect::PracticeExit {
341                    actor: "bob".into(),
342                    reason: Some("satisfied".into()),
343                },
344            ],
345        };
346
347        result.push_beat(beat);
348
349        assert_eq!(result.relationship_deltas.len(), 1);
350        assert_eq!(result.knowledge_transfers.len(), 1);
351        assert_eq!(result.emotional_events.len(), 1);
352        assert_eq!(result.mood_shifts.len(), 1);
353        assert_eq!(result.need_satisfactions.len(), 1);
354        assert_eq!(result.value_shifts.len(), 1);
355        assert_eq!(result.practice_exits.len(), 1);
356    }
357}