Skip to main content

yog_cli/design/
recipe_kinds.rs

1//! Kinds of recipe for the recipe editor — the game's (`yog_registry`'s
2//! built-in definitions) and the mod's own (`.ydef` `kind: "recipe_kind"`),
3//! all described the same way: their slots (and the `.yrecipe` fields the
4//! game's are written in), their values, and — the layout books show them
5//! by (built in, or a `.yui` `recipe_layout`) — the picture (images, the
6//! station, slot frames) with where slots, result and texts go on it.
7
8use yog_ids::Format;
9use std::path::{Path, PathBuf};
10
11use base64::Engine as _;
12use serde::Serialize;
13use yog_book::layout_picture::{Piece, pieces};
14use yog_book::renderer::{BLOCK_PITCH, SLOT_YAW};
15use yog_formats::book::layout::{LayoutPart, RecipeLayout};
16use yog_data::Schema;
17use yog_formats::def::{Def, RecipeKindDef};
18use yog_gfx::{Color, Vec2};
19use yog_registry::recipe_kinds::{self, fields};
20
21use crate::design::{assets::Resources, gfx, images, raster};
22
23/// How much bigger than GUI pixels the picture is drawn.
24const SCALE: u32 = 3;
25
26#[derive(Debug, Serialize)]
27#[serde(rename_all = "camelCase")]
28pub struct Slot {
29    pub name: String,
30    /// What editors call it; `name` if none.
31    pub label: Option<String>,
32    pub description: Option<String>,
33    pub max: u32,
34    pub required: bool,
35    /// The game's kinds: the `.yrecipe` field it's written in.
36    pub field: Option<String>,
37    /// Its places are a crafting grid's (`pattern` and `key`).
38    pub grid: bool,
39}
40
41#[derive(Debug, Serialize)]
42#[serde(rename_all = "camelCase")]
43pub struct ValueSpec {
44    pub name: String,
45    /// `number`, `text` or `bool` — the format's word for its type.
46    pub kind: String,
47    /// What it is when a recipe doesn't say.
48    pub default: Option<f64>,
49}
50
51/// Something the editor puts on the picture, in GUI pixels.
52#[derive(Debug, Serialize)]
53#[serde(rename_all = "camelCase", tag = "type")]
54pub enum Spot {
55    /// The `index`-th ingredient of `slot`.
56    Slot { slot: String, index: usize, at: [f32; 2] },
57    Result { at: [f32; 2] },
58    Count { at: [f32; 2] },
59    /// `{name}` is the recipe's value of that name.
60    Text { text: String, at: [f32; 2] },
61}
62
63#[derive(Debug, Serialize)]
64#[serde(rename_all = "camelCase")]
65pub struct Layout {
66    /// The `.yui` it's laid out in; empty for a built-in layout.
67    pub file: String,
68    pub size: [f32; 2],
69    /// The picture, `SCALE`× GUI pixels, `data:image/png;base64,…`.
70    pub image: Option<String>,
71    pub scale: u32,
72    pub spots: Vec<Spot>,
73}
74
75#[derive(Debug, Serialize)]
76#[serde(rename_all = "camelCase")]
77pub struct Kind {
78    pub id: String,
79    /// What editors call it; the id's path if none.
80    pub label: Option<String>,
81    /// The `.ydef` that declares it; empty for the game's.
82    pub file: String,
83    pub slots: Vec<Slot>,
84    pub values: Vec<ValueSpec>,
85    pub layout: Option<Layout>,
86}
87
88/// The fields every `.yrecipe` has, by the names the format gives them
89/// (`yog_registry::recipe_kinds::fields`) — the editor reads and writes
90/// recipes with these.
91#[derive(Debug, Serialize)]
92#[serde(rename_all = "camelCase")]
93pub struct Fields {
94    #[serde(rename = "type")]
95    pub kind: &'static str,
96    pub result: &'static str,
97    pub count: &'static str,
98    pub slots: &'static str,
99    pub values: &'static str,
100    pub pattern: &'static str,
101    pub key: &'static str,
102    pub grid_side: usize,
103}
104
105/// Every kind the editor knows for the mod at `root`, and the fields.
106#[derive(Debug, Serialize)]
107pub struct Kinds {
108    pub game: Vec<Kind>,
109    pub own: Vec<Kind>,
110    pub fields: Fields,
111}
112
113pub const FIELDS: Fields = Fields {
114    kind: fields::TYPE,
115    result: fields::RESULT,
116    count: fields::COUNT,
117    slots: fields::SLOTS,
118    values: fields::VALUES,
119    pattern: fields::PATTERN,
120    key: fields::KEY,
121    grid_side: recipe_kinds::GRID_SIDE,
122};
123
124/// The kinds for the mod at `root` (none of the mod's without one).
125pub fn all(root: Option<&Path>) -> Kinds {
126    Kinds {
127        game: root.map(game_kinds).unwrap_or_default(),
128        own: root.map(kinds).unwrap_or_default(),
129        fields: FIELDS,
130    }
131}
132
133/// The game's kinds, as books show them: their built-in layouts, or the
134/// mod's own `recipe_layout` for one.
135pub fn game_kinds(root: &Path) -> Vec<Kind> {
136    let layouts = crate::design::mod_ui::recipe_layouts(root);
137    recipe_kinds::GAME.iter().map(|k| describe(root, k, k.id.clone(), String::new(), &layouts)).collect()
138}
139
140/// The kinds the mod at `root` declares, by id.
141pub fn kinds(root: &Path) -> Vec<Kind> {
142    let ns = crate::design::project::mod_id(root).unwrap_or_default();
143    let files = crate::design::files::of(&root.join("content"), Format::Def);
144    let layouts = crate::design::mod_ui::recipe_layouts(root);
145    let mut out: Vec<Kind> = files
146        .iter()
147        .filter_map(|file| {
148            let text = std::fs::read_to_string(file).ok()?;
149            let Def::RecipeKind(k) = yog_data::from_text::<Def>(&text).ok()?.value else { return None };
150            let id = if k.id.contains(':') { k.id.clone() } else { format!("{ns}:{}", k.id) };
151            Some(describe(root, &k, id, file.to_string_lossy().into_owned(), &layouts))
152        })
153        .collect();
154    out.sort_by(|a, b| a.id.cmp(&b.id));
155    out
156}
157
158/// Kind `k` (as `id`, declared in `file`) for the editor: the mod's layout
159/// for it, else a built-in one.
160fn describe(root: &Path, k: &RecipeKindDef, id: String, file: String, layouts: &[(PathBuf, RecipeLayout)]) -> Kind {
161    let own: Vec<RecipeLayout> = layouts.iter().map(|(_, l)| l.clone()).collect();
162    let layout_file = layouts.iter().find(|(_, l)| l.kinds.contains(&id)).map(|(f, _)| f.clone()).unwrap_or_default();
163    Kind {
164        label: k.name.clone(),
165        file,
166        slots: k
167            .slots
168            .0
169            .iter()
170            .map(|(name, s)| Slot {
171                name: name.clone(),
172                label: s.name.clone(),
173                description: s.description.clone(),
174                max: s.max,
175                required: s.required,
176                field: s.field.clone(),
177                grid: s.grid,
178            })
179            .collect(),
180        values: k
181            .values
182            .0
183            .iter()
184            .map(|(name, t)| ValueSpec {
185                name: name.clone(),
186                kind: t.write().as_str().unwrap_or_default().to_string(),
187                default: k.default_of(name),
188            })
189            .collect(),
190        layout: yog_formats::book::layout::find(&own, &id).map(|l| layout(root, &layout_file, l)),
191        id,
192    }
193}
194
195/// The picture of `layout` (what doesn't change with the recipe) and the spots.
196fn layout(root: &Path, file: &Path, layout: &RecipeLayout) -> Layout {
197    let size = layout.size;
198    let mut spots = Vec::new();
199    for p in &layout.parts {
200        match p {
201            LayoutPart::Slots { slot, at, .. } => {
202                spots.extend(at.iter().enumerate().map(|(index, at)| Spot::Slot { slot: slot.clone(), index, at: *at }));
203            }
204            LayoutPart::Result { at, .. } => spots.push(Spot::Result { at: *at }),
205            LayoutPart::Count { at } => spots.push(Spot::Count { at: *at }),
206            LayoutPart::Text { text, at } => spots.push(Spot::Text { text: text.clone(), at: *at }),
207            LayoutPart::Image { .. } | LayoutPart::Item { .. } | LayoutPart::Block { .. } => {}
208        }
209    }
210    let (w, h) = (size[0].ceil().max(1.0) as u32, size[1].ceil().max(1.0) as u32);
211    let ops = gfx::record(w as i32, h as i32, |ctx| {
212        let d = ctx.draw2d();
213        for piece in pieces(layout) {
214            match piece {
215                Piece::Image { source, uv, at } => d.image_region(source, at, Some(uv), Color::WHITE),
216                Piece::Item { item, at } => d.item(item, Vec2::new(at.x, at.y), at.w),
217                Piece::Block { block, at } => d.blocks(&[((0, 0, 0), block)], at, SLOT_YAW, BLOCK_PITCH),
218            }
219        }
220    });
221    images::set_document(Some(root));
222    let res = Resources::for_mod(Some(root));
223    let image = raster::rasterize(&ops, w, h, SCALE, &res)
224        .png()
225        .ok()
226        .map(|png| format!("data:image/png;base64,{}", base64::engine::general_purpose::STANDARD.encode(png)));
227    Layout { file: file.to_string_lossy().into_owned(), size, image, scale: SCALE, spots }
228}
229
230/// Where a new kind's layout goes, under the mod's sources, and what its name ends with.
231const LAYOUT_DIR: &str = "ui";
232const LAYOUT_SUFFIX: &str = "_layout";
233
234/// Makes a new kind of recipe in the mod of `file` — `content/<name>.ydef`
235/// (its slots and values) and `src/ui/<name>_layout.yui` (how it's shown);
236/// the layout's path, to open in the designer.
237pub fn create(file: &Path, name: &str) -> Result<PathBuf, String> {
238    let root = super::mod_root(file).ok_or("not inside a mod")?;
239    let name = name.trim();
240    if name.is_empty() || !name.chars().all(|c| c.is_ascii_lowercase() || c.is_ascii_digit() || c == '_') {
241        return Err("a kind's name: a-z, 0-9 and _".into());
242    }
243    let def = root.join(yog_ids::project::CONTENT).join(Format::Def.file(name));
244    let layout = root.join(yog_ids::project::SRC).join(LAYOUT_DIR).join(Format::Ui.file(&format!("{name}{LAYOUT_SUFFIX}")));
245    if let Some(taken) = [&def, &layout].into_iter().find(|f| f.exists()) {
246        return Err(format!("{} exists", taken.display()));
247    }
248    for (f, text) in [(&def, template(name)), (&layout, layout_template(name))] {
249        if let Some(dir) = f.parent() {
250            std::fs::create_dir_all(dir).map_err(|e| e.to_string())?;
251        }
252        std::fs::write(f, text).map_err(|e| e.to_string())?;
253    }
254    Ok(layout)
255}
256
257/// A new kind's `.ydef`: two slots and a value to start from.
258pub fn template(id: &str) -> String {
259    format!(
260        r#"{{
261  kind: "recipe_kind",
262  id: "{id}",
263  // Named slots: how many ingredients each takes.
264  slots: {{
265    input: {{ required: true }},
266    extra: {{ max: 2 }},
267  }},
268  // Named values a recipe of the kind gives (number, text, bool).
269  values: {{ time: "number" }},
270}}
271"#
272    )
273}
274
275/// A new kind's layout (`src/ui/<id>_layout.yui`), for the screen designer:
276/// the template's slots framed, the result, its time.
277pub fn layout_template(id: &str) -> String {
278    format!(
279        r#"<!-- How recipes of {id} are shown: in books and in the recipe editor. -->
280<Component recipe_layout="{id}" width="100" height="40">
281    <RecipeSlot slot="input" x="4" y="12" frame="true" d:item="minecraft:iron_ore"/>
282    <RecipeSlot slot="extra" x="30" y="4" frame="true"/>
283    <RecipeSlot slot="extra" x="30" y="22" frame="true"/>
284    <RecipeResult x="78" y="12" frame="true" d:item="minecraft:iron_ingot"/>
285    <Label text="{{x:Bind time}} ticks" x="50" y="32"/>
286</Component>
287"#
288    )
289}
290
291#[cfg(test)]
292mod tests {
293    use super::*;
294
295    #[test]
296    fn reads_the_mods_kinds_and_lays_them_out() {
297        let dir = std::env::temp_dir().join(format!("yog-idle-kinds-{}", std::process::id()));
298        let _ = std::fs::remove_dir_all(&dir);
299        std::fs::create_dir_all(dir.join("content/machines")).unwrap();
300        std::fs::write(dir.join("yog.toml"), "[mod]\nid = \"mymod\"\n").unwrap();
301        std::fs::write(dir.join("content/machines/crusher.ydef"), template("crusher")).unwrap();
302        std::fs::create_dir_all(dir.join("src/ui")).unwrap();
303        std::fs::write(dir.join("src/ui/crusher_layout.yui"), layout_template("crusher")).unwrap();
304        std::fs::write(dir.join("content/other.ydef"), r#"{ kind: "sound_group", id: "x" }"#).unwrap();
305        let ks = kinds(&dir);
306        assert_eq!(ks.len(), 1);
307        let k = &ks[0];
308        assert_eq!(k.id, "mymod:crusher");
309        assert_eq!(k.slots.iter().map(|s| (s.name.as_str(), s.max, s.required)).collect::<Vec<_>>(), [("input", 1, true), ("extra", 2, false)]);
310        assert_eq!(k.values[0].kind, "number");
311        let l = k.layout.as_ref().unwrap();
312        assert!(l.image.as_deref().is_some_and(|i| i.starts_with("data:image/png")));
313        assert_eq!(l.spots.iter().filter(|s| matches!(s, Spot::Slot { .. })).count(), 3);
314        assert!(l.spots.iter().any(|s| matches!(s, Spot::Text { text, .. } if text == "{time} ticks")));
315        assert!(l.file.ends_with("crusher_layout.yui"));
316        let game = game_kinds(&dir);
317        assert_eq!(game.iter().map(|k| k.id.as_str()).collect::<Vec<_>>(), recipe_kinds::ids::ALL, "the registry's kinds, in its order");
318        let shaped = game[0].layout.as_ref().unwrap();
319        assert_eq!(shaped.spots.iter().filter(|s| matches!(s, Spot::Slot { slot, .. } if slot == "grid")).count(), 9, "the crafting table's own picture");
320        let smithing = game.iter().find(|k| k.id == recipe_kinds::ids::SMITHING).unwrap().layout.as_ref().unwrap();
321        let parts: Vec<&str> = smithing.spots.iter().filter_map(|s| if let Spot::Slot { slot, .. } = s { Some(slot.as_str()) } else { None }).collect();
322        assert_eq!(parts, ["base", "addition", "template"], "Patchouli's smithing strip");
323        assert!(smithing.spots.iter().any(|s| matches!(s, Spot::Result { .. })));
324        assert!(smithing.image.is_some());
325        let brewing = game.iter().find(|k| k.id == recipe_kinds::ids::BREWING).unwrap();
326        assert_eq!(brewing.slots.iter().map(|s| s.field.as_deref()).collect::<Vec<_>>(), [Some("from"), Some("ingredient")]);
327        assert!(brewing.layout.as_ref().is_some_and(|l| l.image.is_some()), "Yog's own brewing strip");
328        // What the recipe editor reads (recipeModel.ts `RecipeKind`).
329        let json = serde_json::to_value(&l.spots).unwrap();
330        let types: Vec<&str> = json.as_array().unwrap().iter().map(|s| s["type"].as_str().unwrap()).collect();
331        assert_eq!(types, ["slot", "slot", "slot", "result", "text"]);
332        let _ = std::fs::remove_dir_all(&dir);
333    }
334}