Skip to main content

narrative_engine/schema/
narrative_fn.rs

1use serde::{Deserialize, Serialize};
2
3/// The core narrative function taxonomy.
4///
5/// Narrative function is the most important abstraction in the engine.
6/// It separates WHAT narratively is happening from HOW it's expressed
7/// in a specific genre.
8///
9/// Each variant returns normalized pacing, valence, and intensity values
10/// that the grammar system uses to shape output.
11#[derive(Debug, Clone, Serialize, Deserialize, PartialEq)]
12pub enum NarrativeFunction {
13    /// Hidden information becomes known.
14    Revelation,
15    /// Stakes or tension increase.
16    Escalation,
17    /// Two entities in direct opposition.
18    Confrontation,
19    /// Trust is violated.
20    Betrayal,
21    /// Entities align interests.
22    Alliance,
23    /// Something new is found or understood.
24    Discovery,
25    /// Something valued is taken or destroyed.
26    Loss,
27    /// Tension is broken with humor.
28    ComicRelief,
29    /// Future events are hinted at.
30    Foreshadowing,
31    /// An entity's position shifts.
32    StatusChange,
33    /// Game-defined narrative function.
34    Custom(String),
35}
36
37impl NarrativeFunction {
38    /// Returns a normalized pacing value (0.0 = slow/deliberate, 1.0 = fast/urgent).
39    pub fn pacing(&self) -> f32 {
40        match self {
41            Self::Revelation => 0.4,
42            Self::Escalation => 0.8,
43            Self::Confrontation => 0.7,
44            Self::Betrayal => 0.6,
45            Self::Alliance => 0.3,
46            Self::Discovery => 0.5,
47            Self::Loss => 0.4,
48            Self::ComicRelief => 0.6,
49            Self::Foreshadowing => 0.2,
50            Self::StatusChange => 0.5,
51            Self::Custom(_) => 0.5,
52        }
53    }
54
55    /// Returns a normalized valence value (-1.0 = strongly negative, 1.0 = strongly positive).
56    pub fn valence(&self) -> f32 {
57        match self {
58            Self::Revelation => 0.0,
59            Self::Escalation => -0.3,
60            Self::Confrontation => -0.5,
61            Self::Betrayal => -0.8,
62            Self::Alliance => 0.6,
63            Self::Discovery => 0.5,
64            Self::Loss => -0.7,
65            Self::ComicRelief => 0.7,
66            Self::Foreshadowing => -0.2,
67            Self::StatusChange => 0.0,
68            Self::Custom(_) => 0.0,
69        }
70    }
71
72    /// Returns a normalized intensity value (0.0 = subtle/muted, 1.0 = extreme/dramatic).
73    pub fn intensity(&self) -> f32 {
74        match self {
75            Self::Revelation => 0.7,
76            Self::Escalation => 0.8,
77            Self::Confrontation => 0.9,
78            Self::Betrayal => 0.9,
79            Self::Alliance => 0.4,
80            Self::Discovery => 0.6,
81            Self::Loss => 0.8,
82            Self::ComicRelief => 0.3,
83            Self::Foreshadowing => 0.3,
84            Self::StatusChange => 0.5,
85            Self::Custom(_) => 0.5,
86        }
87    }
88
89    /// Returns the snake_case name of this narrative function for grammar rule lookups.
90    pub fn name(&self) -> &str {
91        match self {
92            Self::Revelation => "revelation",
93            Self::Escalation => "escalation",
94            Self::Confrontation => "confrontation",
95            Self::Betrayal => "betrayal",
96            Self::Alliance => "alliance",
97            Self::Discovery => "discovery",
98            Self::Loss => "loss",
99            Self::ComicRelief => "comic_relief",
100            Self::Foreshadowing => "foreshadowing",
101            Self::StatusChange => "status_change",
102            Self::Custom(name) => name.as_str(),
103        }
104    }
105}
106
107#[cfg(test)]
108mod tests {
109    use super::*;
110
111    #[test]
112    fn narrative_fn_variants() {
113        let f = NarrativeFunction::Revelation;
114        assert!(matches!(f, NarrativeFunction::Revelation));
115
116        let custom = NarrativeFunction::Custom("trade".to_string());
117        assert!(matches!(custom, NarrativeFunction::Custom(_)));
118    }
119
120    #[test]
121    fn pacing_values_in_range() {
122        let variants = [
123            NarrativeFunction::Revelation,
124            NarrativeFunction::Escalation,
125            NarrativeFunction::Confrontation,
126            NarrativeFunction::Betrayal,
127            NarrativeFunction::Alliance,
128            NarrativeFunction::Discovery,
129            NarrativeFunction::Loss,
130            NarrativeFunction::ComicRelief,
131            NarrativeFunction::Foreshadowing,
132            NarrativeFunction::StatusChange,
133            NarrativeFunction::Custom("test".to_string()),
134        ];
135        for v in &variants {
136            let p = v.pacing();
137            assert!(
138                (0.0..=1.0).contains(&p),
139                "{:?} pacing {} out of range",
140                v,
141                p
142            );
143        }
144    }
145
146    #[test]
147    fn valence_values_in_range() {
148        let variants = [
149            NarrativeFunction::Revelation,
150            NarrativeFunction::Escalation,
151            NarrativeFunction::Confrontation,
152            NarrativeFunction::Betrayal,
153            NarrativeFunction::Alliance,
154            NarrativeFunction::Discovery,
155            NarrativeFunction::Loss,
156            NarrativeFunction::ComicRelief,
157            NarrativeFunction::Foreshadowing,
158            NarrativeFunction::StatusChange,
159        ];
160        for v in &variants {
161            let val = v.valence();
162            assert!(
163                (-1.0..=1.0).contains(&val),
164                "{:?} valence {} out of range",
165                v,
166                val
167            );
168        }
169    }
170
171    #[test]
172    fn intensity_values_in_range() {
173        let variants = [
174            NarrativeFunction::Revelation,
175            NarrativeFunction::Escalation,
176            NarrativeFunction::Confrontation,
177            NarrativeFunction::Betrayal,
178            NarrativeFunction::Alliance,
179            NarrativeFunction::Discovery,
180            NarrativeFunction::Loss,
181            NarrativeFunction::ComicRelief,
182            NarrativeFunction::Foreshadowing,
183            NarrativeFunction::StatusChange,
184        ];
185        for v in &variants {
186            let i = v.intensity();
187            assert!(
188                (0.0..=1.0).contains(&i),
189                "{:?} intensity {} out of range",
190                v,
191                i
192            );
193        }
194    }
195
196    #[test]
197    fn confrontation_is_high_intensity() {
198        let c = NarrativeFunction::Confrontation;
199        assert!(c.intensity() >= 0.8);
200        assert!(c.valence() < 0.0);
201    }
202
203    #[test]
204    fn alliance_is_positive_valence() {
205        let a = NarrativeFunction::Alliance;
206        assert!(a.valence() > 0.0);
207        assert!(a.intensity() < 0.5);
208    }
209
210    #[test]
211    fn foreshadowing_is_slow_paced() {
212        let f = NarrativeFunction::Foreshadowing;
213        assert!(f.pacing() <= 0.3);
214    }
215
216    #[test]
217    fn name_returns_snake_case() {
218        assert_eq!(NarrativeFunction::ComicRelief.name(), "comic_relief");
219        assert_eq!(NarrativeFunction::StatusChange.name(), "status_change");
220        assert_eq!(NarrativeFunction::Revelation.name(), "revelation");
221        assert_eq!(
222            NarrativeFunction::Custom("my_fn".to_string()).name(),
223            "my_fn"
224        );
225    }
226}