use crate::{SpriteCatalog, SpriteModeMap};
pub fn presentation_validation_mode_ids(
catalog: &SpriteCatalog,
tile_id: Option<&str>,
paperdoll_ref: Option<&str>,
) -> Vec<String> {
if let Some(skin) = paperdoll_ref.filter(|s| !s.is_empty()) {
let sheet = format!("paperdoll.{skin}");
let modes = catalog.mode_ids(&sheet);
if !modes.is_empty() {
return modes.to_vec();
}
}
if let Some(tid) = tile_id.filter(|s| !s.is_empty()) {
return crate::presentation_sheet_mode_ids(catalog, tid, paperdoll_ref);
}
Vec::new()
}
pub fn validate_sprite_modes_for_presentation(
owner_label: &str,
tile_id: Option<&str>,
paperdoll_ref: Option<&str>,
sprite_modes: &SpriteModeMap,
catalog: &SpriteCatalog,
) -> Vec<String> {
let mut errors = Vec::new();
if sprite_modes.is_empty() {
return errors;
}
let sheet_modes = presentation_validation_mode_ids(catalog, tile_id, paperdoll_ref);
if sheet_modes.is_empty() {
errors.push(format!(
"{owner_label}: sprite_modes set but no modes found (tile_id={tile_id:?}, paperdoll_ref={paperdoll_ref:?})"
));
return errors;
}
for (key, mode) in sprite_modes {
if mode_exists_on_sheet(mode, &sheet_modes) {
continue;
}
if npc_sheet_alias(key, &sheet_modes).is_some() {
continue;
}
errors.push(format!(
"{owner_label}: sprite_modes.{key} = '{mode}' not on presentation sheet (have: {})",
sheet_modes.join(", ")
));
}
errors
}
pub fn validate_sprite_mode_maps(
owner_label: &str,
tile_id: Option<&str>,
sprite_modes: &SpriteModeMap,
catalog: &SpriteCatalog,
) -> Vec<String> {
let mut errors = Vec::new();
let Some(tile_id) = tile_id.filter(|s| !s.is_empty()) else {
if !sprite_modes.is_empty() {
errors.push(format!(
"{owner_label}: sprite_modes set but tile_id is missing"
));
}
return errors;
};
if sprite_modes.is_empty() {
return errors;
}
let sheet_modes = catalog.mode_ids(tile_id);
if sheet_modes.is_empty() {
errors.push(format!(
"{owner_label}: tile_id '{tile_id}' not found in sprite catalog or has no modes"
));
return errors;
}
for (key, mode) in sprite_modes {
if mode_exists_on_sheet(mode, sheet_modes) {
continue;
}
if mode_accepted_via_presentation_fallback(tile_id, key, mode, catalog) {
continue;
}
errors.push(format!(
"{owner_label}: sprite_modes.{key} = '{mode}' not on sheet '{tile_id}' (have: {})",
sheet_modes.join(", ")
));
}
errors
}
fn mode_accepted_via_presentation_fallback(
tile_id: &str,
presentation_key: &str,
mode: &str,
catalog: &SpriteCatalog,
) -> bool {
let defaults = catalog.presentation_defaults();
if is_farm_growth_mode(mode) {
if let Some(farm_fb) = &defaults.farm.growth_fallback {
if mode_exists_on_sheet(mode, catalog.mode_ids(&farm_fb.tile_id)) {
return true;
}
}
}
let resource = &defaults.resource;
if let Some(resolved) = applicable_resource_fallback(tile_id, presentation_key, resource) {
if resolved.mode_id == mode
&& mode_exists_on_sheet(mode, catalog.mode_ids(&resolved.tile_id))
{
return true;
}
}
false
}
fn is_farm_growth_mode(mode: &str) -> bool {
mode == "harvested" || mode.starts_with("grow_")
}
fn applicable_resource_fallback<'a>(
tile_id: &str,
presentation_key: &str,
resource: &'a crate::ResourcePresentationDefaults,
) -> Option<&'a crate::SpriteFallback> {
for (suffix, by_key) in &resource.sheet_suffix_fallbacks {
if tile_id.ends_with(suffix.as_str()) {
if let Some(fb) = by_key.get(presentation_key) {
return Some(fb);
}
}
}
resource.fallbacks.get(presentation_key)
}
pub fn validate_sprite_presentation_defaults(catalog: &SpriteCatalog) -> Vec<String> {
let mut errors = Vec::new();
let defaults = catalog.presentation_defaults();
for (key, fallback) in &defaults.resource.fallbacks {
if !crate::RESOURCE_PRESENTATION_KEYS.contains(&key.as_str()) {
errors.push(format!(
"resource fallback uses unknown presentation key '{key}'"
));
continue;
}
validate_fallback_ref(
&format!("resource fallback {key}"),
fallback,
catalog,
&mut errors,
);
}
for (suffix, by_key) in &defaults.resource.sheet_suffix_fallbacks {
if suffix.is_empty() {
errors.push("resource sheet_suffix_fallbacks entry has empty suffix".into());
continue;
}
for (key, fallback) in by_key {
if !crate::RESOURCE_PRESENTATION_KEYS.contains(&key.as_str()) {
errors.push(format!(
"resource sheet_suffix_fallbacks '{suffix}' uses unknown presentation key '{key}'"
));
continue;
}
validate_fallback_ref(
&format!("resource sheet_suffix_fallbacks '{suffix}' {key}"),
fallback,
catalog,
&mut errors,
);
}
}
if let Some(farm_fb) = &defaults.farm.growth_fallback {
let modes = catalog.mode_ids(&farm_fb.tile_id);
if modes.is_empty() {
errors.push(format!(
"farm growth_fallback references missing sprite sheet '{}'",
farm_fb.tile_id
));
} else if !modes.iter().any(|m| m.starts_with("grow_")) {
errors.push(format!(
"farm growth_fallback sheet '{}' has no grow_* modes (have: {})",
farm_fb.tile_id,
modes.join(", ")
));
}
}
errors
}
fn validate_fallback_ref(
label: &str,
fallback: &crate::SpriteFallback,
catalog: &SpriteCatalog,
errors: &mut Vec<String>,
) {
let modes = catalog.mode_ids(&fallback.tile_id);
if modes.is_empty() {
errors.push(format!(
"{label} references missing sprite sheet '{}'",
fallback.tile_id
));
} else if !mode_exists_on_sheet(&fallback.mode_id, modes) {
errors.push(format!(
"{label} mode '{}' not on sheet '{}' (have: {})",
fallback.mode_id,
fallback.tile_id,
modes.join(", ")
));
}
}
pub fn mode_exists_on_sheet(mode: &str, sheet_modes: &[String]) -> bool {
if sheet_modes.iter().any(|m| m == mode) {
return true;
}
let prefix = format!("{mode}_");
sheet_modes.iter().any(|m| m.starts_with(&prefix))
}
pub fn npc_sheet_alias(presentation_key: &str, sheet_modes: &[String]) -> Option<String> {
let candidates: &[&str] = match presentation_key {
"pursue" => &["chase", "alert", "run"],
"running" => &["chase", "run"],
"walking" => &["walk", "idle"],
"combat" => &["combat", "attack", "idle"],
"rest" => &["idle", "sleep", "rest"],
"work" => &["work", "idle"],
"patrol" => &["walk", "patrol", "idle"],
"talking" => &["talk", "idle"],
"chatting" => &["chat", "talk", "idle"],
"idle" => &["idle"],
"telegraph" => &["telegraph", "idle"],
"harvesting" => &["harvest", "work", "idle"],
"crafting" => &["craft", "work", "idle"],
"sprinting" => &["run", "sprint", "chase"],
_ => &[],
};
candidates
.iter()
.find(|c| mode_exists_on_sheet(c, sheet_modes))
.map(|c| c.to_string())
}
#[cfg(test)]
mod tests {
use super::*;
use crate::catalog::load_sprite_catalog;
#[test]
fn catches_unknown_mode() {
let dir = crate::catalog::default_sprites_dir().expect("sprites dir");
let catalog = load_sprite_catalog(&dir).expect("load");
let mut map = SpriteModeMap::new();
map.insert("available".into(), "bogus_mode_xyz".into());
let errs = validate_sprite_mode_maps("item.test", Some("resource.oak_log"), &map, &catalog);
assert_eq!(errs.len(), 1);
assert!(errs[0].contains("bogus_mode_xyz"));
}
#[test]
fn accepts_crop_depleted_via_farm_growth_fallback() {
let catalog =
load_sprite_catalog(&crate::catalog::default_sprites_dir().expect("sprites dir"))
.expect("load");
let sheet = catalog.mode_ids("resource.corn_crop");
assert!(
!mode_exists_on_sheet("harvested", sheet),
"fixture assumes corn_crop omits harvested"
);
let mut map = SpriteModeMap::new();
map.insert("depleted".into(), "harvested".into());
let errs =
validate_sprite_mode_maps("item.corn", Some("resource.corn_crop"), &map, &catalog);
assert!(
errs.is_empty(),
"depleted=harvested should resolve via farm growth_fallback: {}",
errs.join("; ")
);
}
#[test]
fn accepts_tree_depleted_via_sheet_suffix_fallback() {
let catalog =
load_sprite_catalog(&crate::catalog::default_sprites_dir().expect("sprites dir"))
.expect("load");
let sheet = catalog.mode_ids("resource.oak_tree");
assert!(
!mode_exists_on_sheet("depleted", sheet),
"fixture assumes oak_tree omits depleted"
);
let mut map = SpriteModeMap::new();
map.insert("depleted".into(), "depleted".into());
let errs =
validate_sprite_mode_maps("item.oak_log", Some("resource.oak_tree"), &map, &catalog);
assert!(
errs.is_empty(),
"depleted=depleted should resolve via _tree suffix fallback: {}",
errs.join("; ")
);
}
#[test]
fn still_rejects_unknown_mode_despite_fallbacks() {
let catalog =
load_sprite_catalog(&crate::catalog::default_sprites_dir().expect("sprites dir"))
.expect("load");
let mut map = SpriteModeMap::new();
map.insert("depleted".into(), "not_a_real_mode".into());
let errs =
validate_sprite_mode_maps("item.corn", Some("resource.corn_crop"), &map, &catalog);
assert_eq!(errs.len(), 1);
assert!(errs[0].contains("not_a_real_mode"));
}
#[test]
fn accepts_directional_prefix_for_base_mode() {
let sheet_modes = vec!["idle_north".into(), "walk_south".into(), "combat".into()];
assert!(mode_exists_on_sheet("idle", &sheet_modes));
assert!(mode_exists_on_sheet("walk", &sheet_modes));
assert!(mode_exists_on_sheet("combat", &sheet_modes));
assert!(!mode_exists_on_sheet("run", &sheet_modes));
}
#[test]
fn validates_resource_fallback_sheet_and_mode() {
let mut catalog =
load_sprite_catalog(&crate::catalog::default_sprites_dir().expect("sprites dir"))
.expect("load");
let mut defaults = catalog.presentation_defaults().clone();
defaults.resource.fallbacks.insert(
"depleted".into(),
crate::SpriteFallback {
tile_id: "resource.oak_log".into(),
mode_id: "missing".into(),
},
);
catalog.set_presentation_defaults(defaults);
let errors = validate_sprite_presentation_defaults(&catalog);
assert!(errors.iter().any(|e| e.contains("not on sheet")));
}
#[test]
fn repo_presentation_defaults_validate() {
let catalog =
load_sprite_catalog(&crate::catalog::default_sprites_dir().expect("sprites dir"))
.expect("load");
let errors = validate_sprite_presentation_defaults(&catalog);
assert!(
errors.is_empty(),
"presentation-defaults.yaml invalid: {}",
errors.join("; ")
);
let defaults = catalog.presentation_defaults();
assert_eq!(
defaults
.farm
.growth_fallback
.as_ref()
.map(|f| f.tile_id.as_str()),
Some("resource.crop_shared_stages")
);
assert!(defaults.resource.sheet_suffix_fallbacks.contains_key("_tree"));
assert!(defaults.resource.sheet_suffix_fallbacks.contains_key("_ore"));
}
}