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 std::path::{Path, PathBuf};
9use yog_ids::Format;
10
11use std::borrow::Cow;
12use yog_book::layout_picture::{Piece, pieces};
13use yog_book::renderer::{BLOCK_PITCH, SLOT_YAW};
14use yog_data::Schema;
15use yog_formats::book::layout::{LayoutPart, RecipeLayout};
16use yog_formats::def::{Def, RecipeKindDef};
17use yog_gfx::{Color, Vec2};
18use yog_registry::recipe_kinds::{self, fields};
19
20use crate::design::{assets::Resources, gfx, images, raster};
21
22/// How much bigger than GUI pixels the picture is drawn.
23const SCALE: u32 = 3;
24
25#[derive(Debug, yog_data::Schema)]
26#[data(rename_all = "camelCase")]
27pub struct Slot {
28    pub name: String,
29    /// What editors call it; `name` if none.
30    pub label: Option<String>,
31    pub description: Option<String>,
32    pub max: u32,
33    pub required: bool,
34    /// The game's kinds: the `.yrecipe` field it's written in.
35    pub field: Option<String>,
36    /// Its places are a crafting grid's (`pattern` and `key`).
37    pub grid: bool,
38}
39
40#[derive(Debug, yog_data::Schema)]
41#[data(rename_all = "camelCase")]
42pub struct ValueSpec {
43    pub name: String,
44    /// `number`, `text` or `bool` — the format's word for its type.
45    pub kind: String,
46    /// What it is when a recipe doesn't say.
47    pub default: Option<f64>,
48}
49
50/// Something the editor puts on the picture, in GUI pixels.
51#[derive(Debug, yog_data::Schema)]
52#[data(rename_all = "camelCase", tag = "type")]
53pub enum Spot {
54    /// The `index`-th ingredient of `slot`.
55    Slot {
56        slot: String,
57        index: usize,
58        at: [f32; 2],
59    },
60    Result {
61        at: [f32; 2],
62    },
63    Count {
64        at: [f32; 2],
65    },
66    /// `{name}` is the recipe's value of that name.
67    Text {
68        text: String,
69        at: [f32; 2],
70    },
71}
72
73#[derive(Debug, yog_data::Schema)]
74#[data(rename_all = "camelCase")]
75pub struct Layout {
76    /// The `.yui` it's laid out in; empty for a built-in layout.
77    pub file: String,
78    pub size: [f32; 2],
79    /// The picture, `SCALE`× GUI pixels, `data:image/png;base64,…`.
80    pub image: Option<String>,
81    pub scale: u32,
82    pub spots: Vec<Spot>,
83}
84
85#[derive(Debug, yog_data::Schema)]
86#[data(rename_all = "camelCase")]
87pub struct Kind {
88    pub id: String,
89    /// What editors call it; the id's path if none.
90    pub label: Option<String>,
91    /// The `.ydef` that declares it; empty for the game's.
92    pub file: String,
93    pub slots: Vec<Slot>,
94    pub values: Vec<ValueSpec>,
95    pub layout: Option<Layout>,
96}
97
98/// The fields every `.yrecipe` has, by the names the format gives them
99/// (`yog_registry::recipe_kinds::fields`) — the editor reads and writes
100/// recipes with these.
101#[derive(Debug, yog_data::Schema)]
102#[data(rename_all = "camelCase")]
103pub struct Fields {
104    #[data(rename = "type")]
105    pub kind: Cow<'static, str>,
106    pub result: Cow<'static, str>,
107    pub count: Cow<'static, str>,
108    pub slots: Cow<'static, str>,
109    pub values: Cow<'static, str>,
110    pub pattern: Cow<'static, str>,
111    pub key: Cow<'static, str>,
112    pub grid_side: usize,
113}
114
115/// Every kind the editor knows for the mod at `root`, and the fields.
116#[derive(Debug, yog_data::Schema)]
117pub struct Kinds {
118    pub game: Vec<Kind>,
119    pub own: Vec<Kind>,
120    pub fields: Fields,
121}
122
123pub const FIELDS: Fields = Fields {
124    kind: Cow::Borrowed(fields::TYPE),
125    result: Cow::Borrowed(fields::RESULT),
126    count: Cow::Borrowed(fields::COUNT),
127    slots: Cow::Borrowed(fields::SLOTS),
128    values: Cow::Borrowed(fields::VALUES),
129    pattern: Cow::Borrowed(fields::PATTERN),
130    key: Cow::Borrowed(fields::KEY),
131    grid_side: recipe_kinds::GRID_SIDE,
132};
133
134/// The kinds for the mod at `root` (none of the mod's without one).
135pub fn all(root: Option<&Path>) -> Kinds {
136    Kinds {
137        game: root.map(game_kinds).unwrap_or_default(),
138        own: root.map(kinds).unwrap_or_default(),
139        fields: FIELDS,
140    }
141}
142
143/// The game's kinds, as books show them: their built-in layouts, or the
144/// mod's own `recipe_layout` for one.
145pub fn game_kinds(root: &Path) -> Vec<Kind> {
146    let layouts = crate::design::mod_ui::recipe_layouts(root);
147    recipe_kinds::GAME
148        .iter()
149        .map(|k| describe(root, k, k.id.clone(), String::new(), &layouts))
150        .collect()
151}
152
153/// The kinds the mod at `root` declares, by id.
154pub fn kinds(root: &Path) -> Vec<Kind> {
155    let ns = crate::design::project::mod_id(root).unwrap_or_default();
156    let files = crate::files::of(&root.join(yog_ids::project::CONTENT), Format::Def);
157    let layouts = crate::design::mod_ui::recipe_layouts(root);
158    let mut out: Vec<Kind> = files
159        .iter()
160        .filter_map(|file| {
161            let text = crate::files::read_source(file).ok()?;
162            let Def::RecipeKind(k) = yog_data::from_text::<Def>(&text).ok()?.value else {
163                return None;
164            };
165            let id = if k.id.contains(':') {
166                k.id.clone()
167            } else {
168                format!("{ns}:{}", k.id)
169            };
170            Some(describe(
171                root,
172                &k,
173                id,
174                file.to_string_lossy().into_owned(),
175                &layouts,
176            ))
177        })
178        .collect();
179    out.sort_by(|a, b| a.id.cmp(&b.id));
180    out
181}
182
183/// Kind `k` (as `id`, declared in `file`) for the editor: the mod's layout
184/// for it, else a built-in one.
185fn describe(
186    root: &Path,
187    k: &RecipeKindDef,
188    id: String,
189    file: String,
190    layouts: &[(PathBuf, RecipeLayout)],
191) -> Kind {
192    let own: Vec<RecipeLayout> = layouts.iter().map(|(_, l)| l.clone()).collect();
193    let layout_file = layouts
194        .iter()
195        .find(|(_, l)| l.kinds.contains(&id))
196        .map(|(f, _)| f.clone())
197        .unwrap_or_default();
198    Kind {
199        label: k.name.clone(),
200        file,
201        slots: k
202            .slots
203            .0
204            .iter()
205            .map(|(name, s)| Slot {
206                name: name.clone(),
207                label: s.name.clone(),
208                description: s.description.clone(),
209                max: s.max,
210                required: s.required,
211                field: s.field.clone(),
212                grid: s.grid,
213            })
214            .collect(),
215        values: k
216            .values
217            .0
218            .iter()
219            .map(|(name, t)| ValueSpec {
220                name: name.clone(),
221                kind: t.write().as_str().unwrap_or_default().to_string(),
222                default: k.default_of(name),
223            })
224            .collect(),
225        layout: yog_formats::book::layout::find(&own, &id).map(|l| layout(root, &layout_file, l)),
226        id,
227    }
228}
229
230/// The picture of `layout` (what doesn't change with the recipe) and the spots.
231fn layout(root: &Path, file: &Path, layout: &RecipeLayout) -> Layout {
232    let size = layout.size;
233    let mut spots = Vec::new();
234    for p in &layout.parts {
235        match p {
236            LayoutPart::Slots { slot, at, .. } => {
237                spots.extend(at.iter().enumerate().map(|(index, at)| Spot::Slot {
238                    slot: slot.clone(),
239                    index,
240                    at: *at,
241                }));
242            }
243            LayoutPart::Result { at, .. } => spots.push(Spot::Result { at: *at }),
244            LayoutPart::Count { at } => spots.push(Spot::Count { at: *at }),
245            LayoutPart::Text { text, at } => spots.push(Spot::Text {
246                text: text.clone(),
247                at: *at,
248            }),
249            LayoutPart::Image { .. } | LayoutPart::Item { .. } | LayoutPart::Block { .. } => {}
250        }
251    }
252    let (w, h) = (
253        size[0].ceil().max(1.0) as u32,
254        size[1].ceil().max(1.0) as u32,
255    );
256    let ops = gfx::record(w as i32, h as i32, |ctx| {
257        let d = ctx.draw2d();
258        for piece in pieces(layout) {
259            match piece {
260                Piece::Image { source, uv, at } => {
261                    d.image_region(source, at, Some(uv), Color::WHITE)
262                }
263                Piece::Item { item, at } => d.item(item, Vec2::new(at.x, at.y), at.w),
264                Piece::Block { block, at } => {
265                    d.blocks(&[((0, 0, 0), block)], at, SLOT_YAW, BLOCK_PITCH)
266                }
267            }
268        }
269    });
270    images::set_document(Some(root));
271    let res = Resources::for_mod(Some(root));
272    let image = raster::rasterize(&ops, w, h, SCALE, &res).png_url();
273    Layout {
274        file: file.to_string_lossy().into_owned(),
275        size,
276        image,
277        scale: SCALE,
278        spots,
279    }
280}
281
282/// Where a new kind's layout goes, under the mod's sources, and what its name ends with.
283const LAYOUT_DIR: &str = "ui";
284const LAYOUT_SUFFIX: &str = "_layout";
285
286/// Makes a new kind of recipe in the mod of `file` — `content/<name>.ydef`
287/// (its slots and values) and `src/ui/<name>_layout.yui` (how it's shown);
288/// the layout's path, to open in the designer.
289pub fn create(file: &Path, name: &str) -> Result<PathBuf, String> {
290    let root = super::mod_root(file).ok_or("not inside a mod")?;
291    let name = name.trim();
292    if !yog_ids::valid_name(name) {
293        return Err("a kind's name: a-z, 0-9 and _".into());
294    }
295    let def = root
296        .join(yog_ids::project::CONTENT)
297        .join(Format::Def.file(name));
298    let layout = root
299        .join(yog_ids::project::SRC)
300        .join(LAYOUT_DIR)
301        .join(Format::Ui.file(&format!("{name}{LAYOUT_SUFFIX}")));
302    if let Some(taken) = [&def, &layout].into_iter().find(|f| f.exists()) {
303        return Err(format!("{} exists", taken.display()));
304    }
305    for (f, text) in [(&def, template(name)), (&layout, layout_template(name))] {
306        if let Some(dir) = f.parent() {
307            std::fs::create_dir_all(dir).map_err(|e| e.to_string())?;
308        }
309        if yog_ids::Format::of_path(f).is_some_and(|f| f.is_data()) {
310            crate::files::write_source(f, &text)?;
311        } else {
312            std::fs::write(f, text).map_err(|e| e.to_string())?;
313        }
314    }
315    Ok(layout)
316}
317
318/// A new kind's `.ydef`: two slots and a value to start from.
319pub fn template(id: &str) -> String {
320    format!(
321        r#"{{
322  kind: "recipe_kind",
323  id: "{id}",
324  // Named slots: how many ingredients each takes.
325  slots: {{
326    input: {{ required: true }},
327    extra: {{ max: 2 }},
328  }},
329  // Named values a recipe of the kind gives (number, text, bool).
330  values: {{ time: "number" }},
331}}
332"#
333    )
334}
335
336/// A new kind's layout (`src/ui/<id>_layout.yui`), for the screen designer:
337/// the template's slots framed, the result, its time.
338pub fn layout_template(id: &str) -> String {
339    // What the designer shows in them: the game's ore and its ingot.
340    let (input, result) = (yog_ids::qualify("iron_ore"), yog_ids::qualify("iron_ingot"));
341    format!(
342        r#"<!-- How recipes of {id} are shown: in books and in the recipe editor. -->
343<Component recipe_layout="{id}" width="100" height="40">
344    <RecipeSlot slot="input" x="4" y="12" frame="true" d:item="{input}"/>
345    <RecipeSlot slot="extra" x="30" y="4" frame="true"/>
346    <RecipeSlot slot="extra" x="30" y="22" frame="true"/>
347    <RecipeResult x="78" y="12" frame="true" d:item="{result}"/>
348    <Label text="{{x:Bind time}} ticks" x="50" y="32"/>
349</Component>
350"#
351    )
352}
353
354#[cfg(test)]
355mod tests {
356    use super::*;
357
358    #[test]
359    fn reads_the_mods_kinds_and_lays_them_out() {
360        let dir = std::env::temp_dir().join(format!("yog-idle-kinds-{}", std::process::id()));
361        let _ = std::fs::remove_dir_all(&dir);
362        std::fs::create_dir_all(dir.join("content/machines")).unwrap();
363        std::fs::write(dir.join("yog.toml"), "[mod]\nid = \"mymod\"\n").unwrap();
364        std::fs::write(
365            dir.join("content/machines/crusher.ydef"),
366            template("crusher"),
367        )
368        .unwrap();
369        std::fs::create_dir_all(dir.join("src/ui")).unwrap();
370        std::fs::write(
371            dir.join("src/ui/crusher_layout.yui"),
372            layout_template("crusher"),
373        )
374        .unwrap();
375        std::fs::write(
376            dir.join("content/other.ydef"),
377            r#"{ kind: "sound_group", id: "x" }"#,
378        )
379        .unwrap();
380        let ks = kinds(&dir);
381        assert_eq!(ks.len(), 1);
382        let k = &ks[0];
383        assert_eq!(k.id, "mymod:crusher");
384        assert_eq!(
385            k.slots
386                .iter()
387                .map(|s| (s.name.as_str(), s.max, s.required))
388                .collect::<Vec<_>>(),
389            [("input", 1, true), ("extra", 2, false)]
390        );
391        assert_eq!(k.values[0].kind, "number");
392        let l = k.layout.as_ref().unwrap();
393        assert!(
394            l.image
395                .as_deref()
396                .is_some_and(|i| i.starts_with("data:image/png"))
397        );
398        assert_eq!(
399            l.spots
400                .iter()
401                .filter(|s| matches!(s, Spot::Slot { .. }))
402                .count(),
403            3
404        );
405        assert!(
406            l.spots
407                .iter()
408                .any(|s| matches!(s, Spot::Text { text, .. } if text == "{time} ticks"))
409        );
410        assert!(l.file.ends_with("crusher_layout.yui"));
411        let game = game_kinds(&dir);
412        assert_eq!(
413            game.iter().map(|k| k.id.as_str()).collect::<Vec<_>>(),
414            recipe_kinds::ids::ALL,
415            "the registry's kinds, in its order"
416        );
417        let shaped = game[0].layout.as_ref().unwrap();
418        assert_eq!(
419            shaped
420                .spots
421                .iter()
422                .filter(|s| matches!(s, Spot::Slot { slot, .. } if slot == "grid"))
423                .count(),
424            9,
425            "the crafting table's own picture"
426        );
427        let smithing = game
428            .iter()
429            .find(|k| k.id == recipe_kinds::ids::SMITHING)
430            .unwrap()
431            .layout
432            .as_ref()
433            .unwrap();
434        let parts: Vec<&str> = smithing
435            .spots
436            .iter()
437            .filter_map(|s| {
438                if let Spot::Slot { slot, .. } = s {
439                    Some(slot.as_str())
440                } else {
441                    None
442                }
443            })
444            .collect();
445        assert_eq!(
446            parts,
447            ["base", "addition", "template"],
448            "Patchouli's smithing strip"
449        );
450        assert!(
451            smithing
452                .spots
453                .iter()
454                .any(|s| matches!(s, Spot::Result { .. }))
455        );
456        assert!(smithing.image.is_some());
457        let brewing = game
458            .iter()
459            .find(|k| k.id == recipe_kinds::ids::BREWING)
460            .unwrap();
461        assert_eq!(
462            brewing
463                .slots
464                .iter()
465                .map(|s| s.field.as_deref())
466                .collect::<Vec<_>>(),
467            [Some("from"), Some("ingredient")]
468        );
469        assert!(
470            brewing.layout.as_ref().is_some_and(|l| l.image.is_some()),
471            "Yog's own brewing strip"
472        );
473        // What the recipe editor reads (recipeModel.ts `RecipeKind`).
474        let spots = yog_data::Schema::write(&l.spots);
475        let types: Vec<&str> = spots
476            .as_list()
477            .unwrap()
478            .iter()
479            .map(|s| s.get("type").and_then(yog_data::Value::as_str).unwrap())
480            .collect();
481        assert_eq!(types, ["slot", "slot", "slot", "result", "text"]);
482        let _ = std::fs::remove_dir_all(&dir);
483    }
484}