Skip to main content

yog_book/
lib.rs

1//! yog-book — in-game book/documentation framework for Yog mods.
2//!
3//! Provides Patchouli-like book data model plus a full GPU renderer on top of
4//! yog-ui and yog-gfx, with SVG icon support and custom TTF/OTF fonts.
5
6pub mod font;
7pub mod renderer;
8pub mod state;
9pub mod theme;
10
11use serde::{Deserialize, Serialize};
12use yog_registry::ItemDef;
13
14pub use font::BookFont;
15/// The blocks of a multiblock `pattern` (layers top to bottom, rows of
16/// characters) with their `mapping`: ((x, y, z), block state).
17pub fn multiblock_blocks(
18    pattern: &[Vec<String>],
19    mapping: &std::collections::BTreeMap<String, String>,
20) -> Vec<((i32, i32, i32), String)> {
21    let mut out = Vec::new();
22    let layers = pattern.len() as i32;
23    for (li, layer) in pattern.iter().enumerate() {
24        for (z, row) in layer.iter().enumerate() {
25            for (x, ch) in row.chars().enumerate() {
26                if ch == ' ' {
27                    continue;
28                }
29                if let Some(state) = mapping.get(&ch.to_string()) {
30                    out.push(((x as i32, layers - 1 - li as i32, z as i32), state.clone()));
31                }
32            }
33        }
34    }
35    out
36}
37
38pub use renderer::{BOOK_PNG, BOOK_SHEET, BookRenderer, CRAFTING_PNG, CRAFTING_SHEET};
39pub use state::BookViewState;
40pub use theme::BookTheme;
41
42// ── Macros ───────────────────────────────────────────────────────────────────
43
44/// A macro substitution (e.g. `$(thing)` → red color span).
45#[derive(Debug, Clone, Serialize, Deserialize)]
46pub struct BookMacro(pub String, pub String);
47
48// ── Page types ───────────────────────────────────────────────────────────────
49
50/// A single page variant inside a book entry.
51///
52/// The wire format (mod ↔ runtime JSON boundary, produced by
53/// [`BookPage::to_json`]) is internally tagged with snake_case names —
54/// `{"type": "spotlight", "item": "yog:ruby", ...}` — like Patchouli's
55/// per-page "type" field. The serde attributes below keep `Deserialize`
56/// in exact agreement with `to_json`.
57#[derive(Debug, Clone, Serialize, Deserialize)]
58#[serde(tag = "type", rename_all = "snake_case")]
59pub enum BookPage {
60    /// Plain formatted text, optionally with a section title (for non-first pages).
61    Text {
62        text: String,
63        #[serde(default)]
64        title: Option<String>,
65    },
66    /// Display an item outlined (tooltip on hover).
67    Spotlight {
68        /// On the wire this is the item id string ("yog:ruby"); rich
69        /// `ItemDef` maps are accepted too.
70        #[serde(with = "item_ref")]
71        item: ItemDef,
72        #[serde(default)]
73        title: Option<String>,
74        #[serde(default)]
75        text: Option<String>,
76    },
77    /// Crafting recipe display (autorenders 3×3 grid).
78    Crafting {
79        #[serde(rename = "recipe")]
80        recipe_id: String,
81        #[serde(default)]
82        text: Option<String>,
83    },
84    /// Smelting recipe display.
85    Smelting {
86        #[serde(rename = "recipe")]
87        recipe_id: String,
88        #[serde(default)]
89        text: Option<String>,
90    },
91    /// Image overlay page.
92    Image {
93        texture: String,
94        #[serde(default)]
95        title: Option<String>,
96        #[serde(default)]
97        text: Option<String>,
98        #[serde(default)]
99        border: bool,
100    },
101    /// Entity display page (renders a living entity in a box).
102    Entity {
103        #[serde(rename = "entity")]
104        entity_type: String,
105        #[serde(default)]
106        name: Option<String>,
107        #[serde(default)]
108        text: Option<String>,
109    },
110    /// A multiblock structure in 3D, turning (Patchouli's multiblock page):
111    /// `pattern` is its layers top to bottom, each a list of rows; each
112    /// character stands for the block `mapping` gives it (a full block
113    /// state: `"minecraft:furnace[facing=north]"`), ` ` for air.
114    Multiblock {
115        #[serde(default)]
116        name: Option<String>,
117        pattern: Vec<Vec<String>>,
118        mapping: std::collections::BTreeMap<String, String>,
119        #[serde(default)]
120        text: Option<String>,
121    },
122    /// Link to another entry (like Patchouli's relations).
123    Relations {
124        entries: Vec<String>,
125        #[serde(default)]
126        text: Option<String>,
127    },
128    /// Empty separator.
129    Empty,
130    /// Custom mod-defined page (e.g. `example:pattern`).
131    Pattern {
132        op_id: String,
133        #[serde(default)]
134        anchor: String,
135        #[serde(default)]
136        input: String,
137        #[serde(default)]
138        output: String,
139        #[serde(default)]
140        text: String,
141    },
142    /// SVG image page — rasterized at render time via `resvg`.
143    Svg {
144        /// Raw SVG source string.
145        data: String,
146        #[serde(default)]
147        title: Option<String>,
148        #[serde(default)]
149        text: Option<String>,
150    },
151    /// Text rendered with a custom TTF/OTF font.
152    CustomText {
153        text: String,
154        /// Flattened on the wire: `"font_id": …, "size_px": …`.
155        #[serde(flatten)]
156        font: BookFont,
157        /// ARGB color (0xAARRGGBB).
158        color: u32,
159    },
160}
161
162/// (De)serialize `ItemDef` as a plain item-id string ("yog:ruby"), accepting
163/// a full `ItemDef` map on input for richer definitions.
164mod item_ref {
165    use serde::{Deserialize, Deserializer, Serializer};
166    use yog_registry::ItemDef;
167
168    pub fn serialize<S: Serializer>(item: &ItemDef, s: S) -> Result<S::Ok, S::Error> {
169        s.serialize_str(&item.id)
170    }
171
172    pub fn deserialize<'de, D: Deserializer<'de>>(d: D) -> Result<ItemDef, D::Error> {
173        #[derive(Deserialize)]
174        #[serde(untagged)]
175        enum Repr {
176            Id(String),
177            Def(ItemDef),
178        }
179        Ok(match Repr::deserialize(d)? {
180            Repr::Id(id) => ItemDef::new(id),
181            Repr::Def(def) => def,
182        })
183    }
184}
185
186// ── Category ─────────────────────────────────────────────────────────────────
187
188/// Represents a book category tab (e.g. "Basics", "Patterns").
189#[derive(Debug, Clone, Default, Serialize, Deserialize)]
190#[serde(default)]
191pub struct BookCategory {
192    pub id: String,
193    pub name: String,
194    pub description: Option<String>,
195    /// MC texture path for the category icon (e.g. `"minecraft:textures/item/book.png"`).
196    pub icon: Option<String>,
197    /// Raw SVG string for the category icon (takes priority over `icon`).
198    pub icon_svg: Option<String>,
199    /// Sort priority (lower = first).
200    pub sortnum: i32,
201}
202
203// ── Entry ────────────────────────────────────────────────────────────────────
204
205/// One entry in a book (like a "page" in the TOC sidebar).
206#[derive(Debug, Clone, Default, Serialize, Deserialize)]
207#[serde(default)]
208pub struct BookEntry {
209    pub id: String,
210    pub name: String,
211    pub category: String,
212    pub pages: Vec<BookPage>,
213    /// Entry icon (item id or texture path).
214    pub icon: Option<String>,
215    /// Raw SVG icon string (takes priority over `icon`).
216    pub icon_svg: Option<String>,
217    /// If true, hides from the book (used for unlocks).
218    pub secret: bool,
219    /// Sort priority (lower = first).
220    pub priority: i32,
221    /// If true, read by default when opening the book.
222    pub read_by_default: bool,
223    /// Advancement required to unlock.
224    pub advancement: Option<String>,
225}
226
227// ── Book ─────────────────────────────────────────────────────────────────────
228
229/// The top-level book definition — replaces `patchouli_books/<id>/book.json`.
230#[derive(Debug, Clone, Serialize, Deserialize)]
231#[serde(default)]
232pub struct Book {
233    pub id: String,
234    pub name: String,
235    pub nameplate_color: String,
236    pub landing_text: String,
237    pub author: Option<String>,
238    pub book_texture: String,
239    pub filler_texture: String,
240    pub model: String,
241    pub categories: Vec<BookCategory>,
242    pub entries: Vec<BookEntry>,
243    pub macros: Vec<BookMacro>,
244    pub use_resource_pack: bool,
245    pub show_progress: bool,
246    pub i18n: bool,
247    pub creative_tab: Option<String>,
248}
249
250impl Book {
251    pub fn new(id: impl Into<String>, name: impl Into<String>) -> Self {
252        Self {
253            id: id.into(),
254            name: name.into(),
255            nameplate_color: "FFDD98".into(), // Patchouli default nameplateColor
256            landing_text: String::new(),
257            author: None,
258            book_texture: "yog:gui/book".into(),
259            filler_texture: "yog:gui/book_filler".into(),
260            model: "minecraft:book".into(),
261            categories: Vec::new(),
262            entries: Vec::new(),
263            macros: Vec::new(),
264            use_resource_pack: false,
265            show_progress: true,
266            i18n: false,
267            creative_tab: None,
268        }
269    }
270
271    pub fn author(mut self, author: impl Into<String>) -> Self {
272        self.author = Some(author.into());
273        self
274    }
275
276    pub fn book_texture(mut self, tex: impl Into<String>) -> Self {
277        self.book_texture = tex.into();
278        self
279    }
280
281    pub fn filler_texture(mut self, tex: impl Into<String>) -> Self {
282        self.filler_texture = tex.into();
283        self
284    }
285
286    pub fn nameplate(mut self, color: impl Into<String>) -> Self {
287        self.nameplate_color = color.into();
288        self
289    }
290
291    pub fn landing_text(mut self, text: impl Into<String>) -> Self {
292        self.landing_text = text.into();
293        self
294    }
295
296    pub fn model(mut self, model: impl Into<String>) -> Self {
297        self.model = model.into();
298        self
299    }
300
301    pub fn creative_tab(mut self, tab: impl Into<String>) -> Self {
302        self.creative_tab = Some(tab.into());
303        self
304    }
305
306    pub fn show_progress(mut self, show: bool) -> Self {
307        self.show_progress = show;
308        self
309    }
310
311    /// This book with its texts run through `tr` — names, descriptions,
312    /// landing text and every page's title/text. With [`Self::i18n`] on,
313    /// the texts are translation keys and the renderer shows
314    /// `localized(<current language>)` (see [`BookRenderer::set_translator`]).
315    pub fn localized(&self, tr: impl Fn(&str) -> String) -> Book {
316        let opt = |o: &mut Option<String>| {
317            if let Some(t) = o {
318                *t = tr(t);
319            }
320        };
321        let mut b = self.clone();
322        b.name = tr(&b.name);
323        b.landing_text = tr(&b.landing_text);
324        for c in &mut b.categories {
325            c.name = tr(&c.name);
326            opt(&mut c.description);
327        }
328        for e in &mut b.entries {
329            e.name = tr(&e.name);
330            for p in &mut e.pages {
331                match p {
332                    BookPage::Text { text, title } => {
333                        *text = tr(text);
334                        opt(title);
335                    }
336                    BookPage::Spotlight { title, text, .. }
337                    | BookPage::Image { title, text, .. }
338                    | BookPage::Svg { title, text, .. } => {
339                        opt(title);
340                        opt(text);
341                    }
342                    BookPage::Entity { name, text, .. } | BookPage::Multiblock { name, text, .. } => {
343                        opt(name);
344                        opt(text);
345                    }
346                    BookPage::Crafting { text, .. }
347                    | BookPage::Smelting { text, .. }
348                    | BookPage::Relations { text, .. } => opt(text),
349                    BookPage::Pattern { text, .. } | BookPage::CustomText { text, .. } => {
350                        *text = tr(text)
351                    }
352                    BookPage::Empty => {}
353                }
354            }
355        }
356        b
357    }
358
359    pub fn i18n(mut self, val: bool) -> Self {
360        self.i18n = val;
361        self
362    }
363
364    pub fn use_resource_pack(mut self, val: bool) -> Self {
365        self.use_resource_pack = val;
366        self
367    }
368
369    pub fn add_macro(mut self, key: impl Into<String>, value: impl Into<String>) -> Self {
370        self.macros.push(BookMacro(key.into(), value.into()));
371        self
372    }
373
374    pub fn add_category(mut self, category: BookCategory) -> Self {
375        self.categories.push(category);
376        self
377    }
378
379    pub fn add_entry(mut self, entry: BookEntry) -> Self {
380        self.entries.push(entry);
381        self
382    }
383}
384
385impl Default for Book {
386    fn default() -> Self {
387        Self::new("yog:default", "Unknown Book")
388    }
389}
390
391// ── Registry ─────────────────────────────────────────────────────────────────
392
393/// Global registry for all in-game books.
394#[derive(Debug, Default)]
395pub struct BookRegistry {
396    books: std::collections::HashMap<String, Book>,
397}
398
399impl BookRegistry {
400    pub fn register(&mut self, book: Book) {
401        self.books.insert(book.id.clone(), book);
402    }
403
404    pub fn get(&self, id: &str) -> Option<&Book> {
405        self.books.get(id)
406    }
407
408    pub fn all(&self) -> impl Iterator<Item = &Book> {
409        self.books.values()
410    }
411}
412
413// ── Builder helpers ──────────────────────────────────────────────────────────
414
415pub fn text_page(text: impl Into<String>) -> BookPage {
416    BookPage::Text {
417        text: text.into(),
418        title: None,
419    }
420}
421
422pub fn text_page_titled(title: impl Into<String>, text: impl Into<String>) -> BookPage {
423    BookPage::Text {
424        text: text.into(),
425        title: Some(title.into()),
426    }
427}
428
429pub fn spotlight_page(item: ItemDef) -> BookPage {
430    BookPage::Spotlight {
431        item,
432        title: None,
433        text: None,
434    }
435}
436
437pub fn crafting_page(recipe_id: impl Into<String>) -> BookPage {
438    BookPage::Crafting {
439        recipe_id: recipe_id.into(),
440        text: None,
441    }
442}
443
444pub fn crafting_page_with_text(recipe_id: impl Into<String>, text: impl Into<String>) -> BookPage {
445    BookPage::Crafting {
446        recipe_id: recipe_id.into(),
447        text: Some(text.into()),
448    }
449}
450
451pub fn smelting_page(recipe_id: impl Into<String>) -> BookPage {
452    BookPage::Smelting {
453        recipe_id: recipe_id.into(),
454        text: None,
455    }
456}
457
458pub fn image_page(texture: impl Into<String>) -> BookPage {
459    BookPage::Image {
460        texture: texture.into(),
461        title: None,
462        text: None,
463        border: true,
464    }
465}
466
467pub fn entity_page(entity_type: impl Into<String>) -> BookPage {
468    BookPage::Entity {
469        entity_type: entity_type.into(),
470        name: None,
471        text: None,
472    }
473}
474
475pub fn relations_page(entries: Vec<String>) -> BookPage {
476    BookPage::Relations {
477        entries,
478        text: None,
479    }
480}
481
482pub fn pattern_page(
483    op_id: impl Into<String>,
484    anchor: impl Into<String>,
485    input: impl Into<String>,
486    output: impl Into<String>,
487    text: impl Into<String>,
488) -> BookPage {
489    BookPage::Pattern {
490        op_id: op_id.into(),
491        anchor: anchor.into(),
492        input: input.into(),
493        output: output.into(),
494        text: text.into(),
495    }
496}
497
498// ── Book → yog-ui bridge ─────────────────────────────────────────────────────
499pub mod book_ui {
500    use crate::{Book, BookEntry, BookPage};
501    use yog_ui::widget::{self, Widget};
502    use yog_ui::{Align, FlexDir, UiRoot};
503
504    /// Build a `UiRoot` from a `Book`.
505    /// The UI has: left panel (categories + entries), right panel (pages),
506    /// prev/next buttons at bottom.
507    pub fn build_book_ui(
508        book: &Book,
509        selected_cat: usize,
510        selected_entry: usize,
511        current_page: usize,
512    ) -> UiRoot {
513        let mut cats: Vec<Widget> = Vec::new();
514        for (i, cat) in book.categories.iter().enumerate() {
515            let color = if i == selected_cat {
516                0xFF_FFFF55
517            } else {
518                0xFF_CCCCCC
519            };
520            cats.push(
521                widget::button(&cat.name)
522                    .color(color)
523                    .on_click(format!("cat:{}", i)),
524            );
525        }
526
527        let cat = book.categories.get(selected_cat);
528        let mut entries: Vec<Widget> = Vec::new();
529        if let Some(cat) = cat {
530            let cat_entries: Vec<&BookEntry> = book
531                .entries
532                .iter()
533                .filter(|e| e.category == cat.id)
534                .collect();
535            for (i, entry) in cat_entries.iter().enumerate() {
536                let color = if i == selected_entry {
537                    0xFF_FFFF55
538                } else {
539                    0xFF_CCCCCC
540                };
541                let label = if entry.name.len() > 14 {
542                    &entry.name[..14]
543                } else {
544                    &entry.name
545                };
546                entries.push(
547                    widget::button(label)
548                        .color(color)
549                        .on_click(format!("entry:{}", i)),
550                );
551            }
552        }
553
554        let mut pages: Vec<Widget> = Vec::new();
555        if let Some(cat) = cat {
556            let cat_entries: Vec<&BookEntry> = book
557                .entries
558                .iter()
559                .filter(|e| e.category == cat.id)
560                .collect();
561            if let Some(entry) = cat_entries.get(selected_entry)
562                && let Some(page) = entry.pages.get(current_page)
563            {
564                pages.push(render_page(page));
565            }
566        }
567
568        let nav = widget::panel(FlexDir::Row)
569            .gap(4.0)
570            .child(widget::button("<").w(28.0).on_click("prev_page"))
571            .child(
572                widget::label(format!(
573                    "{}/{}",
574                    current_page + 1,
575                    cat.map_or(0, |c| {
576                        book.entries
577                            .iter()
578                            .filter(|e| e.category == c.id)
579                            .nth(selected_entry)
580                            .map_or(0, |e| e.pages.len())
581                    })
582                ))
583                .color(0xFF_888888)
584                .flex(1.0)
585                .align(Align::Center),
586            )
587            .child(widget::button(">").w(28.0).on_click("next_page"));
588
589        UiRoot::new(
590            &book.id,
591            widget::panel(FlexDir::Row)
592                .gap(2.0)
593                .padding(2.0, 2.0, 2.0, 2.0)
594                .bg(0xFF_2A1A0E)
595                .child(
596                    widget::panel(FlexDir::Column)
597                        .w(104.0)
598                        .child(widget::label("Categories").color(0xFF_888888))
599                        .child(widget::panel(FlexDir::Column).gap(1.0).child_many(cats))
600                        .child(widget::label("Entries").color(0xFF_888888))
601                        .child(widget::panel(FlexDir::Column).gap(1.0).child_many(entries)),
602                )
603                .child(
604                    widget::panel(FlexDir::Column)
605                        .flex(1.0)
606                        .gap(2.0)
607                        .child(widget::panel(FlexDir::Column).flex(1.0).child_many(pages))
608                        .child(nav),
609                ),
610        )
611    }
612
613    fn render_page(page: &BookPage) -> Widget {
614        match page {
615            BookPage::Text { text, .. } => widget::label(text).color(0xFF_CCCCAA),
616            BookPage::Spotlight { item, title, text } => {
617                let mut p = widget::panel(FlexDir::Column).gap(2.0);
618                if let Some(t) = title {
619                    p = p.child(widget::label(t).color(0xFF_FFFF55));
620                }
621                p = p.child(widget::item_slot(&item.id));
622                if let Some(t) = text {
623                    p = p.child(widget::label(t).color(0xFF_CCCCAA));
624                }
625                p
626            }
627            BookPage::Crafting { recipe_id, text } => {
628                let mut p = widget::panel(FlexDir::Column).gap(2.0);
629                p = p.child(widget::label(format!("Crafting: {}", recipe_id)).color(0xFF_888888));
630                if let Some(t) = text {
631                    p = p.child(widget::label(t).color(0xFF_CCCCAA));
632                }
633                p
634            }
635            BookPage::Smelting { recipe_id, text } => {
636                let mut p = widget::panel(FlexDir::Column).gap(2.0);
637                p = p.child(widget::label(format!("Smelting: {}", recipe_id)).color(0xFF_888888));
638                if let Some(t) = text {
639                    p = p.child(widget::label(t).color(0xFF_CCCCAA));
640                }
641                p
642            }
643            BookPage::Empty => widget::spacer(),
644            _ => widget::label("(unsupported page)").color(0xFF_888888),
645        }
646    }
647
648    // Helper: add multiple children to a widget
649    trait WidgetExt {
650        fn child_many(self, children: Vec<Widget>) -> Self;
651    }
652    impl WidgetExt for Widget {
653        fn child_many(mut self, children: Vec<Widget>) -> Self {
654            for c in children {
655                self = self.child(c);
656            }
657            self
658        }
659    }
660}
661
662// ── JSON serialization ────────────────────────────────────────────────────────
663
664fn esc(s: &str) -> String {
665    let mut out = String::with_capacity(s.len());
666    for ch in s.chars() {
667        match ch {
668            '\\' => out.push_str("\\\\"),
669            '"' => out.push_str("\\\""),
670            '\n' => out.push_str("\\n"),
671            '\r' => out.push_str("\\r"),
672            '\t' => out.push_str("\\t"),
673            c if c.is_control() => {
674                out.push_str(&format!("\\u{:04x}", c as u32));
675            }
676            _ => out.push(ch),
677        }
678    }
679    out
680}
681
682impl BookPage {
683    /// The page's wire JSON (`{"type": "text", ...}`).
684    pub fn to_json(&self) -> String {
685        serde_json::to_string(self).unwrap_or_default()
686    }
687}
688
689impl BookEntry {
690    pub fn to_json(&self) -> String {
691        let pages: String = self
692            .pages
693            .iter()
694            .map(|p| p.to_json())
695            .collect::<Vec<_>>()
696            .join(",");
697        let icon = self
698            .icon
699            .as_deref()
700            .map(|s| format!(r#","icon":"{}""#, esc(s)))
701            .unwrap_or_default();
702        let adv = self
703            .advancement
704            .as_deref()
705            .map(|s| format!(r#","advancement":"{}""#, esc(s)))
706            .unwrap_or_default();
707        format!(
708            r#"{{"id":"{}","name":"{}","category":"{}","pages":[{}],"secret":{},"priority":{},"read_by_default":{}{}{}}}"#,
709            esc(&self.id),
710            esc(&self.name),
711            esc(&self.category),
712            pages,
713            self.secret,
714            self.priority,
715            self.read_by_default,
716            icon,
717            adv
718        )
719    }
720}
721
722impl BookCategory {
723    pub fn to_json(&self) -> String {
724        let desc = self
725            .description
726            .as_deref()
727            .map(|s| format!(r#","description":"{}""#, esc(s)))
728            .unwrap_or_default();
729        let icon = self
730            .icon
731            .as_deref()
732            .map(|s| format!(r#","icon":"{}""#, esc(s)))
733            .unwrap_or_default();
734        format!(
735            r#"{{"id":"{}","name":"{}","sortnum":{}{}{}}}"#,
736            esc(&self.id),
737            esc(&self.name),
738            self.sortnum,
739            desc,
740            icon
741        )
742    }
743}
744
745impl Book {
746    pub fn to_json(&self) -> String {
747        let cats: String = self
748            .categories
749            .iter()
750            .map(|c| c.to_json())
751            .collect::<Vec<_>>()
752            .join(",");
753        let entries: String = self
754            .entries
755            .iter()
756            .map(|e| e.to_json())
757            .collect::<Vec<_>>()
758            .join(",");
759        let author = self
760            .author
761            .as_deref()
762            .map(|s| format!(r#","author":"{}""#, esc(s)))
763            .unwrap_or_default();
764        let tab = self
765            .creative_tab
766            .as_deref()
767            .map(|s| format!(r#","creative_tab":"{}""#, esc(s)))
768            .unwrap_or_default();
769        format!(
770            r#"{{"id":"{}","name":"{}","nameplate_color":"{}","landing_text":"{}","book_texture":"{}","filler_texture":"{}","model":"{}","show_progress":{},"i18n":{},"use_resource_pack":{},"categories":[{}],"entries":[{}]{}{}}}"#,
771            esc(&self.id),
772            esc(&self.name),
773            esc(&self.nameplate_color),
774            esc(&self.landing_text),
775            esc(&self.book_texture),
776            esc(&self.filler_texture),
777            esc(&self.model),
778            self.show_progress,
779            self.i18n,
780            self.use_resource_pack,
781            cats,
782            entries,
783            author,
784            tab
785        )
786    }
787}
788// ── Tests ─────────────────────────────────────────────────────────────────────
789
790#[cfg(test)]
791mod wire_format_tests {
792    use super::*;
793
794    /// The hand-written `to_json` wire format must stay parseable by the
795    /// serde `Deserialize` impls (runtime side of the mod ↔ runtime boundary).
796    #[test]
797    fn book_to_json_roundtrips_through_serde() {
798        let book = Book::new("yog:test", "Test Book")
799            .author("Tester")
800            .landing_text("hello")
801            .add_category(BookCategory {
802                id: "c1".into(),
803                name: "Cat".into(),
804                description: Some("d".into()),
805                icon: Some("yog:item/ruby".into()),
806                icon_svg: None,
807                sortnum: 0,
808            })
809            .add_entry(BookEntry {
810                id: "e1".into(),
811                name: "Entry".into(),
812                category: "c1".into(),
813                pages: vec![
814                    text_page("plain"),
815                    spotlight_page(yog_registry::ItemDef::new("yog:ruby")),
816                    crafting_page("yog:r1"),
817                    smelting_page("yog:r2"),
818                    BookPage::Empty,
819                ],
820                icon: Some("yog:ruby".into()),
821                icon_svg: None,
822                secret: false,
823                priority: 0,
824                read_by_default: false,
825                advancement: None,
826            });
827
828        let json = book.to_json();
829        let parsed: Book = serde_json::from_str(&json)
830            .unwrap_or_else(|e| panic!("wire JSON failed to parse: {e}\njson: {json}"));
831        assert_eq!(parsed.id, "yog:test");
832        assert_eq!(parsed.entries.len(), 1);
833        assert_eq!(parsed.entries[0].pages.len(), 5);
834        match &parsed.entries[0].pages[1] {
835            BookPage::Spotlight { item, .. } => assert_eq!(item.id, "yog:ruby"),
836            p => panic!("expected spotlight, got {p:?}"),
837        }
838        match &parsed.entries[0].pages[2] {
839            BookPage::Crafting { recipe_id, .. } => assert_eq!(recipe_id, "yog:r1"),
840            p => panic!("expected crafting, got {p:?}"),
841        }
842    }
843}
844
845#[cfg(test)]
846mod localize_tests {
847    use super::*;
848
849    #[test]
850    fn localized_translates_every_text() {
851        let book = Book::new("m:b", "m.book.name")
852            .landing_text("m.book.landing")
853            .add_category(BookCategory {
854                id: "basics".into(),
855                name: "m.cat".into(),
856                ..Default::default()
857            })
858            .add_entry(BookEntry {
859                id: "e".into(),
860                name: "m.entry".into(),
861                category: "basics".into(),
862                pages: vec![text_page_titled("m.title", "m.text"), text_page("raw text")],
863                ..Default::default()
864            });
865        let out = book.localized(|k| {
866            if k.starts_with("m.") {
867                k.to_uppercase()
868            } else {
869                k.to_string()
870            }
871        });
872        assert_eq!(out.name, "M.BOOK.NAME");
873        assert_eq!(out.landing_text, "M.BOOK.LANDING");
874        assert_eq!(out.categories[0].name, "M.CAT");
875        assert_eq!(out.entries[0].name, "M.ENTRY");
876        match &out.entries[0].pages[0] {
877            BookPage::Text { text, title } => {
878                assert_eq!(text, "M.TEXT");
879                assert_eq!(title.as_deref(), Some("M.TITLE"));
880            }
881            p => panic!("{p:?}"),
882        }
883        assert!(
884            matches!(&out.entries[0].pages[1], BookPage::Text { text, .. } if text == "raw text")
885        );
886        assert_eq!(out.entries[0].id, "e");
887    }
888}