1pub mod layout;
10pub mod markup;
11pub mod page_type;
12pub mod refs;
13pub mod renderer;
14pub mod state;
15pub mod theme;
16pub mod ui;
17
18use std::collections::BTreeMap;
19
20use serde::{Deserialize, Serialize};
21use yog_data::Schema;
22use yog_data::kinds::{Block, Category, Entity, Entry, Item, Recipe};
23use yog_data::with::{List, Opt, RefTo, Values};
24
25use refs::{BookText, Icon};
26
27pub use renderer::{BOOK_PNG, BOOK_SHEET, BookRenderer, CRAFTING_PNG, CRAFTING_SHEET, LANG, PageDrawer};
28pub use state::BookViewState;
29pub use theme::BookTheme;
30
31mod value_json {
33 use serde::{Deserialize, Deserializer, Serialize, Serializer};
34
35 pub fn serialize<S: Serializer>(v: &yog_data::Value, s: S) -> Result<S::Ok, S::Error> {
36 yog_data::json::to_json(v).serialize(s)
37 }
38
39 pub fn deserialize<'de, D: Deserializer<'de>>(d: D) -> Result<yog_data::Value, D::Error> {
40 Ok(yog_data::json::from_json(&serde_json::Value::deserialize(d)?))
41 }
42}
43
44pub fn multiblock_blocks(
47 pattern: &[Vec<String>],
48 mapping: &BTreeMap<String, String>,
49) -> Vec<((i32, i32, i32), String)> {
50 let mut out = Vec::new();
51 let layers = pattern.len() as i32;
52 for (li, layer) in pattern.iter().enumerate() {
53 for (z, row) in layer.iter().enumerate() {
54 for (x, ch) in row.chars().enumerate() {
55 if ch == ' ' {
56 continue;
57 }
58 if let Some(state) = mapping.get(&ch.to_string()) {
59 out.push(((x as i32, layers - 1 - li as i32, z as i32), state.clone()));
60 }
61 }
62 }
63 }
64 out
65}
66
67#[derive(Debug, Clone, Serialize, Deserialize, Schema)]
71#[serde(tag = "type", rename_all = "snake_case")]
72#[data(tag = "type")]
73pub enum BookPage {
74 Text {
78 #[data(with = "BookText")]
79 text: String,
80 #[serde(default, skip_serializing_if = "Option::is_none")]
81 #[data(with = "Opt<BookText>")]
82 title: Option<String>,
83 #[serde(default, skip_serializing_if = "Option::is_none")]
85 font: Option<String>,
86 #[serde(default, skip_serializing_if = "Option::is_none")]
88 font_size: Option<f32>,
89 },
90 Spotlight {
92 #[data(with = "RefTo<(Item, Block)>")]
93 item: String,
94 #[serde(default, skip_serializing_if = "Option::is_none")]
95 #[data(with = "Opt<BookText>")]
96 title: Option<String>,
97 #[serde(default, skip_serializing_if = "Option::is_none")]
98 #[data(with = "Opt<BookText>")]
99 text: Option<String>,
100 },
101 Recipe {
103 #[data(with = "RefTo<Recipe>")]
104 recipe: String,
105 #[serde(default, skip_serializing_if = "Option::is_none")]
106 #[data(with = "Opt<BookText>")]
107 text: Option<String>,
108 },
109 Image {
111 texture: String,
113 #[serde(default, skip_serializing_if = "Option::is_none")]
114 #[data(with = "Opt<BookText>")]
115 title: Option<String>,
116 #[serde(default, skip_serializing_if = "Option::is_none")]
117 #[data(with = "Opt<BookText>")]
118 text: Option<String>,
119 },
120 Entity {
122 #[data(with = "RefTo<Entity>")]
123 entity: String,
124 #[serde(default, skip_serializing_if = "Option::is_none")]
125 #[data(with = "Opt<BookText>")]
126 name: Option<String>,
127 #[serde(default, skip_serializing_if = "Option::is_none")]
128 #[data(with = "Opt<BookText>")]
129 text: Option<String>,
130 },
131 Multiblock {
135 #[serde(default, skip_serializing_if = "Option::is_none")]
136 #[data(with = "Opt<BookText>")]
137 name: Option<String>,
138 pattern: Vec<Vec<String>>,
140 #[data(with = "Values<RefTo<Block>>")]
142 mapping: BTreeMap<String, String>,
143 #[serde(default, skip_serializing_if = "Option::is_none")]
144 #[data(with = "Opt<BookText>")]
145 text: Option<String>,
146 },
147 Relations {
149 #[data(with = "List<RefTo<Entry>>")]
150 entries: Vec<String>,
151 #[serde(default, skip_serializing_if = "Option::is_none")]
152 #[data(with = "Opt<BookText>")]
153 text: Option<String>,
154 },
155 Drawn {
160 drawer: String,
162 #[serde(default, skip_serializing_if = "Option::is_none")]
163 #[data(with = "Opt<BookText>")]
164 title: Option<String>,
165 #[serde(default)]
167 #[data(default, range = "0..=150")]
168 height: f32,
169 #[serde(default, skip_serializing_if = "Option::is_none")]
171 #[data(with = "Opt<BookText>")]
172 caption: Option<String>,
173 #[serde(default, skip_serializing_if = "Option::is_none")]
174 #[data(with = "Opt<BookText>")]
175 text: Option<String>,
176 #[serde(default, with = "value_json")]
178 #[data(default)]
179 data: yog_data::Value,
180 },
181 Empty,
183 #[serde(skip)]
186 #[data(other)]
187 Custom(page_type::CustomPage),
188}
189
190#[derive(Debug, Clone, Default, Serialize, Deserialize, Schema)]
194#[serde(default)]
195pub struct BookCategory {
196 pub id: String,
197 #[data(with = "BookText")]
198 pub name: String,
199 #[serde(skip_serializing_if = "Option::is_none")]
200 #[data(with = "Opt<BookText>")]
201 pub description: Option<String>,
202 #[serde(skip_serializing_if = "Option::is_none")]
204 #[data(with = "Opt<Icon>")]
205 pub icon: Option<String>,
206 #[data(default)]
208 pub sortnum: i32,
209}
210
211#[derive(Debug, Clone, Default, Serialize, Deserialize, Schema)]
213#[serde(default)]
214pub struct BookEntry {
215 pub id: String,
216 #[data(with = "BookText")]
217 pub name: String,
218 #[data(with = "RefTo<Category>")]
219 pub category: String,
220 #[data(default)]
221 pub pages: Vec<BookPage>,
222 #[serde(skip_serializing_if = "Option::is_none")]
224 #[data(with = "Opt<Icon>")]
225 pub icon: Option<String>,
226 #[data(default)]
228 pub priority: i32,
229 #[data(default)]
231 pub secret: bool,
232 #[data(default)]
234 pub read_by_default: bool,
235 #[serde(default, skip_serializing_if = "Vec::is_empty")]
238 #[data(default)]
239 pub requires: Vec<String>,
240 #[serde(skip_serializing_if = "Option::is_none")]
242 pub advancement: Option<String>,
243}
244
245#[derive(Debug, Clone, Serialize, Deserialize, Schema)]
249#[serde(default)]
250pub struct Book {
251 pub id: String,
253 #[data(with = "BookText")]
254 pub name: String,
255 #[serde(skip_serializing_if = "Option::is_none")]
257 #[data(with = "Opt<BookText>")]
258 pub subtitle: Option<String>,
259 #[data(default)]
262 pub version: u32,
263 #[data(with = "BookText", default)]
264 pub landing_text: String,
265 #[data(default = "default_nameplate_color")]
267 pub nameplate_color: String,
268 #[data(default = "default_accent")]
270 pub accent: String,
271 #[data(default = "default_book_texture")]
273 pub book_texture: String,
274 #[data(default)]
275 pub categories: Vec<BookCategory>,
276 #[data(default)]
277 pub entries: Vec<BookEntry>,
278 #[data(default)]
281 pub i18n: bool,
282 #[serde(default, skip_serializing_if = "Vec::is_empty")]
284 #[data(default)]
285 pub layouts: Vec<layout::RecipeLayout>,
286 #[serde(skip)]
289 #[data(default)]
290 pub page_types: Vec<page_type::PageType>,
291}
292
293fn default_nameplate_color() -> String {
294 "FFDD98".into()
295}
296
297fn default_accent() -> String {
298 "#8d6acc".into()
299}
300
301fn default_book_texture() -> String {
302 "yog:gui/book".into()
303}
304
305impl Default for Book {
306 fn default() -> Self {
307 Book {
308 id: String::new(),
309 name: String::new(),
310 subtitle: None,
311 version: 0,
312 landing_text: String::new(),
313 nameplate_color: default_nameplate_color(),
314 accent: default_accent(),
315 book_texture: default_book_texture(),
316 categories: Vec::new(),
317 entries: Vec::new(),
318 i18n: false,
319 layouts: Vec::new(),
320 page_types: Vec::new(),
321 }
322 }
323}
324
325impl Book {
326 pub fn localized(&self, tr: impl Fn(&str) -> String) -> Book {
331 let opt = |o: &mut Option<String>| {
332 if let Some(t) = o {
333 *t = tr(t);
334 }
335 };
336 let mut b = self.clone();
337 let types = std::mem::take(&mut b.page_types);
338 b.name = tr(&b.name);
339 b.landing_text = tr(&b.landing_text);
340 opt(&mut b.subtitle);
341 for c in &mut b.categories {
342 c.name = tr(&c.name);
343 opt(&mut c.description);
344 }
345 for e in &mut b.entries {
346 e.name = tr(&e.name);
347 for p in &mut e.pages {
348 match p {
349 BookPage::Text { text, title, .. } => {
350 *text = tr(text);
351 opt(title);
352 }
353 BookPage::Spotlight { title, text, .. }
354 | BookPage::Image { title, text, .. } => {
355 opt(title);
356 opt(text);
357 }
358 BookPage::Entity { name, text, .. }
359 | BookPage::Multiblock { name, text, .. } => {
360 opt(name);
361 opt(text);
362 }
363 BookPage::Recipe { text, .. } | BookPage::Relations { text, .. } => opt(text),
364 BookPage::Drawn { title, caption, text, .. } => {
365 opt(title);
366 opt(caption);
367 opt(text);
368 }
369 BookPage::Empty => {}
370 BookPage::Custom(c) => {
372 let Some(t) = types.iter().find(|t| t.id == c.kind) else { continue };
373 for prop in t.properties.iter().filter(|p| matches!(p.kind, page_type::PropKind::Text | page_type::PropKind::BookText)) {
374 if let Some(yog_data::Value::Str(s)) = c.fields.get_mut(&prop.name) {
375 *s = tr(s);
376 }
377 }
378 }
379 }
380 }
381 }
382 b.page_types = types;
383 b
384 }
385
386 pub fn to_json(&self) -> String {
388 serde_json::to_string(self).unwrap_or_default()
389 }
390}
391
392#[cfg(test)]
393mod tests {
394 use super::*;
395
396 fn text(t: &str) -> BookPage {
397 BookPage::Text {
398 text: t.into(),
399 title: None,
400 font: None,
401 font_size: None,
402 }
403 }
404
405 #[test]
406 fn books_round_trip_through_their_json() {
407 let book = Book {
408 id: "yog:test".into(),
409 name: "Test Book".into(),
410 version: 1,
411 categories: vec![BookCategory {
412 id: "yog:c1".into(),
413 name: "Cat".into(),
414 icon: Some("yog:ruby".into()),
415 ..Default::default()
416 }],
417 entries: vec![BookEntry {
418 id: "yog:e1".into(),
419 name: "Entry".into(),
420 category: "yog:c1".into(),
421 pages: vec![
422 text("plain"),
423 BookPage::Spotlight {
424 item: "yog:ruby".into(),
425 title: None,
426 text: None,
427 },
428 BookPage::Recipe {
429 recipe: "yog:r1".into(),
430 text: None,
431 },
432 BookPage::Empty,
433 ],
434 ..Default::default()
435 }],
436 ..Default::default()
437 };
438 let parsed: Book = serde_json::from_str(&book.to_json()).expect("its JSON parses");
439 assert_eq!(parsed.version, 1);
440 assert_eq!(parsed.entries[0].pages.len(), 4);
441 assert!(
442 matches!(&parsed.entries[0].pages[1], BookPage::Spotlight { item, .. } if item == "yog:ruby")
443 );
444 assert!(
445 matches!(&parsed.entries[0].pages[2], BookPage::Recipe { recipe, .. } if recipe == "yog:r1")
446 );
447 }
448
449 #[test]
450 fn localized_translates_every_text() {
451 let book = Book {
452 id: "m:b".into(),
453 name: "m.book.name".into(),
454 landing_text: "m.book.landing".into(),
455 categories: vec![BookCategory {
456 id: "m:basics".into(),
457 name: "m.cat".into(),
458 ..Default::default()
459 }],
460 entries: vec![BookEntry {
461 id: "m:e".into(),
462 name: "m.entry".into(),
463 category: "m:basics".into(),
464 pages: vec![
465 BookPage::Text {
466 text: "m.text".into(),
467 title: Some("m.title".into()),
468 font: None,
469 font_size: None,
470 },
471 text("raw text"),
472 ],
473 ..Default::default()
474 }],
475 ..Default::default()
476 };
477 let out = book.localized(|k| {
478 if k.starts_with("m.") {
479 k.to_uppercase()
480 } else {
481 k.to_string()
482 }
483 });
484 assert_eq!(out.name, "M.BOOK.NAME");
485 assert_eq!(out.landing_text, "M.BOOK.LANDING");
486 assert_eq!(out.categories[0].name, "M.CAT");
487 assert_eq!(out.entries[0].name, "M.ENTRY");
488 assert!(
489 matches!(&out.entries[0].pages[0], BookPage::Text { text, title, .. } if text == "M.TEXT" && title.as_deref() == Some("M.TITLE"))
490 );
491 assert!(
492 matches!(&out.entries[0].pages[1], BookPage::Text { text, .. } if text == "raw text")
493 );
494 }
495}