1use chrono::{DateTime, Utc};
7use serde::{Deserialize, Serialize, de::DeserializeOwned};
8use uuid::Uuid;
9
10use crate::Edition;
11
12pub trait RulesText:
14 Clone + std::fmt::Debug + PartialEq + Serialize + DeserializeOwned + Send + Sync + 'static
15{
16 const KIND: &'static str;
18 const ONE: &'static str;
20 fn name(&self) -> &str;
22 fn source(&self) -> &str;
24 fn edition(&self) -> Option<Edition>;
26 fn validate(&self) -> Result<(), RulesTextError>;
28}
29
30#[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#[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 #[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
55pub 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#[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 pub level: u8,
78 pub school: String,
79 pub casting_time: String,
80 pub range: String,
81 pub components: String,
83 pub material: String,
85 pub duration: String,
86 pub concentration: bool,
87 pub ritual: bool,
88 pub classes: Vec<String>,
89 pub description: String,
92 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#[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 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
194pub type SpellEntry = Entry<SpellText>;
196pub 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}