romm_api/core/
achievements.rs1use 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}