flatland-presentation 0.2.36

Gfx sprite mode resolution — maps game states to sprite sheet mode ids
Documentation
//! Map runtime game state → sprite sheet mode id.

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::SpriteModeMap;

/// Stable presentation keys for harvestable resource nodes (designer-facing).
pub const RESOURCE_PRESENTATION_KEYS: &[&str] = &["available", "harvesting", "depleted"];

/// Legacy key still accepted in item `sprite_modes` YAML.
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",
    }
}

/// Resolve gfx mode for a resource node view.
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)
}

/// Inputs for NPC wildlife presentation (keeps this crate free of sim types).
pub struct NpcPresentationInput<'a> {
    /// Canonical presentation key (`combat`, `pursue`, `walking`, …).
    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
}

/// Resolve gfx mode for an NPC view.
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)
}

/// Build presentation input for wildlife from sim context.
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,
    }
}

/// Build presentation input for placed (town/building) NPCs.
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,
    }
}

/// Inputs for player sprite resolution.
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,
    }
}

/// Mode ids used to validate presentation → sprite mode resolution.
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()
}

/// Resolve gfx mode for a player entity view.
pub fn resolve_player_sprite_mode(input: &PlayerSpritePresentationInput<'_>) -> Option<String> {
    pick_mode(
        input.presentation_key,
        input.sprite_modes,
        input.sheet_modes,
    )
}

/// Farm crop growth bands from sim (`grow_10` … `grow_100`). Picks the requested stage when
/// present on the sheet, otherwise the nearest lower stage, then common resource modes.
pub fn resolve_farm_growth_sprite_mode(
    growth_mode: &str,
    sheet_modes: &[String],
) -> Option<String> {
    const STAGES: &[&str] = &["grow_10", "grow_20", "grow_60", "grow_100"];
    let Some(idx) = STAGES.iter().position(|s| *s == growth_mode) else {
        return None;
    };
    for i in (0..=idx).rev() {
        let stage = STAGES[i];
        if mode_exists_on_sheet(stage, sheet_modes) {
            return Some(stage.to_string());
        }
    }
    for alt in ["idle", "harvest", "depleted"] {
        if mode_exists_on_sheet(alt, sheet_modes) {
            return Some(alt.to_string());
        }
    }
    sheet_modes.first().cloned()
}

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());
        }
        // Designer YAML maps keys → mode ids that may exist only on client-baked
        // `paperdoll.*` sheets, not in the sim PNG sprite catalog.
        return Some(mode.clone());
    }
    if sheet_modes.iter().any(|m| m == presentation_key) {
        return Some(presentation_key.to_string());
    }
    // Resource state aliases when sprite_modes omitted.
    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);
    }
    // Legacy item YAML may still use `cooldown` as presentation key.
    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());
            }
        }
    }
    // Canonical NPC keys → common sheet mode ids when sprite_modes omitted.
    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::*;

    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 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 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")
        );
    }
}