whiskers.git / crates / whiskers-core / src / character.rs
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}