use flatland_protocol::ResourceNodeState;
use crate::npc_states::{
resolve_placed_npc_presentation_key, resolve_wildlife_presentation_key,
PlacedNpcPresentationInput, WildlifeNpcPresentationInput,
};
use crate::player_states::{resolve_player_presentation_key, PlayerPresentationInput};
use crate::validate::mode_exists_on_sheet;
use crate::{SpriteCatalog, SpriteModeMap};
pub const RESOURCE_PRESENTATION_KEYS: &[&str] = &["available", "harvesting", "depleted"];
pub const RESOURCE_PRESENTATION_KEY_COOLDOWN_LEGACY: &str = "cooldown";
pub fn resource_presentation_key(state: ResourceNodeState) -> &'static str {
match state {
ResourceNodeState::Available => "available",
ResourceNodeState::Harvesting => "harvesting",
ResourceNodeState::Cooldown => "depleted",
}
}
pub fn resolve_resource_sprite_mode(
state: ResourceNodeState,
sprite_modes: &SpriteModeMap,
sheet_modes: &[String],
) -> Option<String> {
let key = resource_presentation_key(state);
pick_mode(key, sprite_modes, sheet_modes)
}
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct ResourceSpriteResolution {
pub tile_id: String,
pub mode_id: String,
}
pub fn resolve_resource_sprite(
tile_id: Option<&str>,
state: ResourceNodeState,
sprite_modes: &SpriteModeMap,
catalog: &SpriteCatalog,
) -> Option<ResourceSpriteResolution> {
let key = resource_presentation_key(state);
let defaults = catalog.presentation_defaults();
let resource = &defaults.resource;
let farm_shared = defaults
.farm
.growth_fallback
.as_ref()
.map(|fb| fb.tile_id.as_str());
let candidate_modes: Vec<&str> = [
sprite_modes.get(key).map(String::as_str),
resource.modes.get(key).map(String::as_str),
]
.into_iter()
.flatten()
.collect();
if let Some(source_tile_id) = tile_id.filter(|id| !id.is_empty()) {
let sheet_modes = catalog.mode_ids(source_tile_id);
for mode_id in &candidate_modes {
if mode_exists_on_sheet(mode_id, sheet_modes) {
return Some(ResourceSpriteResolution {
tile_id: source_tile_id.to_string(),
mode_id: (*mode_id).to_string(),
});
}
}
if let Some(shared_id) = farm_shared {
let shared_modes = catalog.mode_ids(shared_id);
for mode_id in &candidate_modes {
if mode_exists_on_sheet(mode_id, shared_modes) {
return Some(ResourceSpriteResolution {
tile_id: shared_id.to_string(),
mode_id: (*mode_id).to_string(),
});
}
}
}
let alias = match key {
"available" => ["idle", ""],
"harvesting" => ["harvest", "idle"],
"depleted" => ["depleted", "cooldown"],
_ => ["", ""],
};
for mode_id in alias.into_iter().filter(|mode| !mode.is_empty()) {
if mode_exists_on_sheet(mode_id, sheet_modes) {
return Some(ResourceSpriteResolution {
tile_id: source_tile_id.to_string(),
mode_id: mode_id.to_string(),
});
}
}
if key != "depleted" {
if let Some(mode_id) = sheet_modes.first().filter(|mode| !mode.contains('_')) {
return Some(ResourceSpriteResolution {
tile_id: source_tile_id.to_string(),
mode_id: mode_id.clone(),
});
}
}
if let Some(resolved) =
resolve_sheet_suffix_fallback(source_tile_id, key, resource, catalog)
{
return Some(resolved);
}
}
let fallback = resource.fallbacks.get(key)?;
if mode_exists_on_sheet(&fallback.mode_id, catalog.mode_ids(&fallback.tile_id)) {
Some(ResourceSpriteResolution {
tile_id: fallback.tile_id.clone(),
mode_id: fallback.mode_id.clone(),
})
} else {
None
}
}
fn resolve_sheet_suffix_fallback(
source_tile_id: &str,
key: &str,
resource: &crate::ResourcePresentationDefaults,
catalog: &SpriteCatalog,
) -> Option<ResourceSpriteResolution> {
for (suffix, by_key) in &resource.sheet_suffix_fallbacks {
if !source_tile_id.ends_with(suffix.as_str()) {
continue;
}
let fallback = by_key.get(key)?;
if mode_exists_on_sheet(&fallback.mode_id, catalog.mode_ids(&fallback.tile_id)) {
return Some(ResourceSpriteResolution {
tile_id: fallback.tile_id.clone(),
mode_id: fallback.mode_id.clone(),
});
}
}
None
}
pub struct NpcPresentationInput<'a> {
pub presentation_key: &'a str,
pub sprite_modes: &'a SpriteModeMap,
pub sheet_modes: &'a [String],
}
pub fn npc_presentation_key<'a>(input: &'a NpcPresentationInput<'a>) -> &'a str {
input.presentation_key
}
pub fn resolve_npc_sprite_mode(input: &NpcPresentationInput<'_>) -> Option<String> {
let key = npc_presentation_key(input);
pick_mode(key, input.sprite_modes, input.sheet_modes)
}
pub fn wildlife_npc_presentation_input<'a>(
ctx: WildlifeNpcPresentationInput<'a>,
sprite_modes: &'a SpriteModeMap,
sheet_modes: &'a [String],
) -> NpcPresentationInput<'a> {
let key = resolve_wildlife_presentation_key(ctx);
NpcPresentationInput {
presentation_key: key,
sprite_modes,
sheet_modes,
}
}
pub fn placed_npc_presentation_input<'a>(
ctx: PlacedNpcPresentationInput<'a>,
sprite_modes: &'a SpriteModeMap,
sheet_modes: &'a [String],
) -> NpcPresentationInput<'a> {
let key = resolve_placed_npc_presentation_key(ctx);
NpcPresentationInput {
presentation_key: key,
sprite_modes,
sheet_modes,
}
}
pub struct PlayerSpritePresentationInput<'a> {
pub presentation_key: &'a str,
pub sprite_modes: &'a SpriteModeMap,
pub sheet_modes: &'a [String],
}
pub fn player_presentation_input<'a>(
ctx: PlayerPresentationInput,
sprite_modes: &'a SpriteModeMap,
sheet_modes: &'a [String],
) -> PlayerSpritePresentationInput<'a> {
let key = resolve_player_presentation_key(ctx);
PlayerSpritePresentationInput {
presentation_key: key,
sprite_modes,
sheet_modes,
}
}
pub fn presentation_sheet_mode_ids(
catalog: &crate::catalog::SpriteCatalog,
tile_id: &str,
paperdoll_ref: Option<&str>,
) -> Vec<String> {
if let Some(skin_id) = paperdoll_ref.filter(|s| !s.is_empty()) {
let sheet = format!("paperdoll.{skin_id}");
let modes: Vec<String> = catalog.mode_ids(&sheet).iter().cloned().collect();
if !modes.is_empty() {
return modes;
}
}
catalog.mode_ids(tile_id).iter().cloned().collect()
}
pub fn resolve_player_sprite_mode(input: &PlayerSpritePresentationInput<'_>) -> Option<String> {
pick_mode(
input.presentation_key,
input.sprite_modes,
input.sheet_modes,
)
}
const FARM_GROWTH_STAGES: &[&str] = &["grow_10", "grow_20", "grow_60", "grow_100"];
pub fn pick_farm_growth_stage(growth_mode: &str, sheet_modes: &[String]) -> Option<String> {
let idx = FARM_GROWTH_STAGES.iter().position(|s| *s == growth_mode)?;
for i in (0..=idx).rev() {
let stage = FARM_GROWTH_STAGES[i];
if mode_exists_on_sheet(stage, sheet_modes) {
return Some(stage.to_string());
}
}
None
}
fn farm_growth_same_sheet_fallback(sheet_modes: &[String]) -> Option<String> {
for alt in ["idle", "harvest", "depleted", "harvested"] {
if mode_exists_on_sheet(alt, sheet_modes) {
return Some(alt.to_string());
}
}
sheet_modes.first().cloned()
}
pub fn resolve_farm_growth_sprite_mode(
growth_mode: &str,
sheet_modes: &[String],
) -> Option<String> {
if !FARM_GROWTH_STAGES.contains(&growth_mode) {
return None;
}
pick_farm_growth_stage(growth_mode, sheet_modes)
.or_else(|| farm_growth_same_sheet_fallback(sheet_modes))
}
pub fn resolve_farm_growth_sprite(
growth_mode: &str,
source_tile_id: &str,
catalog: &SpriteCatalog,
) -> Option<ResourceSpriteResolution> {
let source_modes = catalog.mode_ids(source_tile_id);
let shared_tile_id = catalog
.presentation_defaults()
.farm
.growth_fallback
.as_ref()
.map(|fb| fb.tile_id.as_str());
let empty: &[String] = &[];
let shared_modes = shared_tile_id
.map(|id| catalog.mode_ids(id))
.unwrap_or(empty);
resolve_farm_growth_sprite_from_modes(
growth_mode,
source_tile_id,
source_modes,
shared_tile_id,
shared_modes,
)
}
pub fn resolve_farm_growth_sprite_from_modes(
growth_mode: &str,
source_tile_id: &str,
source_modes: &[String],
shared_tile_id: Option<&str>,
shared_modes: &[String],
) -> Option<ResourceSpriteResolution> {
if !FARM_GROWTH_STAGES.contains(&growth_mode) {
return None;
}
if let Some(mode_id) = pick_farm_growth_stage(growth_mode, source_modes) {
return Some(ResourceSpriteResolution {
tile_id: source_tile_id.to_string(),
mode_id,
});
}
if let Some(shared_id) = shared_tile_id.filter(|id| !id.is_empty()) {
if let Some(mode_id) = pick_farm_growth_stage(growth_mode, shared_modes) {
return Some(ResourceSpriteResolution {
tile_id: shared_id.to_string(),
mode_id,
});
}
}
farm_growth_same_sheet_fallback(source_modes).map(|mode_id| ResourceSpriteResolution {
tile_id: source_tile_id.to_string(),
mode_id,
})
}
fn pick_mode(
presentation_key: &str,
sprite_modes: &SpriteModeMap,
sheet_modes: &[String],
) -> Option<String> {
if let Some(mode) = sprite_modes.get(presentation_key) {
if sheet_modes.is_empty() || mode_exists_on_sheet(mode, sheet_modes) {
return Some(mode.clone());
}
return Some(mode.clone());
}
if sheet_modes.iter().any(|m| m == presentation_key) {
return Some(presentation_key.to_string());
}
let resource_alias = match presentation_key {
"available" => sheet_modes
.iter()
.find(|m| **m == "idle" || m.starts_with("idle_"))
.map(|m| {
if m.starts_with("idle_") {
"idle".to_string()
} else {
m.clone()
}
}),
"harvesting" => sheet_modes.iter().find(|m| *m == "harvest").cloned(),
"depleted" | "cooldown" => sheet_modes
.iter()
.find(|m| *m == "depleted" || *m == "cooldown")
.cloned(),
_ => None,
};
if let Some(mode) = resource_alias {
return Some(mode);
}
if presentation_key == "cooldown" {
if let Some(mode) = sprite_modes.get("depleted") {
if sheet_modes.is_empty() || mode_exists_on_sheet(mode, sheet_modes) {
return Some(mode.clone());
}
}
}
if let Some(sheet_id) = crate::validate::npc_sheet_alias(presentation_key, sheet_modes) {
return Some(sheet_id);
}
sprite_modes
.get(presentation_key)
.filter(|m| mode_exists_on_sheet(m, sheet_modes))
.cloned()
.or_else(|| sheet_modes.first().filter(|m| !m.contains('_')).cloned())
}
#[cfg(test)]
mod tests {
use super::*;
use std::collections::BTreeMap;
fn modes(ids: &[&str]) -> Vec<String> {
ids.iter().map(|s| s.to_string()).collect()
}
#[test]
fn resource_explicit_map() {
let mut map = SpriteModeMap::new();
map.insert("available".into(), "idle".into());
map.insert("harvesting".into(), "harvest".into());
map.insert("depleted".into(), "depleted".into());
let sheet = modes(&["idle", "harvest", "depleted"]);
assert_eq!(
resolve_resource_sprite_mode(ResourceNodeState::Harvesting, &map, &sheet).as_deref(),
Some("harvest")
);
}
#[test]
fn resource_alias_fallback() {
let map = SpriteModeMap::new();
let sheet = modes(&["idle", "harvest"]);
assert_eq!(
resolve_resource_sprite_mode(ResourceNodeState::Available, &map, &sheet).as_deref(),
Some("idle")
);
assert_eq!(
resolve_resource_sprite_mode(ResourceNodeState::Harvesting, &map, &sheet).as_deref(),
Some("harvest")
);
}
#[test]
fn resource_defaults_use_shared_depleted_fallback() {
let mut catalog = SpriteCatalog::default();
catalog.register_sheet_modes("resource.depleted", vec!["depleted".into()]);
let mut defaults = catalog.presentation_defaults().clone();
defaults.resource.fallbacks.insert(
"depleted".into(),
crate::SpriteFallback {
tile_id: "resource.depleted".into(),
mode_id: "depleted".into(),
},
);
catalog.set_presentation_defaults(defaults);
let result = resolve_resource_sprite(
Some("resource.oak_log"),
ResourceNodeState::Cooldown,
&SpriteModeMap::new(),
&catalog,
);
assert_eq!(
result,
Some(ResourceSpriteResolution {
tile_id: "resource.depleted".into(),
mode_id: "depleted".into(),
})
);
}
#[test]
fn resource_override_wins_when_mode_exists() {
let mut catalog = SpriteCatalog::default();
catalog.register_sheet_modes(
"resource.test",
vec![
"idle".into(),
"harvest_custom".into(),
"depleted_custom".into(),
],
);
let mut map = SpriteModeMap::new();
map.insert("harvesting".into(), "harvest_custom".into());
let result = resolve_resource_sprite(
Some("resource.test"),
ResourceNodeState::Harvesting,
&map,
&catalog,
);
assert_eq!(
result,
Some(ResourceSpriteResolution {
tile_id: "resource.test".into(),
mode_id: "harvest_custom".into(),
})
);
}
#[test]
fn farm_growth_falls_back_to_lower_stage() {
let sheet = modes(&["grow_10", "grow_60", "idle"]);
assert_eq!(
resolve_farm_growth_sprite_mode("grow_100", &sheet).as_deref(),
Some("grow_60")
);
assert_eq!(
resolve_farm_growth_sprite_mode("grow_20", &sheet).as_deref(),
Some("grow_10")
);
}
#[test]
fn farm_growth_falls_back_to_idle() {
let sheet = modes(&["idle", "harvest"]);
assert_eq!(
resolve_farm_growth_sprite_mode("grow_60", &sheet).as_deref(),
Some("idle")
);
}
#[test]
fn farm_growth_uses_shared_stages_sheet_when_crop_omits_early_grows() {
let mut catalog = SpriteCatalog::default();
catalog.register_sheet_modes(
"resource.wheat_crop",
vec!["idle".into(), "harvest".into(), "grow_100".into()],
);
catalog.register_sheet_modes(
"resource.crop_shared_stages",
vec![
"grow_10".into(),
"grow_20".into(),
"grow_60".into(),
"harvested".into(),
],
);
let mut defaults = catalog.presentation_defaults().clone();
defaults.farm.growth_fallback = Some(crate::FarmGrowthFallback {
tile_id: "resource.crop_shared_stages".into(),
});
catalog.set_presentation_defaults(defaults);
assert_eq!(
resolve_farm_growth_sprite("grow_60", "resource.wheat_crop", &catalog),
Some(ResourceSpriteResolution {
tile_id: "resource.crop_shared_stages".into(),
mode_id: "grow_60".into(),
})
);
assert_eq!(
resolve_farm_growth_sprite("grow_100", "resource.wheat_crop", &catalog),
Some(ResourceSpriteResolution {
tile_id: "resource.wheat_crop".into(),
mode_id: "grow_100".into(),
})
);
let mut catalog2 = catalog.clone();
catalog2.register_sheet_modes(
"resource.crop_shared_stages",
vec!["grow_10".into(), "grow_60".into(), "harvested".into()],
);
assert_eq!(
resolve_farm_growth_sprite("grow_20", "resource.wheat_crop", &catalog2),
Some(ResourceSpriteResolution {
tile_id: "resource.crop_shared_stages".into(),
mode_id: "grow_10".into(),
})
);
}
#[test]
fn crop_depleted_harvested_mode_uses_shared_stages() {
let mut catalog = SpriteCatalog::default();
catalog.register_sheet_modes(
"resource.wheat_crop",
vec!["idle".into(), "harvest".into(), "grow_100".into()],
);
catalog.register_sheet_modes(
"resource.crop_shared_stages",
vec!["grow_10".into(), "harvested".into()],
);
let mut defaults = catalog.presentation_defaults().clone();
defaults.farm.growth_fallback = Some(crate::FarmGrowthFallback {
tile_id: "resource.crop_shared_stages".into(),
});
catalog.set_presentation_defaults(defaults);
let mut map = SpriteModeMap::new();
map.insert("depleted".into(), "harvested".into());
let result = resolve_resource_sprite(
Some("resource.wheat_crop"),
ResourceNodeState::Cooldown,
&map,
&catalog,
);
assert_eq!(
result,
Some(ResourceSpriteResolution {
tile_id: "resource.crop_shared_stages".into(),
mode_id: "harvested".into(),
})
);
}
#[test]
fn tree_depleted_uses_sheet_suffix_fallback() {
let mut catalog = SpriteCatalog::default();
catalog.register_sheet_modes("resource.oak_tree", vec!["idle".into(), "harvest".into()]);
catalog.register_sheet_modes("resource.tree_depleted", vec!["depleted".into()]);
catalog.register_sheet_modes("resource.depleted", vec!["depleted".into()]);
let mut defaults = catalog.presentation_defaults().clone();
defaults.resource.fallbacks.insert(
"depleted".into(),
crate::SpriteFallback {
tile_id: "resource.depleted".into(),
mode_id: "depleted".into(),
},
);
defaults.resource.sheet_suffix_fallbacks.insert(
"_tree".into(),
BTreeMap::from([(
"depleted".into(),
crate::SpriteFallback {
tile_id: "resource.tree_depleted".into(),
mode_id: "depleted".into(),
},
)]),
);
catalog.set_presentation_defaults(defaults);
let result = resolve_resource_sprite(
Some("resource.oak_tree"),
ResourceNodeState::Cooldown,
&SpriteModeMap::new(),
&catalog,
);
assert_eq!(
result,
Some(ResourceSpriteResolution {
tile_id: "resource.tree_depleted".into(),
mode_id: "depleted".into(),
})
);
}
#[test]
fn npc_telegraph_presentation_key() {
let map = SpriteModeMap::new();
let sheet = modes(&["idle", "combat", "telegraph"]);
let input = NpcPresentationInput {
presentation_key: "telegraph",
sprite_modes: &map,
sheet_modes: &sheet,
};
assert_eq!(
resolve_npc_sprite_mode(&input).as_deref(),
Some("telegraph")
);
}
#[test]
fn npc_pursue_maps_to_chase_mode() {
let mut map = SpriteModeMap::new();
map.insert("pursue".into(), "chase".into());
let sheet = modes(&["idle", "chase", "combat"]);
let input = wildlife_npc_presentation_input(
WildlifeNpcPresentationInput {
fsm_state: "chase",
telegraph_active: false,
has_target: true,
in_attack_range: false,
is_moving: true,
fast_movement: true,
},
&map,
&sheet,
);
assert_eq!(resolve_npc_sprite_mode(&input).as_deref(), Some("chase"));
assert_eq!(input.presentation_key, "pursue");
}
#[test]
fn npc_legacy_behavior_alias() {
let map = SpriteModeMap::new();
let sheet = modes(&["idle", "chase", "combat"]);
let input = NpcPresentationInput {
presentation_key: "chase",
sprite_modes: &map,
sheet_modes: &sheet,
};
assert_eq!(resolve_npc_sprite_mode(&input).as_deref(), Some("chase"));
}
#[test]
fn player_directional_sheet_returns_base_modes() {
let mut map = SpriteModeMap::new();
map.insert("idle".into(), "idle".into());
map.insert("walking".into(), "walk".into());
map.insert("sprinting".into(), "run".into());
let sheet = modes(&[
"idle_north",
"idle_south",
"walk_north",
"walk_east",
"run_south",
"combat",
]);
let idle = player_presentation_input(
PlayerPresentationInput {
harvesting: false,
crafting: false,
talking: false,
chatting: false,
in_combat: false,
pursue: false,
in_attack_range: false,
is_moving: false,
sprinting: false,
telegraph_active: false,
},
&map,
&sheet,
);
assert_eq!(resolve_player_sprite_mode(&idle).as_deref(), Some("idle"));
let walking = player_presentation_input(
PlayerPresentationInput {
harvesting: false,
crafting: false,
talking: false,
chatting: false,
in_combat: false,
pursue: false,
in_attack_range: false,
is_moving: true,
sprinting: false,
telegraph_active: false,
},
&map,
&sheet,
);
assert_eq!(
resolve_player_sprite_mode(&walking).as_deref(),
Some("walk")
);
}
}