Skip to main content

yog_formats/book/
mod.rs

1//! The `.ybook` format: a guide book (see [`Book`]) — its categories,
2//! entries and pages, the mods' own kinds of page ([`page_type`]) and how
3//! recipes are laid out ([`layout`]). A book names what it shows — items,
4//! blocks, entities, recipes, its own entries — through typed references
5//! (`@item:minecraft:stick`, see [`refs`]); tools check them and hand the
6//! renderer (`yog-book`) plain ids.
7
8pub mod layout;
9pub mod page_type;
10pub mod refs;
11
12use std::collections::BTreeMap;
13
14use serde::{Deserialize, Serialize};
15use yog_data::Schema;
16use yog_data::kinds::{Block, Category, Entity, Entry, Item, Recipe};
17use yog_data::with::{List, Opt, RefTo, Values};
18
19use refs::{BookText, Icon};
20
21
22/// A [`yog_data::Value`] in a book's JSON form.
23mod value_json {
24    use serde::{Deserialize, Deserializer, Serialize, Serializer};
25
26    pub fn serialize<S: Serializer>(v: &yog_data::Value, s: S) -> Result<S::Ok, S::Error> {
27        yog_data::json::to_json(v).serialize(s)
28    }
29
30    pub fn deserialize<'de, D: Deserializer<'de>>(d: D) -> Result<yog_data::Value, D::Error> {
31        Ok(yog_data::json::from_json(&serde_json::Value::deserialize(d)?))
32    }
33}
34
35/// The blocks of a multiblock `pattern` (layers top to bottom, rows of
36/// characters) with their `mapping`: ((x, y, z), block state).
37pub fn multiblock_blocks(
38    pattern: &[Vec<String>],
39    mapping: &BTreeMap<String, String>,
40) -> Vec<((i32, i32, i32), String)> {
41    let mut out = Vec::new();
42    let layers = pattern.len() as i32;
43    for (li, layer) in pattern.iter().enumerate() {
44        for (z, row) in layer.iter().enumerate() {
45            for (x, ch) in row.chars().enumerate() {
46                if ch == ' ' {
47                    continue;
48                }
49                if let Some(state) = mapping.get(&ch.to_string()) {
50                    out.push(((x as i32, layers - 1 - li as i32, z as i32), state.clone()));
51                }
52            }
53        }
54    }
55    out
56}
57
58// ── Pages ────────────────────────────────────────────────────────────────────
59
60/// One page of an entry: `{"type": "spotlight", "item": "@item:minecraft:stick", …}`.
61#[derive(Debug, Clone, Serialize, Deserialize, Schema)]
62#[serde(tag = "type", rename_all = "snake_case")]
63#[data(tag = "type")]
64pub enum BookPage {
65    /// Text (Yog text: `{b|…}`, `{#rrggbb|…}`, `{link=…|…}`), with an
66    /// optional title; in another `font` (a resource-pack font id, e.g. a
67    /// `ttf` provider) at `font_size` pixels a line.
68    Text {
69        #[data(with = "BookText")]
70        text: String,
71        #[serde(default, skip_serializing_if = "Option::is_none")]
72        #[data(with = "Opt<BookText>")]
73        title: Option<String>,
74        /// A resource-pack font id.
75        #[serde(default, skip_serializing_if = "Option::is_none")]
76        font: Option<String>,
77        /// Pixels a line.
78        #[serde(default, skip_serializing_if = "Option::is_none")]
79        font_size: Option<f32>,
80    },
81    /// An item in a frame, its name (or `title`) and text.
82    Spotlight {
83        #[data(with = "RefTo<(Item, Block)>")]
84        item: String,
85        #[serde(default, skip_serializing_if = "Option::is_none")]
86        #[data(with = "Opt<BookText>")]
87        title: Option<String>,
88        #[serde(default, skip_serializing_if = "Option::is_none")]
89        #[data(with = "Opt<BookText>")]
90        text: Option<String>,
91    },
92    /// A recipe as its station shows it (a crafting grid, a furnace, …).
93    Recipe {
94        #[data(with = "RefTo<Recipe>")]
95        recipe: String,
96        #[serde(default, skip_serializing_if = "Option::is_none")]
97        #[data(with = "Opt<BookText>")]
98        text: Option<String>,
99    },
100    /// An image (a Yog image source: a texture, `.svg` too).
101    Image {
102        /// An image source (`mymod:textures/gui/x.png`, `.svg` too).
103        texture: String,
104        #[serde(default, skip_serializing_if = "Option::is_none")]
105        #[data(with = "Opt<BookText>")]
106        title: Option<String>,
107        #[serde(default, skip_serializing_if = "Option::is_none")]
108        #[data(with = "Opt<BookText>")]
109        text: Option<String>,
110    },
111    /// A living entity, turning slowly.
112    Entity {
113        #[data(with = "RefTo<Entity>")]
114        entity: String,
115        #[serde(default, skip_serializing_if = "Option::is_none")]
116        #[data(with = "Opt<BookText>")]
117        name: Option<String>,
118        #[serde(default, skip_serializing_if = "Option::is_none")]
119        #[data(with = "Opt<BookText>")]
120        text: Option<String>,
121    },
122    /// A structure in 3D, turning: `pattern` is its layers top to bottom,
123    /// each a list of rows; each character stands for the block `mapping`
124    /// gives it (a block state: `minecraft:furnace[facing=north]`), ` ` for air.
125    Multiblock {
126        #[serde(default, skip_serializing_if = "Option::is_none")]
127        #[data(with = "Opt<BookText>")]
128        name: Option<String>,
129        /// Layers top to bottom, each a list of rows of characters.
130        pattern: Vec<Vec<String>>,
131        /// What each character is: a block, with a state if it matters.
132        #[data(with = "Values<RefTo<Block>>")]
133        mapping: BTreeMap<String, String>,
134        #[serde(default, skip_serializing_if = "Option::is_none")]
135        #[data(with = "Opt<BookText>")]
136        text: Option<String>,
137    },
138    /// Links to other entries.
139    Relations {
140        #[data(with = "List<RefTo<Entry>>")]
141        entries: Vec<String>,
142        #[serde(default, skip_serializing_if = "Option::is_none")]
143        #[data(with = "Opt<BookText>")]
144        text: Option<String>,
145    },
146    /// A picture the mod's code draws (`Registry::on_book_page`) — Hex
147    /// Casting's patterns, a mod's diagram: the `drawer` registered under
148    /// that id gets `data` and a box the page's width and `height` high,
149    /// between the title and a caption line, over the text.
150    Drawn {
151        /// The id the mod's drawer is registered under (`hexcasting:pattern`).
152        drawer: String,
153        #[serde(default, skip_serializing_if = "Option::is_none")]
154        #[data(with = "Opt<BookText>")]
155        title: Option<String>,
156        /// The picture's height in book pixels (0: none).
157        #[serde(default)]
158        #[data(default, range = "0..=150")]
159        height: f32,
160        /// A line under the picture.
161        #[serde(default, skip_serializing_if = "Option::is_none")]
162        #[data(with = "Opt<BookText>")]
163        caption: Option<String>,
164        #[serde(default, skip_serializing_if = "Option::is_none")]
165        #[data(with = "Opt<BookText>")]
166        text: Option<String>,
167        /// Whatever the drawer reads, as is.
168        #[serde(default, with = "value_json")]
169        #[data(default)]
170        data: yog_data::Value,
171    },
172    /// Nothing (to start the next on a new spread).
173    Empty,
174    /// A page of a mod's own type (`type: "hexcasting:pattern"`), laid out
175    /// by the book's [`page_type::PageType`] of it.
176    #[serde(skip)]
177    #[data(other)]
178    Custom(page_type::CustomPage),
179}
180
181// ── Categories and entries ───────────────────────────────────────────────────
182
183/// A category of the book's home page.
184#[derive(Debug, Clone, Default, Serialize, Deserialize, Schema)]
185#[serde(default)]
186pub struct BookCategory {
187    pub id: String,
188    #[data(with = "BookText")]
189    pub name: String,
190    #[serde(skip_serializing_if = "Option::is_none")]
191    #[data(with = "Opt<BookText>")]
192    pub description: Option<String>,
193    /// An item (`@item:minecraft:book`) or an image (`minecraft:textures/item/book.png`).
194    #[serde(skip_serializing_if = "Option::is_none")]
195    #[data(with = "Opt<Icon>")]
196    pub icon: Option<String>,
197    /// Lower first.
198    #[data(default)]
199    pub sortnum: i32,
200}
201
202/// One entry: its pages, in a category.
203#[derive(Debug, Clone, Default, Serialize, Deserialize, Schema)]
204#[serde(default)]
205pub struct BookEntry {
206    pub id: String,
207    #[data(with = "BookText")]
208    pub name: String,
209    #[data(with = "RefTo<Category>")]
210    pub category: String,
211    #[data(default)]
212    pub pages: Vec<BookPage>,
213    /// An item or an image, as [`BookCategory::icon`].
214    #[serde(skip_serializing_if = "Option::is_none")]
215    #[data(with = "Opt<Icon>")]
216    pub icon: Option<String>,
217    /// Lower first.
218    #[data(default)]
219    pub priority: i32,
220    /// Hidden from the book's lists.
221    #[data(default)]
222    pub secret: bool,
223    /// Shown as read before it's opened.
224    #[data(default)]
225    pub read_by_default: bool,
226    /// Mods it's about — Yog or Java mod ids (`pehkui`): without every one
227    /// of them loaded the book doesn't show it.
228    #[serde(default, skip_serializing_if = "Vec::is_empty")]
229    #[data(default)]
230    pub requires: Vec<String>,
231    /// The advancement that unlocks it.
232    #[serde(skip_serializing_if = "Option::is_none")]
233    pub advancement: Option<String>,
234}
235
236// ── Book ─────────────────────────────────────────────────────────────────────
237
238/// A guide book: what a `.ybook` file holds.
239#[derive(Debug, Clone, Serialize, Deserialize, Schema)]
240#[serde(default)]
241pub struct Book {
242    /// The item that opens it (the mod's namespace if it has none).
243    pub id: String,
244    #[data(with = "BookText")]
245    pub name: String,
246    /// The nameplate's second line when `version` is 0.
247    #[serde(skip_serializing_if = "Option::is_none")]
248    #[data(with = "Opt<BookText>")]
249    pub subtitle: Option<String>,
250    /// The book's edition: its nameplate says "1st Edition", "2nd Edition"…
251    /// (0: the `subtitle` instead).
252    #[data(default)]
253    pub version: u32,
254    #[data(with = "BookText", default)]
255    pub landing_text: String,
256    /// The nameplate's text color (`rrggbb`).
257    #[data(default = "default_nameplate_color")]
258    pub nameplate_color: String,
259    /// The color `{accent|terms}` are shown in (`#rrggbb`).
260    #[data(default = "default_accent")]
261    pub accent: String,
262    /// Its sheet: `ns:textures/x.png`, or Yog's (`yog_book::BOOK_PNG` shows its layout).
263    #[data(default = "default_book_texture")]
264    pub book_texture: String,
265    #[data(default)]
266    pub categories: Vec<BookCategory>,
267    #[data(default)]
268    pub entries: Vec<BookEntry>,
269    /// Its texts are translation keys, shown in the game's language (the
270    /// build turns it on when a text is a `.yres` text or a name).
271    #[data(default)]
272    pub i18n: bool,
273    /// Recipe layouts of its own; the build adds the mod's (`.ydef` recipe kinds and layouts).
274    #[serde(default, skip_serializing_if = "Vec::is_empty")]
275    #[data(default)]
276    pub layouts: Vec<layout::RecipeLayout>,
277    /// The mods' own kinds of page its pages use; the build adds them
278    /// (`.yui` components with `book_page`).
279    #[serde(skip)]
280    #[data(default)]
281    pub page_types: Vec<page_type::PageType>,
282}
283
284fn default_nameplate_color() -> String {
285    "FFDD98".into()
286}
287
288fn default_accent() -> String {
289    "#8d6acc".into()
290}
291
292fn default_book_texture() -> String {
293    yog_ids::yog::BOOK_TEXTURE.into()
294}
295
296impl Default for Book {
297    fn default() -> Self {
298        Book {
299            id: String::new(),
300            name: String::new(),
301            subtitle: None,
302            version: 0,
303            landing_text: String::new(),
304            nameplate_color: default_nameplate_color(),
305            accent: default_accent(),
306            book_texture: default_book_texture(),
307            categories: Vec::new(),
308            entries: Vec::new(),
309            i18n: false,
310            layouts: Vec::new(),
311            page_types: Vec::new(),
312        }
313    }
314}
315
316impl Book {
317    /// This book with its texts run through `tr` — names, descriptions,
318    /// landing text and every page's title/text. With [`Book::i18n`] on,
319    /// the texts are translation keys and the renderer shows
320    /// `localized(<current language>)` (see `yog_book::BookRenderer::set_translator`).
321    pub fn localized(&self, tr: impl Fn(&str) -> String) -> Book {
322        let opt = |o: &mut Option<String>| {
323            if let Some(t) = o {
324                *t = tr(t);
325            }
326        };
327        let mut b = self.clone();
328        let types = std::mem::take(&mut b.page_types);
329        b.name = tr(&b.name);
330        b.landing_text = tr(&b.landing_text);
331        opt(&mut b.subtitle);
332        for c in &mut b.categories {
333            c.name = tr(&c.name);
334            opt(&mut c.description);
335        }
336        for e in &mut b.entries {
337            e.name = tr(&e.name);
338            for p in &mut e.pages {
339                match p {
340                    BookPage::Text { text, title, .. } => {
341                        *text = tr(text);
342                        opt(title);
343                    }
344                    BookPage::Spotlight { title, text, .. }
345                    | BookPage::Image { title, text, .. } => {
346                        opt(title);
347                        opt(text);
348                    }
349                    BookPage::Entity { name, text, .. }
350                    | BookPage::Multiblock { name, text, .. } => {
351                        opt(name);
352                        opt(text);
353                    }
354                    BookPage::Recipe { text, .. } | BookPage::Relations { text, .. } => opt(text),
355                    BookPage::Drawn { title, caption, text, .. } => {
356                        opt(title);
357                        opt(caption);
358                        opt(text);
359                    }
360                    BookPage::Empty => {}
361                    // A mod's page: its texts, as its type says.
362                    BookPage::Custom(c) => {
363                        let Some(t) = types.iter().find(|t| t.id == c.kind) else { continue };
364                        for prop in t.properties.iter().filter(|p| matches!(p.kind, page_type::PropKind::Text | page_type::PropKind::BookText)) {
365                            if let Some(yog_data::Value::Str(s)) = c.fields.get_mut(&prop.name) {
366                                *s = tr(s);
367                            }
368                        }
369                    }
370                }
371            }
372        }
373        b.page_types = types;
374        b
375    }
376
377    /// The book as the JSON hosts and `yog build` pass around.
378    pub fn to_json(&self) -> String {
379        serde_json::to_string(self).unwrap_or_default()
380    }
381}
382
383#[cfg(test)]
384mod tests {
385    use super::*;
386
387    fn text(t: &str) -> BookPage {
388        BookPage::Text {
389            text: t.into(),
390            title: None,
391            font: None,
392            font_size: None,
393        }
394    }
395
396    #[test]
397    fn books_round_trip_through_their_json() {
398        let book = Book {
399            id: "yog:test".into(),
400            name: "Test Book".into(),
401            version: 1,
402            categories: vec![BookCategory {
403                id: "yog:c1".into(),
404                name: "Cat".into(),
405                icon: Some("yog:ruby".into()),
406                ..Default::default()
407            }],
408            entries: vec![BookEntry {
409                id: "yog:e1".into(),
410                name: "Entry".into(),
411                category: "yog:c1".into(),
412                pages: vec![
413                    text("plain"),
414                    BookPage::Spotlight {
415                        item: "yog:ruby".into(),
416                        title: None,
417                        text: None,
418                    },
419                    BookPage::Recipe {
420                        recipe: "yog:r1".into(),
421                        text: None,
422                    },
423                    BookPage::Empty,
424                ],
425                ..Default::default()
426            }],
427            ..Default::default()
428        };
429        let parsed: Book = serde_json::from_str(&book.to_json()).expect("its JSON parses");
430        assert_eq!(parsed.version, 1);
431        assert_eq!(parsed.entries[0].pages.len(), 4);
432        assert!(
433            matches!(&parsed.entries[0].pages[1], BookPage::Spotlight { item, .. } if item == "yog:ruby")
434        );
435        assert!(
436            matches!(&parsed.entries[0].pages[2], BookPage::Recipe { recipe, .. } if recipe == "yog:r1")
437        );
438    }
439
440    #[test]
441    fn localized_translates_every_text() {
442        let book = Book {
443            id: "m:b".into(),
444            name: "m.book.name".into(),
445            landing_text: "m.book.landing".into(),
446            categories: vec![BookCategory {
447                id: "m:basics".into(),
448                name: "m.cat".into(),
449                ..Default::default()
450            }],
451            entries: vec![BookEntry {
452                id: "m:e".into(),
453                name: "m.entry".into(),
454                category: "m:basics".into(),
455                pages: vec![
456                    BookPage::Text {
457                        text: "m.text".into(),
458                        title: Some("m.title".into()),
459                        font: None,
460                        font_size: None,
461                    },
462                    text("raw text"),
463                ],
464                ..Default::default()
465            }],
466            ..Default::default()
467        };
468        let out = book.localized(|k| {
469            if k.starts_with("m.") {
470                k.to_uppercase()
471            } else {
472                k.to_string()
473            }
474        });
475        assert_eq!(out.name, "M.BOOK.NAME");
476        assert_eq!(out.landing_text, "M.BOOK.LANDING");
477        assert_eq!(out.categories[0].name, "M.CAT");
478        assert_eq!(out.entries[0].name, "M.ENTRY");
479        assert!(
480            matches!(&out.entries[0].pages[0], BookPage::Text { text, title, .. } if text == "M.TEXT" && title.as_deref() == Some("M.TITLE"))
481        );
482        assert!(
483            matches!(&out.entries[0].pages[1], BookPage::Text { text, .. } if text == "raw text")
484        );
485    }
486}