Skip to main content

shuyi_assets/dialogue/
check.rs

1//! Checks on parsed scenes: who is on screen, known characters and
2//! expressions, text length, tokens and names written out, reply choices,
3//! `@if` blocks, music cues, line ids that clash, and scene ids unique
4//! across files. What the game knows (its characters, portraits, tokens and
5//! cues) comes from its [`SceneRules`].
6use crate::prelude::*;
7
8use alloc::collections::BTreeMap;
9
10use shuyi_core::text::{Part, split_tokens};
11
12use super::line_id::line_hash;
13use super::parse::{Lines, ParsedScene, PartLines};
14use super::{
15    CharacterId, ChoiceOption, DialogueLimits, Directive, MusicLine, NARRATION_SPEAKER,
16    REPLY_SPEAKER, Side, Step,
17};
18use crate::error::ContentError;
19use crate::font::CellWidths;
20use crate::lang::text_width;
21use crate::names::{Literals, Names, is_name_id, name_token};
22use crate::text::word_wrap;
23
24/// The `{tokens}` a game fills in itself, besides the names table's
25/// (`{n:<id>}`, [`Names`]): a player-named hero's name, say.
26pub trait Tokens {
27    /// The most cells `{token}` can become, or `None` if it isn't one of
28    /// the game's tokens.
29    fn longest(&self, token: &str) -> Option<usize>;
30
31    /// What a `{token}` that is neither the game's nor a name token is
32    /// told.
33    fn unknown(&self, token: &str) -> String {
34        format!("unknown token \"{{{token}}}\"; use a name, {{n:<id>}}")
35    }
36
37    /// What the name token `{token}` (naming `id`) is told before the
38    /// table is asked: a name the game fills in some other way. `None`
39    /// for most ids.
40    fn reserved_name(&self, _id: &str, _token: &str) -> Option<String> {
41        None
42    }
43
44    /// What the name token `{token}` is told when `id` isn't in the names
45    /// table.
46    fn unknown_name(&self, id: &str, token: &str) -> String {
47        format!("unknown name id \"{id}\" in {{{token}}}")
48    }
49
50    /// What a display name written out in a line (`form`, of name `id`)
51    /// is told instead of the usual advice, if the game fills that name in
52    /// some other way.
53    fn written_name(&self, _id: &str, _form: &str) -> Option<String> {
54        None
55    }
56}
57
58/// The tokens of a game that fills in nothing but names. Play its scenes
59/// with `Named` (or `Plain` if its text has no name tokens).
60#[derive(Debug, Clone, Copy, Default, PartialEq, Eq)]
61pub struct NoTokens;
62
63impl Tokens for NoTokens {
64    fn longest(&self, _token: &str) -> Option<usize> {
65        None
66    }
67}
68
69/// What [`check_scene`] checks a scene against: the game's limits, tokens
70/// and names, and whatever it knows of its characters, portraits and
71/// music. A check the game has nothing for passes.
72pub trait SceneRules<D> {
73    /// How much text the box and the menu hold.
74    fn limits(&self) -> &DialogueLimits;
75
76    /// How many cells each character takes, which the limits count: the
77    /// font atlas's ([`CellWidths::of`]). One cell for every character,
78    /// the default, when the game doesn't say.
79    fn widths(&self) -> &CellWidths {
80        &CellWidths::NARROW
81    }
82
83    /// The game's own `{tokens}`.
84    fn tokens(&self) -> &dyn Tokens;
85
86    /// The names table, when it loaded: name tokens and names written out
87    /// are checked against it, and every name token counts as its longest
88    /// name.
89    fn names(&self) -> Option<&Names> {
90        None
91    }
92
93    /// Whether `character` may be placed and speak.
94    fn can_speak(&self, _character: &CharacterId) -> bool {
95        true
96    }
97
98    /// What is wrong with `character` showing `expression`, if anything.
99    fn expression_problem(&self, _character: &CharacterId, _expression: &str) -> Option<String> {
100        None
101    }
102
103    /// What is wrong with `speaker` saying a line of `cells` cells (at
104    /// most, tokens filled in), if anything, beyond the box's limit.
105    fn speech_problem(&self, _speaker: &CharacterId, _cells: usize) -> Option<String> {
106        None
107    }
108
109    /// What is wrong with `@music <cue>`, if anything.
110    fn music_problem(&self, _cue: &str) -> Option<String> {
111        None
112    }
113
114    /// The game's own checks on `parsed`, which
115    /// [`scripts_from_sources`](super::scripts_from_sources) runs after
116    /// [`check_scene`].
117    fn scene_problems(&self, _parsed: &ParsedScene<D>) -> Vec<ContentError> {
118        Vec::new()
119    }
120}
121
122/// Who stands on the left and right.
123type Screen<'a> = [Option<&'a CharacterId>; 2];
124
125/// Checks one scene against `rules`, replaying it to know who is on
126/// screen at each line.
127pub fn check_scene<D: Directive>(
128    parsed: &ParsedScene<D>,
129    rules: &impl SceneRules<D>,
130) -> Vec<ContentError> {
131    let mut c = Checker {
132        file: &parsed.file,
133        rules,
134        limits: *rules.limits(),
135        widths: rules.widths(),
136        tokens: rules.tokens(),
137        names: rules.names(),
138        longest_name: rules.names().map(|names| names.longest(rules.widths())),
139        literals: rules.names().map(Literals::of),
140        hashes: BTreeMap::new(),
141        errors: Vec::new(),
142    };
143    let mut state = State::default();
144    c.steps(&parsed.scene.steps, &parsed.lines, &mut state);
145    if !parsed.scene.steps.iter().any(Step::has_text) {
146        c.err(
147            parsed.line,
148            format!("scene \"{}\" has no speech or narration", parsed.scene.id),
149        );
150    }
151    c.errors
152}
153
154/// The most speech and narration lines `steps` can play: an `@if` block
155/// counts as its longer part.
156fn texts<D>(steps: &[Step<D>]) -> usize {
157    let count = |step: &Step<D>| match step {
158        Step::If {
159            then, otherwise, ..
160        } => texts(then).max(texts(otherwise)),
161        _ => usize::from(step.text().is_some()),
162    };
163    steps.iter().map(count).sum()
164}
165
166/// What the replay knows at a point of the scene: who is on screen, the
167/// caption, and the game's directives in force, by kind
168/// ([`Directive::kind`]).
169#[derive(Debug, Clone, PartialEq, Eq)]
170struct State<'a, D> {
171    screen: Screen<'a>,
172    caption: Option<&'a str>,
173    in_force: BTreeMap<&'static str, &'a D>,
174}
175
176impl<D> Default for State<'_, D> {
177    fn default() -> Self {
178        Self {
179            screen: [None; 2],
180            caption: None,
181            in_force: BTreeMap::new(),
182        }
183    }
184}
185
186impl<D: PartialEq> State<'_, D> {
187    /// The first kind (by name) of directive that `self` and `other` leave
188    /// different ones of in force.
189    fn other_directive(&self, other: &Self) -> Option<&'static str> {
190        let mut kinds: Vec<&'static str> = self
191            .in_force
192            .keys()
193            .chain(other.in_force.keys())
194            .copied()
195            .collect();
196        kinds.sort_unstable();
197        kinds
198            .into_iter()
199            .find(|kind| self.in_force.get(kind) != other.in_force.get(kind))
200    }
201}
202
203struct Checker<'a, R> {
204    file: &'a str,
205    rules: &'a R,
206    limits: DialogueLimits,
207    /// How many cells each character takes.
208    widths: &'a CellWidths,
209    tokens: &'a dyn Tokens,
210    names: Option<&'a Names>,
211    /// The width of the longest name of `names`, found once for the scene
212    /// rather than for each line.
213    longest_name: Option<usize>,
214    /// The literal forms of `names`, found once for the scene too.
215    literals: Option<Literals<'a>>,
216    /// The first line of the scene with each line-id hash: its source
217    /// line, speaker and text.
218    hashes: BTreeMap<String, (u32, String, String)>,
219    errors: Vec<ContentError>,
220}
221
222/// Cells `text` can take once every token is filled in, at most
223/// ([`text_width`], a wide character of `widths` two cells): the game's
224/// `tokens` at their longest ([`Tokens::longest`]) and every name token
225/// as `longest_name` (the longest name in the table, so a rename can't
226/// push the line over its limit). Unknown tokens, and name tokens without
227/// a table, count as written.
228pub fn longest_width(
229    text: &str,
230    tokens: &dyn Tokens,
231    longest_name: Option<usize>,
232    widths: &CellWidths,
233) -> usize {
234    split_tokens(text)
235        .map(|part| match part {
236            Part::Text(t) | Part::Unclosed(t) => text_width(t, widths),
237            Part::Token(t) => token_cells(t, tokens, longest_name, widths),
238        })
239        .sum()
240}
241
242/// Cells `{token}` takes at its longest, as [`longest_width`] counts it.
243fn token_cells(
244    token: &str,
245    tokens: &dyn Tokens,
246    longest_name: Option<usize>,
247    widths: &CellWidths,
248) -> usize {
249    match (name_token(token), longest_name) {
250        (Some(_), Some(longest)) => longest,
251        _ => tokens
252            .longest(token)
253            .unwrap_or(text_width(token, widths) + 2),
254    }
255}
256
257/// Lines `text` takes in a box `width` cells wide ([`word_wrap`], a wide
258/// character of `widths` two cells) once every token is filled in as its
259/// longest: each token is written out as that many `W`s, with the tokens
260/// and names counted as in [`longest_width`]. A token is so a Latin run
261/// that is never broken, the worst case (a typed name of that length).
262/// A line that fits the box here fits it with any player's name.
263pub fn lines_at_longest(
264    text: &str,
265    tokens: &dyn Tokens,
266    longest_name: Option<usize>,
267    width: usize,
268    widths: &CellWidths,
269) -> usize {
270    let mut filled = String::new();
271    for part in split_tokens(text) {
272        match part {
273            Part::Text(t) | Part::Unclosed(t) => filled.push_str(t),
274            Part::Token(t) => {
275                filled.push_str(&"W".repeat(token_cells(t, tokens, longest_name, widths)));
276            }
277        }
278    }
279    word_wrap(&filled, width, widths).len()
280}
281
282/// What is wrong with name token `{token}`, if anything: its id must be in
283/// the names table, and not one the game fills in itself.
284fn name_token_problem(token: &str, tokens: &dyn Tokens, names: Option<&Names>) -> Option<String> {
285    let id = name_token(token).map_or("", |t| t.id);
286    if let Some(problem) = tokens.reserved_name(id, token) {
287        Some(problem)
288    } else if !is_name_id(id) || names.is_some_and(|n| n.get(id).is_none()) {
289        Some(tokens.unknown_name(id, token))
290    } else {
291        None
292    }
293}
294
295/// What is wrong with the `{...}` of `text`, in order: each must be one of
296/// the game's `tokens` or a name token with an id in `names` (any valid id
297/// without a table), and every `{` must close. The rule for a script and
298/// for every language pack's dialogue text.
299pub fn token_problems(text: &str, tokens: &dyn Tokens, names: Option<&Names>) -> Vec<String> {
300    split_tokens(text)
301        .filter_map(|part| match part {
302            Part::Token(t) if name_token(t).is_some() => name_token_problem(t, tokens, names),
303            Part::Token(t) if tokens.longest(t).is_none() => Some(tokens.unknown(t)),
304            Part::Unclosed(_) => Some("\"{\" has no closing \"}\"".to_owned()),
305            Part::Text(_) | Part::Token(_) => None,
306        })
307        .collect()
308}
309
310fn slot(side: Side) -> usize {
311    match side {
312        Side::Left => 0,
313        Side::Right => 1,
314    }
315}
316
317impl<R> Checker<'_, R> {
318    fn err(&mut self, line: u32, message: String) {
319        self.errors
320            .push(ContentError::new(self.file, message).at(line, None));
321    }
322
323    /// Reports a line (`speaker` saying `text`) whose id hash an earlier,
324    /// different line of the scene has: their ids would depend on their
325    /// order. A repeat of the same line is fine (it gets `_2`).
326    fn line_id(&mut self, line: u32, speaker: &str, text: &str) {
327        let hash = line_hash(speaker, text);
328        match self.hashes.get(&hash) {
329            None => {
330                self.hashes
331                    .insert(hash, (line, speaker.to_owned(), text.to_owned()));
332            }
333            Some((_, s, t)) if s == speaker && t == text => {}
334            Some((first, _, t)) => {
335                let message = format!(
336                    "this line and line {first} (\"{t}\") get the same line id hash \
337                     {hash}; reword one of them"
338                );
339                self.err(line, message);
340            }
341        }
342    }
343
344    /// [`lines_at_longest`] of `text`, with the names of the table.
345    fn pages(&self, text: &str) -> usize {
346        lines_at_longest(
347            text,
348            self.tokens,
349            self.longest_name,
350            self.limits.box_width,
351            self.widths,
352        )
353    }
354
355    /// [`longest_width`] of `text`, with the names of the table.
356    fn len(&self, text: &str) -> usize {
357        longest_width(text, self.tokens, self.longest_name, self.widths)
358    }
359
360    /// Reports a display name from the names table written out in `text`
361    /// instead of its token, which a rename would leave behind.
362    fn literal_names(&mut self, line: u32, text: &str) {
363        let (Some(names), Some(literals)) = (self.names, &self.literals) else {
364            return;
365        };
366        let Some((id, form)) = literals.find_in(text) else {
367            return;
368        };
369        let message = self.tokens.written_name(id, form).unwrap_or_else(|| {
370            let name = names.get(id).unwrap_or(form);
371            format!(
372                "\"{form}\" is written out; write {{n:{id}}} (\"{name}\") so a rename reaches this line"
373            )
374        });
375        self.err(line, message);
376    }
377
378    /// Reports every `{...}` in `text` that isn't one of the game's tokens
379    /// or a name token with a known id ([`token_problems`]).
380    fn tokens(&mut self, line: u32, text: &str) {
381        for message in token_problems(text, self.tokens, self.names) {
382            self.err(line, message);
383        }
384    }
385}
386
387impl<R> Checker<'_, R> {
388    /// Checks `steps`, written at `lines`, applying them to `state`.
389    fn steps<'s, D: Directive>(
390        &mut self,
391        steps: &'s [Step<D>],
392        lines: &Lines,
393        state: &mut State<'s, D>,
394    ) where
395        R: SceneRules<D>,
396    {
397        let mut blocks = lines.blocks.iter();
398        for (step, &line) in steps.iter().zip(&lines.steps) {
399            match step {
400                Step::Choice { options } => {
401                    if let Some(parts) = blocks.next() {
402                        self.choice(line, options, parts, state);
403                    }
404                }
405                Step::If {
406                    character,
407                    then,
408                    otherwise,
409                } => {
410                    if let Some(parts) = blocks.next() {
411                        self.branch(line, character, [then, otherwise], parts, state);
412                    }
413                }
414                _ => self.step(step, line, state),
415            }
416        }
417    }
418
419    /// Checks an `@if` block on `line` and applies it to `state`: its
420    /// character can speak, and its two parts leave the screen, the
421    /// caption and the directives in force the same.
422    fn branch<'s, D: Directive>(
423        &mut self,
424        line: u32,
425        character: &CharacterId,
426        branches: [&'s [Step<D>]; 2],
427        parts: &[PartLines],
428        state: &mut State<'s, D>,
429    ) where
430        R: SceneRules<D>,
431    {
432        if !self.rules.can_speak(character) {
433            self.err(line, format!("unknown character \"{}\"", character.0));
434        }
435        let mut after = [state.clone(), state.clone()];
436        for ((steps, part), after) in branches.into_iter().zip(parts).zip(&mut after) {
437            self.steps(steps, &part.lines, after);
438        }
439        let [with, without] = after;
440        // Reported where the part without them starts, if it is written.
441        let at = parts.get(1).map_or(line, |p| p.line);
442        if with.screen != without.screen {
443            self.err(
444                at,
445                format!(
446                    "with \"{}\" here {}; without, {}; an @if block must leave the same \
447                     characters on screen either way",
448                    character.0,
449                    describe(with.screen),
450                    describe(without.screen)
451                ),
452            );
453        } else if with.caption != without.caption {
454            self.err(
455                at,
456                format!(
457                    "this @if block leaves a different caption with \"{}\" here and without; \
458                     it must leave the same caption either way",
459                    character.0
460                ),
461            );
462        } else if let Some(kind) = with.other_directive(&without) {
463            self.err(
464                at,
465                format!(
466                    "this @if block leaves a different @{kind} with \"{}\" here and without; \
467                     it must leave the same {kind} either way",
468                    character.0
469                ),
470            );
471        }
472        *state = with;
473    }
474
475    /// Checks one step (not a block) on `line` and applies it to `state`.
476    fn step<'s, D: Directive>(&mut self, step: &'s Step<D>, line: u32, state: &mut State<'s, D>)
477    where
478        R: SceneRules<D>,
479    {
480        if let Some(text) = step.text() {
481            self.tokens(line, text);
482            self.literal_names(line, text);
483            let len = self.len(text);
484            let limit = self.limits.max_text;
485            if len > limit {
486                self.err(
487                    line,
488                    format!("text is {len} characters; the limit is {limit}"),
489                );
490            } else if self.pages(text) > self.limits.box_lines {
491                let lines = self.pages(text);
492                let box_lines = self.limits.box_lines;
493                self.err(
494                    line,
495                    format!(
496                        "text takes {lines} lines of {} characters; the box shows {box_lines}",
497                        self.limits.box_width
498                    ),
499                );
500            }
501        }
502        match step {
503            Step::Caption { text } => {
504                self.tokens(line, text);
505                self.literal_names(line, text);
506                state.caption = Some(text);
507            }
508            Step::Place {
509                side,
510                character,
511                expression,
512            } => {
513                if !self.rules.can_speak(character) {
514                    self.err(line, format!("unknown character \"{}\"", character.0));
515                }
516                if let Some(message) = self.rules.expression_problem(character, expression) {
517                    self.err(line, message);
518                }
519                if state.screen[slot(side.other())] == Some(character) {
520                    self.err(
521                        line,
522                        format!(
523                            "\"{}\" is already on the {}; a character can't be on both sides",
524                            character.0,
525                            side.other().name()
526                        ),
527                    );
528                }
529                state.screen[slot(*side)] = Some(character);
530            }
531            Step::Clear { side } => state.screen[slot(*side)] = None,
532            Step::Say {
533                speaker,
534                expression,
535                text,
536                ..
537            } => {
538                self.line_id(line, &speaker.0, text);
539                if !self.rules.can_speak(speaker) {
540                    self.err(line, format!("unknown character \"{}\"", speaker.0));
541                } else if !state.screen.contains(&Some(speaker)) {
542                    self.err(
543                        line,
544                        format!(
545                            "\"{}\" speaks but is not on screen; place them with @left or @right first",
546                            speaker.0
547                        ),
548                    );
549                }
550                let problem = expression
551                    .as_deref()
552                    .and_then(|e| self.rules.expression_problem(speaker, e));
553                if let Some(message) = problem {
554                    self.err(line, message);
555                }
556                let len = self.len(text);
557                if let Some(message) = self.rules.speech_problem(speaker, len) {
558                    self.err(line, message);
559                }
560            }
561            Step::Music(MusicLine::Cue(cue)) => {
562                if let Some(message) = self.rules.music_problem(cue) {
563                    self.err(line, message);
564                }
565            }
566            Step::Narrate { text, .. } => self.line_id(line, NARRATION_SPEAKER, text),
567            Step::Directive(directive) => {
568                if let Some(kind) = directive.kind() {
569                    state.in_force.insert(kind, directive);
570                }
571            }
572            Step::Choice { .. } | Step::If { .. } | Step::Music(MusicLine::Stop) => {}
573        }
574    }
575
576    /// Checks a `@choice` block on `line` and applies it to `state`: every
577    /// option must leave the screen as the first one does.
578    fn choice<'s, D: Directive>(
579        &mut self,
580        line: u32,
581        options: &'s [ChoiceOption<D>],
582        parts: &[PartLines],
583        state: &mut State<'s, D>,
584    ) where
585        R: SceneRules<D>,
586    {
587        let DialogueLimits {
588            min_options: min,
589            max_options: max,
590            max_option,
591            max_reaction_texts,
592            ..
593        } = self.limits;
594        let n = options.len();
595        if !(min..=max).contains(&n) {
596            let needs = match max - min {
597                0 => format!("{min}"),
598                1 => format!("{min} or {max}"),
599                _ => format!("{min} to {max}"),
600            };
601            self.err(line, format!("@choice has {n} options; it needs {needs}"));
602        }
603        let mut first: Option<State<'s, D>> = None;
604        for (option, part) in options.iter().zip(parts) {
605            let at = part.line;
606            self.line_id(at, REPLY_SPEAKER, &option.text);
607            self.tokens(at, &option.text);
608            self.literal_names(at, &option.text);
609            let len = self.len(&option.text);
610            if len > max_option {
611                self.err(
612                    at,
613                    format!(
614                        "option text is {len} characters; the limit is {max_option}, so it fits the menu"
615                    ),
616                );
617            }
618            let texts = texts(&option.steps);
619            if texts > max_reaction_texts {
620                self.err(
621                    at,
622                    format!(
623                        "reaction has {texts} speech or narration lines; the limit is \
624                         {max_reaction_texts}, so the scene rejoins quickly"
625                    ),
626                );
627            }
628            let mut after = state.clone();
629            self.steps(&option.steps, &part.lines, &mut after);
630            let Some(f) = &first else {
631                first = Some(after);
632                continue;
633            };
634            if f.screen != after.screen {
635                self.err(
636                    at,
637                    format!(
638                        "after this reaction {}; after the first option's {}; every option \
639                         must leave the same characters on screen",
640                        describe(after.screen),
641                        describe(f.screen)
642                    ),
643                );
644            } else if f.caption != after.caption {
645                self.err(
646                    at,
647                    "this reaction leaves a different caption from the first option's; every \
648                     option must leave the same caption"
649                        .to_owned(),
650                );
651            } else if let Some(kind) = f.other_directive(&after) {
652                self.err(
653                    at,
654                    format!(
655                        "this reaction leaves a different @{kind} from the first option's; \
656                         every option must leave the same {kind}"
657                    ),
658                );
659            }
660        }
661        if let Some(f) = first {
662            *state = f;
663        }
664    }
665}
666
667/// Who is on `screen`, in words: `scout is on the left and nobody on the
668/// right`.
669fn describe(screen: Screen) -> String {
670    fn name(c: Option<&CharacterId>) -> &str {
671        c.map_or("nobody", |c| c.0.as_str())
672    }
673    format!(
674        "{} is on the left and {} on the right",
675        name(screen[0]),
676        name(screen[1])
677    )
678}
679
680/// One error for each scene whose id an earlier scene (in any file)
681/// already has.
682pub fn check_duplicates<D>(scenes: &[ParsedScene<D>]) -> Vec<ContentError> {
683    let mut first: BTreeMap<&str, &ParsedScene<D>> = BTreeMap::new();
684    let mut errors = Vec::new();
685    for s in scenes {
686        if let Some(f) = first.get(s.scene.id.as_str()) {
687            errors.push(
688                ContentError::new(
689                    &s.file,
690                    format!(
691                        "duplicate scene id \"{}\"; first used at {}:{}",
692                        s.scene.id, f.file, f.line
693                    ),
694                )
695                .at(s.line, None),
696            );
697        } else {
698            first.insert(&s.scene.id, s);
699        }
700    }
701    errors
702}