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();
57    let resource = &defaults.resource;
58    let farm_shared = defaults
59        .farm
60        .growth_fallback
61        .as_ref()
62        .map(|fb| fb.tile_id.as_str());
63
64    let candidate_modes: Vec<&str> = [
65        sprite_modes.get(key).map(String::as_str),
66        resource.modes.get(key).map(String::as_str),
67    ]
68    .into_iter()
69    .flatten()
70    .collect();
71
72    if let Some(source_tile_id) = tile_id.filter(|id| !id.is_empty()) {
73        let sheet_modes = catalog.mode_ids(source_tile_id);
74        for mode_id in &candidate_modes {
75            if mode_exists_on_sheet(mode_id, sheet_modes) {
76                return Some(ResourceSpriteResolution {
77                    tile_id: source_tile_id.to_string(),
78                    mode_id: (*mode_id).to_string(),
79                });
80            }
81        }
82
83        // Crop sheets may omit `harvested` / early grow rows; try the shared stages sheet.
84        if let Some(shared_id) = farm_shared {
85            let shared_modes = catalog.mode_ids(shared_id);
86            for mode_id in &candidate_modes {
87                if mode_exists_on_sheet(mode_id, shared_modes) {
88                    return Some(ResourceSpriteResolution {
89                        tile_id: shared_id.to_string(),
90                        mode_id: (*mode_id).to_string(),
91                    });
92                }
93            }
94        }
95
96        let alias = match key {
97            "available" => ["idle", ""],
98            "harvesting" => ["harvest", "idle"],
99            "depleted" => ["depleted", "cooldown"],
100            _ => ["", ""],
101        };
102        for mode_id in alias.into_iter().filter(|mode| !mode.is_empty()) {
103            if mode_exists_on_sheet(mode_id, sheet_modes) {
104                return Some(ResourceSpriteResolution {
105                    tile_id: source_tile_id.to_string(),
106                    mode_id: mode_id.to_string(),
107                });
108            }
109        }
110
111        if key != "depleted" {
112            if let Some(mode_id) = sheet_modes.first().filter(|mode| !mode.contains('_')) {
113                return Some(ResourceSpriteResolution {
114                    tile_id: source_tile_id.to_string(),
115                    mode_id: mode_id.clone(),
116                });
117            }
118        }
119
120        if let Some(resolved) =
121            resolve_sheet_suffix_fallback(source_tile_id, key, resource, catalog)
122        {
123            return Some(resolved);
124        }
125    }
126
127    let fallback = resource.fallbacks.get(key)?;
128    if mode_exists_on_sheet(&fallback.mode_id, catalog.mode_ids(&fallback.tile_id)) {
129        Some(ResourceSpriteResolution {
130            tile_id: fallback.tile_id.clone(),
131            mode_id: fallback.mode_id.clone(),
132        })
133    } else {
134        None
135    }
136}
137
138fn resolve_sheet_suffix_fallback(
139    source_tile_id: &str,
140    key: &str,
141    resource: &crate::ResourcePresentationDefaults,
142    catalog: &SpriteCatalog,
143) -> Option<ResourceSpriteResolution> {
144    for (suffix, by_key) in &resource.sheet_suffix_fallbacks {
145        if !source_tile_id.ends_with(suffix.as_str()) {
146            continue;
147        }
148        let fallback = by_key.get(key)?;
149        if mode_exists_on_sheet(&fallback.mode_id, catalog.mode_ids(&fallback.tile_id)) {
150            return Some(ResourceSpriteResolution {
151                tile_id: fallback.tile_id.clone(),
152                mode_id: fallback.mode_id.clone(),
153            });
154        }
155    }
156    None
157}
158
159/// Inputs for NPC wildlife presentation (keeps this crate free of sim types).
160pub struct NpcPresentationInput<'a> {
161    /// Canonical presentation key (`combat`, `pursue`, `walking`, …).
162    pub presentation_key: &'a str,
163    pub sprite_modes: &'a SpriteModeMap,
164    pub sheet_modes: &'a [String],
165}
166
167pub fn npc_presentation_key<'a>(input: &'a NpcPresentationInput<'a>) -> &'a str {
168    input.presentation_key
169}
170
171/// Resolve gfx mode for an NPC view.
172pub fn resolve_npc_sprite_mode(input: &NpcPresentationInput<'_>) -> Option<String> {
173    let key = npc_presentation_key(input);
174    pick_mode(key, input.sprite_modes, input.sheet_modes)
175}
176
177/// Build presentation input for wildlife from sim context.
178pub fn wildlife_npc_presentation_input<'a>(
179    ctx: WildlifeNpcPresentationInput<'a>,
180    sprite_modes: &'a SpriteModeMap,
181    sheet_modes: &'a [String],
182) -> NpcPresentationInput<'a> {
183    let key = resolve_wildlife_presentation_key(ctx);
184    NpcPresentationInput {
185        presentation_key: key,
186        sprite_modes,
187        sheet_modes,
188    }
189}
190
191/// Build presentation input for placed (town/building) NPCs.
192pub fn placed_npc_presentation_input<'a>(
193    ctx: PlacedNpcPresentationInput<'a>,
194    sprite_modes: &'a SpriteModeMap,
195    sheet_modes: &'a [String],
196) -> NpcPresentationInput<'a> {
197    let key = resolve_placed_npc_presentation_key(ctx);
198    NpcPresentationInput {
199        presentation_key: key,
200        sprite_modes,
201        sheet_modes,
202    }
203}
204
205/// Inputs for player sprite resolution.
206pub struct PlayerSpritePresentationInput<'a> {
207    pub presentation_key: &'a str,
208    pub sprite_modes: &'a SpriteModeMap,
209    pub sheet_modes: &'a [String],
210}
211
212pub fn player_presentation_input<'a>(
213    ctx: PlayerPresentationInput,
214    sprite_modes: &'a SpriteModeMap,
215    sheet_modes: &'a [String],
216) -> PlayerSpritePresentationInput<'a> {
217    let key = resolve_player_presentation_key(ctx);
218    PlayerSpritePresentationInput {
219        presentation_key: key,
220        sprite_modes,
221        sheet_modes,
222    }
223}
224
225/// Mode ids used to validate presentation → sprite mode resolution.
226pub fn presentation_sheet_mode_ids(
227    catalog: &crate::catalog::SpriteCatalog,
228    tile_id: &str,
229    paperdoll_ref: Option<&str>,
230) -> Vec<String> {
231    if let Some(skin_id) = paperdoll_ref.filter(|s| !s.is_empty()) {
232        let sheet = format!("paperdoll.{skin_id}");
233        let modes: Vec<String> = catalog.mode_ids(&sheet).iter().cloned().collect();
234        if !modes.is_empty() {
235            return modes;
236        }
237    }
238    catalog.mode_ids(tile_id).iter().cloned().collect()
239}
240
241/// Resolve gfx mode for a player entity view.
242pub fn resolve_player_sprite_mode(input: &PlayerSpritePresentationInput<'_>) -> Option<String> {
243    pick_mode(
244        input.presentation_key,
245        input.sprite_modes,
246        input.sheet_modes,
247    )
248}
249
250const FARM_GROWTH_STAGES: &[&str] = &["grow_10", "grow_20", "grow_60", "grow_100"];
251
252/// Nearest-lower `grow_*` mode on a sheet (no idle fallback).
253pub fn pick_farm_growth_stage(growth_mode: &str, sheet_modes: &[String]) -> Option<String> {
254    let idx = FARM_GROWTH_STAGES.iter().position(|s| *s == growth_mode)?;
255    for i in (0..=idx).rev() {
256        let stage = FARM_GROWTH_STAGES[i];
257        if mode_exists_on_sheet(stage, sheet_modes) {
258            return Some(stage.to_string());
259        }
260    }
261    None
262}
263
264fn farm_growth_same_sheet_fallback(sheet_modes: &[String]) -> Option<String> {
265    for alt in ["idle", "harvest", "depleted", "harvested"] {
266        if mode_exists_on_sheet(alt, sheet_modes) {
267            return Some(alt.to_string());
268        }
269    }
270    sheet_modes.first().cloned()
271}
272
273/// Farm crop growth bands from sim (`grow_10` … `grow_100`). Picks the requested stage when
274/// present on the sheet, otherwise the nearest lower stage, then common resource modes.
275///
276/// Prefer [`resolve_farm_growth_sprite`] when a shared stages sheet is configured.
277pub fn resolve_farm_growth_sprite_mode(
278    growth_mode: &str,
279    sheet_modes: &[String],
280) -> Option<String> {
281    if !FARM_GROWTH_STAGES.contains(&growth_mode) {
282        return None;
283    }
284    pick_farm_growth_stage(growth_mode, sheet_modes)
285        .or_else(|| farm_growth_same_sheet_fallback(sheet_modes))
286}
287
288/// Resolve farm growth to a concrete sheet + mode, using `farm.growth_fallback` when the
289/// crop sheet lacks early grow stages.
290pub fn resolve_farm_growth_sprite(
291    growth_mode: &str,
292    source_tile_id: &str,
293    catalog: &SpriteCatalog,
294) -> Option<ResourceSpriteResolution> {
295    let source_modes = catalog.mode_ids(source_tile_id);
296    let shared_tile_id = catalog
297        .presentation_defaults()
298        .farm
299        .growth_fallback
300        .as_ref()
301        .map(|fb| fb.tile_id.as_str());
302    let empty: &[String] = &[];
303    let shared_modes = shared_tile_id
304        .map(|id| catalog.mode_ids(id))
305        .unwrap_or(empty);
306    resolve_farm_growth_sprite_from_modes(
307        growth_mode,
308        source_tile_id,
309        source_modes,
310        shared_tile_id,
311        shared_modes,
312    )
313}
314
315/// Same as [`resolve_farm_growth_sprite`] but with explicit mode lists (gfx catalog).
316pub fn resolve_farm_growth_sprite_from_modes(
317    growth_mode: &str,
318    source_tile_id: &str,
319    source_modes: &[String],
320    shared_tile_id: Option<&str>,
321    shared_modes: &[String],
322) -> Option<ResourceSpriteResolution> {
323    if !FARM_GROWTH_STAGES.contains(&growth_mode) {
324        return None;
325    }
326    if let Some(mode_id) = pick_farm_growth_stage(growth_mode, source_modes) {
327        return Some(ResourceSpriteResolution {
328            tile_id: source_tile_id.to_string(),
329            mode_id,
330        });
331    }
332    if let Some(shared_id) = shared_tile_id.filter(|id| !id.is_empty()) {
333        if let Some(mode_id) = pick_farm_growth_stage(growth_mode, shared_modes) {
334            return Some(ResourceSpriteResolution {
335                tile_id: shared_id.to_string(),
336                mode_id,
337            });
338        }
339    }
340    farm_growth_same_sheet_fallback(source_modes).map(|mode_id| ResourceSpriteResolution {
341        tile_id: source_tile_id.to_string(),
342        mode_id,
343    })
344}
345
346fn pick_mode(
347    presentation_key: &str,
348    sprite_modes: &SpriteModeMap,
349    sheet_modes: &[String],
350) -> Option<String> {
351    if let Some(mode) = sprite_modes.get(presentation_key) {
352        if sheet_modes.is_empty() || mode_exists_on_sheet(mode, sheet_modes) {
353            return Some(mode.clone());
354        }
355        // Designer YAML maps keys → mode ids that may exist only on client-baked
356        // `paperdoll.*` sheets, not in the sim PNG sprite catalog.
357        return Some(mode.clone());
358    }
359    if sheet_modes.iter().any(|m| m == presentation_key) {
360        return Some(presentation_key.to_string());
361    }
362    // Resource state aliases when sprite_modes omitted.
363    let resource_alias = match presentation_key {
364        "available" => sheet_modes
365            .iter()
366            .find(|m| **m == "idle" || m.starts_with("idle_"))
367            .map(|m| {
368                if m.starts_with("idle_") {
369                    "idle".to_string()
370                } else {
371                    m.clone()
372                }
373            }),
374        "harvesting" => sheet_modes.iter().find(|m| *m == "harvest").cloned(),
375        "depleted" | "cooldown" => sheet_modes
376            .iter()
377            .find(|m| *m == "depleted" || *m == "cooldown")
378            .cloned(),
379        _ => None,
380    };
381    if let Some(mode) = resource_alias {
382        return Some(mode);
383    }
384    // Legacy item YAML may still use `cooldown` as presentation key.
385    if presentation_key == "cooldown" {
386        if let Some(mode) = sprite_modes.get("depleted") {
387            if sheet_modes.is_empty() || mode_exists_on_sheet(mode, sheet_modes) {
388                return Some(mode.clone());
389            }
390        }
391    }
392    // Canonical NPC keys → common sheet mode ids when sprite_modes omitted.
393    if let Some(sheet_id) = crate::validate::npc_sheet_alias(presentation_key, sheet_modes) {
394        return Some(sheet_id);
395    }
396    sprite_modes
397        .get(presentation_key)
398        .filter(|m| mode_exists_on_sheet(m, sheet_modes))
399        .cloned()
400        .or_else(|| sheet_modes.first().filter(|m| !m.contains('_')).cloned())
401}
402
403#[cfg(test)]
404mod tests {
405    use super::*;
406    use std::collections::BTreeMap;
407
408    fn modes(ids: &[&str]) -> Vec<String> {
409        ids.iter().map(|s| s.to_string()).collect()
410    }
411
412    #[test]
413    fn resource_explicit_map() {
414        let mut map = SpriteModeMap::new();
415        map.insert("available".into(), "idle".into());
416        map.insert("harvesting".into(), "harvest".into());
417        map.insert("depleted".into(), "depleted".into());
418        let sheet = modes(&["idle", "harvest", "depleted"]);
419        assert_eq!(
420            resolve_resource_sprite_mode(ResourceNodeState::Harvesting, &map, &sheet).as_deref(),
421            Some("harvest")
422        );
423    }
424
425    #[test]
426    fn resource_alias_fallback() {
427        let map = SpriteModeMap::new();
428        let sheet = modes(&["idle", "harvest"]);
429        assert_eq!(
430            resolve_resource_sprite_mode(ResourceNodeState::Available, &map, &sheet).as_deref(),
431            Some("idle")
432        );
433        assert_eq!(
434            resolve_resource_sprite_mode(ResourceNodeState::Harvesting, &map, &sheet).as_deref(),
435            Some("harvest")
436        );
437    }
438
439    #[test]
440    fn resource_defaults_use_shared_depleted_fallback() {
441        let mut catalog = SpriteCatalog::default();
442        catalog.register_sheet_modes("resource.depleted", vec!["depleted".into()]);
443        let mut defaults = catalog.presentation_defaults().clone();
444        defaults.resource.fallbacks.insert(
445            "depleted".into(),
446            crate::SpriteFallback {
447                tile_id: "resource.depleted".into(),
448                mode_id: "depleted".into(),
449            },
450        );
451        catalog.set_presentation_defaults(defaults);
452
453        let result = resolve_resource_sprite(
454            Some("resource.oak_log"),
455            ResourceNodeState::Cooldown,
456            &SpriteModeMap::new(),
457            &catalog,
458        );
459        assert_eq!(
460            result,
461            Some(ResourceSpriteResolution {
462                tile_id: "resource.depleted".into(),
463                mode_id: "depleted".into(),
464            })
465        );
466    }
467
468    #[test]
469    fn resource_override_wins_when_mode_exists() {
470        let mut catalog = SpriteCatalog::default();
471        catalog.register_sheet_modes(
472            "resource.test",
473            vec![
474                "idle".into(),
475                "harvest_custom".into(),
476                "depleted_custom".into(),
477            ],
478        );
479        let mut map = SpriteModeMap::new();
480        map.insert("harvesting".into(), "harvest_custom".into());
481
482        let result = resolve_resource_sprite(
483            Some("resource.test"),
484            ResourceNodeState::Harvesting,
485            &map,
486            &catalog,
487        );
488        assert_eq!(
489            result,
490            Some(ResourceSpriteResolution {
491                tile_id: "resource.test".into(),
492                mode_id: "harvest_custom".into(),
493            })
494        );
495    }
496
497    #[test]
498    fn farm_growth_falls_back_to_lower_stage() {
499        let sheet = modes(&["grow_10", "grow_60", "idle"]);
500        assert_eq!(
501            resolve_farm_growth_sprite_mode("grow_100", &sheet).as_deref(),
502            Some("grow_60")
503        );
504        assert_eq!(
505            resolve_farm_growth_sprite_mode("grow_20", &sheet).as_deref(),
506            Some("grow_10")
507        );
508    }
509
510    #[test]
511    fn farm_growth_falls_back_to_idle() {
512        let sheet = modes(&["idle", "harvest"]);
513        assert_eq!(
514            resolve_farm_growth_sprite_mode("grow_60", &sheet).as_deref(),
515            Some("idle")
516        );
517    }
518
519    #[test]
520    fn farm_growth_uses_shared_stages_sheet_when_crop_omits_early_grows() {
521        let mut catalog = SpriteCatalog::default();
522        catalog.register_sheet_modes(
523            "resource.wheat_crop",
524            vec!["idle".into(), "harvest".into(), "grow_100".into()],
525        );
526        catalog.register_sheet_modes(
527            "resource.crop_shared_stages",
528            vec![
529                "grow_10".into(),
530                "grow_20".into(),
531                "grow_60".into(),
532                "harvested".into(),
533            ],
534        );
535        let mut defaults = catalog.presentation_defaults().clone();
536        defaults.farm.growth_fallback = Some(crate::FarmGrowthFallback {
537            tile_id: "resource.crop_shared_stages".into(),
538        });
539        catalog.set_presentation_defaults(defaults);
540
541        assert_eq!(
542            resolve_farm_growth_sprite("grow_60", "resource.wheat_crop", &catalog),
543            Some(ResourceSpriteResolution {
544                tile_id: "resource.crop_shared_stages".into(),
545                mode_id: "grow_60".into(),
546            })
547        );
548        assert_eq!(
549            resolve_farm_growth_sprite("grow_100", "resource.wheat_crop", &catalog),
550            Some(ResourceSpriteResolution {
551                tile_id: "resource.wheat_crop".into(),
552                mode_id: "grow_100".into(),
553            })
554        );
555        // Nearest-lower on the shared sheet when grow_20 is missing there too.
556        let mut catalog2 = catalog.clone();
557        catalog2.register_sheet_modes(
558            "resource.crop_shared_stages",
559            vec!["grow_10".into(), "grow_60".into(), "harvested".into()],
560        );
561        assert_eq!(
562            resolve_farm_growth_sprite("grow_20", "resource.wheat_crop", &catalog2),
563            Some(ResourceSpriteResolution {
564                tile_id: "resource.crop_shared_stages".into(),
565                mode_id: "grow_10".into(),
566            })
567        );
568    }
569
570    #[test]
571    fn crop_depleted_harvested_mode_uses_shared_stages() {
572        let mut catalog = SpriteCatalog::default();
573        catalog.register_sheet_modes(
574            "resource.wheat_crop",
575            vec!["idle".into(), "harvest".into(), "grow_100".into()],
576        );
577        catalog.register_sheet_modes(
578            "resource.crop_shared_stages",
579            vec!["grow_10".into(), "harvested".into()],
580        );
581        let mut defaults = catalog.presentation_defaults().clone();
582        defaults.farm.growth_fallback = Some(crate::FarmGrowthFallback {
583            tile_id: "resource.crop_shared_stages".into(),
584        });
585        catalog.set_presentation_defaults(defaults);
586
587        let mut map = SpriteModeMap::new();
588        map.insert("depleted".into(), "harvested".into());
589        let result = resolve_resource_sprite(
590            Some("resource.wheat_crop"),
591            ResourceNodeState::Cooldown,
592            &map,
593            &catalog,
594        );
595        assert_eq!(
596            result,
597            Some(ResourceSpriteResolution {
598                tile_id: "resource.crop_shared_stages".into(),
599                mode_id: "harvested".into(),
600            })
601        );
602    }
603
604    #[test]
605    fn tree_depleted_uses_sheet_suffix_fallback() {
606        let mut catalog = SpriteCatalog::default();
607        catalog.register_sheet_modes("resource.oak_tree", vec!["idle".into(), "harvest".into()]);
608        catalog.register_sheet_modes("resource.tree_depleted", vec!["depleted".into()]);
609        catalog.register_sheet_modes("resource.depleted", vec!["depleted".into()]);
610        let mut defaults = catalog.presentation_defaults().clone();
611        defaults.resource.fallbacks.insert(
612            "depleted".into(),
613            crate::SpriteFallback {
614                tile_id: "resource.depleted".into(),
615                mode_id: "depleted".into(),
616            },
617        );
618        defaults.resource.sheet_suffix_fallbacks.insert(
619            "_tree".into(),
620            BTreeMap::from([(
621                "depleted".into(),
622                crate::SpriteFallback {
623                    tile_id: "resource.tree_depleted".into(),
624                    mode_id: "depleted".into(),
625                },
626            )]),
627        );
628        catalog.set_presentation_defaults(defaults);
629
630        let result = resolve_resource_sprite(
631            Some("resource.oak_tree"),
632            ResourceNodeState::Cooldown,
633            &SpriteModeMap::new(),
634            &catalog,
635        );
636        assert_eq!(
637            result,
638            Some(ResourceSpriteResolution {
639                tile_id: "resource.tree_depleted".into(),
640                mode_id: "depleted".into(),
641            })
642        );
643    }
644
645    #[test]
646    fn npc_telegraph_presentation_key() {
647        let map = SpriteModeMap::new();
648        let sheet = modes(&["idle", "combat", "telegraph"]);
649        let input = NpcPresentationInput {
650            presentation_key: "telegraph",
651            sprite_modes: &map,
652            sheet_modes: &sheet,
653        };
654        assert_eq!(
655            resolve_npc_sprite_mode(&input).as_deref(),
656            Some("telegraph")
657        );
658    }
659
660    #[test]
661    fn npc_pursue_maps_to_chase_mode() {
662        let mut map = SpriteModeMap::new();
663        map.insert("pursue".into(), "chase".into());
664        let sheet = modes(&["idle", "chase", "combat"]);
665        let input = wildlife_npc_presentation_input(
666            WildlifeNpcPresentationInput {
667                fsm_state: "chase",
668                telegraph_active: false,
669                has_target: true,
670                in_attack_range: false,
671                is_moving: true,
672                fast_movement: true,
673            },
674            &map,
675            &sheet,
676        );
677        assert_eq!(resolve_npc_sprite_mode(&input).as_deref(), Some("chase"));
678        assert_eq!(input.presentation_key, "pursue");
679    }
680
681    #[test]
682    fn npc_legacy_behavior_alias() {
683        let map = SpriteModeMap::new();
684        let sheet = modes(&["idle", "chase", "combat"]);
685        let input = NpcPresentationInput {
686            presentation_key: "chase",
687            sprite_modes: &map,
688            sheet_modes: &sheet,
689        };
690        assert_eq!(resolve_npc_sprite_mode(&input).as_deref(), Some("chase"));
691    }
692
693    #[test]
694    fn player_directional_sheet_returns_base_modes() {
695        let mut map = SpriteModeMap::new();
696        map.insert("idle".into(), "idle".into());
697        map.insert("walking".into(), "walk".into());
698        map.insert("sprinting".into(), "run".into());
699        let sheet = modes(&[
700            "idle_north",
701            "idle_south",
702            "walk_north",
703            "walk_east",
704            "run_south",
705            "combat",
706        ]);
707
708        let idle = player_presentation_input(
709            PlayerPresentationInput {
710                harvesting: false,
711                crafting: false,
712                talking: false,
713                chatting: false,
714                in_combat: false,
715                pursue: false,
716                in_attack_range: false,
717                is_moving: false,
718                sprinting: false,
719                telegraph_active: false,
720            },
721            &map,
722            &sheet,
723        );
724        assert_eq!(resolve_player_sprite_mode(&idle).as_deref(), Some("idle"));
725
726        let walking = player_presentation_input(
727            PlayerPresentationInput {
728                harvesting: false,
729                crafting: false,
730                talking: false,
731                chatting: false,
732                in_combat: false,
733                pursue: false,
734                in_attack_range: false,
735                is_moving: true,
736                sprinting: false,
737                telegraph_active: false,
738            },
739            &map,
740            &sheet,
741        );
742        assert_eq!(
743            resolve_player_sprite_mode(&walking).as_deref(),
744            Some("walk")
745        );
746    }
747}