Skip to main content

rosu_mods/
simple.rs

1use std::{
2    collections::HashMap,
3    fmt::{Debug, Formatter, Result as FmtResult},
4};
5
6use crate::{Acronym, GameModIntermode};
7
8/// A simplified version of [`GameMod`].
9///
10/// [`GameMod`]: crate::GameMod
11#[cfg_attr(feature = "serde", derive(serde::Deserialize, serde::Serialize))]
12#[cfg_attr(
13    feature = "rkyv",
14    derive(rkyv::Archive, rkyv::Serialize, rkyv::Deserialize)
15)]
16#[derive(Clone, Debug, PartialEq)]
17pub struct GameModSimple {
18    pub acronym: Acronym,
19    #[cfg_attr(feature = "serde", serde(default))]
20    pub settings: HashMap<Box<str>, SettingSimple>,
21}
22
23impl GameModSimple {
24    /// Convert a [`GameModSimple`] to a [`GameModIntermode`].
25    pub fn as_intermode(&self) -> GameModIntermode {
26        GameModIntermode::from_acronym(self.acronym)
27    }
28
29    /// Convert a [`GameModSimple`] into a [`GameMod`].
30    ///
31    /// The `seed` controls which [`GameMode`] to target and whether unknown
32    /// fields are rejected:
33    ///
34    /// - [`GameModSeed::Mode`] targets a specific mode.
35    /// - [`GameModSeed::GuessMode`] tries each mode in turn and picks the
36    ///   first one whose settings all match.
37    ///
38    /// Returns `Ok(GameMod::Unknown*(..))` if the acronym is not valid for the
39    /// resolved mode — that is a legitimate, expected outcome rather than an
40    /// error.
41    ///
42    /// Returns `Err` only when the settings themselves are malformed: a value
43    /// has the wrong type for its field, or — when `deny_unknown_fields` is
44    /// `true` in the seed — a key is not recognised by the target mod.
45    ///
46    /// [`GameMode`]: crate::GameMode
47    /// [`GameMod`]: crate::GameMod
48    /// [`GameModSeed::Mode`]: crate::serde::GameModSeed::Mode
49    /// [`GameModSeed::GuessMode`]: crate::serde::GameModSeed::GuessMode
50    #[cfg(feature = "serde")]
51    #[cfg_attr(all(docsrs, not(doctest)), doc(cfg(feature = "serde")))]
52    pub fn try_as_mod(
53        self,
54        seed: crate::serde::GameModSeed,
55    ) -> Result<crate::GameMod, GameModSimpleConversionError> {
56        use serde::de::DeserializeSeed;
57
58        use crate::serde::GameModSettings;
59
60        let settings = GameModSettings::from_simple_settings(&self.settings);
61
62        // Drive GameModSeed::visit_map by presenting a two-entry map:
63        //   { "acronym": <str>, "settings": <fields> }
64        let d = simple_deserializer::SimpleMapDeserializer::new(self.acronym.as_str(), &settings);
65
66        seed.deserialize(d)
67            .map_err(|e| GameModSimpleConversionError {
68                msg: e.to_string().into_boxed_str(),
69            })
70    }
71}
72
73/// Error returned by [`GameModSimple::try_as_mod`].
74///
75/// This is produced when the settings stored in a [`GameModSimple`] are
76/// incompatible with the target [`GameMod`] variant — for example when a
77/// value has the wrong type for its field, or when an unrecognised field key
78/// is encountered and `deny_unknown_fields` is `true`.
79///
80/// An *unknown acronym* is **not** an error; [`GameModSimple::try_as_mod`]
81/// returns `Ok(GameMod::Unknown*(..))` in that case.
82///
83/// [`GameMod`]: crate::GameMod
84#[cfg(feature = "serde")]
85#[cfg_attr(all(docsrs, not(doctest)), doc(cfg(feature = "serde")))]
86#[derive(Debug)]
87pub struct GameModSimpleConversionError {
88    msg: Box<str>,
89}
90
91/// A setting value for [`GameModSimple`].
92#[cfg_attr(feature = "serde", derive(serde::Serialize), serde(untagged))]
93#[cfg_attr(
94    feature = "rkyv",
95    derive(rkyv::Archive, rkyv::Serialize, rkyv::Deserialize)
96)]
97#[derive(Clone, PartialEq)]
98pub enum SettingSimple {
99    Bool(bool),
100    Number(f64),
101    String(String),
102}
103
104impl Debug for SettingSimple {
105    fn fmt(&self, f: &mut Formatter<'_>) -> FmtResult {
106        match self {
107            SettingSimple::Bool(value) => Debug::fmt(value, f),
108            SettingSimple::Number(value) => Debug::fmt(value, f),
109            SettingSimple::String(value) => Debug::fmt(value, f),
110        }
111    }
112}
113
114#[cfg(feature = "serde")]
115#[cfg_attr(all(docsrs, not(doctest)), doc(cfg(feature = "serde")))]
116const _: () = {
117    use std::{error::Error, fmt::Display};
118
119    use serde::de::{Deserialize, Deserializer};
120
121    use crate::serde::Value;
122
123    impl<'de> Deserialize<'de> for SettingSimple {
124        fn deserialize<D: Deserializer<'de>>(d: D) -> Result<Self, D::Error> {
125            match Value::deserialize(d)? {
126                Value::Bool(value) => Ok(Self::Bool(value)),
127                Value::Str(value) => Ok(Self::String(value.into_owned())),
128                Value::Number(value) => Ok(Self::Number(value)),
129            }
130        }
131    }
132
133    impl Display for GameModSimpleConversionError {
134        fn fmt(&self, f: &mut Formatter<'_>) -> FmtResult {
135            f.write_str(&self.msg)
136        }
137    }
138
139    impl Error for GameModSimpleConversionError {}
140};
141
142#[cfg(feature = "serde")]
143mod simple_deserializer {
144    use serde::{
145        de::{value::BorrowedStrDeserializer, DeserializeSeed, Error, MapAccess, Visitor},
146        Deserializer,
147    };
148
149    use crate::serde::{GameModDeserializeError, GameModSettings};
150
151    // -------------------------------------------------------------------------
152    // SimpleMapDeserializer
153    //
154    // Presents a two-entry map  { "acronym": <str>, "settings": <fields> }
155    // to GameModSeed::visit_map, which expects exactly that shape. This lets
156    // us fully reuse GameModSeed's dispatch logic — including the GuessMode
157    // path that tries every mode — without duplicating any of it.
158    // -------------------------------------------------------------------------
159
160    pub(super) struct SimpleMapDeserializer<'a> {
161        acronym: &'a str,
162        settings: &'a GameModSettings<'a>,
163    }
164
165    impl<'a> SimpleMapDeserializer<'a> {
166        pub(super) const fn new(acronym: &'a str, settings: &'a GameModSettings<'a>) -> Self {
167            Self { acronym, settings }
168        }
169    }
170
171    impl<'de, 'a: 'de> Deserializer<'de> for SimpleMapDeserializer<'a> {
172        type Error = GameModDeserializeError;
173
174        fn deserialize_any<V: Visitor<'de>>(self, visitor: V) -> Result<V::Value, Self::Error> {
175            self.deserialize_map(visitor)
176        }
177
178        fn deserialize_map<V: Visitor<'de>>(self, visitor: V) -> Result<V::Value, Self::Error> {
179            visitor.visit_map(SimpleMapAccess::new(self.acronym, self.settings))
180        }
181
182        serde::forward_to_deserialize_any! {
183            bool i8 i16 i32 i64 u8 u16 u32 u64 f32 f64 char str string bytes
184            byte_buf option unit unit_struct newtype_struct seq tuple tuple_struct
185            struct enum identifier ignored_any
186        }
187    }
188
189    // State machine driving the two-entry map:
190    //   key1 → value1 → key2 → value2 → done.
191    enum MapState {
192        AcronymKey,
193        AcronymValue,
194        SettingsKey,
195        SettingsValue,
196        Done,
197    }
198
199    struct SimpleMapAccess<'a> {
200        acronym: &'a str,
201        settings: &'a GameModSettings<'a>,
202        state: MapState,
203    }
204
205    impl<'a> SimpleMapAccess<'a> {
206        const fn new(acronym: &'a str, settings: &'a GameModSettings<'a>) -> Self {
207            Self {
208                acronym,
209                settings,
210                state: MapState::AcronymKey,
211            }
212        }
213    }
214
215    impl<'de, 'a: 'de> MapAccess<'de> for SimpleMapAccess<'a> {
216        type Error = GameModDeserializeError;
217
218        fn next_key_seed<K: DeserializeSeed<'de>>(
219            &mut self,
220            seed: K,
221        ) -> Result<Option<K::Value>, Self::Error> {
222            match self.state {
223                MapState::AcronymKey => {
224                    self.state = MapState::AcronymValue;
225                    let d = BorrowedStrDeserializer::new("acronym");
226
227                    seed.deserialize(d).map(Some)
228                }
229                MapState::SettingsKey => {
230                    self.state = MapState::SettingsValue;
231                    let d = BorrowedStrDeserializer::new("settings");
232
233                    seed.deserialize(d).map(Some)
234                }
235                _ => Ok(None),
236            }
237        }
238
239        fn next_value_seed<V: DeserializeSeed<'de>>(
240            &mut self,
241            seed: V,
242        ) -> Result<V::Value, Self::Error> {
243            match self.state {
244                MapState::AcronymValue => {
245                    self.state = MapState::SettingsKey;
246                    let d = BorrowedStrDeserializer::<GameModDeserializeError>::new(self.acronym);
247
248                    seed.deserialize(d)
249                }
250                MapState::SettingsValue => {
251                    self.state = MapState::Done;
252
253                    // GameModSettings<'a> implements Deserializer, so
254                    // feeding it to the seed drives the per-mod field visitor.
255                    seed.deserialize(self.settings)
256                }
257                _ => Err(GameModDeserializeError::custom(
258                    "next_value called out of sequence",
259                )),
260            }
261        }
262
263        fn size_hint(&self) -> Option<usize> {
264            Some(2)
265        }
266    }
267}
268
269#[cfg(test)]
270mod tests {
271    mod common {
272        #![allow(unused, reason = "depends on enabled features")]
273
274        pub(super) use crate::{GameMod, GameMode};
275
276        pub(super) use super::super::*;
277
278        pub(super) const JSON: &str = r#"[
279            {
280                "acronym":"DA",
281                "settings":{
282                    "scroll_speed":2
283                }
284            },
285            {
286                "acronym":"CS"
287            }
288        ]"#;
289    }
290
291    #[allow(unused, reason = "depends on enabled features")]
292    use common::*;
293
294    #[test]
295    #[cfg(feature = "serde")]
296    fn roundtrip_serde() {
297        let mods: Vec<GameModSimple> = serde_json::from_str(JSON).unwrap();
298
299        let expected = vec![
300            GameModSimple {
301                acronym: "DA".parse().unwrap(),
302                settings: vec![("scroll_speed".into(), SettingSimple::Number(2.0))]
303                    .into_iter()
304                    .collect(),
305            },
306            GameModSimple {
307                acronym: "CS".parse().unwrap(),
308                settings: HashMap::new(),
309            },
310        ];
311
312        assert_eq!(mods, expected);
313
314        let serialized = serde_json::to_string(&mods).unwrap();
315        let deserialized: Vec<GameModSimple> = serde_json::from_str(&serialized).unwrap();
316
317        assert_eq!(mods, deserialized);
318    }
319
320    /// Converting a mod whose acronym is valid for the given mode, with a
321    /// correctly typed setting that exists on that variant.
322    #[test]
323    #[cfg(feature = "serde")]
324    fn try_as_mod_known_with_setting() {
325        use crate::{generated_mods::DifficultyAdjustTaiko, serde::GameModSeed};
326
327        let simple = GameModSimple {
328            acronym: "DA".parse().unwrap(),
329            settings: [("scroll_speed".into(), SettingSimple::Number(2.0))]
330                .into_iter()
331                .collect(),
332        };
333
334        assert_eq!(
335            simple
336                .try_as_mod(GameModSeed::Mode {
337                    mode: GameMode::Taiko,
338                    deny_unknown_fields: true
339                })
340                .unwrap(),
341            GameMod::DifficultyAdjustTaiko(DifficultyAdjustTaiko {
342                scroll_speed: Some(2.0),
343                ..Default::default()
344            })
345        );
346    }
347
348    /// Multiple settings are all forwarded correctly.
349    #[test]
350    #[cfg(feature = "serde")]
351    fn try_as_mod_multiple_settings() {
352        use crate::serde::GameModSeed;
353
354        let simple = GameModSimple {
355            acronym: "DA".parse().unwrap(),
356            settings: [
357                ("approach_rate".into(), SettingSimple::Number(9.5)),
358                ("circle_size".into(), SettingSimple::Number(4.0)),
359            ]
360            .into_iter()
361            .collect(),
362        };
363
364        let GameMod::DifficultyAdjustOsu(da) = simple
365            .try_as_mod(GameModSeed::Mode {
366                mode: GameMode::Osu,
367                deny_unknown_fields: true,
368            })
369            .unwrap()
370        else {
371            panic!("expected DifficultyAdjustOsu");
372        };
373
374        assert_eq!(da.approach_rate, Some(9.5));
375        assert_eq!(da.circle_size, Some(4.0));
376    }
377
378    /// A mod with an empty settings map converts to its default variant.
379    #[test]
380    #[cfg(feature = "serde")]
381    fn try_as_mod_no_settings() {
382        use crate::serde::GameModSeed;
383
384        let simple = GameModSimple {
385            acronym: "CS".parse().unwrap(),
386            settings: HashMap::new(),
387        };
388
389        assert_eq!(
390            simple
391                .try_as_mod(GameModSeed::Mode {
392                    mode: GameMode::Taiko,
393                    deny_unknown_fields: true
394                })
395                .unwrap(),
396            GameMod::ConstantSpeedTaiko(Default::default())
397        );
398    }
399
400    /// An acronym that does not exist for the given mode produces the
401    /// appropriate `Unknown*` variant — this is `Ok`, not `Err`.
402    #[test]
403    #[cfg(feature = "serde")]
404    fn try_as_mod_unknown_acronym_is_ok() {
405        use crate::{generated_mods::UnknownMod, serde::GameModSeed};
406
407        let simple = GameModSimple {
408            acronym: "XX".parse().unwrap(),
409            settings: HashMap::new(),
410        };
411
412        assert_eq!(
413            simple
414                .try_as_mod(GameModSeed::Mode {
415                    mode: GameMode::Osu,
416                    deny_unknown_fields: true
417                })
418                .unwrap(),
419            GameMod::UnknownOsu(UnknownMod {
420                acronym: "XX".parse().unwrap()
421            })
422        );
423    }
424
425    /// A mode-specific acronym produces `Unknown*` when a different mode is
426    /// requested — also `Ok`.
427    #[test]
428    #[cfg(feature = "serde")]
429    fn try_as_mod_wrong_mode_is_ok_unknown() {
430        use crate::{generated_mods::UnknownMod, serde::GameModSeed};
431
432        // "FI" (FadeIn) only exists for Mania.
433        let simple = GameModSimple {
434            acronym: "FI".parse().unwrap(),
435            settings: HashMap::new(),
436        };
437
438        assert_eq!(
439            simple
440                .try_as_mod(GameModSeed::Mode {
441                    mode: GameMode::Osu,
442                    deny_unknown_fields: true
443                })
444                .unwrap(),
445            GameMod::UnknownOsu(UnknownMod {
446                acronym: "FI".parse().unwrap()
447            })
448        );
449    }
450
451    /// GuessMode picks the correct mode-specific variant automatically.
452    #[test]
453    #[cfg(feature = "serde")]
454    fn try_as_mod_guess_mode_picks_correct_variant() {
455        use crate::{generated_mods::FadeInMania, serde::GameModSeed};
456
457        // "FI" only exists for Mania; GuessMode should find it.
458        let simple = GameModSimple {
459            acronym: "FI".parse().unwrap(),
460            settings: HashMap::new(),
461        };
462
463        assert_eq!(
464            simple
465                .try_as_mod(GameModSeed::GuessMode {
466                    deny_unknown_fields: true
467                })
468                .unwrap(),
469            GameMod::FadeInMania(FadeInMania::default())
470        );
471    }
472
473    /// GuessMode with a setting that only matches one mode's variant selects
474    /// that mode even when the acronym exists across multiple modes.
475    #[test]
476    #[cfg(feature = "serde")]
477    fn try_as_mod_guess_mode_uses_settings_to_disambiguate() {
478        use crate::{generated_mods::DifficultyAdjustTaiko, serde::GameModSeed};
479
480        // "DA" exists for every mode, but `scroll_speed` is only a field on
481        // the Taiko variant — GuessMode with deny_unknown_fields should pick it.
482        let simple = GameModSimple {
483            acronym: "DA".parse().unwrap(),
484            settings: [("scroll_speed".into(), SettingSimple::Number(1.5))]
485                .into_iter()
486                .collect(),
487        };
488
489        assert_eq!(
490            simple
491                .try_as_mod(GameModSeed::GuessMode {
492                    deny_unknown_fields: true
493                })
494                .unwrap(),
495            GameMod::DifficultyAdjustTaiko(DifficultyAdjustTaiko {
496                scroll_speed: Some(1.5),
497                ..Default::default()
498            })
499        );
500    }
501
502    /// An unrecognised field key with `deny_unknown_fields: true` is an error.
503    #[test]
504    #[cfg(feature = "serde")]
505    fn try_as_mod_unknown_field_denied_is_err() {
506        use crate::serde::GameModSeed;
507
508        let simple = GameModSimple {
509            acronym: "CS".parse().unwrap(),
510            settings: [("not_a_real_field".into(), SettingSimple::Bool(true))]
511                .into_iter()
512                .collect(),
513        };
514
515        assert!(simple
516            .try_as_mod(GameModSeed::Mode {
517                mode: GameMode::Taiko,
518                deny_unknown_fields: true
519            })
520            .is_err());
521    }
522
523    /// The same unrecognised field is silently ignored with
524    /// `deny_unknown_fields: false`, producing the default variant.
525    #[test]
526    #[cfg(feature = "serde")]
527    fn try_as_mod_unknown_field_allowed_is_ok() {
528        use crate::serde::GameModSeed;
529
530        let simple = GameModSimple {
531            acronym: "CS".parse().unwrap(),
532            settings: [("not_a_real_field".into(), SettingSimple::Bool(true))]
533                .into_iter()
534                .collect(),
535        };
536
537        assert_eq!(
538            simple
539                .try_as_mod(GameModSeed::Mode {
540                    mode: GameMode::Taiko,
541                    deny_unknown_fields: false
542                })
543                .unwrap(),
544            GameMod::ConstantSpeedTaiko(Default::default())
545        );
546    }
547
548    /// A setting value with the wrong type for its field (a bool where a
549    /// number is expected) is an error regardless of `deny_unknown_fields`.
550    #[test]
551    #[cfg(feature = "serde")]
552    fn try_as_mod_wrong_value_type_is_err() {
553        use crate::serde::GameModSeed;
554
555        // `scroll_speed` on DifficultyAdjustTaiko expects an f64, not a bool.
556        let simple = GameModSimple {
557            acronym: "DA".parse().unwrap(),
558            settings: [("scroll_speed".into(), SettingSimple::Bool(true))]
559                .into_iter()
560                .collect(),
561        };
562
563        assert!(simple
564            .clone()
565            .try_as_mod(GameModSeed::Mode {
566                mode: GameMode::Taiko,
567                deny_unknown_fields: false
568            })
569            .is_err());
570        assert!(simple
571            .try_as_mod(GameModSeed::Mode {
572                mode: GameMode::Taiko,
573                deny_unknown_fields: true
574            })
575            .is_err());
576    }
577
578    /// String settings are forwarded correctly.
579    #[test]
580    #[cfg(feature = "serde")]
581    fn try_as_mod_string_setting() {
582        use crate::serde::GameModSeed;
583
584        let simple = GameModSimple {
585            acronym: "AC".parse().unwrap(),
586            settings: [(
587                "accuracy_judge_mode".into(),
588                SettingSimple::String("standard_all".into()),
589            )]
590            .into_iter()
591            .collect(),
592        };
593
594        let GameMod::AccuracyChallengeOsu(ac) = simple
595            .try_as_mod(GameModSeed::Mode {
596                mode: GameMode::Osu,
597                deny_unknown_fields: true,
598            })
599            .unwrap()
600        else {
601            panic!("expected AccuracyChallengeOsu");
602        };
603
604        assert_eq!(ac.accuracy_judge_mode.as_deref(), Some("standard_all"));
605    }
606
607    /// Bool settings are forwarded correctly.
608    #[test]
609    #[cfg(feature = "serde")]
610    fn try_as_mod_bool_setting() {
611        use crate::{generated_mods::SuddenDeathOsu, serde::GameModSeed};
612
613        let simple = GameModSimple {
614            acronym: "SD".parse().unwrap(),
615            settings: [("restart".into(), SettingSimple::Bool(true))]
616                .into_iter()
617                .collect(),
618        };
619
620        assert_eq!(
621            simple
622                .try_as_mod(GameModSeed::Mode {
623                    mode: GameMode::Osu,
624                    deny_unknown_fields: true
625                })
626                .unwrap(),
627            GameMod::SuddenDeathOsu(SuddenDeathOsu {
628                restart: Some(true),
629                ..Default::default()
630            })
631        );
632    }
633
634    #[test]
635    #[cfg(feature = "rkyv")]
636    fn roundtrip_rkyv() {
637        use rkyv::{
638            rancor::{BoxedError as Err, Strategy},
639            Archived, Deserialize,
640        };
641
642        let mods: Vec<GameModSimple> = serde_json::from_str(JSON).unwrap();
643
644        let bytes = rkyv::to_bytes::<Err>(&mods).unwrap();
645        let archived = rkyv::access::<Archived<Vec<GameModSimple>>, Err>(&bytes).unwrap();
646        let deserialized: Vec<GameModSimple> = archived
647            .deserialize(Strategy::<_, Err>::wrap(&mut ()))
648            .unwrap();
649
650        assert_eq!(mods, deserialized);
651    }
652}