1pub mod markup;
10pub mod pattern;
11pub mod refs;
12pub mod renderer;
13pub mod state;
14pub mod theme;
15
16use std::collections::BTreeMap;
17
18use serde::{Deserialize, Serialize};
19
20pub use renderer::{BOOK_PNG, BOOK_SHEET, BookRenderer, CRAFTING_PNG, CRAFTING_SHEET, LANG};
21pub use state::BookViewState;
22pub use theme::BookTheme;
23
24pub fn multiblock_blocks(pattern: &[Vec<String>], mapping: &BTreeMap<String, String>) -> Vec<((i32, i32, i32), String)> {
27 let mut out = Vec::new();
28 let layers = pattern.len() as i32;
29 for (li, layer) in pattern.iter().enumerate() {
30 for (z, row) in layer.iter().enumerate() {
31 for (x, ch) in row.chars().enumerate() {
32 if ch == ' ' {
33 continue;
34 }
35 if let Some(state) = mapping.get(&ch.to_string()) {
36 out.push(((x as i32, layers - 1 - li as i32, z as i32), state.clone()));
37 }
38 }
39 }
40 }
41 out
42}
43
44#[derive(Debug, Clone, Serialize, Deserialize)]
48#[serde(tag = "type", rename_all = "snake_case")]
49pub enum BookPage {
50 Text {
54 text: String,
55 #[serde(default, skip_serializing_if = "Option::is_none")]
56 title: Option<String>,
57 #[serde(default, skip_serializing_if = "Option::is_none")]
58 font: Option<String>,
59 #[serde(default, skip_serializing_if = "Option::is_none")]
60 font_size: Option<f32>,
61 },
62 Spotlight {
64 item: String,
65 #[serde(default, skip_serializing_if = "Option::is_none")]
66 title: Option<String>,
67 #[serde(default, skip_serializing_if = "Option::is_none")]
68 text: Option<String>,
69 },
70 Recipe {
72 recipe: String,
73 #[serde(default, skip_serializing_if = "Option::is_none")]
74 text: Option<String>,
75 },
76 Image {
78 texture: String,
79 #[serde(default, skip_serializing_if = "Option::is_none")]
80 title: Option<String>,
81 #[serde(default, skip_serializing_if = "Option::is_none")]
82 text: Option<String>,
83 },
84 Entity {
86 entity: String,
87 #[serde(default, skip_serializing_if = "Option::is_none")]
88 name: Option<String>,
89 #[serde(default, skip_serializing_if = "Option::is_none")]
90 text: Option<String>,
91 },
92 Multiblock {
96 #[serde(default, skip_serializing_if = "Option::is_none")]
97 name: Option<String>,
98 pattern: Vec<Vec<String>>,
99 mapping: BTreeMap<String, String>,
100 #[serde(default, skip_serializing_if = "Option::is_none")]
101 text: Option<String>,
102 },
103 Relations {
105 entries: Vec<String>,
106 #[serde(default, skip_serializing_if = "Option::is_none")]
107 text: Option<String>,
108 },
109 Pattern {
112 op_id: String,
113 #[serde(default, skip_serializing_if = "Option::is_none")]
115 name: Option<String>,
116 #[serde(default)]
117 input: String,
118 #[serde(default)]
119 output: String,
120 #[serde(default)]
121 text: String,
122 #[serde(default, skip_serializing_if = "Vec::is_empty")]
124 patterns: Vec<pattern::HexPattern>,
125 },
126 Empty,
128}
129
130#[derive(Debug, Clone, Default, Serialize, Deserialize)]
134#[serde(default)]
135pub struct BookCategory {
136 pub id: String,
137 pub name: String,
138 #[serde(skip_serializing_if = "Option::is_none")]
139 pub description: Option<String>,
140 #[serde(skip_serializing_if = "Option::is_none")]
142 pub icon: Option<String>,
143 pub sortnum: i32,
145}
146
147#[derive(Debug, Clone, Default, Serialize, Deserialize)]
149#[serde(default)]
150pub struct BookEntry {
151 pub id: String,
152 pub name: String,
153 pub category: String,
154 pub pages: Vec<BookPage>,
155 #[serde(skip_serializing_if = "Option::is_none")]
157 pub icon: Option<String>,
158 pub priority: i32,
160 pub secret: bool,
162 pub read_by_default: bool,
164 #[serde(skip_serializing_if = "Option::is_none")]
166 pub advancement: Option<String>,
167}
168
169#[derive(Debug, Clone, Serialize, Deserialize)]
173#[serde(default)]
174pub struct Book {
175 pub id: String,
177 pub name: String,
178 #[serde(skip_serializing_if = "Option::is_none")]
180 pub subtitle: Option<String>,
181 pub version: u32,
184 pub landing_text: String,
185 pub nameplate_color: String,
187 pub accent: String,
189 pub book_texture: String,
191 pub categories: Vec<BookCategory>,
192 pub entries: Vec<BookEntry>,
193 pub i18n: bool,
195}
196
197impl Default for Book {
198 fn default() -> Self {
199 Book {
200 id: String::new(),
201 name: String::new(),
202 subtitle: None,
203 version: 0,
204 landing_text: String::new(),
205 nameplate_color: "FFDD98".into(),
206 accent: "#8d6acc".into(),
207 book_texture: "yog:gui/book".into(),
208 categories: Vec::new(),
209 entries: Vec::new(),
210 i18n: false,
211 }
212 }
213}
214
215impl Book {
216 pub fn localized(&self, tr: impl Fn(&str) -> String) -> Book {
221 let opt = |o: &mut Option<String>| {
222 if let Some(t) = o {
223 *t = tr(t);
224 }
225 };
226 let mut b = self.clone();
227 b.name = tr(&b.name);
228 b.landing_text = tr(&b.landing_text);
229 opt(&mut b.subtitle);
230 for c in &mut b.categories {
231 c.name = tr(&c.name);
232 opt(&mut c.description);
233 }
234 for e in &mut b.entries {
235 e.name = tr(&e.name);
236 for p in &mut e.pages {
237 match p {
238 BookPage::Text { text, title, .. } => {
239 *text = tr(text);
240 opt(title);
241 }
242 BookPage::Spotlight { title, text, .. } | BookPage::Image { title, text, .. } => {
243 opt(title);
244 opt(text);
245 }
246 BookPage::Entity { name, text, .. } | BookPage::Multiblock { name, text, .. } => {
247 opt(name);
248 opt(text);
249 }
250 BookPage::Recipe { text, .. } | BookPage::Relations { text, .. } => opt(text),
251 BookPage::Pattern { text, name, .. } => {
252 *text = tr(text);
253 opt(name);
254 }
255 BookPage::Empty => {}
256 }
257 }
258 }
259 b
260 }
261
262 pub fn to_json(&self) -> String {
264 serde_json::to_string(self).unwrap_or_default()
265 }
266}
267
268#[cfg(test)]
269mod tests {
270 use super::*;
271
272 fn text(t: &str) -> BookPage {
273 BookPage::Text { text: t.into(), title: None, font: None, font_size: None }
274 }
275
276 #[test]
277 fn books_round_trip_through_their_json() {
278 let book = Book {
279 id: "yog:test".into(),
280 name: "Test Book".into(),
281 version: 1,
282 categories: vec![BookCategory { id: "yog:c1".into(), name: "Cat".into(), icon: Some("yog:ruby".into()), ..Default::default() }],
283 entries: vec![BookEntry {
284 id: "yog:e1".into(),
285 name: "Entry".into(),
286 category: "yog:c1".into(),
287 pages: vec![
288 text("plain"),
289 BookPage::Spotlight { item: "yog:ruby".into(), title: None, text: None },
290 BookPage::Recipe { recipe: "yog:r1".into(), text: None },
291 BookPage::Empty,
292 ],
293 ..Default::default()
294 }],
295 ..Default::default()
296 };
297 let parsed: Book = serde_json::from_str(&book.to_json()).expect("its JSON parses");
298 assert_eq!(parsed.version, 1);
299 assert_eq!(parsed.entries[0].pages.len(), 4);
300 assert!(matches!(&parsed.entries[0].pages[1], BookPage::Spotlight { item, .. } if item == "yog:ruby"));
301 assert!(matches!(&parsed.entries[0].pages[2], BookPage::Recipe { recipe, .. } if recipe == "yog:r1"));
302 }
303
304 #[test]
305 fn localized_translates_every_text() {
306 let book = Book {
307 id: "m:b".into(),
308 name: "m.book.name".into(),
309 landing_text: "m.book.landing".into(),
310 categories: vec![BookCategory { id: "m:basics".into(), name: "m.cat".into(), ..Default::default() }],
311 entries: vec![BookEntry {
312 id: "m:e".into(),
313 name: "m.entry".into(),
314 category: "m:basics".into(),
315 pages: vec![
316 BookPage::Text { text: "m.text".into(), title: Some("m.title".into()), font: None, font_size: None },
317 text("raw text"),
318 ],
319 ..Default::default()
320 }],
321 ..Default::default()
322 };
323 let out = book.localized(|k| if k.starts_with("m.") { k.to_uppercase() } else { k.to_string() });
324 assert_eq!(out.name, "M.BOOK.NAME");
325 assert_eq!(out.landing_text, "M.BOOK.LANDING");
326 assert_eq!(out.categories[0].name, "M.CAT");
327 assert_eq!(out.entries[0].name, "M.ENTRY");
328 assert!(matches!(&out.entries[0].pages[0], BookPage::Text { text, title, .. } if text == "M.TEXT" && title.as_deref() == Some("M.TITLE")));
329 assert!(matches!(&out.entries[0].pages[1], BookPage::Text { text, .. } if text == "raw text"));
330 }
331}