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