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
245static UNLOCKED: std::sync::OnceLock<fn(&str) -> bool> = std::sync::OnceLock::new();
249
250pub fn set_unlocked(check: fn(&str) -> bool) {
252 let _ = UNLOCKED.set(check);
253}
254
255pub fn shown(entry: &BookEntry) -> bool {
258 !entry.secret && entry.advancement.as_deref().is_none_or(|a| UNLOCKED.get().is_none_or(|check| check(a)))
259}
260
261#[derive(Debug, Clone, Serialize, Deserialize, Schema)]
265#[serde(default)]
266pub struct Book {
267 pub id: String,
269 #[data(with = "BookText")]
270 pub name: String,
271 #[serde(skip_serializing_if = "Option::is_none")]
273 #[data(with = "Opt<BookText>")]
274 pub subtitle: Option<String>,
275 #[data(default)]
278 pub version: u32,
279 #[data(with = "BookText", default)]
280 pub landing_text: String,
281 #[data(default = "default_nameplate_color")]
283 pub nameplate_color: String,
284 #[data(default = "default_accent")]
286 pub accent: String,
287 #[data(default = "default_book_texture")]
289 pub book_texture: String,
290 #[data(default)]
291 pub categories: Vec<BookCategory>,
292 #[data(default)]
293 pub entries: Vec<BookEntry>,
294 #[data(default)]
297 pub i18n: bool,
298 #[serde(default, skip_serializing_if = "Vec::is_empty")]
300 #[data(default)]
301 pub layouts: Vec<layout::RecipeLayout>,
302 #[serde(skip)]
305 #[data(default)]
306 pub page_types: Vec<page_type::PageType>,
307}
308
309fn default_nameplate_color() -> String {
310 "FFDD98".into()
311}
312
313fn default_accent() -> String {
314 "#8d6acc".into()
315}
316
317fn default_book_texture() -> String {
318 "yog:gui/book".into()
319}
320
321impl Default for Book {
322 fn default() -> Self {
323 Book {
324 id: String::new(),
325 name: String::new(),
326 subtitle: None,
327 version: 0,
328 landing_text: String::new(),
329 nameplate_color: default_nameplate_color(),
330 accent: default_accent(),
331 book_texture: default_book_texture(),
332 categories: Vec::new(),
333 entries: Vec::new(),
334 i18n: false,
335 layouts: Vec::new(),
336 page_types: Vec::new(),
337 }
338 }
339}
340
341impl Book {
342 pub fn localized(&self, tr: impl Fn(&str) -> String) -> Book {
347 let opt = |o: &mut Option<String>| {
348 if let Some(t) = o {
349 *t = tr(t);
350 }
351 };
352 let mut b = self.clone();
353 let types = std::mem::take(&mut b.page_types);
354 b.name = tr(&b.name);
355 b.landing_text = tr(&b.landing_text);
356 opt(&mut b.subtitle);
357 for c in &mut b.categories {
358 c.name = tr(&c.name);
359 opt(&mut c.description);
360 }
361 for e in &mut b.entries {
362 e.name = tr(&e.name);
363 for p in &mut e.pages {
364 match p {
365 BookPage::Text { text, title, .. } => {
366 *text = tr(text);
367 opt(title);
368 }
369 BookPage::Spotlight { title, text, .. }
370 | BookPage::Image { title, text, .. } => {
371 opt(title);
372 opt(text);
373 }
374 BookPage::Entity { name, text, .. }
375 | BookPage::Multiblock { name, text, .. } => {
376 opt(name);
377 opt(text);
378 }
379 BookPage::Recipe { text, .. } | BookPage::Relations { text, .. } => opt(text),
380 BookPage::Drawn { title, caption, text, .. } => {
381 opt(title);
382 opt(caption);
383 opt(text);
384 }
385 BookPage::Empty => {}
386 BookPage::Custom(c) => {
388 let Some(t) = types.iter().find(|t| t.id == c.kind) else { continue };
389 for prop in t.properties.iter().filter(|p| matches!(p.kind, page_type::PropKind::Text | page_type::PropKind::BookText)) {
390 if let Some(yog_data::Value::Str(s)) = c.fields.get_mut(&prop.name) {
391 *s = tr(s);
392 }
393 }
394 }
395 }
396 }
397 }
398 b.page_types = types;
399 b
400 }
401
402 pub fn to_json(&self) -> String {
404 serde_json::to_string(self).unwrap_or_default()
405 }
406}
407
408#[cfg(test)]
409mod tests {
410 use super::*;
411
412 fn text(t: &str) -> BookPage {
413 BookPage::Text {
414 text: t.into(),
415 title: None,
416 font: None,
417 font_size: None,
418 }
419 }
420
421 #[test]
422 fn books_round_trip_through_their_json() {
423 let book = Book {
424 id: "yog:test".into(),
425 name: "Test Book".into(),
426 version: 1,
427 categories: vec![BookCategory {
428 id: "yog:c1".into(),
429 name: "Cat".into(),
430 icon: Some("yog:ruby".into()),
431 ..Default::default()
432 }],
433 entries: vec![BookEntry {
434 id: "yog:e1".into(),
435 name: "Entry".into(),
436 category: "yog:c1".into(),
437 pages: vec![
438 text("plain"),
439 BookPage::Spotlight {
440 item: "yog:ruby".into(),
441 title: None,
442 text: None,
443 },
444 BookPage::Recipe {
445 recipe: "yog:r1".into(),
446 text: None,
447 },
448 BookPage::Empty,
449 ],
450 ..Default::default()
451 }],
452 ..Default::default()
453 };
454 let parsed: Book = serde_json::from_str(&book.to_json()).expect("its JSON parses");
455 assert_eq!(parsed.version, 1);
456 assert_eq!(parsed.entries[0].pages.len(), 4);
457 assert!(
458 matches!(&parsed.entries[0].pages[1], BookPage::Spotlight { item, .. } if item == "yog:ruby")
459 );
460 assert!(
461 matches!(&parsed.entries[0].pages[2], BookPage::Recipe { recipe, .. } if recipe == "yog:r1")
462 );
463 }
464
465 #[test]
466 fn localized_translates_every_text() {
467 let book = Book {
468 id: "m:b".into(),
469 name: "m.book.name".into(),
470 landing_text: "m.book.landing".into(),
471 categories: vec![BookCategory {
472 id: "m:basics".into(),
473 name: "m.cat".into(),
474 ..Default::default()
475 }],
476 entries: vec![BookEntry {
477 id: "m:e".into(),
478 name: "m.entry".into(),
479 category: "m:basics".into(),
480 pages: vec![
481 BookPage::Text {
482 text: "m.text".into(),
483 title: Some("m.title".into()),
484 font: None,
485 font_size: None,
486 },
487 text("raw text"),
488 ],
489 ..Default::default()
490 }],
491 ..Default::default()
492 };
493 let out = book.localized(|k| {
494 if k.starts_with("m.") {
495 k.to_uppercase()
496 } else {
497 k.to_string()
498 }
499 });
500 assert_eq!(out.name, "M.BOOK.NAME");
501 assert_eq!(out.landing_text, "M.BOOK.LANDING");
502 assert_eq!(out.categories[0].name, "M.CAT");
503 assert_eq!(out.entries[0].name, "M.ENTRY");
504 assert!(
505 matches!(&out.entries[0].pages[0], BookPage::Text { text, title, .. } if text == "M.TEXT" && title.as_deref() == Some("M.TITLE"))
506 );
507 assert!(
508 matches!(&out.entries[0].pages[1], BookPage::Text { text, .. } if text == "raw text")
509 );
510 }
511}