Skip to main content

romm_api/core/
achievements.rs

1use std::collections::HashMap;
2
3use crate::types::achievements::{
4    AchievementRow, EarnedAchievement, MergedRaMetadata, RaAchievement, RaUserGameProgression,
5    RaUserProgression,
6};
7
8#[derive(Debug, Clone, PartialEq, Eq)]
9pub enum AchievementLoadResult {
10    Loaded {
11        rows: Vec<AchievementRow>,
12        summary: (usize, usize),
13    },
14    Empty(String),
15}
16
17fn badge_key(achievement: &RaAchievement) -> Option<&str> {
18    achievement
19        .badge_id
20        .as_deref()
21        .or(achievement.badge_name.as_deref())
22        .filter(|s| !s.is_empty())
23}
24
25pub fn merge_achievements(
26    metadata: &MergedRaMetadata,
27    progression: Option<&RaUserGameProgression>,
28) -> Vec<AchievementRow> {
29    let earned_by_badge: HashMap<&str, &EarnedAchievement> = progression
30        .map(|p| {
31            p.earned_achievements
32                .iter()
33                .map(|e| (e.id.as_str(), e))
34                .collect()
35        })
36        .unwrap_or_default();
37
38    let mut indexed: Vec<(i64, AchievementRow)> = metadata
39        .achievements
40        .iter()
41        .map(|a| {
42            let order = a.display_order.unwrap_or(i64::MAX);
43            let earned = badge_key(a).and_then(|k| earned_by_badge.get(k).copied());
44            (
45                order,
46                AchievementRow {
47                    title: a.title.clone(),
48                    description: a.description.clone(),
49                    points: a.points,
50                    earned: earned.is_some(),
51                    earned_at: earned.and_then(|e| e.date.clone()),
52                },
53            )
54        })
55        .collect();
56
57    indexed.sort_by_key(|(order, _)| *order);
58    indexed.into_iter().map(|(_, row)| row).collect()
59}
60
61pub fn summary(rows: &[AchievementRow]) -> (usize, usize) {
62    let earned = rows.iter().filter(|r| r.earned).count();
63    (earned, rows.len())
64}
65
66pub fn achievement_empty_message(
67    ra_id: Option<i64>,
68    ra_username: Option<&str>,
69    metadata: Option<&MergedRaMetadata>,
70) -> Option<&'static str> {
71    if ra_id.is_none() {
72        return Some("Not matched to RetroAchievements");
73    }
74    if ra_username
75        .map(str::trim)
76        .filter(|s| !s.is_empty())
77        .is_none()
78    {
79        return Some("Set RA username in RomM profile (web UI)");
80    }
81    if metadata.map(|m| m.achievements.is_empty()).unwrap_or(true) {
82        return Some("No achievement metadata on server");
83    }
84    None
85}
86
87pub fn prepare_achievements(
88    ra_id: Option<i64>,
89    metadata: Option<&MergedRaMetadata>,
90    ra_username: Option<&str>,
91    progression: Option<&RaUserProgression>,
92) -> AchievementLoadResult {
93    if let Some(msg) = achievement_empty_message(ra_id, ra_username, metadata) {
94        return AchievementLoadResult::Empty(msg.to_string());
95    }
96    let ra_id = ra_id.expect("checked above");
97    let metadata = metadata.expect("checked above");
98    let game_progression =
99        progression.and_then(|p| p.results.iter().find(|r| r.rom_ra_id == Some(ra_id)));
100    let rows = merge_achievements(metadata, game_progression);
101    AchievementLoadResult::Loaded {
102        summary: summary(&rows),
103        rows,
104    }
105}
106
107#[cfg(test)]
108mod tests {
109    use super::*;
110    use crate::types::achievements::{EarnedAchievement, RaAchievement, RaUserGameProgression};
111
112    fn sample_metadata() -> MergedRaMetadata {
113        MergedRaMetadata {
114            achievements: vec![
115                RaAchievement {
116                    title: "First steps".into(),
117                    description: Some("Begin the adventure".into()),
118                    points: Some(5),
119                    badge_id: Some("85541".into()),
120                    badge_name: None,
121                    display_order: Some(0),
122                },
123                RaAchievement {
124                    title: "Speed run".into(),
125                    description: None,
126                    points: Some(10),
127                    badge_id: Some("85542".into()),
128                    badge_name: None,
129                    display_order: Some(1),
130                },
131            ],
132        }
133    }
134
135    #[test]
136    fn merge_marks_earned_by_badge_id() {
137        let progression = RaUserGameProgression {
138            rom_ra_id: Some(14402),
139            num_awarded: Some(1),
140            max_possible: Some(2),
141            earned_achievements: vec![EarnedAchievement {
142                id: "85541".into(),
143                date: Some("2022-08-23 22:56:38".into()),
144                date_hardcore: None,
145            }],
146        };
147        let rows = merge_achievements(&sample_metadata(), Some(&progression));
148        assert_eq!(rows.len(), 2);
149        assert!(rows[0].earned);
150        assert!(!rows[1].earned);
151        assert_eq!(summary(&rows), (1, 2));
152    }
153
154    #[test]
155    fn prepare_returns_empty_when_ra_id_missing() {
156        let result = prepare_achievements(None, None, Some("player"), None);
157        assert_eq!(
158            result,
159            AchievementLoadResult::Empty("Not matched to RetroAchievements".into())
160        );
161    }
162
163    #[test]
164    fn current_user_fixture_deserializes() {
165        let json = r#"{"id":1,"ra_username":"player1","ra_progression":{"total":1,"results":[]}}"#;
166        let user: crate::types::CurrentUser = serde_json::from_str(json).unwrap();
167        assert_eq!(user.ra_username.as_deref(), Some("player1"));
168    }
169}