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::{SpriteCatalog, 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/// Effective tile and mode for a resource node.
38#[derive(Debug, Clone, PartialEq, Eq)]
39pub struct ResourceSpriteResolution {
40    pub tile_id: String,
41    pub mode_id: String,
42}
43
44/// Resolve a resource state using item overrides, shared defaults, and a generic fallback.
45///
46/// Resource overrides are intentionally validated against the source sheet. Unlike NPC and
47/// player mappings, an invalid resource override must not hide the node or leave it showing the
48/// ready-state art; the resolver continues through defaults and the configured fallback.
49pub fn resolve_resource_sprite(
50    tile_id: Option<&str>,
51    state: ResourceNodeState,
52    sprite_modes: &SpriteModeMap,
53    catalog: &SpriteCatalog,
54) -> Option<ResourceSpriteResolution> {
55    let key = resource_presentation_key(state);
56    let defaults = &catalog.presentation_defaults().resource;
57
58    if let Some(source_tile_id) = tile_id.filter(|id| !id.is_empty()) {
59        let sheet_modes = catalog.mode_ids(source_tile_id);
60        for mode_id in [
61            sprite_modes.get(key).map(String::as_str),
62            defaults.modes.get(key).map(String::as_str),
63        ]
64        .into_iter()
65        .flatten()
66        {
67            if mode_exists_on_sheet(mode_id, sheet_modes) {
68                return Some(ResourceSpriteResolution {
69                    tile_id: source_tile_id.to_string(),
70                    mode_id: mode_id.to_string(),
71                });
72            }
73        }
74
75        let alias = match key {
76            "available" => ["idle", ""],
77            "harvesting" => ["harvest", "idle"],
78            "depleted" => ["depleted", "cooldown"],
79            _ => ["", ""],
80        };
81        for mode_id in alias.into_iter().filter(|mode| !mode.is_empty()) {
82            if mode_exists_on_sheet(mode_id, sheet_modes) {
83                return Some(ResourceSpriteResolution {
84                    tile_id: source_tile_id.to_string(),
85                    mode_id: mode_id.to_string(),
86                });
87            }
88        }
89
90        if key != "depleted" {
91            if let Some(mode_id) = sheet_modes.first().filter(|mode| !mode.contains('_')) {
92                return Some(ResourceSpriteResolution {
93                    tile_id: source_tile_id.to_string(),
94                    mode_id: mode_id.clone(),
95                });
96            }
97        }
98    }
99
100    let fallback = defaults.fallbacks.get(key)?;
101    if mode_exists_on_sheet(&fallback.mode_id, catalog.mode_ids(&fallback.tile_id)) {
102        Some(ResourceSpriteResolution {
103            tile_id: fallback.tile_id.clone(),
104            mode_id: fallback.mode_id.clone(),
105        })
106    } else {
107        None
108    }
109}
110
111/// Inputs for NPC wildlife presentation (keeps this crate free of sim types).
112pub struct NpcPresentationInput<'a> {
113    /// Canonical presentation key (`combat`, `pursue`, `walking`, …).
114    pub presentation_key: &'a str,
115    pub sprite_modes: &'a SpriteModeMap,
116    pub sheet_modes: &'a [String],
117}
118
119pub fn npc_presentation_key<'a>(input: &'a NpcPresentationInput<'a>) -> &'a str {
120    input.presentation_key
121}
122
123/// Resolve gfx mode for an NPC view.
124pub fn resolve_npc_sprite_mode(input: &NpcPresentationInput<'_>) -> Option<String> {
125    let key = npc_presentation_key(input);
126    pick_mode(key, input.sprite_modes, input.sheet_modes)
127}
128
129/// Build presentation input for wildlife from sim context.
130pub fn wildlife_npc_presentation_input<'a>(
131    ctx: WildlifeNpcPresentationInput<'a>,
132    sprite_modes: &'a SpriteModeMap,
133    sheet_modes: &'a [String],
134) -> NpcPresentationInput<'a> {
135    let key = resolve_wildlife_presentation_key(ctx);
136    NpcPresentationInput {
137        presentation_key: key,
138        sprite_modes,
139        sheet_modes,
140    }
141}
142
143/// Build presentation input for placed (town/building) NPCs.
144pub fn placed_npc_presentation_input<'a>(
145    ctx: PlacedNpcPresentationInput<'a>,
146    sprite_modes: &'a SpriteModeMap,
147    sheet_modes: &'a [String],
148) -> NpcPresentationInput<'a> {
149    let key = resolve_placed_npc_presentation_key(ctx);
150    NpcPresentationInput {
151        presentation_key: key,
152        sprite_modes,
153        sheet_modes,
154    }
155}
156
157/// Inputs for player sprite resolution.
158pub struct PlayerSpritePresentationInput<'a> {
159    pub presentation_key: &'a str,
160    pub sprite_modes: &'a SpriteModeMap,
161    pub sheet_modes: &'a [String],
162}
163
164pub fn player_presentation_input<'a>(
165    ctx: PlayerPresentationInput,
166    sprite_modes: &'a SpriteModeMap,
167    sheet_modes: &'a [String],
168) -> PlayerSpritePresentationInput<'a> {
169    let key = resolve_player_presentation_key(ctx);
170    PlayerSpritePresentationInput {
171        presentation_key: key,
172        sprite_modes,
173        sheet_modes,
174    }
175}
176
177/// Mode ids used to validate presentation → sprite mode resolution.
178pub fn presentation_sheet_mode_ids(
179    catalog: &crate::catalog::SpriteCatalog,
180    tile_id: &str,
181    paperdoll_ref: Option<&str>,
182) -> Vec<String> {
183    if let Some(skin_id) = paperdoll_ref.filter(|s| !s.is_empty()) {
184        let sheet = format!("paperdoll.{skin_id}");
185        let modes: Vec<String> = catalog.mode_ids(&sheet).iter().cloned().collect();
186        if !modes.is_empty() {
187            return modes;
188        }
189    }
190    catalog.mode_ids(tile_id).iter().cloned().collect()
191}
192
193/// Resolve gfx mode for a player entity view.
194pub fn resolve_player_sprite_mode(input: &PlayerSpritePresentationInput<'_>) -> Option<String> {
195    pick_mode(
196        input.presentation_key,
197        input.sprite_modes,
198        input.sheet_modes,
199    )
200}
201
202/// Farm crop growth bands from sim (`grow_10` … `grow_100`). Picks the requested stage when
203/// present on the sheet, otherwise the nearest lower stage, then common resource modes.
204pub fn resolve_farm_growth_sprite_mode(
205    growth_mode: &str,
206    sheet_modes: &[String],
207) -> Option<String> {
208    const STAGES: &[&str] = &["grow_10", "grow_20", "grow_60", "grow_100"];
209    let Some(idx) = STAGES.iter().position(|s| *s == growth_mode) else {
210        return None;
211    };
212    for i in (0..=idx).rev() {
213        let stage = STAGES[i];
214        if mode_exists_on_sheet(stage, sheet_modes) {
215            return Some(stage.to_string());
216        }
217    }
218    for alt in ["idle", "harvest", "depleted"] {
219        if mode_exists_on_sheet(alt, sheet_modes) {
220            return Some(alt.to_string());
221        }
222    }
223    sheet_modes.first().cloned()
224}
225
226fn pick_mode(
227    presentation_key: &str,
228    sprite_modes: &SpriteModeMap,
229    sheet_modes: &[String],
230) -> Option<String> {
231    if let Some(mode) = sprite_modes.get(presentation_key) {
232        if sheet_modes.is_empty() || mode_exists_on_sheet(mode, sheet_modes) {
233            return Some(mode.clone());
234        }
235        // Designer YAML maps keys → mode ids that may exist only on client-baked
236        // `paperdoll.*` sheets, not in the sim PNG sprite catalog.
237        return Some(mode.clone());
238    }
239    if sheet_modes.iter().any(|m| m == presentation_key) {
240        return Some(presentation_key.to_string());
241    }
242    // Resource state aliases when sprite_modes omitted.
243    let resource_alias = match presentation_key {
244        "available" => sheet_modes
245            .iter()
246            .find(|m| **m == "idle" || m.starts_with("idle_"))
247            .map(|m| {
248                if m.starts_with("idle_") {
249                    "idle".to_string()
250                } else {
251                    m.clone()
252                }
253            }),
254        "harvesting" => sheet_modes.iter().find(|m| *m == "harvest").cloned(),
255        "depleted" | "cooldown" => sheet_modes
256            .iter()
257            .find(|m| *m == "depleted" || *m == "cooldown")
258            .cloned(),
259        _ => None,
260    };
261    if let Some(mode) = resource_alias {
262        return Some(mode);
263    }
264    // Legacy item YAML may still use `cooldown` as presentation key.
265    if presentation_key == "cooldown" {
266        if let Some(mode) = sprite_modes.get("depleted") {
267            if sheet_modes.is_empty() || mode_exists_on_sheet(mode, sheet_modes) {
268                return Some(mode.clone());
269            }
270        }
271    }
272    // Canonical NPC keys → common sheet mode ids when sprite_modes omitted.
273    if let Some(sheet_id) = crate::validate::npc_sheet_alias(presentation_key, sheet_modes) {
274        return Some(sheet_id);
275    }
276    sprite_modes
277        .get(presentation_key)
278        .filter(|m| mode_exists_on_sheet(m, sheet_modes))
279        .cloned()
280        .or_else(|| sheet_modes.first().filter(|m| !m.contains('_')).cloned())
281}
282
283#[cfg(test)]
284mod tests {
285    use super::*;
286
287    fn modes(ids: &[&str]) -> Vec<String> {
288        ids.iter().map(|s| s.to_string()).collect()
289    }
290
291    #[test]
292    fn resource_explicit_map() {
293        let mut map = SpriteModeMap::new();
294        map.insert("available".into(), "idle".into());
295        map.insert("harvesting".into(), "harvest".into());
296        map.insert("depleted".into(), "depleted".into());
297        let sheet = modes(&["idle", "harvest", "depleted"]);
298        assert_eq!(
299            resolve_resource_sprite_mode(ResourceNodeState::Harvesting, &map, &sheet).as_deref(),
300            Some("harvest")
301        );
302    }
303
304    #[test]
305    fn resource_alias_fallback() {
306        let map = SpriteModeMap::new();
307        let sheet = modes(&["idle", "harvest"]);
308        assert_eq!(
309            resolve_resource_sprite_mode(ResourceNodeState::Available, &map, &sheet).as_deref(),
310            Some("idle")
311        );
312        assert_eq!(
313            resolve_resource_sprite_mode(ResourceNodeState::Harvesting, &map, &sheet).as_deref(),
314            Some("harvest")
315        );
316    }
317
318    #[test]
319    fn resource_defaults_use_shared_depleted_fallback() {
320        let mut catalog = SpriteCatalog::default();
321        catalog.register_sheet_modes("resource.depleted", vec!["depleted".into()]);
322        let mut defaults = catalog.presentation_defaults().clone();
323        defaults.resource.fallbacks.insert(
324            "depleted".into(),
325            crate::SpriteFallback {
326                tile_id: "resource.depleted".into(),
327                mode_id: "depleted".into(),
328            },
329        );
330        catalog.set_presentation_defaults(defaults);
331
332        let result = resolve_resource_sprite(
333            Some("resource.oak_log"),
334            ResourceNodeState::Cooldown,
335            &SpriteModeMap::new(),
336            &catalog,
337        );
338        assert_eq!(
339            result,
340            Some(ResourceSpriteResolution {
341                tile_id: "resource.depleted".into(),
342                mode_id: "depleted".into(),
343            })
344        );
345    }
346
347    #[test]
348    fn resource_override_wins_when_mode_exists() {
349        let mut catalog = SpriteCatalog::default();
350        catalog.register_sheet_modes(
351            "resource.test",
352            vec![
353                "idle".into(),
354                "harvest_custom".into(),
355                "depleted_custom".into(),
356            ],
357        );
358        let mut map = SpriteModeMap::new();
359        map.insert("harvesting".into(), "harvest_custom".into());
360
361        let result = resolve_resource_sprite(
362            Some("resource.test"),
363            ResourceNodeState::Harvesting,
364            &map,
365            &catalog,
366        );
367        assert_eq!(
368            result,
369            Some(ResourceSpriteResolution {
370                tile_id: "resource.test".into(),
371                mode_id: "harvest_custom".into(),
372            })
373        );
374    }
375
376    #[test]
377    fn farm_growth_falls_back_to_lower_stage() {
378        let sheet = modes(&["grow_10", "grow_60", "idle"]);
379        assert_eq!(
380            resolve_farm_growth_sprite_mode("grow_100", &sheet).as_deref(),
381            Some("grow_60")
382        );
383        assert_eq!(
384            resolve_farm_growth_sprite_mode("grow_20", &sheet).as_deref(),
385            Some("grow_10")
386        );
387    }
388
389    #[test]
390    fn farm_growth_falls_back_to_idle() {
391        let sheet = modes(&["idle", "harvest"]);
392        assert_eq!(
393            resolve_farm_growth_sprite_mode("grow_60", &sheet).as_deref(),
394            Some("idle")
395        );
396    }
397
398    #[test]
399    fn npc_telegraph_presentation_key() {
400        let map = SpriteModeMap::new();
401        let sheet = modes(&["idle", "combat", "telegraph"]);
402        let input = NpcPresentationInput {
403            presentation_key: "telegraph",
404            sprite_modes: &map,
405            sheet_modes: &sheet,
406        };
407        assert_eq!(
408            resolve_npc_sprite_mode(&input).as_deref(),
409            Some("telegraph")
410        );
411    }
412
413    #[test]
414    fn npc_pursue_maps_to_chase_mode() {
415        let mut map = SpriteModeMap::new();
416        map.insert("pursue".into(), "chase".into());
417        let sheet = modes(&["idle", "chase", "combat"]);
418        let input = wildlife_npc_presentation_input(
419            WildlifeNpcPresentationInput {
420                fsm_state: "chase",
421                telegraph_active: false,
422                has_target: true,
423                in_attack_range: false,
424                is_moving: true,
425                fast_movement: true,
426            },
427            &map,
428            &sheet,
429        );
430        assert_eq!(resolve_npc_sprite_mode(&input).as_deref(), Some("chase"));
431        assert_eq!(input.presentation_key, "pursue");
432    }
433
434    #[test]
435    fn npc_legacy_behavior_alias() {
436        let map = SpriteModeMap::new();
437        let sheet = modes(&["idle", "chase", "combat"]);
438        let input = NpcPresentationInput {
439            presentation_key: "chase",
440            sprite_modes: &map,
441            sheet_modes: &sheet,
442        };
443        assert_eq!(resolve_npc_sprite_mode(&input).as_deref(), Some("chase"));
444    }
445
446    #[test]
447    fn player_directional_sheet_returns_base_modes() {
448        let mut map = SpriteModeMap::new();
449        map.insert("idle".into(), "idle".into());
450        map.insert("walking".into(), "walk".into());
451        map.insert("sprinting".into(), "run".into());
452        let sheet = modes(&[
453            "idle_north",
454            "idle_south",
455            "walk_north",
456            "walk_east",
457            "run_south",
458            "combat",
459        ]);
460
461        let idle = player_presentation_input(
462            PlayerPresentationInput {
463                harvesting: false,
464                crafting: false,
465                talking: false,
466                chatting: false,
467                in_combat: false,
468                pursue: false,
469                in_attack_range: false,
470                is_moving: false,
471                sprinting: false,
472                telegraph_active: false,
473            },
474            &map,
475            &sheet,
476        );
477        assert_eq!(resolve_player_sprite_mode(&idle).as_deref(), Some("idle"));
478
479        let walking = player_presentation_input(
480            PlayerPresentationInput {
481                harvesting: false,
482                crafting: false,
483                talking: false,
484                chatting: false,
485                in_combat: false,
486                pursue: false,
487                in_attack_range: false,
488                is_moving: true,
489                sprinting: false,
490                telegraph_active: false,
491            },
492            &map,
493            &sheet,
494        );
495        assert_eq!(
496            resolve_player_sprite_mode(&walking).as_deref(),
497            Some("walk")
498        );
499    }
500}