Skip to main content

flatland_presentation/
resolve.rs

1//! Map runtime game state → sprite sheet mode id.
2
3use flatland_protocol::ResourceNodeState;
4
5use crate::npc_states::{
6    resolve_placed_npc_presentation_key, resolve_wildlife_presentation_key,
7    PlacedNpcPresentationInput, WildlifeNpcPresentationInput,
8};
9use crate::player_states::{resolve_player_presentation_key, PlayerPresentationInput};
10use crate::validate::mode_exists_on_sheet;
11use crate::SpriteModeMap;
12
13/// Stable presentation keys for harvestable resource nodes (designer-facing).
14pub const RESOURCE_PRESENTATION_KEYS: &[&str] = &["available", "harvesting", "depleted"];
15
16/// Legacy key still accepted in item `sprite_modes` YAML.
17pub const RESOURCE_PRESENTATION_KEY_COOLDOWN_LEGACY: &str = "cooldown";
18
19pub fn resource_presentation_key(state: ResourceNodeState) -> &'static str {
20    match state {
21        ResourceNodeState::Available => "available",
22        ResourceNodeState::Harvesting => "harvesting",
23        ResourceNodeState::Cooldown => "depleted",
24    }
25}
26
27/// Resolve gfx mode for a resource node view.
28pub fn resolve_resource_sprite_mode(
29    state: ResourceNodeState,
30    sprite_modes: &SpriteModeMap,
31    sheet_modes: &[String],
32) -> Option<String> {
33    let key = resource_presentation_key(state);
34    pick_mode(key, sprite_modes, sheet_modes)
35}
36
37/// Inputs for NPC wildlife presentation (keeps this crate free of sim types).
38pub struct NpcPresentationInput<'a> {
39    /// Canonical presentation key (`combat`, `pursue`, `walking`, …).
40    pub presentation_key: &'a str,
41    pub sprite_modes: &'a SpriteModeMap,
42    pub sheet_modes: &'a [String],
43}
44
45pub fn npc_presentation_key<'a>(input: &'a NpcPresentationInput<'a>) -> &'a str {
46    input.presentation_key
47}
48
49/// Resolve gfx mode for an NPC view.
50pub fn resolve_npc_sprite_mode(input: &NpcPresentationInput<'_>) -> Option<String> {
51    let key = npc_presentation_key(input);
52    pick_mode(key, input.sprite_modes, input.sheet_modes)
53}
54
55/// Build presentation input for wildlife from sim context.
56pub fn wildlife_npc_presentation_input<'a>(
57    ctx: WildlifeNpcPresentationInput<'a>,
58    sprite_modes: &'a SpriteModeMap,
59    sheet_modes: &'a [String],
60) -> NpcPresentationInput<'a> {
61    let key = resolve_wildlife_presentation_key(ctx);
62    NpcPresentationInput {
63        presentation_key: key,
64        sprite_modes,
65        sheet_modes,
66    }
67}
68
69/// Build presentation input for placed (town/building) NPCs.
70pub fn placed_npc_presentation_input<'a>(
71    ctx: PlacedNpcPresentationInput<'a>,
72    sprite_modes: &'a SpriteModeMap,
73    sheet_modes: &'a [String],
74) -> NpcPresentationInput<'a> {
75    let key = resolve_placed_npc_presentation_key(ctx);
76    NpcPresentationInput {
77        presentation_key: key,
78        sprite_modes,
79        sheet_modes,
80    }
81}
82
83/// Inputs for player sprite resolution.
84pub struct PlayerSpritePresentationInput<'a> {
85    pub presentation_key: &'a str,
86    pub sprite_modes: &'a SpriteModeMap,
87    pub sheet_modes: &'a [String],
88}
89
90pub fn player_presentation_input<'a>(
91    ctx: PlayerPresentationInput,
92    sprite_modes: &'a SpriteModeMap,
93    sheet_modes: &'a [String],
94) -> PlayerSpritePresentationInput<'a> {
95    let key = resolve_player_presentation_key(ctx);
96    PlayerSpritePresentationInput {
97        presentation_key: key,
98        sprite_modes,
99        sheet_modes,
100    }
101}
102
103/// Mode ids used to validate presentation → sprite mode resolution.
104pub fn presentation_sheet_mode_ids(
105    catalog: &crate::catalog::SpriteCatalog,
106    tile_id: &str,
107    paperdoll_ref: Option<&str>,
108) -> Vec<String> {
109    if let Some(skin_id) = paperdoll_ref.filter(|s| !s.is_empty()) {
110        let sheet = format!("paperdoll.{skin_id}");
111        let modes: Vec<String> = catalog.mode_ids(&sheet).iter().cloned().collect();
112        if !modes.is_empty() {
113            return modes;
114        }
115    }
116    catalog.mode_ids(tile_id).iter().cloned().collect()
117}
118
119/// Resolve gfx mode for a player entity view.
120pub fn resolve_player_sprite_mode(input: &PlayerSpritePresentationInput<'_>) -> Option<String> {
121    pick_mode(
122        input.presentation_key,
123        input.sprite_modes,
124        input.sheet_modes,
125    )
126}
127
128/// Farm crop growth bands from sim (`grow_10` … `grow_100`). Picks the requested stage when
129/// present on the sheet, otherwise the nearest lower stage, then common resource modes.
130pub fn resolve_farm_growth_sprite_mode(
131    growth_mode: &str,
132    sheet_modes: &[String],
133) -> Option<String> {
134    const STAGES: &[&str] = &["grow_10", "grow_20", "grow_60", "grow_100"];
135    let Some(idx) = STAGES.iter().position(|s| *s == growth_mode) else {
136        return None;
137    };
138    for i in (0..=idx).rev() {
139        let stage = STAGES[i];
140        if mode_exists_on_sheet(stage, sheet_modes) {
141            return Some(stage.to_string());
142        }
143    }
144    for alt in ["idle", "harvest", "depleted"] {
145        if mode_exists_on_sheet(alt, sheet_modes) {
146            return Some(alt.to_string());
147        }
148    }
149    sheet_modes.first().cloned()
150}
151
152fn pick_mode(
153    presentation_key: &str,
154    sprite_modes: &SpriteModeMap,
155    sheet_modes: &[String],
156) -> Option<String> {
157    if let Some(mode) = sprite_modes.get(presentation_key) {
158        if sheet_modes.is_empty() || mode_exists_on_sheet(mode, sheet_modes) {
159            return Some(mode.clone());
160        }
161        // Designer YAML maps keys → mode ids that may exist only on client-baked
162        // `paperdoll.*` sheets, not in the sim PNG sprite catalog.
163        return Some(mode.clone());
164    }
165    if sheet_modes.iter().any(|m| m == presentation_key) {
166        return Some(presentation_key.to_string());
167    }
168    // Resource state aliases when sprite_modes omitted.
169    let resource_alias = match presentation_key {
170        "available" => sheet_modes
171            .iter()
172            .find(|m| **m == "idle" || m.starts_with("idle_"))
173            .map(|m| {
174                if m.starts_with("idle_") {
175                    "idle".to_string()
176                } else {
177                    m.clone()
178                }
179            }),
180        "harvesting" => sheet_modes.iter().find(|m| *m == "harvest").cloned(),
181        "depleted" | "cooldown" => sheet_modes
182            .iter()
183            .find(|m| *m == "depleted" || *m == "cooldown")
184            .cloned(),
185        _ => None,
186    };
187    if let Some(mode) = resource_alias {
188        return Some(mode);
189    }
190    // Legacy item YAML may still use `cooldown` as presentation key.
191    if presentation_key == "cooldown" {
192        if let Some(mode) = sprite_modes.get("depleted") {
193            if sheet_modes.is_empty() || mode_exists_on_sheet(mode, sheet_modes) {
194                return Some(mode.clone());
195            }
196        }
197    }
198    // Canonical NPC keys → common sheet mode ids when sprite_modes omitted.
199    if let Some(sheet_id) = crate::validate::npc_sheet_alias(presentation_key, sheet_modes) {
200        return Some(sheet_id);
201    }
202    sprite_modes
203        .get(presentation_key)
204        .filter(|m| mode_exists_on_sheet(m, sheet_modes))
205        .cloned()
206        .or_else(|| sheet_modes.first().filter(|m| !m.contains('_')).cloned())
207}
208
209#[cfg(test)]
210mod tests {
211    use super::*;
212
213    fn modes(ids: &[&str]) -> Vec<String> {
214        ids.iter().map(|s| s.to_string()).collect()
215    }
216
217    #[test]
218    fn resource_explicit_map() {
219        let mut map = SpriteModeMap::new();
220        map.insert("available".into(), "idle".into());
221        map.insert("harvesting".into(), "harvest".into());
222        map.insert("depleted".into(), "depleted".into());
223        let sheet = modes(&["idle", "harvest", "depleted"]);
224        assert_eq!(
225            resolve_resource_sprite_mode(ResourceNodeState::Harvesting, &map, &sheet).as_deref(),
226            Some("harvest")
227        );
228    }
229
230    #[test]
231    fn resource_alias_fallback() {
232        let map = SpriteModeMap::new();
233        let sheet = modes(&["idle", "harvest"]);
234        assert_eq!(
235            resolve_resource_sprite_mode(ResourceNodeState::Available, &map, &sheet).as_deref(),
236            Some("idle")
237        );
238        assert_eq!(
239            resolve_resource_sprite_mode(ResourceNodeState::Harvesting, &map, &sheet).as_deref(),
240            Some("harvest")
241        );
242    }
243
244    #[test]
245    fn farm_growth_falls_back_to_lower_stage() {
246        let sheet = modes(&["grow_10", "grow_60", "idle"]);
247        assert_eq!(
248            resolve_farm_growth_sprite_mode("grow_100", &sheet).as_deref(),
249            Some("grow_60")
250        );
251        assert_eq!(
252            resolve_farm_growth_sprite_mode("grow_20", &sheet).as_deref(),
253            Some("grow_10")
254        );
255    }
256
257    #[test]
258    fn farm_growth_falls_back_to_idle() {
259        let sheet = modes(&["idle", "harvest"]);
260        assert_eq!(
261            resolve_farm_growth_sprite_mode("grow_60", &sheet).as_deref(),
262            Some("idle")
263        );
264    }
265
266    #[test]
267    fn npc_telegraph_presentation_key() {
268        let map = SpriteModeMap::new();
269        let sheet = modes(&["idle", "combat", "telegraph"]);
270        let input = NpcPresentationInput {
271            presentation_key: "telegraph",
272            sprite_modes: &map,
273            sheet_modes: &sheet,
274        };
275        assert_eq!(
276            resolve_npc_sprite_mode(&input).as_deref(),
277            Some("telegraph")
278        );
279    }
280
281    #[test]
282    fn npc_pursue_maps_to_chase_mode() {
283        let mut map = SpriteModeMap::new();
284        map.insert("pursue".into(), "chase".into());
285        let sheet = modes(&["idle", "chase", "combat"]);
286        let input = wildlife_npc_presentation_input(
287            WildlifeNpcPresentationInput {
288                fsm_state: "chase",
289                telegraph_active: false,
290                has_target: true,
291                in_attack_range: false,
292                is_moving: true,
293                fast_movement: true,
294            },
295            &map,
296            &sheet,
297        );
298        assert_eq!(resolve_npc_sprite_mode(&input).as_deref(), Some("chase"));
299        assert_eq!(input.presentation_key, "pursue");
300    }
301
302    #[test]
303    fn npc_legacy_behavior_alias() {
304        let map = SpriteModeMap::new();
305        let sheet = modes(&["idle", "chase", "combat"]);
306        let input = NpcPresentationInput {
307            presentation_key: "chase",
308            sprite_modes: &map,
309            sheet_modes: &sheet,
310        };
311        assert_eq!(resolve_npc_sprite_mode(&input).as_deref(), Some("chase"));
312    }
313
314    #[test]
315    fn player_directional_sheet_returns_base_modes() {
316        let mut map = SpriteModeMap::new();
317        map.insert("idle".into(), "idle".into());
318        map.insert("walking".into(), "walk".into());
319        map.insert("sprinting".into(), "run".into());
320        let sheet = modes(&[
321            "idle_north",
322            "idle_south",
323            "walk_north",
324            "walk_east",
325            "run_south",
326            "combat",
327        ]);
328
329        let idle = player_presentation_input(
330            PlayerPresentationInput {
331                harvesting: false,
332                crafting: false,
333                talking: false,
334                chatting: false,
335                in_combat: false,
336                pursue: false,
337                in_attack_range: false,
338                is_moving: false,
339                sprinting: false,
340                telegraph_active: false,
341            },
342            &map,
343            &sheet,
344        );
345        assert_eq!(resolve_player_sprite_mode(&idle).as_deref(), Some("idle"));
346
347        let walking = player_presentation_input(
348            PlayerPresentationInput {
349                harvesting: false,
350                crafting: false,
351                talking: false,
352                chatting: false,
353                in_combat: false,
354                pursue: false,
355                in_attack_range: false,
356                is_moving: true,
357                sprinting: false,
358                telegraph_active: false,
359            },
360            &map,
361            &sheet,
362        );
363        assert_eq!(
364            resolve_player_sprite_mode(&walking).as_deref(),
365            Some("walk")
366        );
367    }
368}