Skip to main content

shuyi_assets/
tip.rs

1//! One-time contextual tips: what triggers each and what it says. The
2//! game's own type names its triggers; its screens decide when one has
3//! happened and remember which tips were seen. A tip's text names keys with
4//! placeholders (`{Confirm}`), filled in with the player's keys when shown.
5//! A tip may keep its instructions apart from its text (`how`): a game whose
6//! tips are spoken by a recorded voice keeps the keys there, since a clip
7//! made ahead of time can't say a key the player rebound.
8use crate::prelude::*;
9
10use alloc::collections::BTreeSet;
11use core::fmt::Debug;
12
13use serde::Deserialize;
14use serde::de::DeserializeOwned;
15
16use crate::error::ContentError;
17use crate::font::CellWidths;
18use crate::lang::text_width;
19use crate::ron::parse_ron;
20
21/// How much text a tip's box holds, in cells.
22#[derive(Debug, Clone, Copy, PartialEq, Eq)]
23pub struct TipLimits {
24    /// Most text lines a tip may have.
25    pub text_lines: usize,
26    /// Longest text line, before placeholders are filled in.
27    pub line_chars: usize,
28    /// Longest tip title.
29    pub title_chars: usize,
30    /// Most lines of a tip's `how`; 0 if the game's tips have none.
31    pub how_lines: usize,
32}
33
34/// One tip; `T` is the game's triggers.
35#[derive(Debug, Clone, PartialEq, Eq, Deserialize)]
36#[serde(deny_unknown_fields)]
37#[serde(bound = "T: DeserializeOwned")]
38pub struct Tip<T> {
39    /// Stable id (`snake_case`); what is remembered once the tip was seen.
40    pub id: String,
41    /// What makes it appear.
42    pub trigger: T,
43    /// Shown in the box's border.
44    pub title: String,
45    /// The body: lines separated by `\n`, with `{Action}` placeholders
46    /// for key names.
47    pub text: String,
48    /// What to do, apart from the text: plain instructions, lines
49    /// separated by `\n`, with `{Action}` placeholders. Written `how:
50    /// "..."`; `None` if the tip has none.
51    #[serde(default, deserialize_with = "some_name")]
52    pub how: Option<String>,
53    /// The expression of whoever speaks the tip while it shows, if it
54    /// names one. Written `face: "sad"`.
55    #[serde(default, deserialize_with = "some_name")]
56    pub face: Option<String>,
57}
58
59/// A name written plainly (`face: "sad"`, not `Some("sad")`).
60fn some_name<'de, D: serde::Deserializer<'de>>(d: D) -> Result<Option<String>, D::Error> {
61    String::deserialize(d).map(Some)
62}
63
64impl<T> Tip<T> {
65    /// The expression while the tip shows: its own, else `default`.
66    pub fn face_or<'a>(&'a self, default: &'a str) -> &'a str {
67        self.face.as_deref().unwrap_or(default)
68    }
69}
70
71#[derive(Deserialize)]
72#[serde(deny_unknown_fields)]
73#[serde(bound = "T: DeserializeOwned")]
74struct RawFile<T> {
75    tips: Vec<Tip<T>>,
76}
77
78/// Every tip, in file order.
79#[derive(Debug, Clone, PartialEq, Eq)]
80pub struct TipTable<T> {
81    /// The tips.
82    pub tips: Vec<Tip<T>>,
83}
84
85impl<T> Default for TipTable<T> {
86    fn default() -> Self {
87        Self { tips: Vec::new() }
88    }
89}
90
91impl<T: PartialEq + Copy> TipTable<T> {
92    /// The tip that `trigger` shows, if the file has one.
93    pub fn for_trigger(&self, trigger: T) -> Option<&Tip<T>> {
94        self.tips.iter().find(|t| t.trigger == trigger)
95    }
96}
97
98/// What [`from_source`] checks tips against besides their box: which
99/// placeholders exist, and what is wrong with a tip's face.
100pub struct TipRules<'a> {
101    /// How much text the box holds.
102    pub limits: TipLimits,
103    /// How many cells each character takes, which the limits count: the
104    /// font atlas's ([`CellWidths::of`]), or [`CellWidths::NARROW`].
105    pub widths: &'a CellWidths,
106    /// Whether `{name}` is a placeholder the game fills in.
107    pub placeholder: &'a dyn Fn(&str) -> bool,
108    /// What is wrong with a tip showing the face `face` (its own, or the
109    /// game's default), if anything.
110    pub face_problem: &'a dyn Fn(&str) -> Option<String>,
111    /// The face of a tip that names none.
112    pub default_face: &'a str,
113}
114
115/// Parses and validates tips `source`, attributing errors to `file`: ids
116/// are lowercase and used once, a trigger has one tip, the title and text
117/// fit `rules`' box, every placeholder is one of the game's and every face
118/// is fine by the game. Reports every problem found.
119pub fn from_source<T: DeserializeOwned + Debug + Ord>(
120    file: &str,
121    source: &str,
122    rules: &TipRules<'_>,
123) -> Result<TipTable<T>, Vec<ContentError>> {
124    let TipLimits {
125        text_lines,
126        line_chars,
127        title_chars,
128        how_lines,
129    } = rules.limits;
130    let raw: RawFile<T> = parse_ron(file, source).map_err(|e| vec![e])?;
131    let mut errors = Vec::new();
132    let mut ids = BTreeSet::new();
133    let mut triggers = BTreeSet::new();
134    for tip in &raw.tips {
135        let mut err = |message: String| {
136            errors.push(ContentError::new(
137                file,
138                format!("tip \"{}\": {message}", tip.id),
139            ));
140        };
141        let id_ok = !tip.id.is_empty()
142            && tip
143                .id
144                .bytes()
145                .all(|b| b.is_ascii_lowercase() || b.is_ascii_digit() || b == b'_');
146        if !id_ok {
147            err("the id must be lowercase letters, digits and `_`".into());
148        }
149        if !ids.insert(tip.id.as_str()) {
150            err("the id is used twice".into());
151        }
152        if !triggers.insert(&tip.trigger) {
153            err(format!("{:?} already has a tip", tip.trigger));
154        }
155        if tip.title.is_empty() || text_width(&tip.title, rules.widths) > title_chars {
156            err(format!("the title must be 1 to {title_chars} characters"));
157        }
158        let lines: Vec<&str> = tip.text.lines().collect();
159        if lines.is_empty() || lines.len() > text_lines {
160            err(format!("the text must have 1 to {text_lines} lines"));
161        }
162        let how: Vec<&str> = tip
163            .how
164            .as_deref()
165            .map_or(Vec::new(), |h| h.lines().collect());
166        if tip.how.is_some() && (how.is_empty() || how.len() > how_lines) {
167            err(format!("the how must have 1 to {how_lines} lines"));
168        }
169        for line in lines.into_iter().chain(how) {
170            if text_width(line, rules.widths) > line_chars {
171                err(format!(
172                    "a text line is over {line_chars} characters: {line:?}"
173                ));
174            }
175        }
176        for text in core::iter::once(&tip.text).chain(&tip.how) {
177            for name in placeholders(text) {
178                if !(rules.placeholder)(name) {
179                    err(format!("unknown placeholder {{{name}}}"));
180                }
181            }
182        }
183        if let Some(problem) = (rules.face_problem)(tip.face_or(rules.default_face)) {
184            err(problem);
185        }
186    }
187    if errors.is_empty() {
188        Ok(TipTable { tips: raw.tips })
189    } else {
190        Err(errors)
191    }
192}
193
194/// The names between braces in `text`, in order (`{Confirm}` gives
195/// `Confirm`). An unclosed `{` is ignored.
196pub fn placeholders(text: &str) -> Vec<&str> {
197    let mut names = Vec::new();
198    let mut rest = text;
199    while let Some((_, after)) = rest.split_once('{') {
200        let Some((name, tail)) = after.split_once('}') else {
201            break;
202        };
203        names.push(name);
204        rest = tail;
205    }
206    names
207}
208
209#[cfg(test)]
210mod tests;