whiskers.git / crates / whiskers-core / src / character.rs

A character: who Whiskers is to her. An id, a look, and the voice the character owns.

The characters are a closed list, data in this file: the drawn cats (the household's looks, which 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 only a character of the list, so a document cannot point at one that does not exist. The voice is not in this list: it is a household setting per character (see limits), because the household chooses it.

The moods of a pack character (listening, thinking, speaking) are the app's to draw; this module says only who there is and in what order the child steps through them.

11use std::fmt;
13use serde::{Deserialize, Serialize};
14use whiskers_icons::IconId;
15
16use crate::limits::CatTheme;
17use crate::voice_id::VoiceId;
18
19use whiskers_art::{Character, Drawn};

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}

What a character looks like.

31#[derive(Clone, Debug, PartialEq, Eq)]
32pub enum Look {

One of the drawn characters (the two cats and the bunny), with every mood.

34    Cat(CatTheme),

A pixel icon of the admitted set, used as the picture.

36    Icon(IconId),
37}

Which character: one of the list, never anything else.

40#[derive(Clone, Copy, PartialEq, Eq, PartialOrd, Ord, Hash)]
41pub struct CharacterId(&'static str);

Why a text is not a character.

44#[derive(Clone, Copy, Debug, PartialEq, Eq)]
45pub struct NotACharacter;
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 {

The character called id.

69    pub fn new(id: &str) -> Result<Self, NotACharacter> {
70        ids().find(|c| c.0 == id).ok_or(NotACharacter)
71    }
73    pub fn as_str(self) -> &'static str {
74        self.0
75    }

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    }
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    }

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    }

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}

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}
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}

Why a character speaks in the household's base voice.

123#[derive(Clone, Copy, Debug, PartialEq, Eq)]
124pub enum BaseBecause {

No voice was chosen for the character.

126    NoneChosen,

One was chosen, and the account no longer lists it.

128    NotListed,

One was chosen, and the service refused to speak in it.

130    Refused,
131}

The voice a character is heard in: its own, or else the base voice, and which and why. Never another character's voice: the only two answers are this character's own voice and the household's base, so a 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}
142impl VoiceToUse {

The voice for character, given the voice the household chose for it (if any) and the voices the service lists (None while the list could not be read: the voice is then used and the service has the last word, see [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    }

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}
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}