shuyi_assets/dialogue/line_id.rs
1//! Line ids: every speech line, narration line and reply has
2//! an id `<scene id>_<8 hex>`, a hash of who says it and its English text
3//! as written. Translations and voice clips attach to it, so rewording a
4//! line gives it a new id and moving it does not.
5use crate::prelude::*;
6use serde::{Deserialize, Serialize};
7
8use alloc::collections::BTreeMap;
9use core::fmt;
10
11use super::{Scene, Step};
12
13/// What stands for the speaker in a narration line's hash and listing.
14pub const NARRATION_SPEAKER: &str = ">";
15/// What stands for the speaker in a reply's hash and listing.
16pub const REPLY_SPEAKER: &str = "*";
17
18/// The id of one dialogue line: `<scene id>_<8 hex>`, with `_2`, `_3`… on
19/// repeats of the same speaker and text in one scene. Unique across every
20/// dialogue file. Empty on a step built by hand until
21/// [`Scene::assign_line_ids`] runs (the parser runs it).
22#[derive(Debug, Clone, PartialEq, Eq, PartialOrd, Ord, Hash, Default, Serialize, Deserialize)]
23pub struct LineId(String);
24
25impl LineId {
26 /// The id written `id`, as a file that refers to lines names one (a
27 /// voice manifest). Nothing checks that a line has it.
28 pub fn new(id: impl Into<String>) -> Self {
29 Self(id.into())
30 }
31
32 /// The id as text.
33 pub fn as_str(&self) -> &str {
34 &self.0
35 }
36
37 /// The id of the scene the line is in: the id without its hash and
38 /// repeat number (`ch1_gate_1a2b3c4d_2` → `ch1_gate`). An id that
39 /// doesn't end like a line id is returned whole.
40 pub fn scene_id(&self) -> &str {
41 scene_of(&self.0).unwrap_or(&self.0)
42 }
43}
44
45/// The id of the scene the line with id `id` is in, or `None` if `id`
46/// doesn't end like a line id (`_<8 hex>`, then `_<n>` on a repeat).
47pub fn scene_of(id: &str) -> Option<&str> {
48 /// `id` without a `_<8 hex>` ending, if it has one.
49 fn strip_hash(id: &str) -> Option<&str> {
50 id.rsplit_once('_')
51 .filter(|(_, tail)| tail.len() == 8 && tail.bytes().all(|b| b.is_ascii_hexdigit()))
52 .map(|(scene, _)| scene)
53 }
54 // A repeat: `<scene>_<hash>_<n>`.
55 let repeat = || {
56 id.rsplit_once('_')
57 .filter(|(_, n)| !n.is_empty() && n.bytes().all(|b| b.is_ascii_digit()))
58 .and_then(|(rest, _)| strip_hash(rest))
59 };
60 strip_hash(id).or_else(repeat)
61}
62
63impl fmt::Display for LineId {
64 fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
65 f.write_str(&self.0)
66 }
67}
68
69/// One line that shows text, as [`Scene::lines`] lists it.
70#[derive(Debug, Clone, Copy, PartialEq, Eq)]
71pub struct Line<'a> {
72 /// Its id.
73 pub id: &'a LineId,
74 /// Who says it: a character id, [`NARRATION_SPEAKER`] or
75 /// [`REPLY_SPEAKER`].
76 pub speaker: &'a str,
77 /// Its text as written (tokens unexpanded).
78 pub text: &'a str,
79 /// How it is said, for a voice or an actor; a reply has none.
80 pub direction: Option<&'a str>,
81}
82
83/// FNV-1a, 64 bits. Written here because line ids are stored in translation
84/// and voice files: the hash must never change (`std`'s hasher may).
85pub(super) fn fnv1a64(bytes: &[u8]) -> u64 {
86 const OFFSET_BASIS: u64 = 0xcbf2_9ce4_8422_2325;
87 const PRIME: u64 = 0x0000_0100_0000_01b3;
88 bytes.iter().fold(OFFSET_BASIS, |hash, &b| {
89 (hash ^ u64::from(b)).wrapping_mul(PRIME)
90 })
91}
92
93/// The hash part of a line id: the low 32 bits of FNV-1a 64 over
94/// `speaker`, a newline and `text`, as 8 lowercase hex digits.
95pub fn line_hash(speaker: &str, text: &str) -> String {
96 let hash = fnv1a64(format!("{speaker}\n{text}").as_bytes());
97 format!("{:08x}", hash & 0xffff_ffff)
98}
99
100/// What stands for the speaker in a caption's hash.
101const CAPTION_SPEAKER: &str = "@caption";
102
103/// The key a language pack translates the caption `text` of scene `scene`
104/// by: `caption.<scene id>_<8 hex>`, hashed like a line id.
105/// The same caption twice in a scene has one key.
106pub fn caption_key(scene: &str, text: &str) -> String {
107 format!("caption.{scene}_{}", line_hash(CAPTION_SPEAKER, text))
108}
109
110/// Calls `f(speaker, text, id)` for every line of `steps` that shows text,
111/// in script order: a reply, then its reaction, then the next reply; an
112/// `@if` block's lines, then its `@else`'s.
113fn visit_mut<D>(steps: &mut [Step<D>], f: &mut impl FnMut(&str, &str, &mut LineId)) {
114 for step in steps {
115 match step {
116 Step::Say {
117 speaker,
118 text,
119 line,
120 ..
121 } => f(&speaker.0, text, line),
122 Step::Narrate { text, line, .. } => f(NARRATION_SPEAKER, text, line),
123 Step::Choice { options } => {
124 for o in options {
125 f(REPLY_SPEAKER, &o.text, &mut o.line);
126 visit_mut(&mut o.steps, f);
127 }
128 }
129 Step::If {
130 then, otherwise, ..
131 } => {
132 visit_mut(then, f);
133 visit_mut(otherwise, f);
134 }
135 Step::Caption { .. }
136 | Step::Place { .. }
137 | Step::Clear { .. }
138 | Step::Music(_)
139 | Step::Directive(_) => {}
140 }
141 }
142}
143
144/// Adds the lines of `steps` to `out`, in the order of [`visit_mut`].
145fn collect<'a, D>(steps: &'a [Step<D>], out: &mut Vec<Line<'a>>) {
146 for step in steps {
147 match step {
148 Step::Say {
149 speaker,
150 text,
151 direction,
152 line,
153 ..
154 } => out.push(Line {
155 id: line,
156 speaker: &speaker.0,
157 text,
158 direction: direction.as_deref(),
159 }),
160 Step::Narrate {
161 text,
162 direction,
163 line,
164 } => out.push(Line {
165 id: line,
166 speaker: NARRATION_SPEAKER,
167 text,
168 direction: direction.as_deref(),
169 }),
170 Step::Choice { options } => {
171 for o in options {
172 out.push(Line {
173 id: &o.line,
174 speaker: REPLY_SPEAKER,
175 text: &o.text,
176 direction: None,
177 });
178 collect(&o.steps, out);
179 }
180 }
181 Step::If {
182 then, otherwise, ..
183 } => {
184 collect(then, out);
185 collect(otherwise, out);
186 }
187 Step::Caption { .. }
188 | Step::Place { .. }
189 | Step::Clear { .. }
190 | Step::Music(_)
191 | Step::Directive(_) => {}
192 }
193 }
194}
195
196impl<D> Scene<D> {
197 /// Every line that shows text (speech, narration, replies and the lines
198 /// of their reactions), in script order, whoever is there: both parts
199 /// of every `@if` block.
200 pub fn lines(&self) -> Vec<Line<'_>> {
201 let mut out = Vec::new();
202 collect(&self.steps, &mut out);
203 out
204 }
205
206 /// Gives every line of the scene its id, from the scene id, the
207 /// speakers and the texts as they are now. The parser calls this on
208 /// each scene it reads; call it after building or editing a scene by
209 /// hand.
210 pub fn assign_line_ids(&mut self) {
211 let id = &self.id;
212 let mut seen: BTreeMap<String, u32> = BTreeMap::new();
213 visit_mut(&mut self.steps, &mut |speaker, text, line| {
214 let hash = line_hash(speaker, text);
215 let n = seen.entry(hash.clone()).or_insert(0);
216 *n += 1;
217 *line = LineId(if *n == 1 {
218 format!("{id}_{hash}")
219 } else {
220 format!("{id}_{hash}_{n}")
221 });
222 });
223 }
224}