aede_core/
achievements.rs

1//! Owner-private discoveries derived from retained personal evidence; locked definitions never leave the engine.
2use crate::{
3    model::Catalog,
4    user::{ClientPreference, UserData},
5};
6use serde_json::{Value, json};
7use std::collections::{BTreeMap, BTreeSet};
8const KEY: &str = "aede.achievements.v1";
9struct Definition {
10    id: String,
11    fr: String,
12    en: String,
13    detail_fr: String,
14    detail_en: String,
15    met: bool,
16    icon: &'static str,
17}
18fn definitions(catalog: &Catalog, data: &UserData, owner: &str, search: bool) -> Vec<Definition> {
19    let annotations: Vec<_> = data
20        .annotations
21        .iter()
22        .filter(|r| r.owner == owner)
23        .collect();
24    let plays: Vec<_> = data.plays.iter().filter(|r| r.owner == owner).collect();
25    let completed = crate::listening::completed_tracks(data, owner);
26    let albums = crate::listening::completed_albums(catalog, data, owner);
27    let mut discographies: BTreeMap<_, Vec<_>> = BTreeMap::new();
28    for r in &catalog.releases {
29        if let Some(artist) = r.album_artist_id {
30            discographies.entry(artist).or_default().push(r.id);
31        }
32    }
33    let tags: BTreeSet<_> = annotations.iter().flat_map(|r| r.tags.iter()).collect();
34    let families = [
35        (
36            "loved",
37            "Favoris",
38            "Favourites",
39            "Ajouter des favoris",
40            "Add favourites",
41            annotations.iter().filter(|r| r.loved).count() as u64,
42            "heart",
43            [1, 5, 10, 25, 50, 100],
44        ),
45        (
46            "rated",
47            "Étoiles",
48            "Stars",
49            "Noter des éléments",
50            "Rate items",
51            annotations.iter().filter(|r| r.rating.is_some()).count() as u64,
52            "star",
53            [1, 5, 10, 25, 50, 100],
54        ),
55        (
56            "notes",
57            "Carnet",
58            "Notebook",
59            "Écrire des notes personnelles",
60            "Write personal notes",
61            annotations
62                .iter()
63                .filter(|r| r.note.as_ref().is_some_and(|n| !n.trim().is_empty()))
64                .count() as u64,
65            "edit",
66            [1, 3, 10, 25, 50, 100],
67        ),
68        (
69            "tags",
70            "Repères",
71            "Landmarks",
72            "Utiliser des tags distincts",
73            "Use distinct tags",
74            tags.len() as u64,
75            "tags",
76            [1, 3, 10, 25, 50, 100],
77        ),
78        (
79            "tracks",
80            "À l'écoute",
81            "Listening",
82            "Écouter des pistes distinctes jusqu'au bout",
83            "Finish distinct tracks",
84            completed.len() as u64,
85            "volume",
86            [1, 10, 25, 100, 250, 1000],
87        ),
88        (
89            "albums",
90            "Du début à la fin",
91            "From beginning to end",
92            "Terminer des albums du catalogue",
93            "Complete catalogue albums",
94            albums.len() as u64,
95            "disc",
96            [1, 5, 10, 25, 50, 100],
97        ),
98        (
99            "artists",
100            "En profondeur",
101            "In depth",
102            "Terminer les discographies présentes dans le catalogue",
103            "Complete discographies present in the catalogue",
104            discographies
105                .values()
106                .filter(|rs| rs.iter().all(|r| albums.contains(r)))
107                .count() as u64,
108            "users",
109            [1, 2, 5, 10, 25, 50],
110        ),
111        (
112            "hours",
113            "Le temps de la musique",
114            "Time for music",
115            "Cumuler des heures d'écoute dans l'historique conservé",
116            "Accumulate listening hours in retained history",
117            plays
118                .iter()
119                .fold(0_u64, |sum, r| sum.saturating_add(r.ms_played))
120                / 3600000,
121            "history",
122            [1, 5, 10, 25, 50, 100],
123        ),
124    ];
125    let mut result = Vec::new();
126    for (key, fr, en, df, de, count, icon, limits) in families {
127        for threshold in limits {
128            result.push(Definition {
129                id: format!("{key}-{threshold}"),
130                fr: format!("{fr} · {threshold}"),
131                en: format!("{en} · {threshold}"),
132                detail_fr: format!("{df} : {threshold}."),
133                detail_en: format!("{de}: {threshold}."),
134                met: count >= threshold,
135                icon,
136            });
137        }
138    }
139    let count = data.collections.iter().filter(|r| r.owner == owner).count();
140    for threshold in [1, 3, 10] {
141        result.push(Definition {
142            id: format!("collections-{threshold}"),
143            fr: format!("Votre sélection · {threshold}"),
144            en: format!("Your selection · {threshold}"),
145            detail_fr: format!("Créer {threshold} collection(s)."),
146            detail_en: format!("Create {threshold} collection(s)."),
147            met: count >= threshold,
148            icon: "library",
149        });
150    }
151    result.push(Definition {
152        id: "search-1".into(),
153        fr: "Au-delà du titre".into(),
154        en: "Beyond the title".into(),
155        detail_fr: "Effectuer une recherche avancée dans Phémios.".into(),
156        detail_en: "Perform an advanced search in Phémios.".into(),
157        met: search,
158        icon: "search",
159    });
160    result
161}
162/// Retain monotonic unlock dates, refusing malformed records rather than erasing private state.
163/// Search is a client-declared successful action; completion uses retained Play evidence only.
164pub fn refresh(
165    catalog: &Catalog,
166    data: &mut UserData,
167    owner: &str,
168    advanced: bool,
169    at: u64,
170) -> Result<Value, String> {
171    let row = data
172        .client_preferences
173        .iter()
174        .position(|r| r.owner == owner && r.key == KEY);
175    let previous = match row {
176        Some(i) => serde_json::from_str::<Value>(&data.client_preferences[i].value)
177            .map_err(|_| "achievement store is unreadable")?,
178        None => json!({"version":1,"unlocked":{},"search":false}),
179    };
180    if previous["version"] != 1 || !previous["search"].is_boolean() {
181        return Err("achievement store is unreadable".into());
182    }
183    let object = previous["unlocked"]
184        .as_object()
185        .filter(|r| r.len() <= 256)
186        .ok_or("achievement store is unreadable")?;
187    let mut unlocked = BTreeMap::new();
188    for (key, value) in object {
189        if key.len() > 64 || !key.bytes().all(|b| b.is_ascii_alphanumeric() || b == b'-') {
190            return Err("achievement store is unreadable".into());
191        }
192        unlocked.insert(
193            key.clone(),
194            value.as_u64().ok_or("achievement store is unreadable")?,
195        );
196    }
197    let search = advanced || previous["search"].as_bool().unwrap_or(false);
198    let definitions = definitions(catalog, data, owner, search);
199    for d in &definitions {
200        if d.met {
201            unlocked.entry(d.id.clone()).or_insert(at);
202        }
203    }
204    let value = json!({"version":1,"unlocked":unlocked,"search":search}).to_string();
205    if !crate::user::valid_preference(KEY, &value) {
206        return Err("achievement store exceeds its bound".into());
207    }
208    match row {
209        Some(i) => data.client_preferences[i].value = value,
210        None => {
211            if data.client_preferences.len() >= 8192
212                || data
213                    .client_preferences
214                    .iter()
215                    .filter(|r| r.owner == owner)
216                    .count()
217                    >= 64
218            {
219                return Err("personal store is full".into());
220            }
221            data.client_preferences.push(ClientPreference {
222                owner: owner.into(),
223                key: KEY.into(),
224                value,
225            });
226        }
227    }
228    let items:Vec<_>=definitions.iter().enumerate().map(|(index,d)| {
229  if let Some(at)=unlocked.get(&d.id) {json!({"slot":index,"unlocked_at":at,"title":{"fr":d.fr,"en":d.en},"description":{"fr":d.detail_fr,"en":d.detail_en},"icon":d.icon})}
230  else {json!({"slot":index,"unlocked_at":null})}
231 }).collect();
232    Ok(
233        json!({"items":items,"unlocked":definitions.iter().filter(|d|unlocked.contains_key(&d.id)).count(),"total":definitions.len()}),
234    )
235}
236#[cfg(test)]
237#[path = "achievements_tests.rs"]
238mod tests;