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::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
37pub struct NpcPresentationInput<'a> {
39 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
49pub 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
55pub 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
69pub 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
83pub 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
103pub 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
119pub 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
128pub 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 return Some(mode.clone());
164 }
165 if sheet_modes.iter().any(|m| m == presentation_key) {
166 return Some(presentation_key.to_string());
167 }
168 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 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 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}