Skip to main content

shuyi_assets/lang/
lines.rs

1//! A pack's dialogue text (`dialogue/<file>.ron`): one entry per line id or
2//! caption key. Rewording a source line gives it a new id, so the old
3//! entry becomes an **orphan**: it fails nothing, and [`Lang::status`]
4//! pairs it with the new line it most likely belongs to.
5use crate::prelude::*;
6
7use alloc::collections::BTreeMap;
8use core::fmt;
9
10use serde::de::DeserializeSeed;
11use serde::de::{self, Deserializer, MapAccess, Visitor};
12use serde::{Deserialize, Serialize};
13
14use super::source::{LineKind, SourceText};
15use super::{Lang, LangCode, LangPack, PackRules};
16use crate::dialogue::{
17    ChoiceOption, DialogueLimits, LineId, Scene, Step, caption_key, longest_width, scene_of,
18    token_problems,
19};
20use crate::error::{ContentError, codes};
21use crate::names::Names;
22
23/// What every caption key starts with.
24const CAPTION_PREFIX: &str = "caption.";
25
26/// A dialogue entry as written: `key`, `source`, and `text` or a
27/// `text_<variant>` for each variant.
28struct RawLine {
29    key: String,
30    source: String,
31    text: Option<String>,
32    /// The `text_<variant>` fields, by variant.
33    variants: BTreeMap<String, String>,
34}
35
36/// Reads a [`RawLine`] whose variants may be any of `variants`.
37struct RawLineSeed<'a> {
38    variants: &'a [&'a str],
39}
40
41impl<'de> DeserializeSeed<'de> for RawLineSeed<'_> {
42    type Value = RawLine;
43
44    fn deserialize<D: Deserializer<'de>>(self, deserializer: D) -> Result<RawLine, D::Error> {
45        deserializer.deserialize_struct("RawLine", &["key", "source", "text"], self)
46    }
47}
48
49impl<'de> Visitor<'de> for RawLineSeed<'_> {
50    type Value = RawLine;
51
52    fn expecting(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
53        f.write_str("a dialogue entry: key, source and text")
54    }
55
56    fn visit_map<A: MapAccess<'de>>(self, mut map: A) -> Result<RawLine, A::Error> {
57        let mut fields: BTreeMap<String, String> = BTreeMap::new();
58        while let Some(name) = map.next_key::<String>()? {
59            let known = matches!(name.as_str(), "key" | "source" | "text")
60                || name
61                    .strip_prefix("text_")
62                    .is_some_and(|v| self.variants.contains(&v));
63            if !known {
64                let mut expected = vec!["`key`".to_owned(), "`source`".to_owned()];
65                expected.push("`text`".to_owned());
66                expected.extend(self.variants.iter().map(|v| format!("`text_{v}`")));
67                return Err(de::Error::custom(format!(
68                    "unknown field `{name}`, expected one of {}",
69                    expected.join(", ")
70                )));
71            }
72            if fields.contains_key(&name) {
73                return Err(de::Error::custom(format!("duplicate field `{name}`")));
74            }
75            let value = map.next_value::<String>()?;
76            fields.insert(name, value);
77        }
78        let mut take = |name: &'static str| fields.remove(name);
79        let key = take("key").ok_or_else(|| de::Error::missing_field("key"))?;
80        let source = take("source").ok_or_else(|| de::Error::missing_field("source"))?;
81        let text = take("text");
82        let variants = fields
83            .into_iter()
84            .map(|(name, text)| (name["text_".len()..].to_owned(), text))
85            .collect();
86        Ok(RawLine {
87            key,
88            source,
89            text,
90            variants,
91        })
92    }
93}
94
95/// Reads a file of [`RawLine`]s.
96struct RawLines<'a> {
97    variants: &'a [&'a str],
98}
99
100impl<'de> DeserializeSeed<'de> for RawLines<'_> {
101    type Value = Vec<RawLine>;
102
103    fn deserialize<D: Deserializer<'de>>(self, deserializer: D) -> Result<Vec<RawLine>, D::Error> {
104        deserializer.deserialize_seq(self)
105    }
106}
107
108impl<'de> Visitor<'de> for RawLines<'_> {
109    type Value = Vec<RawLine>;
110
111    fn expecting(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
112        f.write_str("a list of dialogue entries")
113    }
114
115    fn visit_seq<A: de::SeqAccess<'de>>(self, mut seq: A) -> Result<Vec<RawLine>, A::Error> {
116        let mut lines = Vec::new();
117        let seed = || RawLineSeed {
118            variants: self.variants,
119        };
120        while let Some(line) = seq.next_element_seed(seed())? {
121            lines.push(line);
122        }
123        Ok(lines)
124    }
125}
126
127/// A line in a pack's language.
128#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
129pub enum LineText {
130    /// The same whichever variant is played (`text`).
131    One(String),
132    /// One per variant the game names (`text_<variant>`), for a language
133    /// where the line changes with more than a token (a hero's gender,
134    /// say), by variant name.
135    Variants(BTreeMap<String, String>),
136}
137
138impl LineText {
139    /// The text of `variant`: the one text, else the variant's. `None` if
140    /// the line has variants and `variant` isn't one of them.
141    pub fn get(&self, variant: &str) -> Option<&str> {
142        match self {
143            LineText::One(text) => Some(text),
144            LineText::Variants(texts) => texts.get(variant).map(String::as_str),
145        }
146    }
147
148    /// Every text of the entry.
149    pub fn all(&self) -> Vec<&str> {
150        match self {
151            LineText::One(text) => vec![text],
152            LineText::Variants(texts) => texts.values().map(String::as_str).collect(),
153        }
154    }
155}
156
157/// One dialogue line or caption of a pack.
158#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
159pub struct LineEntry {
160    /// The line id, or the caption key.
161    pub key: String,
162    /// The English the text was made from.
163    pub source: String,
164    /// The text in the pack's language.
165    pub text: LineText,
166}
167
168/// A pack entry for a dialogue line that English no longer has.
169#[derive(Debug, Clone, PartialEq, Eq)]
170pub struct Orphan {
171    /// The entry's key: the id the line had.
172    pub key: String,
173    /// The English it was made from.
174    pub source: String,
175    /// Its translation.
176    pub text: LineText,
177    /// The key of the line of the same scene, of the same kind and without
178    /// an entry, whose English is nearest to `source`: most likely the
179    /// reworded line. `None` if the scene has no such line.
180    pub nearest: Option<String>,
181}
182
183/// The id of the scene the line id or caption key `key` belongs to, or
184/// `None` if `key` is neither.
185fn scene_of_key(key: &str) -> Option<&str> {
186    scene_of(key.strip_prefix(CAPTION_PREFIX).unwrap_or(key))
187}
188
189/// Whether `key` is a caption key.
190fn is_caption(key: &str) -> bool {
191    key.starts_with(CAPTION_PREFIX)
192}
193
194/// The most cells a line of `kind` may take under `limits`, if it has a
195/// limit.
196fn limit(kind: LineKind, limits: &DialogueLimits) -> Option<usize> {
197    match kind {
198        LineKind::Speech => Some(limits.max_text),
199        LineKind::Reply => Some(limits.max_option),
200        LineKind::Caption => None,
201    }
202}
203
204/// What an entry with neither one `text` nor every variant is told (an
205/// entry has `text`, or both `text_a` and `text_b`).
206fn variants_message(variants: &[&str]) -> String {
207    let fields: Vec<String> = variants.iter().map(|v| format!("`text_{v}`")).collect();
208    let all = match fields.as_slice() {
209        [] => return "an entry has `text`".to_owned(),
210        [one] => one.clone(),
211        [a, b] => format!("both {a} and {b}"),
212        [rest @ .., last] => format!("all of {}, and {last}", rest.join(", ")),
213    };
214    format!("an entry has `text`, or {all}")
215}
216
217/// Parses and validates the dialogue entries of the pack file `file`
218/// (`source`), adding them to `lines` and every problem to `errors`.
219///
220/// An entry has `text`, or a `text_<variant>` for every one of `rules`'
221/// variants. Its key is a line id or a caption key, used once in the pack.
222/// Its text follows the token rule of English dialogue (only the game's
223/// tokens and name tokens with an id in `data`'s names table). When English
224/// has the line, the text also keeps to the line's length limit, counted in
225/// cells, with every name token as long as the longest of `names` (the
226/// pack's).
227pub(super) fn add_lines(
228    (file, source): (&str, &str),
229    data: &SourceText,
230    names: &Names,
231    rules: &PackRules<'_>,
232    lines: &mut BTreeMap<String, LineEntry>,
233    errors: &mut Vec<ContentError>,
234) {
235    let seed = RawLines {
236        variants: rules.variants,
237    };
238    let raw = crate::ron::parse_ron_seed(file, source, seed)
239        .map_err(|e| errors.push(e))
240        .unwrap_or_default();
241    let longest_name = names.longest(rules.widths);
242    for entry in raw {
243        let at = |message: &str| ContentError::new(file, format!("\"{}\": {message}", entry.key));
244        if scene_of_key(&entry.key).is_none() {
245            errors.push(at("not a line id or a caption key"));
246        }
247        if lines.contains_key(&entry.key) {
248            errors.push(at("the key is used twice").with_code(codes::DUPLICATE));
249        }
250        let every = entry.variants.len() == rules.variants.len();
251        let text = match (entry.text, entry.variants.is_empty()) {
252            (Some(text), true) => LineText::One(text),
253            (None, false) if every => LineText::Variants(entry.variants),
254            _ => {
255                errors.push(at(&variants_message(rules.variants)));
256                continue;
257            }
258        };
259        let english = data.lines.get(&entry.key);
260        for text in text.all() {
261            if text.trim().is_empty() {
262                errors.push(at("the text must not be empty"));
263            }
264            for problem in token_problems(text, rules.tokens, Some(&data.names)) {
265                errors.push(at(&problem));
266            }
267            let width = longest_width(text, rules.tokens, Some(longest_name), rules.widths);
268            match english.and_then(|line| limit(line.kind, &rules.limits)) {
269                Some(limit) if width > limit => {
270                    errors.push(at(&format!("text is {width} cells; the limit is {limit}")));
271                }
272                _ => {}
273            }
274        }
275        let entry = LineEntry {
276            key: entry.key,
277            source: entry.source,
278            text,
279        };
280        lines.insert(entry.key.clone(), entry);
281    }
282}
283
284/// How many single-character edits turn `a` into `b` (Levenshtein).
285fn distance(a: &str, b: &str) -> usize {
286    let b: Vec<char> = b.chars().collect();
287    let mut row: Vec<usize> = (0..=b.len()).collect();
288    for (i, ca) in a.chars().enumerate() {
289        let mut diagonal = row[0];
290        row[0] = i + 1;
291        for (j, &cb) in b.iter().enumerate() {
292            let substituted = diagonal + usize::from(ca != cb);
293            diagonal = row[j + 1];
294            row[j + 1] = substituted.min(diagonal + 1).min(row[j] + 1);
295        }
296    }
297    row[b.len()]
298}
299
300impl LangPack {
301    /// The pack's entry for the dialogue line or caption `key`, if it has
302    /// one.
303    pub fn line(&self, key: &str) -> Option<&LineEntry> {
304        self.lines.get(key)
305    }
306
307    /// The pack's dialogue entries English has no line for, each paired
308    /// with the nearest untranslated line of its scene, in key order.
309    pub(super) fn orphans(&self, data: &SourceText) -> Vec<Orphan> {
310        let orphan = |entry: &LineEntry| {
311            let scene = scene_of_key(&entry.key).unwrap_or_default();
312            let caption = is_caption(&entry.key);
313            let nearest = data
314                .lines
315                .iter()
316                .filter(|(key, line)| {
317                    line.scene == scene
318                        && (line.kind == LineKind::Caption) == caption
319                        && !self.lines.contains_key(*key)
320                })
321                .min_by_key(|(_, line)| distance(&entry.source, &line.text))
322                .map(|(key, _)| key.clone());
323            Orphan {
324                key: entry.key.clone(),
325                source: entry.source.clone(),
326                text: entry.text.clone(),
327                nearest,
328            }
329        };
330        self.lines
331            .values()
332            .filter(|entry| !data.lines.contains_key(&entry.key))
333            .map(orphan)
334            .collect()
335    }
336}
337
338impl Lang {
339    /// The dialogue line or caption `key` in language `code`, as played
340    /// for `variant`: the first entry along [`chain`](Self::chain) made
341    /// from exactly `english` (today's source text); otherwise `english`.
342    pub fn line<'a>(
343        &'a self,
344        code: &LangCode,
345        key: &str,
346        english: &'a str,
347        variant: &str,
348    ) -> &'a str {
349        self.packs_along(code)
350            .find_map(|(_, pack)| pack.lines.get(key).filter(|entry| entry.source == english))
351            .map_or(english, |entry| entry.text.get(variant).unwrap_or(english))
352    }
353
354    /// `scene` in language `code`, as played for `variant`: every line,
355    /// reply and caption translated along [`chain`](Self::chain), the
356    /// others as the source has them. The lines keep their ids (a voice
357    /// clip is found by them) and their tokens.
358    #[must_use]
359    pub fn scene<D: Clone>(&self, code: &LangCode, scene: &Scene<D>, variant: &str) -> Scene<D> {
360        let mut scene = scene.clone();
361        if self.packs_along(code).next().is_some() {
362            let id = scene.id.clone();
363            self.translate(code, &id, &mut scene.steps, variant);
364        }
365        scene
366    }
367
368    /// Puts the steps `steps` of scene `scene` in language `code`.
369    fn translate<D>(&self, code: &LangCode, scene: &str, steps: &mut [Step<D>], variant: &str) {
370        let line = |id: &LineId, text: &mut String| {
371            let shown = self.line(code, id.as_str(), text, variant).to_owned();
372            *text = shown;
373        };
374        for step in steps {
375            match step {
376                Step::Caption { text } => {
377                    let shown = self.line(code, &caption_key(scene, text), text, variant);
378                    *text = shown.to_owned();
379                }
380                Step::Say { text, line: id, .. } | Step::Narrate { text, line: id, .. } => {
381                    line(id, text);
382                }
383                Step::Choice { options } => {
384                    for ChoiceOption {
385                        text,
386                        line: id,
387                        steps,
388                        ..
389                    } in options
390                    {
391                        line(id, text);
392                        self.translate(code, scene, steps, variant);
393                    }
394                }
395                Step::If {
396                    then, otherwise, ..
397                } => {
398                    self.translate(code, scene, then, variant);
399                    self.translate(code, scene, otherwise, variant);
400                }
401                Step::Place { .. } | Step::Clear { .. } | Step::Music(_) | Step::Directive(_) => {}
402            }
403        }
404    }
405}
406
407#[cfg(test)]
408mod tests {
409    use serde::de::IntoDeserializer;
410    use serde::de::value::{BoolDeserializer, Error};
411
412    use super::*;
413
414    /// What a file and an entry are told they should be.
415    #[test]
416    fn a_wrong_value_is_told_what_was_expected() {
417        let wrong = || -> BoolDeserializer<Error> { true.into_deserializer() };
418        let file = RawLines { variants: &[] }.deserialize(wrong());
419        assert_eq!(
420            file.err().map(|e| e.to_string()).as_deref(),
421            Some("invalid type: boolean `true`, expected a list of dialogue entries")
422        );
423        let entry = RawLineSeed { variants: &[] }.deserialize(wrong());
424        assert_eq!(
425            entry.err().map(|e| e.to_string()).as_deref(),
426            Some("invalid type: boolean `true`, expected a dialogue entry: key, source and text")
427        );
428    }
429
430    #[test]
431    fn distance_counts_single_character_edits() {
432        assert_eq!(distance("", ""), 0);
433        assert_eq!(distance("abc", "abc"), 0);
434        assert_eq!(distance("", "abc"), 3);
435        assert_eq!(distance("abc", ""), 3);
436        assert_eq!(distance("kitten", "sitting"), 3);
437        assert_eq!(distance("flaw", "lawn"), 2);
438        assert_eq!(distance("abc", "cab"), 2);
439        assert_eq!(distance("abcdef", "abdef"), 1);
440        assert_eq!(distance("abdef", "abcdef"), 1);
441        // Characters, not bytes.
442        assert_eq!(distance("né", "ne"), 1);
443    }
444
445    #[test]
446    fn a_key_names_its_scene() {
447        assert_eq!(scene_of_key("ch1_gate_0123abcd"), Some("ch1_gate"));
448        assert_eq!(scene_of_key("ch1_gate_0123abcd_12"), Some("ch1_gate"));
449        assert_eq!(scene_of_key("caption.ch1_gate_0123abcd"), Some("ch1_gate"));
450        assert_eq!(scene_of_key("ch1_gate"), None);
451        assert_eq!(scene_of_key("caption.ch1_gate"), None);
452        assert!(is_caption("caption.a_0123abcd"));
453        assert!(!is_caption("a_0123abcd"));
454    }
455
456    #[test]
457    fn only_text_boxes_and_replies_have_a_limit() {
458        let limits = crate::dialogue::tests::LIMITS;
459        assert_eq!(limit(LineKind::Speech, &limits), Some(limits.max_text));
460        assert_eq!(limit(LineKind::Reply, &limits), Some(limits.max_option));
461        assert_eq!(limit(LineKind::Caption, &limits), None);
462    }
463
464    #[test]
465    fn the_message_names_every_variant() {
466        assert_eq!(variants_message(&[]), "an entry has `text`");
467        assert_eq!(variants_message(&["a"]), "an entry has `text`, or `text_a`");
468        assert_eq!(
469            variants_message(&["m", "f"]),
470            "an entry has `text`, or both `text_m` and `text_f`"
471        );
472        assert_eq!(
473            variants_message(&["a", "b", "c"]),
474            "an entry has `text`, or all of `text_a`, `text_b`, and `text_c`"
475        );
476    }
477
478    #[test]
479    fn a_line_has_its_one_text_or_its_variants() {
480        let one = LineText::One("x".to_owned());
481        assert_eq!(one.get("a"), Some("x"));
482        assert_eq!(one.all(), ["x"]);
483        let texts = [("a", "1"), ("b", "2")].map(|(v, t)| (v.to_owned(), t.to_owned()));
484        let both = LineText::Variants(texts.into());
485        assert_eq!(both.get("a"), Some("1"));
486        assert_eq!(both.get("b"), Some("2"));
487        assert_eq!(both.get("c"), None);
488        assert_eq!(both.all(), ["1", "2"]);
489    }
490}