1//! A character: who Whiskers is to her. An id, a look, and the voice the character owns. 2//! 3//! The characters are a closed list, data in this file: the drawn cats (the household's looks, which 4//! until now were `CatTheme`: the grey cat, the ginger cat and the bunny) and a small set of friends from the pixel icon pack. A [`CharacterId`] can name 5//! only a character of the list, so a document cannot point at one that does not exist. The voice is not in 6//! this list: it is a household setting per character (see `limits`), because the household chooses it. 7//! 8//! The moods of a pack character (listening, thinking, speaking) are the app's to draw; this module says only 9//! who there is and in what order the child steps through them. 10 11use std::fmt; 12 13use serde::{Deserialize, Serialize}; 14use whiskers_icons::IconId; 15 16use crate::limits::CatTheme; 17use crate::voice_id::VoiceId; 18 19use whiskers_art::{Character, Drawn}; 20 21/// The household's cat look that a drawn character is. 22fn theme_of(drawn: Drawn) -> CatTheme { 23 match drawn { 24 Drawn::Grey => CatTheme::Grey, 25 Drawn::Ginger => CatTheme::Ginger, 26 Drawn::Bunny => CatTheme::Bunny, 27 } 28} 29 30/// What a character looks like. 31#[derive(Clone, Debug, PartialEq, Eq)] 32pub enum Look { 33 /// One of the drawn characters (the two cats and the bunny), with every mood. 34 Cat(CatTheme), 35 /// A pixel icon of the admitted set, used as the picture. 36 Icon(IconId), 37} 38 39/// Which character: one of the list, never anything else. 40#[derive(Clone, Copy, PartialEq, Eq, PartialOrd, Ord, Hash)] 41pub struct CharacterId(&'static str); 42 43/// Why a text is not a character. 44#[derive(Clone, Copy, Debug, PartialEq, Eq)] 45pub struct NotACharacter; 46 47impl fmt::Display for NotACharacter { 48 fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result { 49 f.write_str("not a character of the list") 50 } 51} 52 53impl std::error::Error for NotACharacter {} 54 55impl fmt::Debug for CharacterId { 56 fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result { 57 write!(f, "CharacterId({})", self.0) 58 } 59} 60 61impl fmt::Display for CharacterId { 62 fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result { 63 f.write_str(self.0) 64 } 65} 66 67impl CharacterId { 68 /// The character called `id`. 69 pub fn new(id: &str) -> Result<Self, NotACharacter> { 70 ids().find(|c| c.0 == id).ok_or(NotACharacter) 71 } 72 73 pub fn as_str(self) -> &'static str { 74 self.0 75 } 76 77 /// The character the cat look names: what a household that never chose one has. 78 pub fn of_theme(theme: CatTheme) -> Self { 79 Drawn::ALL.into_iter().find(|d| theme_of(*d) == theme).map(|d| CharacterId(d.id())).expect("every cat look is a drawn character") 80 } 81 82 pub fn look(self) -> Look { 83 match Character::parse(self.0).expect("a character of the list") { 84 Character::Drawn(d) => Look::Cat(theme_of(d)), 85 Character::Pack(p) => Look::Icon(IconId::new(p.id).expect("a pack character is an admitted icon")), 86 } 87 } 88 89 /// The next character, after the last the first. 90 pub fn next(self) -> Self { 91 let all: Vec<Self> = ids().collect(); 92 let at = all.iter().position(|c| *c == self).expect("a character is in the list"); 93 all[(at + 1) % all.len()] 94 } 95 96 /// The previous character, before the first the last. 97 pub fn previous(self) -> Self { 98 let all: Vec<Self> = ids().collect(); 99 let at = all.iter().position(|c| *c == self).expect("a character is in the list"); 100 all[(at + all.len() - 1) % all.len()] 101 } 102} 103 104/// Every character, in the order the chooser steps through them: the drawn characters, then the friends. 105pub fn ids() -> impl Iterator<Item = CharacterId> { 106 Character::all().into_iter().map(|c| CharacterId(c.id())) 107} 108 109impl Serialize for CharacterId { 110 fn serialize<S: serde::Serializer>(&self, s: S) -> Result<S::Ok, S::Error> { 111 s.serialize_str(self.0) 112 } 113} 114 115impl<'de> Deserialize<'de> for CharacterId { 116 fn deserialize<D: serde::Deserializer<'de>>(d: D) -> Result<Self, D::Error> { 117 let text = String::deserialize(d)?; 118 CharacterId::new(&text).map_err(|_| serde::de::Error::custom(format!("{NotACharacter}"))) 119 } 120} 121 122/// Why a character speaks in the household's base voice. 123#[derive(Clone, Copy, Debug, PartialEq, Eq)] 124pub enum BaseBecause { 125 /// No voice was chosen for the character. 126 NoneChosen, 127 /// One was chosen, and the account no longer lists it. 128 NotListed, 129 /// One was chosen, and the service refused to speak in it. 130 Refused, 131} 132 133/// The voice a character is heard in: its own, or else the base voice, and which and why. **Never another 134/// character's voice**: the only two answers are this character's own voice and the household's base, so a 135/// missing voice cannot be filled by lending one that belongs to someone else. 136#[derive(Clone, Debug, PartialEq, Eq)] 137pub enum VoiceToUse { 138 Own(VoiceId), 139 Base(BaseBecause), 140} 141 142impl VoiceToUse { 143 /// The voice for `character`, given the voice the household chose for it (if any) and the voices the service lists (`None` while 144 /// the list could not be read: the voice is then used and the service has the last word, see 145 /// [`VoiceToUse::refused`]). Saying so goes in the log, with ids only: the character, and a tag of the voice. 146 pub fn of(character: CharacterId, chosen: Option<&VoiceId>, listed: Option<&[VoiceId]>) -> VoiceToUse { 147 let Some(own) = chosen else { 148 ::log::debug!("{character} has no voice of its own: the base voice"); 149 return VoiceToUse::Base(BaseBecause::NoneChosen); 150 }; 151 match listed { 152 Some(list) if !list.contains(own) => { 153 ::log::warn!("{character}'s voice {} is not listed by the service: speaking in the base voice", own.tag()); 154 VoiceToUse::Base(BaseBecause::NotListed) 155 } 156 _ => VoiceToUse::Own(own.clone()), 157 } 158 } 159 160 /// The service refused to speak in `character`'s own voice: the base voice, and the log says so. 161 pub fn refused(character: CharacterId, own: &VoiceId) -> VoiceToUse { 162 ::log::warn!("{character}'s voice {} was refused by the service: speaking in the base voice", own.tag()); 163 VoiceToUse::Base(BaseBecause::Refused) 164 } 165} 166 167#[cfg(test)] 168mod tests { 169 use super::*; 170 171 #[test] 172 fn every_pack_character_is_an_admitted_icon_and_no_id_repeats() { 173 for p in whiskers_art::PACK { 174 assert!(IconId::new(p.id).is_ok(), "{} is not an admitted icon", p.id); 175 } 176 let all: Vec<_> = ids().collect(); 177 let mut names: Vec<_> = all.iter().map(|c| c.as_str()).collect(); 178 names.sort_unstable(); 179 names.dedup(); 180 assert_eq!(names.len(), all.len(), "an id is one character"); 181 for c in &all { 182 let _ = c.look(); 183 } 184 } 185 186 #[test] 187 fn the_looks_are_the_three_drawn_characters_in_order_before_the_pack() { 188 assert_eq!(CharacterId::of_theme(CatTheme::Grey).look(), Look::Cat(CatTheme::Grey)); 189 assert_eq!(CharacterId::of_theme(CatTheme::Ginger).look(), Look::Cat(CatTheme::Ginger)); 190 assert_eq!(CharacterId::of_theme(CatTheme::Bunny).look(), Look::Cat(CatTheme::Bunny)); 191 let first: Vec<_> = ids().take(4).map(|c| c.as_str()).collect(); 192 assert_eq!(first, ["grey-cat", "ginger-cat", "bunny", "panda"]); 193 for theme in CatTheme::ALL { 194 assert!(matches!(CharacterId::of_theme(theme).look(), Look::Cat(t) if t == theme)); 195 } 196 assert_eq!(ids().next(), Some(CharacterId::of_theme(CatTheme::Grey)), "the household's default is first"); 197 } 198 199 #[test] 200 fn stepping_wraps_around_both_ways() { 201 let all: Vec<_> = ids().collect(); 202 assert_eq!(all.last().unwrap().next(), all[0]); 203 assert_eq!(all[0].previous(), *all.last().unwrap()); 204 for c in &all { 205 assert_eq!(c.next().previous(), *c); 206 assert_eq!(c.previous().next(), *c); 207 } 208 let mut c = all[0]; 209 for _ in 0..all.len() { 210 c = c.next(); 211 } 212 assert_eq!(c, all[0], "a full circle comes back"); 213 } 214 215 #[test] 216 fn a_document_cannot_name_a_character_that_is_not_there() { 217 assert!(serde_json::from_str::<CharacterId>("\"panda\"").is_ok()); 218 assert!(serde_json::from_str::<CharacterId>("\"beer\"").is_err()); 219 assert_eq!(serde_json::to_string(&CharacterId::new("fox").unwrap()).unwrap(), "\"fox\""); 220 assert!(CharacterId::new("").is_err()); 221 } 222 223 #[test] 224 fn a_character_without_its_voice_speaks_in_the_base_voice_and_never_in_another_characters() { 225 let (panda, fox) = (CharacterId::new("panda").unwrap(), CharacterId::new("fox").unwrap()); 226 let mut config = crate::limits::HouseholdConfig::default(); 227 config.character_voices.insert(panda, VoiceId::new("vp").unwrap()); 228 assert_eq!(VoiceToUse::of(panda, config.voice_of(panda), None), VoiceToUse::Own(VoiceId::new("vp").unwrap()), "list unknown: its own, the service decides"); 229 let listed = [VoiceId::new("vp").unwrap(), VoiceId::new("other").unwrap()]; 230 assert_eq!(VoiceToUse::of(panda, config.voice_of(panda), Some(&listed)), VoiceToUse::Own(VoiceId::new("vp").unwrap())); 231 assert_eq!(VoiceToUse::of(fox, config.voice_of(fox), Some(&listed)), VoiceToUse::Base(BaseBecause::NoneChosen), "fox is not lent panda's voice"); 232 assert_eq!(VoiceToUse::of(panda, config.voice_of(panda), Some(&[VoiceId::new("other").unwrap()])), VoiceToUse::Base(BaseBecause::NotListed)); 233 assert_eq!(VoiceToUse::refused(panda, &VoiceId::new("vp").unwrap()), VoiceToUse::Base(BaseBecause::Refused)); 234 } 235}