Skip to main content

encounter/resolution/
multi_beat.rs

1//! Versu-style turn-based encounter resolution.
2
3use crate::affordance::CatalogEntry;
4use crate::practice::{DurationPolicy, PracticeSpec, TurnPolicy};
5use crate::scoring::{AcceptanceEval, ActionScorer};
6use crate::types::{Beat, Effect, EncounterResult};
7
8/// Multi-beat resolution: participants take turns across multiple beats,
9/// re-scoring actions each beat. Supports RoundRobin and AdjacencyPair
10/// turn policies, and respects PracticeExit effects.
11pub struct MultiBeat;
12
13impl MultiBeat {
14    /// Generic over P (precondition type). Scores are recomputed each beat
15    /// by the provided `scorer`, allowing world-state changes to influence
16    /// later action selection.
17    pub fn resolve<P: Clone>(
18        &self,
19        participants: &[String],
20        practice: &PracticeSpec,
21        catalog: &[CatalogEntry<P>],
22        scorer: &dyn ActionScorer<P>,
23        acceptance: &dyn AcceptanceEval<P>,
24    ) -> EncounterResult {
25        let max_beats = match practice.duration_policy {
26            DurationPolicy::MultiBeat { max_beats } => max_beats,
27            DurationPolicy::SingleExchange => 1,
28            DurationPolicy::UntilResolved => usize::MAX,
29        };
30
31        let mut result = EncounterResult::new(participants.to_vec(), Some(practice.name.clone()));
32
33        if participants.is_empty() {
34            return result;
35        }
36
37        // Filter catalog to affordances allowed by the practice.
38        let allowed: Vec<CatalogEntry<P>> = catalog
39            .iter()
40            .filter(|e| practice.affordances.contains(&e.spec.name))
41            .cloned()
42            .collect();
43
44        let mut speaker_idx = 0usize;
45
46        for _beat_num in 0..max_beats {
47            let speaker = &participants[speaker_idx % participants.len()];
48            let responder_idx = (speaker_idx + 1) % participants.len();
49            let responder = &participants[responder_idx];
50
51            let scored = scorer.score_actions(speaker, &allowed, participants);
52            let Some(best) = scored.iter().max_by(|a, b| {
53                a.score
54                    .partial_cmp(&b.score)
55                    .unwrap_or(std::cmp::Ordering::Equal)
56            }) else {
57                break;
58            };
59
60            let accepted = acceptance.evaluate(responder, best);
61            let effects = if accepted {
62                best.entry.spec.effects_on_accept.clone()
63            } else {
64                best.entry.spec.effects_on_reject.clone()
65            };
66
67            let exit_requested = effects
68                .iter()
69                .any(|e| matches!(e, Effect::PracticeExit { .. }));
70
71            let beat = Beat {
72                actor: speaker.clone(),
73                action: best.entry.spec.name.clone(),
74                accepted,
75                effects,
76            };
77            result.push_beat(beat);
78
79            // Check for escalation-worthy conditions.
80            if let Some(esc) = crate::escalation::check_escalation(
81                result.beats.last().unwrap(),
82                result.beats.len() - 1,
83            ) {
84                result.escalation_requested = true;
85                result.escalation_requests.push(esc);
86            }
87
88            if exit_requested {
89                break;
90            }
91
92            speaker_idx = match practice.turn_policy {
93                TurnPolicy::RoundRobin => speaker_idx + 1,
94                TurnPolicy::AdjacencyPair => responder_idx,
95            };
96        }
97        result
98    }
99}