Skip to main content

flatland_presentation/
validate.rs

1//! Validate `sprite_modes` maps against gfx sprite sheets.
2
3use crate::{SpriteCatalog, SpriteModeMap};
4
5/// Mode ids used to validate an entity's `sprite_modes` (PNG sheet and/or baked paperdoll).
6pub fn presentation_validation_mode_ids(
7    catalog: &SpriteCatalog,
8    tile_id: Option<&str>,
9    paperdoll_ref: Option<&str>,
10) -> Vec<String> {
11    if let Some(skin) = paperdoll_ref.filter(|s| !s.is_empty()) {
12        let sheet = format!("paperdoll.{skin}");
13        let modes = catalog.mode_ids(&sheet);
14        if !modes.is_empty() {
15            return modes.to_vec();
16        }
17    }
18    if let Some(tid) = tile_id.filter(|s| !s.is_empty()) {
19        return crate::presentation_sheet_mode_ids(catalog, tid, paperdoll_ref);
20    }
21    Vec::new()
22}
23
24/// Validate `sprite_modes` values against PNG and/or paperdoll bake mode lists.
25pub fn validate_sprite_modes_for_presentation(
26    owner_label: &str,
27    tile_id: Option<&str>,
28    paperdoll_ref: Option<&str>,
29    sprite_modes: &SpriteModeMap,
30    catalog: &SpriteCatalog,
31) -> Vec<String> {
32    let mut errors = Vec::new();
33    if sprite_modes.is_empty() {
34        return errors;
35    }
36    let sheet_modes = presentation_validation_mode_ids(catalog, tile_id, paperdoll_ref);
37    if sheet_modes.is_empty() {
38        errors.push(format!(
39            "{owner_label}: sprite_modes set but no modes found (tile_id={tile_id:?}, paperdoll_ref={paperdoll_ref:?})"
40        ));
41        return errors;
42    }
43    for (key, mode) in sprite_modes {
44        if mode_exists_on_sheet(mode, &sheet_modes) {
45            continue;
46        }
47        if npc_sheet_alias(key, &sheet_modes).is_some() {
48            continue;
49        }
50        errors.push(format!(
51            "{owner_label}: sprite_modes.{key} = '{mode}' not on presentation sheet (have: {})",
52            sheet_modes.join(", ")
53        ));
54    }
55    errors
56}
57
58/// Validate that every value in `sprite_modes` exists on the sheet for `tile_id`.
59pub fn validate_sprite_mode_maps(
60    owner_label: &str,
61    tile_id: Option<&str>,
62    sprite_modes: &SpriteModeMap,
63    catalog: &SpriteCatalog,
64) -> Vec<String> {
65    let mut errors = Vec::new();
66    let Some(tile_id) = tile_id.filter(|s| !s.is_empty()) else {
67        if !sprite_modes.is_empty() {
68            errors.push(format!(
69                "{owner_label}: sprite_modes set but tile_id is missing"
70            ));
71        }
72        return errors;
73    };
74    if sprite_modes.is_empty() {
75        return errors;
76    }
77    let sheet_modes = catalog.mode_ids(tile_id);
78    if sheet_modes.is_empty() {
79        errors.push(format!(
80            "{owner_label}: tile_id '{tile_id}' not found in sprite catalog or has no modes"
81        ));
82        return errors;
83    }
84    for (key, mode) in sprite_modes {
85        if !mode_exists_on_sheet(mode, &sheet_modes) {
86            errors.push(format!(
87                "{owner_label}: sprite_modes.{key} = '{mode}' not on sheet '{tile_id}' (have: {})",
88                sheet_modes.join(", ")
89            ));
90        }
91    }
92    errors
93}
94
95/// True when `mode` is on the sheet, or directional variants like `walk_north` exist for base `walk`.
96pub fn mode_exists_on_sheet(mode: &str, sheet_modes: &[String]) -> bool {
97    if sheet_modes.iter().any(|m| m == mode) {
98        return true;
99    }
100    let prefix = format!("{mode}_");
101    sheet_modes.iter().any(|m| m.starts_with(&prefix))
102}
103
104/// Canonical presentation key → first matching sheet mode id (shared with runtime resolve).
105pub fn npc_sheet_alias(presentation_key: &str, sheet_modes: &[String]) -> Option<String> {
106    let candidates: &[&str] = match presentation_key {
107        "pursue" => &["chase", "alert", "run"],
108        "running" => &["chase", "run"],
109        "walking" => &["walk", "idle"],
110        "combat" => &["combat", "attack", "idle"],
111        "rest" => &["idle", "sleep", "rest"],
112        "work" => &["work", "idle"],
113        "patrol" => &["walk", "patrol", "idle"],
114        "talking" => &["talk", "idle"],
115        "chatting" => &["chat", "talk", "idle"],
116        "idle" => &["idle"],
117        "telegraph" => &["telegraph", "idle"],
118        "harvesting" => &["harvest", "work", "idle"],
119        "crafting" => &["craft", "work", "idle"],
120        "sprinting" => &["run", "sprint", "chase"],
121        _ => &[],
122    };
123    candidates
124        .iter()
125        .find(|c| mode_exists_on_sheet(c, sheet_modes))
126        .map(|c| c.to_string())
127}
128
129#[cfg(test)]
130mod tests {
131    use super::*;
132    use crate::catalog::load_sprite_catalog;
133
134    #[test]
135    fn catches_unknown_mode() {
136        let dir = crate::catalog::default_sprites_dir().expect("sprites dir");
137        let catalog = load_sprite_catalog(&dir).expect("load");
138        let mut map = SpriteModeMap::new();
139        map.insert("available".into(), "bogus_mode_xyz".into());
140        let errs = validate_sprite_mode_maps("item.test", Some("resource.oak_log"), &map, &catalog);
141        assert_eq!(errs.len(), 1);
142        assert!(errs[0].contains("bogus_mode_xyz"));
143    }
144
145    #[test]
146    fn accepts_directional_prefix_for_base_mode() {
147        let sheet_modes = vec!["idle_north".into(), "walk_south".into(), "combat".into()];
148        assert!(mode_exists_on_sheet("idle", &sheet_modes));
149        assert!(mode_exists_on_sheet("walk", &sheet_modes));
150        assert!(mode_exists_on_sheet("combat", &sheet_modes));
151        assert!(!mode_exists_on_sheet("run", &sheet_modes));
152    }
153}