Skip to main content

edda_types/
rules_text.rs

1//! Rules text: spells' and conditions' full descriptions, kept like
2//! monsters in a shared library (the SRD) and per campaign (homebrew), and
3//! looked up by name, e.g. for a spell on a character sheet or an effect
4//! in a fight.
5
6use chrono::{DateTime, Utc};
7use serde::{Deserialize, Serialize, de::DeserializeOwned};
8use uuid::Uuid;
9
10use crate::Edition;
11
12/// Something kept as rules text: what it's called and where it's from.
13pub trait RulesText:
14    Clone + std::fmt::Debug + PartialEq + Serialize + DeserializeOwned + Send + Sync + 'static
15{
16    /// What's stored in the `kind` column, and the API's path: `spells`.
17    const KIND: &'static str;
18    /// One of them, in words: `spell`.
19    const ONE: &'static str;
20    /// What it's called.
21    fn name(&self) -> &str;
22    /// Where it's from: "SRD 5.2", "Homebrew".
23    fn source(&self) -> &str;
24    /// The rules it's written for; `None` suits either.
25    fn edition(&self) -> Option<Edition>;
26    /// Checks it can be kept.
27    fn validate(&self) -> Result<(), RulesTextError>;
28}
29
30/// Why a spell or condition can't be kept.
31#[derive(Debug, Clone, PartialEq, Eq, thiserror::Error)]
32pub enum RulesTextError {
33    #[error("name must not be empty")]
34    EmptyName,
35    #[error("a spell's level is 0 (a cantrip) to 9")]
36    SpellLevel,
37}
38
39/// A stored piece of rules text: server-assigned metadata plus the text.
40#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
41#[cfg_attr(feature = "openapi", derive(utoipa::ToSchema))]
42#[serde(bound = "T: RulesText")]
43pub struct Entry<T: RulesText> {
44    pub id: Uuid,
45    /// The campaign whose homebrew it is; `None` for the shared library.
46    #[serde(default, skip_serializing_if = "Option::is_none")]
47    pub campaign: Option<Uuid>,
48    #[serde(flatten)]
49    pub text: T,
50    pub version: u64,
51    pub created_at: DateTime<Utc>,
52    pub updated_at: DateTime<Utc>,
53}
54
55/// The one called `name` (any case), preferring the campaign's own to the
56/// shared library's.
57pub fn named<'a, T: RulesText>(entries: &'a [Entry<T>], name: &str) -> Option<&'a Entry<T>> {
58    let mut matching = entries
59        .iter()
60        .filter(|e| e.text.name().eq_ignore_ascii_case(name.trim()));
61    let first = matching.next()?;
62    Some(if first.campaign.is_some() {
63        first
64    } else {
65        matching.find(|e| e.campaign.is_some()).unwrap_or(first)
66    })
67}
68
69/// A spell as the books describe it. Everything but the name has a
70/// default, so a homebrew spell needs only what it has.
71#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
72#[cfg_attr(feature = "openapi", derive(utoipa::ToSchema))]
73#[serde(default)]
74pub struct SpellText {
75    pub name: String,
76    /// 0 for a cantrip.
77    pub level: u8,
78    pub school: String,
79    pub casting_time: String,
80    pub range: String,
81    /// `V, S, M`.
82    pub components: String,
83    /// What the M stands for.
84    pub material: String,
85    pub duration: String,
86    pub concentration: bool,
87    pub ritual: bool,
88    pub classes: Vec<String>,
89    /// Paragraphs, one per line; `- ` starts a list item and `**…**` is
90    /// bold.
91    pub description: String,
92    /// Casting it with a higher slot (or, for a cantrip, at higher levels).
93    pub higher_levels: String,
94    pub source: String,
95    pub edition: Option<Edition>,
96}
97
98impl Default for SpellText {
99    fn default() -> Self {
100        Self {
101            name: String::new(),
102            level: 0,
103            school: String::new(),
104            casting_time: String::new(),
105            range: String::new(),
106            components: String::new(),
107            material: String::new(),
108            duration: String::new(),
109            concentration: false,
110            ritual: false,
111            classes: Vec::new(),
112            description: String::new(),
113            higher_levels: String::new(),
114            source: "Homebrew".into(),
115            edition: None,
116        }
117    }
118}
119
120impl RulesText for SpellText {
121    const KIND: &'static str = "spells";
122    const ONE: &'static str = "spell";
123
124    fn name(&self) -> &str {
125        &self.name
126    }
127
128    fn source(&self) -> &str {
129        &self.source
130    }
131
132    fn edition(&self) -> Option<Edition> {
133        self.edition
134    }
135
136    fn validate(&self) -> Result<(), RulesTextError> {
137        if self.name.trim().is_empty() {
138            return Err(RulesTextError::EmptyName);
139        }
140        if self.level > 9 {
141            return Err(RulesTextError::SpellLevel);
142        }
143        Ok(())
144    }
145}
146
147/// A condition, such as Prone, or any other named effect worth describing.
148#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
149#[cfg_attr(feature = "openapi", derive(utoipa::ToSchema))]
150#[serde(default)]
151pub struct ConditionText {
152    pub name: String,
153    /// As for [`SpellText::description`].
154    pub description: String,
155    pub source: String,
156    pub edition: Option<Edition>,
157}
158
159impl Default for ConditionText {
160    fn default() -> Self {
161        Self {
162            name: String::new(),
163            description: String::new(),
164            source: "Homebrew".into(),
165            edition: None,
166        }
167    }
168}
169
170impl RulesText for ConditionText {
171    const KIND: &'static str = "conditions";
172    const ONE: &'static str = "condition";
173
174    fn name(&self) -> &str {
175        &self.name
176    }
177
178    fn source(&self) -> &str {
179        &self.source
180    }
181
182    fn edition(&self) -> Option<Edition> {
183        self.edition
184    }
185
186    fn validate(&self) -> Result<(), RulesTextError> {
187        if self.name.trim().is_empty() {
188            return Err(RulesTextError::EmptyName);
189        }
190        Ok(())
191    }
192}
193
194/// A stored spell.
195pub type SpellEntry = Entry<SpellText>;
196/// A stored condition.
197pub type ConditionEntry = Entry<ConditionText>;
198
199#[cfg(test)]
200mod tests {
201    use super::*;
202
203    fn entry(name: &str, own: bool) -> ConditionEntry {
204        Entry {
205            id: Uuid::new_v4(),
206            campaign: own.then(Uuid::new_v4),
207            text: ConditionText {
208                name: name.into(),
209                ..Default::default()
210            },
211            version: 1,
212            created_at: Utc::now(),
213            updated_at: Utc::now(),
214        }
215    }
216
217    #[test]
218    fn names_are_found_in_any_case_the_campaigns_own_first() {
219        let entries = [
220            entry("Prone", false),
221            entry("Marked", true),
222            entry("prone", true),
223        ];
224        assert_eq!(named(&entries, "PRONE").unwrap().id, entries[2].id);
225        assert_eq!(named(&entries, " marked ").unwrap().id, entries[1].id);
226        assert!(named(&entries, "Pron").is_none());
227    }
228
229    #[test]
230    fn a_stored_entry_is_flat_json() {
231        let e = entry("Prone", false);
232        let json = serde_json::to_value(&e).unwrap();
233        assert_eq!(json["name"], "Prone");
234        assert_eq!(serde_json::from_value::<ConditionEntry>(json).unwrap(), e);
235        let spell: SpellText = serde_json::from_str(r#"{"name":"Zap","level":1}"#).unwrap();
236        assert_eq!((spell.level, spell.source.as_str()), (1, "Homebrew"));
237        assert_eq!(
238            SpellText { level: 10, ..spell }.validate(),
239            Err(RulesTextError::SpellLevel)
240        );
241    }
242}