battler/config/
clause.rs

1use std::str::FromStr;
2
3use serde::{
4    Deserialize,
5    Serialize,
6};
7use serde_string_enum::{
8    DeserializeLabeledStringEnum,
9    SerializeLabeledStringEnum,
10};
11
12use crate::{
13    battle::CoreBattleOptions,
14    common::{
15        Id,
16        Identifiable,
17    },
18    config::{
19        hooks::clause_hooks,
20        RuleSet,
21        SerializedRuleSet,
22    },
23    error::{
24        general_error,
25        Error,
26    },
27    mons::Type,
28    teams::{
29        MonData,
30        TeamValidationProblems,
31        TeamValidator,
32    },
33};
34
35/// The type of a clause value.
36#[derive(Debug, Clone, PartialEq, Eq, SerializeLabeledStringEnum, DeserializeLabeledStringEnum)]
37pub enum ClauseValueType {
38    #[string = "Type"]
39    Type,
40    #[string = "PositiveInteger"]
41    PositiveInteger,
42    #[string = "NonNegativeInteger"]
43    NonNegativeInteger,
44}
45
46/// Data for an individual clause.
47///
48/// A clause is a generalization of a rule: a clause can be a compound rule made up of several more
49/// rules, or it can be a simple rule with an assigned value.
50#[derive(Debug, Default, Clone, Serialize, Deserialize)]
51pub struct ClauseData {
52    /// Clause name.
53    pub name: String,
54    /// Clause description.
55    pub description: String,
56    /// Message added to the battle log on battle start.
57    #[serde(default)]
58    pub rule_log: Option<String>,
59    /// Is a value required?
60    #[serde(default)]
61    pub requires_value: bool,
62    /// Type of value enforced by validation.
63    #[serde(default)]
64    pub value_type: Option<ClauseValueType>,
65    /// Nested rules added to the battle format.
66    #[serde(default)]
67    pub rules: SerializedRuleSet,
68}
69
70type ValidateRuleCallack = dyn Fn(&RuleSet, &str) -> Result<(), Error> + Send + Sync;
71type ValidateMonCallback =
72    dyn Fn(&TeamValidator, &mut MonData) -> TeamValidationProblems + Send + Sync;
73type ValidateTeamCallback =
74    dyn Fn(&TeamValidator, &mut [&mut MonData]) -> TeamValidationProblems + Send + Sync;
75type ValidateCoreBattleOptionsCallback =
76    dyn Fn(&RuleSet, &mut CoreBattleOptions) -> Result<(), Error> + Send + Sync;
77
78/// Static hooks for clauses.
79///
80/// These hooks are exclusive to clauses, so they are not represented in the same way as generic
81/// battle effects.
82#[derive(Default)]
83pub(in crate::config) struct ClauseStaticHooks {
84    /// Hook for rule validation (validating this rule in the context of all other rules).
85    pub on_validate_rule: Option<Box<ValidateRuleCallack>>,
86    /// Hook for Mon validaiton.
87    pub on_validate_mon: Option<Box<ValidateMonCallback>>,
88    /// Hook for team validation.
89    pub on_validate_team: Option<Box<ValidateTeamCallback>>,
90    /// Hook for [`CoreBattleOptions`] validation.
91    pub on_validate_core_battle_options: Option<Box<ValidateCoreBattleOptionsCallback>>,
92}
93
94/// A rule that modifies the validation, start, or team preview stages of a battle.
95///
96/// A clause is a generalization of a rule: a clause can be a compound rule made up of several more
97/// rules, or it can be a simple rule with an assigned value.
98#[derive(Clone)]
99pub struct Clause {
100    id: Id,
101    pub data: ClauseData,
102    hooks: &'static ClauseStaticHooks,
103}
104
105impl Clause {
106    /// Creates a new clause.
107    pub fn new(id: Id, data: ClauseData) -> Self {
108        let hooks = clause_hooks(&id);
109        Self { id, data, hooks }
110    }
111
112    /// Validates the given value according to clause's configuration.
113    pub fn validate_value(&self, value: &str) -> Result<(), Error> {
114        if value.is_empty() {
115            if self.data.requires_value {
116                return Err(general_error("missing value"));
117            }
118            Ok(())
119        } else {
120            match self.data.value_type {
121                Some(ClauseValueType::Type) => {
122                    Type::from_str(value).map_err(general_error).map(|_| ())
123                }
124                Some(ClauseValueType::PositiveInteger) => {
125                    value.parse::<u32>().map_err(general_error).and_then(|val| {
126                        if val > 0 {
127                            Ok(())
128                        } else {
129                            Err(general_error("integer cannot be 0"))
130                        }
131                    })
132                }
133                Some(ClauseValueType::NonNegativeInteger) => {
134                    value.parse::<u32>().map_err(general_error).map(|_| ())
135                }
136                _ => Ok(()),
137            }
138        }
139    }
140
141    /// Runs the hook for rule validation.
142    pub fn on_validate_rule(&self, rules: &RuleSet, value: &str) -> Result<(), Error> {
143        self.hooks
144            .on_validate_rule
145            .as_ref()
146            .map_or(Ok(()), |f| f(rules, value))
147    }
148
149    /// Runs the hook for Mon validation.
150    pub fn on_validate_mon(
151        &self,
152        validator: &TeamValidator,
153        mon: &mut MonData,
154    ) -> TeamValidationProblems {
155        self.hooks
156            .on_validate_mon
157            .as_ref()
158            .map_or(TeamValidationProblems::default(), |f| f(validator, mon))
159    }
160
161    /// Runs the hook for team validation.
162    pub fn on_validate_team(
163        &self,
164        validator: &TeamValidator,
165        team: &mut [&mut MonData],
166    ) -> TeamValidationProblems {
167        self.hooks
168            .on_validate_team
169            .as_ref()
170            .map_or(TeamValidationProblems::default(), |f| f(validator, team))
171    }
172
173    /// Runs the hook for [`CoreBattleOptions`] validation.
174    pub fn on_validate_core_battle_options(
175        &self,
176        rules: &RuleSet,
177        options: &mut CoreBattleOptions,
178    ) -> Result<(), Error> {
179        self.hooks
180            .on_validate_core_battle_options
181            .as_ref()
182            .map_or(Ok(()), |f| f(rules, options))
183    }
184}
185
186impl Identifiable for Clause {
187    fn id(&self) -> &Id {
188        &self.id
189    }
190}
191
192#[cfg(test)]
193mod clause_value_type_tests {
194    use crate::{
195        common::{
196            test_string_deserialization,
197            test_string_serialization,
198        },
199        config::ClauseValueType,
200    };
201
202    #[test]
203    fn serializes_to_string() {
204        test_string_serialization(ClauseValueType::Type, "Type");
205        test_string_serialization(ClauseValueType::PositiveInteger, "PositiveInteger");
206        test_string_serialization(ClauseValueType::NonNegativeInteger, "NonNegativeInteger");
207    }
208
209    #[test]
210    fn deserializes_lowercase() {
211        test_string_deserialization("type", ClauseValueType::Type);
212        test_string_deserialization("positiveinteger", ClauseValueType::PositiveInteger);
213        test_string_deserialization("nonnegativeinteger", ClauseValueType::NonNegativeInteger);
214    }
215}
216
217#[cfg(test)]
218mod clause_tests {
219    use lazy_static::lazy_static;
220
221    use crate::{
222        battle::{
223            BattleType,
224            CoreBattleOptions,
225            FieldData,
226            PlayerData,
227            PlayerOptions,
228            PlayerType,
229            SideData,
230        },
231        common::Id,
232        config::{
233            Clause,
234            ClauseData,
235            ClauseStaticHooks,
236            ClauseValueType,
237            Format,
238            RuleSet,
239            SerializedRuleSet,
240        },
241        dex::{
242            Dex,
243            LocalDataStore,
244        },
245        error::{
246            general_error,
247            Error,
248            WrapOptionError,
249        },
250        teams::{
251            MonData,
252            TeamData,
253            TeamValidationProblems,
254            TeamValidator,
255        },
256    };
257
258    #[test]
259    fn validates_type_value() {
260        let clause = Clause::new(
261            Id::from_known("testclause"),
262            ClauseData {
263                name: "Test Clause".to_owned(),
264                requires_value: true,
265                value_type: Some(ClauseValueType::Type),
266                ..Default::default()
267            },
268        );
269        assert!(clause
270            .validate_value("")
271            .err()
272            .unwrap()
273            .full_description()
274            .contains("missing value"));
275        assert!(clause
276            .validate_value("bird")
277            .err()
278            .unwrap()
279            .full_description()
280            .contains("invalid"));
281        assert!(clause.validate_value("grass").is_ok());
282    }
283
284    #[test]
285    fn validates_positive_integer() {
286        let clause = Clause::new(
287            Id::from_known("testclause"),
288            ClauseData {
289                name: "Test Clause".to_owned(),
290                requires_value: false,
291                value_type: Some(ClauseValueType::PositiveInteger),
292                ..Default::default()
293            },
294        );
295        assert!(clause.validate_value("").is_ok());
296        assert!(clause
297            .validate_value("bad")
298            .err()
299            .unwrap()
300            .full_description()
301            .contains("invalid digit"));
302        assert!(clause
303            .validate_value("-1")
304            .err()
305            .unwrap()
306            .full_description()
307            .contains("invalid digit"));
308        assert!(clause
309            .validate_value("0")
310            .err()
311            .unwrap()
312            .full_description()
313            .contains("integer cannot be 0"));
314        assert!(clause.validate_value("10").is_ok());
315    }
316
317    #[test]
318    fn validates_non_negative_integer() {
319        let clause = Clause::new(
320            Id::from_known("testclause"),
321            ClauseData {
322                name: "Test Clause".to_owned(),
323                requires_value: false,
324                value_type: Some(ClauseValueType::NonNegativeInteger),
325                ..Default::default()
326            },
327        );
328        assert!(clause.validate_value("").is_ok());
329        assert!(clause
330            .validate_value("bad")
331            .err()
332            .unwrap()
333            .full_description()
334            .contains("invalid digit"));
335        assert!(clause
336            .validate_value("-20")
337            .err()
338            .unwrap()
339            .full_description()
340            .contains("invalid digit"));
341        assert!(clause.validate_value("0").is_ok());
342        assert!(clause.validate_value("10").is_ok());
343    }
344
345    fn construct_ruleset(
346        serialized: &str,
347        battle_type: &BattleType,
348        dex: &Dex,
349    ) -> Result<RuleSet, Error> {
350        let ruleset = serde_json::from_str::<SerializedRuleSet>(serialized).unwrap();
351        RuleSet::new(ruleset, battle_type, dex)
352    }
353
354    #[test]
355    fn validates_rules() {
356        let data = LocalDataStore::new_from_env("DATA_DIR").unwrap();
357        let dex = Dex::new(&data).unwrap();
358        let ruleset = construct_ruleset(
359            r#"[
360                "Other Rule = value"
361            ]"#,
362            &BattleType::Singles,
363            &dex,
364        )
365        .unwrap();
366        lazy_static! {
367            static ref HOOKS: ClauseStaticHooks = ClauseStaticHooks {
368                on_validate_rule: Some(Box::new(|rules, value| {
369                    if rules
370                        .value(&Id::from_known("otherrule"))
371                        .is_some_and(|other_value| other_value == value)
372                    {
373                        return Err(general_error("expected error"));
374                    }
375                    Ok(())
376                })),
377                ..Default::default()
378            };
379        }
380        let clause = Clause {
381            id: Id::from("testclause"),
382            data: ClauseData::default(),
383            hooks: &HOOKS,
384        };
385        assert!(clause.on_validate_rule(&ruleset, "other").is_ok());
386        assert!(clause
387            .on_validate_rule(&ruleset, "value")
388            .err()
389            .unwrap()
390            .full_description()
391            .contains("expected error"));
392    }
393
394    fn construct_format(dex: &Dex) -> Format {
395        Format::new(
396            serde_json::from_str(
397                r#"{
398                    "battle_type": "Singles",
399                    "rules": []
400                }"#,
401            )
402            .unwrap(),
403            &dex,
404        )
405        .unwrap()
406    }
407
408    #[test]
409    fn validates_mon() {
410        let data = LocalDataStore::new_from_env("DATA_DIR").unwrap();
411        let dex = Dex::new(&data).unwrap();
412        let format = construct_format(&dex);
413        let validator = TeamValidator::new(&format, &dex);
414        lazy_static! {
415            static ref HOOKS: ClauseStaticHooks = ClauseStaticHooks {
416                on_validate_mon: Some(Box::new(|_, mon| {
417                    if mon.level != 1 {
418                        return TeamValidationProblems::problem("level 1 required".to_owned());
419                    }
420                    TeamValidationProblems::default()
421                })),
422                ..Default::default()
423            };
424        }
425        let clause = Clause {
426            id: Id::from("testclause"),
427            data: ClauseData::default(),
428            hooks: &HOOKS,
429        };
430        let mut mon = serde_json::from_str(
431            r#"{
432                "name": "Bulba Fett",
433                "species": "Bulbasaur",
434                "ability": "Overgrow",
435                "moves": [],
436                "nature": "Adamant",
437                "gender": "M",
438                "level": 50
439              }"#,
440        )
441        .unwrap();
442        assert!(clause
443            .on_validate_mon(&validator, &mut mon)
444            .problems
445            .contains(&"level 1 required".to_owned()));
446
447        mon.level = 1;
448        assert!(clause
449            .on_validate_mon(&validator, &mut mon)
450            .problems
451            .is_empty());
452    }
453
454    #[test]
455    fn validates_team() {
456        let data = LocalDataStore::new_from_env("DATA_DIR").unwrap();
457        let dex = Dex::new(&data).unwrap();
458        let format = construct_format(&dex);
459        let validator = TeamValidator::new(&format, &dex);
460        lazy_static! {
461            static ref HOOKS: ClauseStaticHooks = ClauseStaticHooks {
462                on_validate_team: Some(Box::new(|_, team| {
463                    if team.len() <= 1 {
464                        return TeamValidationProblems::problem(
465                            "must have more than 1 Mon".to_owned(),
466                        );
467                    }
468                    TeamValidationProblems::default()
469                })),
470                ..Default::default()
471            };
472        }
473        let clause = Clause {
474            id: Id::from("testclause"),
475            data: ClauseData::default(),
476            hooks: &HOOKS,
477        };
478        let mut mon = serde_json::from_str::<MonData>(
479            r#"{
480                "name": "Bulba Fett",
481                "species": "Bulbasaur",
482                "ability": "Overgrow",
483                "moves": [],
484                "nature": "Adamant",
485                "gender": "M",
486                "level": 50
487              }"#,
488        )
489        .unwrap();
490        assert!(clause
491            .on_validate_team(&validator, &mut [&mut mon])
492            .problems
493            .contains(&"must have more than 1 Mon".to_owned()));
494
495        let mut mon2 = mon.clone();
496        assert!(clause
497            .on_validate_team(&validator, &mut [&mut mon, &mut mon2])
498            .problems
499            .is_empty());
500    }
501
502    #[test]
503    fn validates_core_battle_options() {
504        let data = LocalDataStore::new_from_env("DATA_DIR").unwrap();
505        let dex = Dex::new(&data).unwrap();
506        let ruleset = construct_ruleset(
507            r#"[
508                "Players Per Side = 2"
509            ]"#,
510            &BattleType::Singles,
511            &dex,
512        )
513        .unwrap();
514        lazy_static! {
515            static ref HOOKS: ClauseStaticHooks = ClauseStaticHooks {
516                on_validate_core_battle_options: Some(Box::new(|rules, options| {
517                    let players_per_side = rules
518                        .numeric_value(&Id::from_known("playersperside"))
519                        .wrap_expectation_with_format(format_args!(
520                            "Players Per Side must be an integer"
521                        ))? as usize;
522                    if options.side_1.players.len() != players_per_side {
523                        return Err(general_error(format!(
524                            "Side 1 does not have {players_per_side} players",
525                        )));
526                    }
527                    if options.side_2.players.len() != players_per_side {
528                        return Err(general_error(format!(
529                            "Side 2 does not have {players_per_side} players",
530                        )));
531                    }
532                    Ok(())
533                })),
534                ..Default::default()
535            };
536        }
537        let clause = Clause {
538            id: Id::from("playersperside"),
539            data: ClauseData::default(),
540            hooks: &HOOKS,
541        };
542
543        let mut bad_options = CoreBattleOptions {
544            seed: None,
545            format: None,
546            field: FieldData::default(),
547            side_1: SideData {
548                name: "Side 1".to_owned(),
549                players: Vec::new(),
550            },
551            side_2: SideData {
552                name: "Side 2".to_owned(),
553                players: Vec::new(),
554            },
555        };
556        assert!(clause
557            .on_validate_core_battle_options(&ruleset, &mut bad_options)
558            .err()
559            .unwrap()
560            .full_description()
561            .contains("does not have 2 players"));
562
563        let mut good_options = CoreBattleOptions {
564            seed: None,
565            format: None,
566            field: FieldData::default(),
567            side_1: SideData {
568                name: "Side 1".to_owned(),
569                players: Vec::from_iter([
570                    PlayerData {
571                        id: "player-1".to_owned(),
572                        name: "Player 1".to_owned(),
573                        team: TeamData::default(),
574                        player_type: PlayerType::Trainer,
575                        player_options: PlayerOptions::default(),
576                    },
577                    PlayerData {
578                        id: "player-2".to_owned(),
579                        name: "Player 2".to_owned(),
580                        team: TeamData::default(),
581                        player_type: PlayerType::Trainer,
582                        player_options: PlayerOptions::default(),
583                    },
584                ]),
585            },
586            side_2: SideData {
587                name: "Side 2".to_owned(),
588                players: Vec::from_iter([
589                    PlayerData {
590                        id: "player-3".to_owned(),
591                        name: "Player 3".to_owned(),
592                        team: TeamData::default(),
593                        player_type: PlayerType::Trainer,
594                        player_options: PlayerOptions::default(),
595                    },
596                    PlayerData {
597                        id: "player-4".to_owned(),
598                        name: "Player 4".to_owned(),
599                        team: TeamData::default(),
600                        player_type: PlayerType::Trainer,
601                        player_options: PlayerOptions::default(),
602                    },
603                ]),
604            },
605        };
606        assert!(clause
607            .on_validate_core_battle_options(&ruleset, &mut good_options)
608            .is_ok());
609    }
610
611    #[test]
612    fn hooks_do_nothing_by_default() {
613        let data = LocalDataStore::new_from_env("DATA_DIR").unwrap();
614        let dex = Dex::new(&data).unwrap();
615        let format = construct_format(&dex);
616        let validator = TeamValidator::new(&format, &dex);
617        lazy_static! {
618            static ref HOOKS: ClauseStaticHooks = ClauseStaticHooks::default();
619        }
620        let clause = Clause {
621            id: Id::from("testclause"),
622            data: ClauseData::default(),
623            hooks: &HOOKS,
624        };
625        let mut mon = serde_json::from_str::<MonData>(
626            r#"{
627                "name": "Bulba Fett",
628                "species": "Bulbasaur",
629                "ability": "Overgrow",
630                "moves": [],
631                "nature": "Adamant",
632                "gender": "M",
633                "level": 50
634              }"#,
635        )
636        .unwrap();
637        assert!(clause.validate_value("value").is_ok());
638        assert!(clause.on_validate_rule(&format.rules, "value").is_ok());
639        assert!(clause
640            .on_validate_mon(&validator, &mut mon)
641            .problems
642            .is_empty());
643        assert!(clause
644            .on_validate_team(&validator, &mut [&mut mon])
645            .problems
646            .is_empty());
647    }
648}