team04_server/config/
game.rs

1use crate::board::TileType;
2use crate::unit::LightsaberType;
3use serde::de::Error;
4use serde::{Deserialize, Serialize};
5use std::collections::{BTreeMap, HashMap};
6use std::fmt::Display;
7use std::hash::Hash;
8use std::ops::RangeInclusive;
9
10/// A game configuration.
11///
12/// The fields named `timeout_*` are the durations the corresponding phases
13/// in milliseconds, except [Self::timeout_lobby].
14#[derive(Debug, PartialEq)]
15pub struct GameConfig {
16    /// The number of lives a player starts with.
17    pub player_lives: u64,
18
19    /// How many protocol violations lead to a forceful disconnection. When
20    /// `0`, this behavior SHOULD be disabled.
21    pub max_strikes: u64,
22
23    /// The maximum number of rounds the game should take.
24    pub max_rounds: u64,
25
26    /// Should a fight take more than this number of movement & attack actions,
27    /// the fight should end with a tie.
28    pub max_fight_rounds: u64,
29
30    /// The minimum number of units of equal type to activate power mode.
31    pub power_mode_threshold: u64,
32
33    /// The bonus on every base stat of units in power mode. Given as a factor,
34    /// i.e. `base_stat*power_mode_modifier = power_mode_stat`.
35    pub power_mode_modifier: f64,
36
37    pub lightsaber_modifier_red: LightsaberModifier,
38    pub lightsaber_modifier_green: LightsaberModifier,
39    pub lightsaber_modifier_blue: LightsaberModifier,
40
41    /// The probabilities of getting offered a unit of a certain level in a
42    /// round's purchase phase. The index into this [Vec] is the `current_round - 1`.
43    pub unit_probabilities: Vec<UnitProbability>,
44
45    /// A per player time limit for selecting a character in the lobby, given
46    /// in milliseconds.
47    pub timeout_lobby: u64,
48    pub timeout_lightsaber_shop_phase: u64,
49    pub timeout_unit_shop_phase: u64,
50    pub timeout_placement_phase: u64,
51    pub timeout_fight_phase: u64,
52    pub live_loss_on_defeat: LiveLoss,
53    pub tile_modifier_force: TileModifier,
54    pub tile_modifier_medic_center: TileModifier,
55    pub tile_modifier_lava: TileModifier,
56}
57
58impl GameConfig {
59    fn from_json(json: json::GameConfigJson) -> Result<Self, GameConfigError> {
60        let mut lightsaber_modifiers = json
61            .lightSaberModifiers
62            .iter()
63            .map(|(k, v)| {
64                if !(v.base_value.is_finite() && v.base_value >= 0.0) {
65                    return Err(GameConfigError::NegativeLightsaberModifierBaseValue);
66                }
67                if !(v.interval.0.is_finite() && v.interval.1.is_finite() && v.interval.0 >= 0.0) {
68                    return Err(GameConfigError::NegativeLightsaberModifierInterval);
69                }
70                if !(v.interval.0 <= v.interval.1) {
71                    return Err(GameConfigError::ReverseLightsaberModifierInterval);
72                }
73                Ok((
74                    *k,
75                    LightsaberModifier {
76                        base_value: v.base_value,
77                        interval: v.interval,
78                    },
79                ))
80            })
81            .collect::<Result<HashMap<_, _>, _>>()?;
82
83        Ok(Self {
84            player_lives: if json.playerLives > 0 {
85                json.playerLives
86            } else {
87                return Err(GameConfigError::InsufficientPlayerLives);
88            },
89            max_strikes: json.maxStrikes,
90            max_rounds: if json.maxRounds > 0 {
91                json.maxRounds
92            } else {
93                return Err(GameConfigError::InsufficientMaxRounds);
94            },
95            max_fight_rounds: if json.maxFightRounds > 0 {
96                json.maxFightRounds
97            } else {
98                return Err(GameConfigError::InsufficientMaxFightRounds);
99            },
100            power_mode_threshold: if json.powerModeThreshold > 2 {
101                json.powerModeThreshold
102            } else {
103                return Err(GameConfigError::InsufficientPowerModeThreshold);
104            },
105            power_mode_modifier: {
106                let pm = json.powerModeModifier;
107                if !(pm.is_finite() && (1.0..=2.0).contains(&pm)) {
108                    return Err(GameConfigError::PowerModeModifierOutOfRange);
109                }
110                pm
111            },
112            lightsaber_modifier_red: lightsaber_modifiers
113                .remove(&LightsaberType::Red)
114                .ok_or(GameConfigError::MissingRedLightsaberModifiers)?,
115            lightsaber_modifier_green: lightsaber_modifiers
116                .remove(&LightsaberType::Green)
117                .ok_or(GameConfigError::MissingGreenLightsaberModifiers)?,
118            lightsaber_modifier_blue: lightsaber_modifiers
119                .remove(&LightsaberType::Blue)
120                .ok_or(GameConfigError::MissingBlueLightsaberModifiers)?,
121            unit_probabilities: {
122                let probs = json
123                    .unitProbabilities
124                    .iter()
125                    .enumerate()
126                    .map(|(n, p)| {
127                        if !(p.level1.is_finite() && p.level1 <= 1.0 && p.level1 >= 0.0) {
128                            return Err(GameConfigError::BadUnitProbabilitiesLevel1(n));
129                        }
130                        if p.level2 > 1.0 || p.level2 < 0.0 {
131                            return Err(GameConfigError::BadUnitProbabilitiesLevel2(n));
132                        }
133                        let level3 = 1.0 - p.level1 - p.level2;
134                        // We give them some leeway, because float inacurracies
135                        if level3 < -0.001 {
136                            return Err(GameConfigError::UnitProbabilitiesTooLarge(n));
137                        }
138                        Ok(UnitProbability {
139                            level1: p.level1,
140                            level2: p.level2,
141                            level3: level3.max(0.0),
142                        })
143                    })
144                    .collect::<Result<Vec<_>, _>>()?;
145
146                if probs.len() != 8 {
147                    return Err(GameConfigError::InvalidUnitProbabilitiesLength);
148                }
149                probs
150            },
151            timeout_lobby: *json
152                .timeouts
153                .get(&PhaseType::Lobby)
154                .ok_or(GameConfigError::MissingLobbyTimeout)?,
155            timeout_lightsaber_shop_phase: *json
156                .timeouts
157                .get(&PhaseType::LightsaberShopPhase)
158                .ok_or(GameConfigError::MissingLightsaberShopTimeout)?,
159            timeout_unit_shop_phase: *json
160                .timeouts
161                .get(&PhaseType::UnitShopPhase)
162                .ok_or(GameConfigError::MissingUnitShopTimeout)?,
163            timeout_placement_phase: *json
164                .timeouts
165                .get(&PhaseType::PlacementPhase)
166                .ok_or(GameConfigError::MissingPlacementTimeout)?,
167            timeout_fight_phase: *json
168                .timeouts
169                .get(&PhaseType::FightPhase)
170                .ok_or(GameConfigError::MissingFightTimeout)?,
171            live_loss_on_defeat: {
172                if let Some((first, _)) = json.liveLossOnDefeat.first_key_value() {
173                    if *first != 1 {
174                        return Err(GameConfigError::NonOneFirstLiveLossOnDefeat);
175                    }
176                } else {
177                    return Err(GameConfigError::EmptyLiveLossOnDefeat);
178                }
179                LiveLoss {
180                    map: json.liveLossOnDefeat.clone(),
181                }
182            },
183            tile_modifier_force: json
184                .fieldModifiers
185                .get(&TileType::Force)
186                .ok_or(GameConfigError::MissingFieldModifierForce)?
187                .clone(),
188            tile_modifier_medic_center: json
189                .fieldModifiers
190                .get(&TileType::MedicCenter)
191                .ok_or(GameConfigError::MissingFieldModifierMedicCenter)?
192                .clone(),
193            tile_modifier_lava: json
194                .fieldModifiers
195                .get(&TileType::Lava)
196                .ok_or(GameConfigError::MissingFieldModifierLava)?
197                .clone(),
198        })
199    }
200}
201#[derive(PartialEq)]
202pub enum GameConfigError {
203    NegativeLightsaberModifierBaseValue,
204    NegativeLightsaberModifierInterval,
205    ReverseLightsaberModifierInterval,
206    InsufficientPlayerLives,
207    InsufficientMaxRounds,
208    InsufficientMaxFightRounds,
209    InsufficientPowerModeThreshold,
210    PowerModeModifierOutOfRange,
211    MissingRedLightsaberModifiers,
212    MissingGreenLightsaberModifiers,
213    MissingBlueLightsaberModifiers,
214    BadUnitProbabilitiesLevel1(usize),
215    BadUnitProbabilitiesLevel2(usize),
216    UnitProbabilitiesTooLarge(usize),
217    InvalidUnitProbabilitiesLength,
218    MissingLobbyTimeout,
219    MissingLightsaberShopTimeout,
220    MissingUnitShopTimeout,
221    MissingPlacementTimeout,
222    MissingFightTimeout,
223    NonOneFirstLiveLossOnDefeat,
224    EmptyLiveLossOnDefeat,
225    MissingFieldModifierForce,
226    MissingFieldModifierMedicCenter,
227    MissingFieldModifierLava,
228}
229impl Display for GameConfigError {
230    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
231        match self {
232            Self::NegativeLightsaberModifierBaseValue => {
233                write!(f, "baseValue of lightsaber modifier must not be negative")
234            }
235            Self::NegativeLightsaberModifierInterval => {
236                write!(f, "interval of lightsaber modifier must not be negative")
237            }
238            Self::ReverseLightsaberModifierInterval => write!(
239                f,
240                "interval of lightsaber modifier's start must not be bigger than the end"
241            ),
242            Self::InsufficientPlayerLives => write!(f, "playerLives must be at least 1"),
243            Self::InsufficientMaxRounds => write!(f, "maxRounds must be at least 1"),
244            Self::InsufficientMaxFightRounds => write!(f, "maxFightRounds must be at least 1"),
245            Self::InsufficientPowerModeThreshold => {
246                write!(f, "powerModeThreshold must be bigger than 2")
247            }
248            Self::PowerModeModifierOutOfRange => {
249                write!(f, "powerModeModifier must be within [1, 2]")
250            }
251            Self::MissingRedLightsaberModifiers => write!(f, "missing red lightsaber modifiers"),
252            Self::MissingGreenLightsaberModifiers => {
253                write!(f, "missing green lightsaber modifiers")
254            }
255            Self::MissingBlueLightsaberModifiers => write!(f, "missing blue lightsaber modifiers"),
256            Self::BadUnitProbabilitiesLevel1(n) => {
257                write!(f, "unitProbabilities[{n}].level1 must be within [0, 1]")
258            }
259            Self::BadUnitProbabilitiesLevel2(n) => {
260                write!(f, "unitProbabilities[{n}].level2 must be within [0, 1]")
261            }
262            Self::UnitProbabilitiesTooLarge(n) => write!(
263                f,
264                "unitProbabilities[{n}]: probabilities must add up to <= 1"
265            ),
266            Self::InvalidUnitProbabilitiesLength => {
267                write!(f, "unitProbabilities must have exactly 8 entries")
268            }
269            Self::MissingLobbyTimeout => write!(f, "missing LOBBY timeout"),
270            Self::MissingLightsaberShopTimeout => {
271                write!(f, "missing LIGHTSABER_SHOP_PHASE timeout")
272            }
273            Self::MissingUnitShopTimeout => write!(f, "missing UNIT_SHOP_PHASE timeout"),
274            Self::MissingPlacementTimeout => write!(f, "missing PLACEMENT_PHASE timeout"),
275            Self::MissingFightTimeout => write!(f, "missing FIGHT_PHASE timeout"),
276            Self::NonOneFirstLiveLossOnDefeat => {
277                write!(f, "the first key of liveLossOnDefeat must be 1")
278            }
279            Self::EmptyLiveLossOnDefeat => write!(f, "liveLossOnDefeat must not be empty"),
280            Self::MissingFieldModifierForce => write!(f, "Missing fieldModifiers entry FORCE"),
281            Self::MissingFieldModifierMedicCenter => {
282                write!(f, "Missing fieldModifiers entry MEDIC_CENTER")
283            }
284            Self::MissingFieldModifierLava => write!(f, "Missing fieldModifiers entry LAVA"),
285        }
286    }
287}
288impl std::fmt::Debug for GameConfigError {
289    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
290        write!(f, "{self}")
291    }
292}
293impl std::error::Error for GameConfigError {}
294
295impl<'de> Deserialize<'de> for GameConfig {
296    fn deserialize<D>(deserializer: D) -> Result<Self, D::Error>
297    where
298        D: serde::Deserializer<'de>,
299    {
300        let json = json::GameConfigJson::deserialize(deserializer)?;
301
302        Self::from_json(json).map_err(|e| Error::custom(e.to_string()))
303    }
304}
305
306impl Serialize for GameConfig {
307    fn serialize<S>(&self, serializer: S) -> Result<S::Ok, S::Error>
308    where
309        S: serde::Serializer,
310    {
311        let mut json_conf = json::GameConfigJson {
312            playerLives: self.player_lives,
313            maxStrikes: self.max_strikes,
314            maxRounds: self.max_rounds,
315            maxFightRounds: self.max_fight_rounds,
316            powerModeThreshold: self.power_mode_threshold,
317            powerModeModifier: self.power_mode_modifier,
318            lightSaberModifiers: HashMap::new(),
319            unitProbabilities: self
320                .unit_probabilities
321                .iter()
322                .map(|prob| json::UnitProbabilityJson {
323                    level1: prob.level1,
324                    level2: prob.level2,
325                })
326                .collect(),
327            timeouts: HashMap::new(),
328            liveLossOnDefeat: self.live_loss_on_defeat.map.clone(),
329            fieldModifiers: HashMap::new(),
330        };
331        json_conf
332            .lightSaberModifiers
333            .insert(LightsaberType::Red, self.lightsaber_modifier_red.clone());
334        json_conf.lightSaberModifiers.insert(
335            LightsaberType::Green,
336            self.lightsaber_modifier_green.clone(),
337        );
338        json_conf
339            .lightSaberModifiers
340            .insert(LightsaberType::Blue, self.lightsaber_modifier_blue.clone());
341        json_conf
342            .timeouts
343            .insert(PhaseType::Lobby, self.timeout_lobby);
344        json_conf.timeouts.insert(
345            PhaseType::LightsaberShopPhase,
346            self.timeout_lightsaber_shop_phase,
347        );
348        json_conf
349            .timeouts
350            .insert(PhaseType::UnitShopPhase, self.timeout_unit_shop_phase);
351        json_conf
352            .timeouts
353            .insert(PhaseType::PlacementPhase, self.timeout_placement_phase);
354        json_conf
355            .timeouts
356            .insert(PhaseType::FightPhase, self.timeout_fight_phase);
357        json_conf
358            .fieldModifiers
359            .insert(TileType::Force, self.tile_modifier_force.clone());
360        json_conf.fieldModifiers.insert(
361            TileType::MedicCenter,
362            self.tile_modifier_medic_center.clone(),
363        );
364        json_conf
365            .fieldModifiers
366            .insert(TileType::Lava, self.tile_modifier_lava.clone());
367        json_conf.serialize(serializer)
368    }
369}
370
371/// Stores the probabilities of getting offered a unit of a certain level.
372/// Probablities are given in the range `[0.0, 1.0]`.
373#[derive(Debug, PartialEq)]
374pub struct UnitProbability {
375    pub level1: f64,
376    pub level2: f64,
377    pub level3: f64,
378}
379
380// Is this loss?
381/// An abstraction over the live loss function defined in the game configuration.
382#[derive(Debug, PartialEq)]
383pub struct LiveLoss {
384    map: BTreeMap<u64, u64>,
385}
386
387impl LiveLoss {
388    /// Gets the lives a player losing a match should lose at the specified `round`.
389    ///
390    /// # Panics
391    /// Panics if `round` is `0`.
392    pub fn get_loss(&self, round: u64) -> u64 {
393        *self
394            .map
395            .range(..=round)
396            .next_back()
397            .expect("Round should not be 0")
398            .1
399    }
400
401    pub(super) fn new(map: BTreeMap<u64, u64>) -> Self {
402        Self { map }
403    }
404}
405
406/// Defines the value range of the stat boost of lightsabers.
407#[derive(Serialize, Deserialize, Debug, Clone, PartialEq)]
408pub struct LightsaberModifier {
409    #[serde(rename = "baseValue")]
410    pub base_value: f64,
411    pub interval: (f64, f64),
412}
413impl LightsaberModifier {
414    /// Returns `self.interval` as a range (`min..=max`),
415    /// which can be useful e.g. for [rand::random_range].
416    pub fn interval(&self) -> RangeInclusive<f64> {
417        self.interval.0..=self.interval.1
418    }
419}
420
421/// Defines a tile's stat modifiers.
422#[derive(Deserialize, Serialize, Debug, Clone, PartialEq)]
423pub struct TileModifier {
424    pub health: i64,
425    pub armor: i64,
426    pub attack: i64,
427}
428
429#[derive(Deserialize, Serialize, Debug, PartialEq, Eq, Hash, Clone, Copy)]
430#[serde(rename_all = "SCREAMING_SNAKE_CASE")]
431pub enum PhaseType {
432    Lobby,
433    LightsaberShopPhase,
434    UnitShopPhase,
435    PlacementPhase,
436    FightPhase,
437}
438
439#[allow(non_snake_case)]
440mod json {
441    use super::*;
442
443    #[derive(Deserialize, Serialize)]
444    pub struct GameConfigJson {
445        pub playerLives: u64,
446        pub maxStrikes: u64,
447        pub maxRounds: u64,
448        pub maxFightRounds: u64,
449        pub powerModeThreshold: u64,
450        pub powerModeModifier: f64,
451        pub lightSaberModifiers: HashMap<LightsaberType, LightsaberModifier>,
452        pub unitProbabilities: Vec<UnitProbabilityJson>,
453        pub timeouts: HashMap<PhaseType, u64>,
454        pub liveLossOnDefeat: BTreeMap<u64, u64>,
455        pub fieldModifiers: HashMap<TileType, TileModifier>,
456    }
457
458    #[derive(Deserialize, Serialize, Clone)]
459    pub struct UnitProbabilityJson {
460        pub level1: f64,
461        pub level2: f64,
462    }
463}
464
465#[cfg(test)]
466mod test {
467    use crate::{
468        board::TileType,
469        config::{
470            GameConfig,
471            game::{
472                GameConfigError, LightsaberModifier, PhaseType, TileModifier,
473                json::UnitProbabilityJson,
474            },
475        },
476        unit::LightsaberType,
477    };
478
479    use super::json::GameConfigJson;
480
481    macro_rules! assert_err_matches {
482        ($pat:pat, $mod_cfg:expr) => {{
483            let mut valid = valid();
484            ($mod_cfg)(&mut valid);
485            match GameConfig::from_json(valid) {
486                Err(e) => {
487                    if matches!(e, $pat) {
488                        eprintln!("correctly got error {e:?}");
489                    } else {
490                        panic!("expected {}, but got '{e}'", stringify!($pat));
491                    }
492                }
493                Ok(_) => panic!("expected {}, but got Ok(_)", stringify!($pat)),
494            }
495        }};
496    }
497
498    #[test]
499    fn from_json() {
500        assert_eq!(GameConfig::from_json(valid()).err(), None);
501
502        assert_err_matches!(
503            GameConfigError::NegativeLightsaberModifierBaseValue,
504            |cfg: &mut GameConfigJson| {
505                cfg.lightSaberModifiers
506                    .get_mut(&LightsaberType::Red)
507                    .unwrap()
508                    .base_value = -0.5;
509            }
510        );
511        assert_err_matches!(
512            GameConfigError::NegativeLightsaberModifierInterval,
513            |cfg: &mut GameConfigJson| {
514                cfg.lightSaberModifiers
515                    .get_mut(&LightsaberType::Green)
516                    .unwrap()
517                    .interval
518                    .0 = -0.5;
519            }
520        );
521        assert_err_matches!(
522            GameConfigError::ReverseLightsaberModifierInterval,
523            |cfg: &mut GameConfigJson| {
524                cfg.lightSaberModifiers
525                    .get_mut(&LightsaberType::Green)
526                    .unwrap()
527                    .interval = (0.7, 0.3);
528            }
529        );
530        assert_err_matches!(
531            GameConfigError::InsufficientPlayerLives,
532            |cfg: &mut GameConfigJson| {
533                cfg.playerLives = 0;
534            }
535        );
536        assert_err_matches!(
537            GameConfigError::InsufficientMaxRounds,
538            |cfg: &mut GameConfigJson| {
539                cfg.maxRounds = 0;
540            }
541        );
542        assert_err_matches!(
543            GameConfigError::InsufficientMaxFightRounds,
544            |cfg: &mut GameConfigJson| {
545                cfg.maxFightRounds = 0;
546            }
547        );
548        assert_err_matches!(
549            GameConfigError::InsufficientPowerModeThreshold,
550            |cfg: &mut GameConfigJson| {
551                cfg.powerModeThreshold = 2;
552            }
553        );
554        assert_err_matches!(
555            GameConfigError::PowerModeModifierOutOfRange,
556            |cfg: &mut GameConfigJson| {
557                cfg.powerModeModifier = 0.9;
558            }
559        );
560        assert_err_matches!(
561            GameConfigError::PowerModeModifierOutOfRange,
562            |cfg: &mut GameConfigJson| {
563                cfg.powerModeModifier = 2.1;
564            }
565        );
566        assert_err_matches!(
567            GameConfigError::MissingRedLightsaberModifiers,
568            |cfg: &mut GameConfigJson| {
569                cfg.lightSaberModifiers.remove(&LightsaberType::Red);
570            }
571        );
572        assert_err_matches!(
573            GameConfigError::MissingGreenLightsaberModifiers,
574            |cfg: &mut GameConfigJson| {
575                cfg.lightSaberModifiers.remove(&LightsaberType::Green);
576            }
577        );
578        assert_err_matches!(
579            GameConfigError::MissingBlueLightsaberModifiers,
580            |cfg: &mut GameConfigJson| {
581                cfg.lightSaberModifiers.remove(&LightsaberType::Blue);
582            }
583        );
584        assert_err_matches!(
585            GameConfigError::BadUnitProbabilitiesLevel1(1),
586            |cfg: &mut GameConfigJson| {
587                cfg.unitProbabilities[1].level1 = 1.1;
588            }
589        );
590        assert_err_matches!(
591            GameConfigError::BadUnitProbabilitiesLevel2(2),
592            |cfg: &mut GameConfigJson| {
593                cfg.unitProbabilities[2].level2 = 1.1;
594            }
595        );
596        assert_err_matches!(
597            GameConfigError::UnitProbabilitiesTooLarge(3),
598            |cfg: &mut GameConfigJson| {
599                cfg.unitProbabilities[3].level1 = 0.6;
600                cfg.unitProbabilities[3].level2 = 0.6;
601            }
602        );
603        assert_err_matches!(
604            GameConfigError::InvalidUnitProbabilitiesLength,
605            |cfg: &mut GameConfigJson| {
606                cfg.unitProbabilities.pop();
607            }
608        );
609        assert_err_matches!(
610            GameConfigError::InvalidUnitProbabilitiesLength,
611            |cfg: &mut GameConfigJson| {
612                cfg.unitProbabilities
613                    .push(cfg.unitProbabilities.last().unwrap().clone());
614            }
615        );
616        assert_err_matches!(
617            GameConfigError::MissingLobbyTimeout,
618            |cfg: &mut GameConfigJson| {
619                cfg.timeouts.remove(&PhaseType::Lobby);
620            }
621        );
622        assert_err_matches!(
623            GameConfigError::MissingLightsaberShopTimeout,
624            |cfg: &mut GameConfigJson| {
625                cfg.timeouts.remove(&PhaseType::LightsaberShopPhase);
626            }
627        );
628        assert_err_matches!(
629            GameConfigError::MissingUnitShopTimeout,
630            |cfg: &mut GameConfigJson| {
631                cfg.timeouts.remove(&PhaseType::UnitShopPhase);
632            }
633        );
634        assert_err_matches!(
635            GameConfigError::MissingPlacementTimeout,
636            |cfg: &mut GameConfigJson| {
637                cfg.timeouts.remove(&PhaseType::PlacementPhase);
638            }
639        );
640        assert_err_matches!(
641            GameConfigError::MissingFightTimeout,
642            |cfg: &mut GameConfigJson| {
643                cfg.timeouts.remove(&PhaseType::FightPhase);
644            }
645        );
646        assert_err_matches!(
647            GameConfigError::NonOneFirstLiveLossOnDefeat,
648            |cfg: &mut GameConfigJson| {
649                cfg.liveLossOnDefeat.remove(&1);
650            }
651        );
652        assert_err_matches!(
653            GameConfigError::EmptyLiveLossOnDefeat,
654            |cfg: &mut GameConfigJson| {
655                cfg.liveLossOnDefeat.clear();
656            }
657        );
658        assert_err_matches!(
659            GameConfigError::MissingFieldModifierForce,
660            |cfg: &mut GameConfigJson| {
661                cfg.fieldModifiers.remove(&TileType::Force);
662            }
663        );
664        assert_err_matches!(
665            GameConfigError::MissingFieldModifierMedicCenter,
666            |cfg: &mut GameConfigJson| {
667                cfg.fieldModifiers.remove(&TileType::MedicCenter);
668            }
669        );
670        assert_err_matches!(
671            GameConfigError::MissingFieldModifierLava,
672            |cfg: &mut GameConfigJson| {
673                cfg.fieldModifiers.remove(&TileType::Lava);
674            }
675        );
676    }
677
678    fn valid() -> GameConfigJson {
679        GameConfigJson {
680            playerLives: 5,
681            maxStrikes: 3,
682            maxRounds: 10,
683            maxFightRounds: 7,
684            powerModeThreshold: 4,
685            powerModeModifier: 1.3,
686            lightSaberModifiers: [
687                (
688                    LightsaberType::Red,
689                    LightsaberModifier {
690                        base_value: 0.2,
691                        interval: (0.1, 0.3),
692                    },
693                ),
694                (
695                    LightsaberType::Green,
696                    LightsaberModifier {
697                        base_value: 0.25,
698                        interval: (0.15, 0.35),
699                    },
700                ),
701                (
702                    LightsaberType::Blue,
703                    LightsaberModifier {
704                        base_value: 0.18,
705                        interval: (0.03, 0.12),
706                    },
707                ),
708            ]
709            .into_iter()
710            .collect(),
711            unitProbabilities: vec![
712                UnitProbabilityJson {
713                    level1: 0.9,
714                    level2: 0.1,
715                },
716                UnitProbabilityJson {
717                    level1: 0.6,
718                    level2: 0.3,
719                },
720                UnitProbabilityJson {
721                    level1: 0.4,
722                    level2: 0.4,
723                },
724                UnitProbabilityJson {
725                    level1: 0.2,
726                    level2: 0.5,
727                },
728                UnitProbabilityJson {
729                    level1: 0.1,
730                    level2: 0.5,
731                },
732                UnitProbabilityJson {
733                    level1: 0.1,
734                    level2: 0.4,
735                },
736                UnitProbabilityJson {
737                    level1: 0.1,
738                    level2: 0.2,
739                },
740                UnitProbabilityJson {
741                    level1: 0.1,
742                    level2: 0.1,
743                },
744            ],
745            timeouts: [
746                (PhaseType::Lobby, 10_000),
747                (PhaseType::LightsaberShopPhase, 6_000),
748                (PhaseType::UnitShopPhase, 10_000),
749                (PhaseType::PlacementPhase, 20_000),
750                (PhaseType::FightPhase, 12_000),
751            ]
752            .into_iter()
753            .collect(),
754            liveLossOnDefeat: [(1, 1), (2, 10), (3, 20), (7, 15)].into_iter().collect(),
755            fieldModifiers: [
756                (
757                    TileType::Force,
758                    TileModifier {
759                        health: 0,
760                        armor: 0,
761                        attack: 2,
762                    },
763                ),
764                (
765                    TileType::MedicCenter,
766                    TileModifier {
767                        health: 5,
768                        armor: 0,
769                        attack: 0,
770                    },
771                ),
772                (
773                    TileType::Lava,
774                    TileModifier {
775                        health: -3,
776                        armor: 0,
777                        attack: 0,
778                    },
779                ),
780            ]
781            .into_iter()
782            .collect(),
783        }
784    }
785}