Skip to main content

encounter/resolution/
background.rs

1//! BackgroundScheme resolution protocol — a long-duration plot that
2//! accumulates progress over many ticks, then resolves to a single
3//! consequential beat. Inspired by the progress-bar shape of CK3 schemes;
4//! agents, discovery, and counter-actions are intentionally out of scope —
5//! consumers add those at the drama-manager layer.
6
7use crate::types::{Beat, Effect, EncounterResult};
8use serde::Serialize;
9
10/// Phase of a background scheme.
11#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize)]
12pub enum SchemePhase {
13    /// The scheme is being set up; no progress has been made yet.
14    Preparation,
15    /// The scheme is actively in motion.
16    Execution,
17    /// The scheme has reached its threshold and is complete.
18    Resolved,
19}
20
21/// State of an ongoing background scheme.
22///
23/// Created with [`BackgroundScheme::new`], advanced over time via
24/// [`BackgroundScheme::advance`], and converted to a final
25/// [`EncounterResult`] via [`BackgroundScheme::to_result`] once the phase
26/// reaches [`SchemePhase::Resolved`].
27#[derive(Debug, Clone, Serialize)]
28pub struct BackgroundScheme {
29    /// The character running the scheme.
30    pub initiator: String,
31    /// The character the scheme is directed against.
32    pub target: String,
33    /// Identifier for the type of scheme (e.g. `"assassination"`).
34    pub scheme_type: String,
35    /// Accumulated progress toward the threshold.
36    pub progress: f64,
37    /// Progress value at which the scheme resolves.
38    pub threshold: f64,
39    /// Current phase of the scheme lifecycle.
40    pub phase: SchemePhase,
41    /// Labels describing situational advantages held by the initiator.
42    pub advantages: Vec<String>,
43}
44
45impl BackgroundScheme {
46    /// Create a new scheme in the [`SchemePhase::Preparation`] phase with zero progress.
47    pub fn new(initiator: String, target: String, scheme_type: String, threshold: f64) -> Self {
48        Self {
49            initiator,
50            target,
51            scheme_type,
52            progress: 0.0,
53            threshold,
54            phase: SchemePhase::Preparation,
55            advantages: Vec::new(),
56        }
57    }
58
59    /// Advance progress. Returns true if scheme resolved this tick.
60    ///
61    /// Calling `advance` on an already-resolved scheme is a no-op (returns
62    /// `false` and does not change progress or phase). Phase transitions are
63    /// one-way: Preparation → Execution → Resolved.
64    pub fn advance(&mut self, delta: f64) -> bool {
65        if self.phase == SchemePhase::Resolved {
66            return false;
67        }
68        self.progress = (self.progress + delta).max(0.0);
69        if self.phase == SchemePhase::Preparation && self.progress > 0.0 {
70            self.phase = SchemePhase::Execution;
71        }
72        if self.progress >= self.threshold {
73            self.phase = SchemePhase::Resolved;
74            return true;
75        }
76        false
77    }
78
79    /// Record an advantage label for the initiator.
80    pub fn add_advantage(&mut self, label: String) {
81        self.advantages.push(label);
82    }
83
84    /// Convert resolved scheme to an [`EncounterResult`] with one beat.
85    ///
86    /// The resulting beat carries either the success or failure effects
87    /// depending on whether the scheme reached [`SchemePhase::Resolved`].
88    /// The same escalation check used by [`crate::resolution::MultiBeat`]
89    /// runs on the resolution beat — high-magnitude relationship deltas or
90    /// emotion intensities populate `result.escalation_requests`, since the
91    /// resolution beat of a scheme is often the most consequential moment in
92    /// the encounter.
93    pub fn to_result(
94        &self,
95        success_effects: Vec<Effect>,
96        failure_effects: Vec<Effect>,
97    ) -> EncounterResult {
98        let success = self.phase == SchemePhase::Resolved;
99        let effects = if success {
100            success_effects
101        } else {
102            failure_effects
103        };
104        let mut result = EncounterResult::new(
105            vec![self.initiator.clone(), self.target.clone()],
106            Some(self.scheme_type.clone()),
107        );
108        let beat = Beat {
109            actor: self.initiator.clone(),
110            action: format!("{}_resolution", self.scheme_type),
111            accepted: success,
112            effects,
113        };
114        result.push_beat(beat);
115
116        // Run the same escalation check MultiBeat uses — a scheme's
117        // resolution beat is typically the highest-stakes moment in the
118        // encounter, so silently dropping the signal would be a bug.
119        if let Some(esc) = crate::escalation::check_escalation(
120            result.beats.last().unwrap(),
121            result.beats.len() - 1,
122        ) {
123            result.escalation_requested = true;
124            result.escalation_requests.push(esc);
125        }
126
127        result
128    }
129}
130
131#[cfg(test)]
132mod tests {
133    use super::*;
134    use crate::escalation::FidelityHint;
135    use crate::types::Effect;
136
137    #[test]
138    fn scheme_starts_in_preparation() {
139        let state = BackgroundScheme::new(
140            "alice".to_string(),
141            "bob".to_string(),
142            "assassination".to_string(),
143            10.0,
144        );
145        assert_eq!(state.phase, SchemePhase::Preparation);
146        assert_eq!(state.progress, 0.0);
147    }
148
149    #[test]
150    fn advance_transitions_to_execution() {
151        let mut state = BackgroundScheme::new(
152            "alice".to_string(),
153            "bob".to_string(),
154            "assassination".to_string(),
155            10.0,
156        );
157        let resolved = state.advance(2.0);
158        assert!(!resolved);
159        assert_eq!(state.phase, SchemePhase::Execution);
160        assert!((state.progress - 2.0).abs() < f64::EPSILON);
161    }
162
163    #[test]
164    fn advance_resolves_at_threshold() {
165        let mut state = BackgroundScheme::new(
166            "alice".to_string(),
167            "bob".to_string(),
168            "blackmail".to_string(),
169            10.0,
170        );
171        let first = state.advance(5.0);
172        assert!(!first);
173        assert_eq!(state.phase, SchemePhase::Execution);
174
175        let second = state.advance(5.0);
176        assert!(second);
177        assert_eq!(state.phase, SchemePhase::Resolved);
178    }
179
180    #[test]
181    fn setback_cannot_go_below_zero() {
182        let mut state = BackgroundScheme::new(
183            "alice".to_string(),
184            "bob".to_string(),
185            "seduction".to_string(),
186            10.0,
187        );
188        state.advance(3.0);
189        state.advance(-5.0);
190        assert_eq!(state.progress, 0.0);
191    }
192
193    #[test]
194    fn to_result_produces_one_beat_and_escalates() {
195        let mut state = BackgroundScheme::new(
196            "alice".to_string(),
197            "bob".to_string(),
198            "spy_ring".to_string(),
199            5.0,
200        );
201        state.advance(5.0);
202        assert_eq!(state.phase, SchemePhase::Resolved);
203
204        let success_effects = vec![Effect::RelationshipDelta {
205            axis: "trust".to_string(),
206            from: "alice".to_string(),
207            to: "bob".to_string(),
208            delta: -0.5,
209        }];
210        let failure_effects = vec![];
211
212        let result = state.to_result(success_effects, failure_effects);
213        assert_eq!(result.beats.len(), 1);
214        assert!(result.beats[0].accepted);
215        assert_eq!(result.beats[0].effects.len(), 1);
216        assert_eq!(result.relationship_deltas.len(), 1);
217
218        // The -0.5 trust delta should trigger an Active-tier escalation.
219        assert!(result.escalation_requested);
220        assert_eq!(result.escalation_requests.len(), 1);
221        assert_eq!(
222            result.escalation_requests[0].suggested_fidelity,
223            FidelityHint::Active
224        );
225    }
226
227    #[test]
228    fn to_result_does_not_escalate_on_mild_effects() {
229        let mut state = BackgroundScheme::new(
230            "alice".to_string(),
231            "bob".to_string(),
232            "courtship".to_string(),
233            1.0,
234        );
235        state.advance(1.0);
236
237        let success_effects = vec![Effect::RelationshipDelta {
238            axis: "affection".to_string(),
239            from: "bob".to_string(),
240            to: "alice".to_string(),
241            delta: 0.1,
242        }];
243        let result = state.to_result(success_effects, vec![]);
244
245        assert!(!result.escalation_requested);
246        assert!(result.escalation_requests.is_empty());
247    }
248}