1use 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
13pub const RESOURCE_PRESENTATION_KEYS: &[&str] = &["available", "harvesting", "depleted"];
15
16pub 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
27pub 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#[derive(Debug, Clone, PartialEq, Eq)]
39pub struct ResourceSpriteResolution {
40 pub tile_id: String,
41 pub mode_id: String,
42}
43
44pub 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 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
159pub struct NpcPresentationInput<'a> {
161 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
171pub 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
177pub 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
191pub 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
205pub 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
225pub 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
241pub 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
252pub 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
273pub 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
288pub 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
315pub 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 return Some(mode.clone());
358 }
359 if sheet_modes.iter().any(|m| m == presentation_key) {
360 return Some(presentation_key.to_string());
361 }
362 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 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 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 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}