1use std::collections::BTreeMap;
6
7use serde::{Deserialize, Serialize};
8#[cfg(test)]
9use wowlab_types::game::GearSlot;
10use wowlab_types::{
11 constants::{CURRENT_EXPANSION_ID, MAX_PLAYER_LEVEL},
12 game::{GearEntry, SpecId},
13 sim::{EncounterDefinition, EncounterValidationError},
14};
15
16pub const INTENT_VERSION: &str = "v2";
18const OBSOLETE_ENCOUNTER_SETTINGS: [&str; 6] = [
19 "duration_s",
20 "enemy_count",
21 "target_count",
22 "fight_style",
23 "level",
24 "expansion_id",
25];
26
27#[derive(Debug, Deserialize)]
29#[serde(deny_unknown_fields)]
30pub struct IntentInput {
31 pub spec_id: u32,
32 pub rotation_id: String,
33 #[serde(default, skip_serializing_if = "Option::is_none")]
34 pub loadout: Option<String>,
35 #[serde(default)]
37 pub expansion_talents: BTreeMap<String, Vec<String>>,
38 #[serde(default)]
39 pub settings: BTreeMap<String, serde_json::Value>,
41 #[serde(default)]
42 pub equipment: Vec<GearEntry>,
43 pub player_level: u16,
44 pub player_expansion_id: u32,
45 pub encounter: EncounterDefinition,
46}
47
48#[derive(Clone, Debug, Deserialize, PartialEq, Serialize)]
50#[serde(deny_unknown_fields)]
51pub struct SimConfigIntent {
52 pub intent_version: String,
53 pub spec: u32,
54 pub rotation_id: String,
55 #[serde(default, skip_serializing_if = "Option::is_none")]
56 pub loadout: Option<String>,
57 #[serde(default, skip_serializing_if = "BTreeMap::is_empty")]
59 pub expansion_talents: BTreeMap<String, Vec<String>>,
60 pub player_level: u16,
61 pub player_expansion_id: u32,
62 pub encounter: EncounterDefinition,
63 pub settings: BTreeMap<String, toml::Value>,
65 pub gear: Vec<GearEntry>,
66}
67
68#[derive(Clone, Copy, Debug, Eq, PartialEq)]
70#[non_exhaustive]
71pub enum IntentConfigOperation {
72 Build,
73 Parse,
74 Serialize,
75}
76
77wowlab_engine_macros::define_error! {
78#[derive(Debug)]
80#[non_exhaustive]
81pub struct IntentConfigError {
82 #[source]
83 kind: IntentConfigErrorKind,
84}
85
86#[derive(Debug, thiserror::Error)]
87enum IntentConfigErrorKind {
88 #[error("TOML parse error: {source}")]
89 TomlDeserialize {
90 operation: IntentConfigOperation,
91 #[source]
92 source: toml::de::Error,
93 },
94 #[error("TOML serialization error: {source}")]
95 TomlSerialize {
96 operation: IntentConfigOperation,
97 #[source]
98 source: toml::ser::Error,
99 },
100 #[error("unknown wow spec id: {spec_id}")]
101 UnknownSpec { spec_id: u32 },
102 #[error("unsupported number: {number}")]
103 UnsupportedSettingNumber {
104 setting: String,
105 number: serde_json::Number,
106 },
107 #[error("null values are not supported in TOML")]
108 NullSetting { setting: String },
109 #[error("unsupported intent version {found:?}; only {supported:?} is accepted")]
110 UnsupportedIntentVersion {
111 operation: IntentConfigOperation,
112 found: String,
113 supported: &'static str,
114 },
115 #[error("obsolete aggregate encounter setting {key:?}; author the typed encounter instead")]
116 ObsoleteEncounterSetting {
117 operation: IntentConfigOperation,
118 key: &'static str,
119 },
120 #[error("invalid player_level {player_level}: must be non-zero")]
121 InvalidPlayerLevel {
122 operation: IntentConfigOperation,
123 player_level: u16,
124 },
125 #[error("invalid player_expansion_id {player_expansion_id}: must be non-zero")]
126 InvalidPlayerExpansionId {
127 operation: IntentConfigOperation,
128 player_expansion_id: u32,
129 },
130 #[error("invalid encounter: {source}")]
131 InvalidEncounter {
132 operation: IntentConfigOperation,
133 #[source]
134 source: EncounterValidationError,
135 },
136}
137}
138
139impl IntentConfigError {
140 fn toml_deserialize(operation: IntentConfigOperation, source: toml::de::Error) -> Self {
141 Self {
142 kind: IntentConfigErrorKind::TomlDeserialize { operation, source },
143 }
144 }
145
146 fn toml_serialize(operation: IntentConfigOperation, source: toml::ser::Error) -> Self {
147 Self {
148 kind: IntentConfigErrorKind::TomlSerialize { operation, source },
149 }
150 }
151
152 const fn unknown_spec(spec_id: u32) -> Self {
153 Self {
154 kind: IntentConfigErrorKind::UnknownSpec { spec_id },
155 }
156 }
157
158 fn unsupported_setting_number(setting: String, number: serde_json::Number) -> Self {
159 Self {
160 kind: IntentConfigErrorKind::UnsupportedSettingNumber { setting, number },
161 }
162 }
163
164 fn null_setting(setting: String) -> Self {
165 Self {
166 kind: IntentConfigErrorKind::NullSetting { setting },
167 }
168 }
169
170 fn unsupported_version(operation: IntentConfigOperation, found: String) -> Self {
171 Self {
172 kind: IntentConfigErrorKind::UnsupportedIntentVersion {
173 operation,
174 found,
175 supported: INTENT_VERSION,
176 },
177 }
178 }
179
180 const fn obsolete_encounter_setting(
181 operation: IntentConfigOperation,
182 key: &'static str,
183 ) -> Self {
184 Self {
185 kind: IntentConfigErrorKind::ObsoleteEncounterSetting { operation, key },
186 }
187 }
188
189 const fn invalid_player_level(operation: IntentConfigOperation, player_level: u16) -> Self {
190 Self {
191 kind: IntentConfigErrorKind::InvalidPlayerLevel {
192 operation,
193 player_level,
194 },
195 }
196 }
197
198 const fn invalid_player_expansion_id(
199 operation: IntentConfigOperation,
200 player_expansion_id: u32,
201 ) -> Self {
202 Self {
203 kind: IntentConfigErrorKind::InvalidPlayerExpansionId {
204 operation,
205 player_expansion_id,
206 },
207 }
208 }
209
210 fn invalid_encounter(
211 operation: IntentConfigOperation,
212 source: EncounterValidationError,
213 ) -> Self {
214 Self {
215 kind: IntentConfigErrorKind::InvalidEncounter { operation, source },
216 }
217 }
218
219 #[must_use]
221 pub const fn operation(&self) -> IntentConfigOperation {
222 match self.kind {
223 IntentConfigErrorKind::TomlDeserialize { operation, .. }
224 | IntentConfigErrorKind::TomlSerialize { operation, .. }
225 | IntentConfigErrorKind::UnsupportedIntentVersion { operation, .. }
226 | IntentConfigErrorKind::ObsoleteEncounterSetting { operation, .. }
227 | IntentConfigErrorKind::InvalidPlayerLevel { operation, .. }
228 | IntentConfigErrorKind::InvalidPlayerExpansionId { operation, .. }
229 | IntentConfigErrorKind::InvalidEncounter { operation, .. } => operation,
230 IntentConfigErrorKind::UnknownSpec { .. }
231 | IntentConfigErrorKind::UnsupportedSettingNumber { .. }
232 | IntentConfigErrorKind::NullSetting { .. } => IntentConfigOperation::Build,
233 }
234 }
235
236 #[must_use]
238 pub const fn unknown_spec_id(&self) -> Option<u32> {
239 match self.kind {
240 IntentConfigErrorKind::UnknownSpec { spec_id } => Some(spec_id),
241 _ => None,
242 }
243 }
244
245 #[must_use]
247 pub fn setting_key(&self) -> Option<&str> {
248 match &self.kind {
249 IntentConfigErrorKind::UnsupportedSettingNumber { setting, .. }
250 | IntentConfigErrorKind::NullSetting { setting } => Some(setting),
251 _ => None,
252 }
253 }
254
255 #[must_use]
257 pub fn unsupported_version_value(&self) -> Option<&str> {
258 match &self.kind {
259 IntentConfigErrorKind::UnsupportedIntentVersion { found, .. } => Some(found),
260 _ => None,
261 }
262 }
263
264 #[must_use]
266 pub const fn invalid_player_level_value(&self) -> Option<u16> {
267 match self.kind {
268 IntentConfigErrorKind::InvalidPlayerLevel { player_level, .. } => Some(player_level),
269 _ => None,
270 }
271 }
272
273 #[must_use]
275 pub const fn invalid_player_expansion_id_value(&self) -> Option<u32> {
276 match self.kind {
277 IntentConfigErrorKind::InvalidPlayerExpansionId {
278 player_expansion_id,
279 ..
280 } => Some(player_expansion_id),
281 _ => None,
282 }
283 }
284}
285
286impl SimConfigIntent {
287 #[must_use]
293 pub fn patchwerk(spec: SpecId, duration_s: f64, rotation_id: impl Into<String>) -> Self {
294 Self {
295 intent_version: INTENT_VERSION.to_string(),
296 spec: spec.wow_spec_id(),
297 rotation_id: rotation_id.into(),
298 loadout: None,
299 expansion_talents: BTreeMap::new(),
300 player_level: MAX_PLAYER_LEVEL,
301 player_expansion_id: CURRENT_EXPANSION_ID,
302 encounter: EncounterDefinition::patchwerk(
303 duration_s,
304 MAX_PLAYER_LEVEL,
305 CURRENT_EXPANSION_ID,
306 )
307 .expect("default Patchwerk context is valid"),
308 settings: BTreeMap::new(),
309 gear: Vec::new(),
310 }
311 }
312}
313
314pub fn build_sim_config(input: IntentInput) -> Result<String, IntentConfigError> {
320 let spec = SpecId::from_wow_spec_id(input.spec_id)
321 .ok_or_else(|| IntentConfigError::unknown_spec(input.spec_id))?;
322 let spec_id = spec.wow_spec_id();
323
324 let mut settings = BTreeMap::new();
325
326 for (k, v) in input.settings {
327 let tv = json_to_toml(v, &k)?;
328
329 settings.insert(k, tv);
330 }
331
332 let intent = SimConfigIntent {
333 intent_version: INTENT_VERSION.to_string(),
334 spec: spec_id,
335 rotation_id: input.rotation_id,
336 loadout: input.loadout,
337 expansion_talents: input.expansion_talents,
338 player_level: input.player_level,
339 player_expansion_id: input.player_expansion_id,
340 encounter: input.encounter,
341 settings,
342 gear: input.equipment,
343 };
344
345 validate_intent(&intent, IntentConfigOperation::Build)?;
346
347 toml::to_string_pretty(&intent)
348 .map_err(|source| IntentConfigError::toml_serialize(IntentConfigOperation::Build, source))
349}
350
351pub fn parse_sim_config(toml_str: &str) -> Result<SimConfigIntent, IntentConfigError> {
357 let value: toml::Value = toml::from_str(toml_str).map_err(|source| {
358 IntentConfigError::toml_deserialize(IntentConfigOperation::Parse, source)
359 })?;
360 let found = value
361 .get("intent_version")
362 .and_then(toml::Value::as_str)
363 .unwrap_or_default();
364
365 if found != INTENT_VERSION {
366 return Err(IntentConfigError::unsupported_version(
367 IntentConfigOperation::Parse,
368 found.to_string(),
369 ));
370 }
371
372 let intent: SimConfigIntent = toml::from_str(toml_str).map_err(|source| {
373 IntentConfigError::toml_deserialize(IntentConfigOperation::Parse, source)
374 })?;
375
376 validate_intent(&intent, IntentConfigOperation::Parse)?;
377
378 Ok(intent)
379}
380
381fn validate_intent(
382 intent: &SimConfigIntent,
383 operation: IntentConfigOperation,
384) -> Result<(), IntentConfigError> {
385 if intent.intent_version != INTENT_VERSION {
386 return Err(IntentConfigError::unsupported_version(
387 operation,
388 intent.intent_version.clone(),
389 ));
390 }
391
392 if intent.player_level == 0 {
393 return Err(IntentConfigError::invalid_player_level(
394 operation,
395 intent.player_level,
396 ));
397 }
398
399 if intent.player_expansion_id == 0 {
400 return Err(IntentConfigError::invalid_player_expansion_id(
401 operation,
402 intent.player_expansion_id,
403 ));
404 }
405
406 for key in OBSOLETE_ENCOUNTER_SETTINGS {
407 if intent.settings.contains_key(key) {
408 return Err(IntentConfigError::obsolete_encounter_setting(
409 operation, key,
410 ));
411 }
412 }
413
414 intent
415 .encounter
416 .validate()
417 .map_err(|source| IntentConfigError::invalid_encounter(operation, source))?;
418
419 Ok(())
420}
421
422pub fn serialize_sim_config(intent: &SimConfigIntent) -> Result<String, IntentConfigError> {
428 validate_intent(intent, IntentConfigOperation::Serialize)?;
429
430 toml::to_string_pretty(intent).map_err(|source| {
431 IntentConfigError::toml_serialize(IntentConfigOperation::Serialize, source)
432 })
433}
434
435fn json_to_toml(v: serde_json::Value, setting: &str) -> Result<toml::Value, IntentConfigError> {
437 match v {
438 serde_json::Value::Bool(b) => Ok(toml::Value::Boolean(b)),
439 serde_json::Value::Number(n) => {
440 if let Some(i) = n.as_i64() {
441 Ok(toml::Value::Integer(i))
442 } else if let Some(f) = n.as_f64() {
443 Ok(toml::Value::Float(f))
444 } else {
445 Err(IntentConfigError::unsupported_setting_number(
446 setting.to_string(),
447 n,
448 ))
449 }
450 }
451 serde_json::Value::String(s) => Ok(toml::Value::String(s)),
452 serde_json::Value::Array(arr) => {
453 let values = arr
454 .into_iter()
455 .map(|value| json_to_toml(value, setting))
456 .collect::<Result<Vec<_>, _>>()?;
457
458 Ok(toml::Value::Array(values))
459 }
460 serde_json::Value::Object(map) => {
461 let mut table = toml::map::Map::new();
462 let mut sorted = BTreeMap::new();
463
464 for (k, val) in map {
465 sorted.insert(k, val);
466 }
467
468 for (k, val) in sorted {
469 table.insert(k, json_to_toml(val, setting)?);
470 }
471
472 Ok(toml::Value::Table(table))
473 }
474 serde_json::Value::Null => Err(IntentConfigError::null_setting(setting.to_string())),
475 }
476}
477
478#[cfg(test)]
479mod tests {
480 use std::error::Error as _;
481
482 use googletest::prelude::*;
483 use rstest::rstest;
484
485 use super::*;
486
487 fn patchwerk_input(spec_id: u32, rotation_id: &str) -> IntentInput {
488 IntentInput {
489 spec_id,
490 rotation_id: rotation_id.to_string(),
491 loadout: None,
492 expansion_talents: BTreeMap::new(),
493 settings: BTreeMap::new(),
494 equipment: Vec::new(),
495 player_level: MAX_PLAYER_LEVEL,
496 player_expansion_id: CURRENT_EXPANSION_ID,
497 encounter: EncounterDefinition::patchwerk(
498 300.0,
499 MAX_PLAYER_LEVEL,
500 CURRENT_EXPANSION_ID,
501 )
502 .expect("current Patchwerk input is valid"),
503 }
504 }
505
506 #[gtest]
507 fn test_build_sim_config_beast_mastery() -> Result<()> {
508 let input = IntentInput {
509 equipment: vec![GearEntry {
510 slot: GearSlot::Head,
511 id: 12345,
512 bonus_ids: Some(vec![1, 2, 3]),
513 enchant_id: None,
514 gem_ids: None,
515 crafted_stats: None,
516 crafting_quality: None,
517 drop_level: None,
518 ilevel: None,
519 }],
520 ..patchwerk_input(253, "rot-123")
521 };
522
523 let toml_str = build_sim_config(input).or_fail()?;
524
525 verify_that!(toml_str, contains_substring("intent_version = \"v2\""))?;
526 verify_that!(toml_str, contains_substring("spec = 253"))?;
527 verify_that!(toml_str, contains_substring("rotation_id = \"rot-123\""))?;
528 verify_that!(toml_str, contains_substring("id = 12345"))?;
529 verify_that!(toml_str, contains_substring("fixed_duration_s = 300.0"))?;
530 verify_that!(
531 toml_str,
532 contains_substring("display_name = \"Training Dummy\"")
533 )?;
534
535 Ok(())
536 }
537
538 #[gtest]
539 fn test_build_sim_config_unknown_spec() -> Result<()> {
540 let input = patchwerk_input(9999, "rot-123");
541
542 let error = build_sim_config(input).err().or_fail()?;
543
544 verify_that!(error.to_string(), eq("unknown wow spec id: 9999"))?;
545 verify_that!(error.operation(), eq(IntentConfigOperation::Build))?;
546 verify_that!(error.unknown_spec_id(), some(eq(9999)))?;
547
548 Ok(())
549 }
550
551 #[gtest]
552 fn test_build_sim_config_accepts_all_valid_specs() -> Result<()> {
553 let input = patchwerk_input(65, "rot-123");
554
555 let toml_str = build_sim_config(input).or_fail()?;
556
557 verify_that!(toml_str, contains_substring("spec = 65"))?;
558
559 Ok(())
560 }
561
562 #[gtest]
563 fn test_build_sim_config_uses_explicit_current_encounter() -> Result<()> {
564 let mut settings = BTreeMap::new();
565
566 settings.insert("bloodlust".into(), serde_json::Value::Bool(true));
567
568 let input = IntentInput {
569 settings,
570 player_level: 70,
571 player_expansion_id: 10,
572 encounter: EncounterDefinition::patchwerk(120.0, 70, 10).or_fail()?,
573 ..patchwerk_input(254, "rot-456")
574 };
575
576 let toml_str = build_sim_config(input).or_fail()?;
577
578 verify_that!(toml_str, contains_substring("spec = 254"))?;
579 verify_that!(toml_str, contains_substring("bloodlust = true"))?;
580 let parsed = parse_sim_config(&toml_str).or_fail()?;
581
582 verify_that!(parsed.encounter.fixed_duration_s, some(near(120.0, 1e-9)))?;
583 verify_that!(parsed.encounter.enemies[0].level, eq(70))?;
584 verify_that!(parsed.player_level, eq(70))?;
585 verify_that!(parsed.player_expansion_id, eq(10))?;
586 verify_that!(parsed.settings.len(), eq(1))?;
587
588 Ok(())
589 }
590
591 #[gtest]
592 fn test_parse_sim_config_roundtrip() -> Result<()> {
593 let input = patchwerk_input(253, "rot-1");
594 let toml_str = build_sim_config(input).or_fail()?;
595 let parsed = parse_sim_config(&toml_str).or_fail()?;
596
597 verify_that!(
598 parsed,
599 matches_pattern!(SimConfigIntent {
600 intent_version: "v2",
601 spec: eq(&253),
602 rotation_id: "rot-1",
603 ..
604 })
605 )?;
606
607 Ok(())
608 }
609
610 #[gtest]
611 fn test_build_sim_config_passes_loadout() -> Result<()> {
612 let input = IntentInput {
613 loadout: Some("ABCDEF".into()),
614 ..patchwerk_input(260, "rot-1")
615 };
616 let toml_str = build_sim_config(input).or_fail()?;
617
618 verify_that!(toml_str, contains_substring("loadout = \"ABCDEF\""))?;
619 let parsed = parse_sim_config(&toml_str).or_fail()?;
620
621 verify_that!(parsed.loadout.as_deref(), some(eq("ABCDEF")))?;
622
623 Ok(())
624 }
625
626 #[gtest]
627 fn test_json_to_toml_null_rejected() -> Result<()> {
628 let error = json_to_toml(serde_json::Value::Null, "optional_feature")
629 .err()
630 .or_fail()?;
631
632 verify_that!(
633 error.to_string(),
634 eq("null values are not supported in TOML")
635 )?;
636 verify_that!(error.operation(), eq(IntentConfigOperation::Build))?;
637 verify_that!(error.setting_key(), some(eq("optional_feature")))?;
638
639 Ok(())
640 }
641
642 #[gtest]
646 #[rstest]
647 #[case::boolean(serde_json::Value::Bool(true), toml::Value::Boolean(true))]
648 #[case::integer(serde_json::json!(42), toml::Value::Integer(42))]
649 #[case::string(serde_json::Value::String("x".to_string()), toml::Value::String("x".to_string()))]
650 #[case::array(
651 serde_json::json!([1, 2]),
652 toml::Value::Array(vec![toml::Value::Integer(1), toml::Value::Integer(2)])
653 )]
654 fn json_to_toml_arms(
655 #[case] input: serde_json::Value,
656 #[case] expected: toml::Value,
657 ) -> Result<()> {
658 verify_that!(json_to_toml(input, "fixture"), ok(eq(&expected)))
659 }
660
661 #[gtest]
662 fn json_to_toml_float() -> Result<()> {
663 let value = json_to_toml(serde_json::json!(1.5), "fixture").or_fail()?;
664
665 verify_that!(value.as_float(), some(near(1.5, 1e-9)))?;
666
667 Ok(())
668 }
669
670 #[gtest]
671 fn json_to_toml_object_sorts_keys() -> Result<()> {
672 let value = json_to_toml(serde_json::json!({ "b": 1, "a": 2 }), "fixture").or_fail()?;
673 let keys: Vec<&str> = value
674 .as_table()
675 .or_fail()?
676 .keys()
677 .map(String::as_str)
678 .collect();
679
680 verify_that!(keys, container_eq(["a", "b"]))?;
681
682 Ok(())
683 }
684
685 #[gtest]
686 fn patchwerk_known_answer() -> Result<()> {
687 let intent = SimConfigIntent::patchwerk(SpecId::BeastMastery, 240.0, "rot-x");
688
689 verify_that!(
690 intent,
691 matches_pattern!(SimConfigIntent {
692 intent_version: "v2",
693 rotation_id: "rot-x",
694 spec: eq(&SpecId::BeastMastery.wow_spec_id()),
695 loadout: none(),
696 gear: is_empty(),
697 settings: is_empty(),
698 player_level: eq(&MAX_PLAYER_LEVEL),
699 player_expansion_id: eq(&CURRENT_EXPANSION_ID),
700 ..
701 })
702 )?;
703 verify_that!(intent.encounter.fixed_duration_s, some(near(240.0, 1e-9)))?;
704
705 Ok(())
706 }
707
708 #[gtest]
709 fn serialize_sim_config_roundtrip() -> Result<()> {
710 let intent = SimConfigIntent::patchwerk(SpecId::BeastMastery, 300.0, "rot-y");
711 let serialized = serialize_sim_config(&intent);
712
713 verify_that!(&serialized, ok(anything()))?;
714
715 let serialized = serialized.or_fail()?;
716 let parsed = parse_sim_config(&serialized).or_fail()?;
717
718 verify_that!(
719 parsed,
720 matches_pattern!(SimConfigIntent {
721 intent_version: "v2",
722 rotation_id: "rot-y",
723 spec: eq(&SpecId::BeastMastery.wow_spec_id()),
724 ..
725 })
726 )?;
727
728 Ok(())
729 }
730
731 #[gtest]
732 fn obsolete_v1_intent_is_rejected_without_migration() -> Result<()> {
733 let source = r#"
734intent_version = "v1"
735spec = 253
736rotation_id = "obsolete"
737gear = []
738"#;
739 let error = parse_sim_config(source).err().or_fail()?;
740
741 verify_that!(error.unsupported_version_value(), some(eq("v1")))
742 }
743
744 #[gtest]
745 fn malformed_toml_preserves_parse_operation_and_source() -> Result<()> {
746 let error = parse_sim_config("[").err().or_fail()?;
747
748 verify_that!(error.operation(), eq(IntentConfigOperation::Parse))?;
749 verify_that!(error.to_string(), starts_with("TOML parse error:"))?;
750
751 verify_true!(
752 error
753 .source()
754 .and_then(std::error::Error::source)
755 .is_some_and(<dyn std::error::Error>::is::<toml::de::Error>)
756 )
757 }
758
759 #[gtest]
760 fn empty_toml_is_an_unsupported_version_without_a_source() -> Result<()> {
761 let error = parse_sim_config("").err().or_fail()?;
762
763 verify_that!(error.operation(), eq(IntentConfigOperation::Parse))?;
764 verify_that!(error.unsupported_version_value(), some(eq("")))?;
765
766 let kind = error.source().or_fail()?;
767
768 verify_that!(kind.source(), none())
769 }
770
771 #[gtest]
772 fn unicode_values_roundtrip_unchanged() -> Result<()> {
773 let mut intent = SimConfigIntent::patchwerk(SpecId::BeastMastery, 300.0, "旋转-🦊");
774
775 intent.settings.insert(
776 "greeting".to_string(),
777 toml::Value::String("Grüße 世界".to_string()),
778 );
779
780 let serialized = serialize_sim_config(&intent).or_fail()?;
781 let parsed = parse_sim_config(&serialized).or_fail()?;
782
783 verify_that!(parsed.rotation_id, eq("旋转-🦊"))?;
784
785 verify_that!(
786 parsed
787 .settings
788 .get("greeting")
789 .and_then(toml::Value::as_str),
790 some(eq("Grüße 世界"))
791 )
792 }
793
794 #[gtest]
795 fn unknown_intent_version_is_typed_error() -> Result<()> {
796 let source = r#"intent_version = "v9""#;
797 let error = parse_sim_config(source).err().or_fail()?;
798
799 verify_that!(error.unsupported_version_value(), some(eq("v9")))
800 }
801
802 #[gtest]
803 fn current_intent_rejects_zero_player_game_data_context() -> Result<()> {
804 let mut intent = SimConfigIntent::patchwerk(SpecId::BeastMastery, 300.0, "rotation");
805
806 intent.player_level = 0;
807 let serialized = toml::to_string_pretty(&intent).or_fail()?;
808 let error = parse_sim_config(&serialized).err().or_fail()?;
809
810 verify_that!(error.invalid_player_level_value(), some(eq(0)))?;
811
812 intent.player_level = 80;
813 intent.player_expansion_id = 0;
814 let serialized = toml::to_string_pretty(&intent).or_fail()?;
815 let error = parse_sim_config(&serialized).err().or_fail()?;
816
817 verify_that!(error.invalid_player_expansion_id_value(), some(eq(0)))
818 }
819
820 #[gtest]
821 fn current_intent_rejects_obsolete_aggregate_settings() -> Result<()> {
822 let mut intent = SimConfigIntent::patchwerk(SpecId::BeastMastery, 300.0, "rotation");
823
824 intent.player_level = 90;
825 intent.player_expansion_id = 11;
826 intent
827 .settings
828 .insert("level".to_string(), toml::Value::Integer(70));
829 intent
830 .settings
831 .insert("expansion_id".to_string(), toml::Value::Integer(10));
832
833 verify_that!(
834 serialize_sim_config(&intent),
835 err(displays_as(contains_substring(
836 "obsolete aggregate encounter setting \"level\""
837 )))
838 )
839 }
840
841 #[gtest]
842 fn build_sim_config_nested_settings() -> Result<()> {
843 let mut settings = BTreeMap::new();
844
845 settings.insert("tags".to_string(), serde_json::json!(["a", "b"]));
846 settings.insert("nested".to_string(), serde_json::json!({ "x": 1, "y": 2 }));
847
848 let input = IntentInput {
849 settings,
850 ..patchwerk_input(253, "rot-nested")
851 };
852
853 let toml_str = build_sim_config(input).or_fail()?;
854
855 verify_that!(
856 toml_str,
857 contains_substring("tags = [\n \"a\",\n \"b\",\n]")
858 )?;
859 verify_that!(toml_str, contains_substring("[settings.nested]"))?;
860 verify_that!(toml_str, contains_substring("x = 1"))?;
861 verify_that!(toml_str, contains_substring("y = 2"))?;
862
863 Ok(())
864 }
865
866 #[gtest]
867 fn test_all_specs_have_snake_case() -> Result<()> {
868 use strum::IntoEnumIterator;
869
870 for spec in SpecId::iter() {
871 let snake = spec.toml_key().to_string();
872
873 verify_that!(snake.as_bytes(), not(is_empty()))?;
874 verify_that!(
875 snake.chars().all(|c| c.is_ascii_lowercase() || c == '_'),
876 eq(true)
877 )?;
878 }
879
880 Ok(())
881 }
882
883 #[gtest]
884 fn test_settings_serialization_is_deterministic() -> Result<()> {
885 let encounter = serde_json::to_value(
886 EncounterDefinition::patchwerk(300.0, MAX_PLAYER_LEVEL, CURRENT_EXPANSION_ID)
887 .or_fail()?,
888 )
889 .or_fail()?;
890 let input_a: IntentInput = serde_json::from_value(serde_json::json!({
891 "spec_id": 253,
892 "rotation_id": "rot-1",
893 "player_level": MAX_PLAYER_LEVEL,
894 "player_expansion_id": CURRENT_EXPANSION_ID,
895 "encounter": encounter,
896 "settings": {
897 "zeta": true,
898 "alpha": 1
899 },
900 "equipment": []
901 }))
902 .or_fail()?;
903
904 let input_b: IntentInput = serde_json::from_value(serde_json::json!({
905 "spec_id": 253,
906 "rotation_id": "rot-1",
907 "player_level": MAX_PLAYER_LEVEL,
908 "player_expansion_id": CURRENT_EXPANSION_ID,
909 "encounter": serde_json::to_value(
910 EncounterDefinition::patchwerk(
911 300.0,
912 MAX_PLAYER_LEVEL,
913 CURRENT_EXPANSION_ID,
914 )
915 .or_fail()?,
916 )
917 .or_fail()?,
918 "settings": {
919 "alpha": 1,
920 "zeta": true
921 },
922 "equipment": []
923 }))
924 .or_fail()?;
925
926 let a = build_sim_config(input_a).or_fail()?;
927 let b = build_sim_config(input_b).or_fail()?;
928
929 verify_that!(a, eq(&b))?;
930
931 Ok(())
932 }
933}