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#[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#[derive(Debug, Default, Clone, Serialize, Deserialize)]
51pub struct ClauseData {
52 pub name: String,
54 pub description: String,
56 #[serde(default)]
58 pub rule_log: Option<String>,
59 #[serde(default)]
61 pub requires_value: bool,
62 #[serde(default)]
64 pub value_type: Option<ClauseValueType>,
65 #[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#[derive(Default)]
83pub(in crate::config) struct ClauseStaticHooks {
84 pub on_validate_rule: Option<Box<ValidateRuleCallack>>,
86 pub on_validate_mon: Option<Box<ValidateMonCallback>>,
88 pub on_validate_team: Option<Box<ValidateTeamCallback>>,
90 pub on_validate_core_battle_options: Option<Box<ValidateCoreBattleOptionsCallback>>,
92}
93
94#[derive(Clone)]
99pub struct Clause {
100 id: Id,
101 pub data: ClauseData,
102 hooks: &'static ClauseStaticHooks,
103}
104
105impl Clause {
106 pub fn new(id: Id, data: ClauseData) -> Self {
108 let hooks = clause_hooks(&id);
109 Self { id, data, hooks }
110 }
111
112 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 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 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 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 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}