1use rakata_core::tables;
15use rakata_formats::twoda::{TwoDa, TwoDaSource};
16
17use crate::utc::{Utc, UtcClass, UtcEquipmentItem, UtcInventoryItem, UtcSpecialAbility};
18
19#[allow(clippy::enum_variant_names)]
38#[derive(Debug, Clone, PartialEq, Eq)]
39pub enum DecodedFeat {
40 ArmourProfLight {
42 feat_id: u16,
44 },
45 ArmourProfMedium {
47 feat_id: u16,
49 },
50 ArmourProfHeavy {
52 feat_id: u16,
54 },
55 WeaponProfBlaster {
57 feat_id: u16,
59 },
60 WeaponProfBlasterRifle {
62 feat_id: u16,
64 },
65 WeaponProfHeavyWeapons {
67 feat_id: u16,
69 },
70 WeaponProfMeleeWeapons {
72 feat_id: u16,
74 },
75 WeaponProfLightsaber {
77 feat_id: u16,
79 },
80 WeaponProfGrenade {
84 feat_id: u16,
86 },
87 WeaponProfSimpleWeapons {
90 feat_id: u16,
92 },
93 ProficiencyAll {
97 feat_id: u16,
99 },
100 WeaponFocusBlaster {
102 feat_id: u16,
104 },
105 WeaponFocusBlasterRifle {
107 feat_id: u16,
109 },
110 WeaponFocusGrenade {
112 feat_id: u16,
114 },
115 WeaponFocusHeavyWeapons {
117 feat_id: u16,
119 },
120 WeaponFocusLightsaber {
122 feat_id: u16,
124 },
125 WeaponFocusMeleeWeapons {
127 feat_id: u16,
129 },
130 WeaponFocusSimpleWeapons {
133 feat_id: u16,
135 },
136 WeaponSpecBlaster {
138 feat_id: u16,
140 },
141 WeaponSpecBlasterRifle {
143 feat_id: u16,
145 },
146 WeaponSpecGrenade {
148 feat_id: u16,
150 },
151 WeaponSpecHeavyWeapons {
153 feat_id: u16,
155 },
156 WeaponSpecLightsaber {
158 feat_id: u16,
160 },
161 WeaponSpecMeleeWeapons {
163 feat_id: u16,
165 },
166 WeaponSpecSimpleWeapons {
169 feat_id: u16,
171 },
172 PowerAttack {
174 feat_id: u16,
176 },
177 ImprovedPowerAttack {
179 feat_id: u16,
181 },
182 MasterPowerAttack {
184 feat_id: u16,
186 },
187 PowerBlast {
189 feat_id: u16,
191 },
192 ImprovedPowerBlast {
194 feat_id: u16,
196 },
197 MasterPowerBlast {
199 feat_id: u16,
201 },
202 CriticalStrike {
204 feat_id: u16,
206 },
207 ImprovedCriticalStrike {
209 feat_id: u16,
211 },
212 MasterCriticalStrike {
214 feat_id: u16,
216 },
217 Flurry {
219 feat_id: u16,
221 },
222 ImprovedFlurry {
224 feat_id: u16,
226 },
227 SniperShot {
229 feat_id: u16,
231 },
232 ImprovedSniperShot {
234 feat_id: u16,
236 },
237 MasterSniperShot {
239 feat_id: u16,
241 },
242 RapidShot {
244 feat_id: u16,
246 },
247 ImprovedRapidShot {
249 feat_id: u16,
251 },
252 MultiShot {
254 feat_id: u16,
256 },
257 WhirlwindAttack {
259 feat_id: u16,
261 },
262 TwoWeaponFighting {
264 feat_id: u16,
266 },
267 TwoWeaponAdvanced {
269 feat_id: u16,
271 },
272 TwoWeaponMastery {
274 feat_id: u16,
276 },
277 JediDefense {
279 feat_id: u16,
281 },
282 AdvancedJediDefense {
284 feat_id: u16,
286 },
287 MasterJediDefense {
289 feat_id: u16,
291 },
292 ForceFocus {
295 feat_id: u16,
297 },
298 ForceFocusAdvanced {
300 feat_id: u16,
302 },
303 ForceFocusMastery {
305 feat_id: u16,
307 },
308 ForceFocusAlter {
311 feat_id: u16,
313 },
314 ForceFocusControl {
317 feat_id: u16,
319 },
320 ForceJump {
322 feat_id: u16,
324 },
325 ForceJumpAdvanced {
327 feat_id: u16,
329 },
330 ForceJumpMastery {
332 feat_id: u16,
334 },
335 ForceImmunityFear {
337 feat_id: u16,
339 },
340 ForceImmunityStun {
342 feat_id: u16,
344 },
345 ForceImmunityParalysis {
347 feat_id: u16,
349 },
350 BattleMeditation {
353 feat_id: u16,
355 },
356 ForceCamoflage {
359 feat_id: u16,
361 },
362 SneakAttack1D6 {
364 feat_id: u16,
366 },
367 SneakAttack2D6 {
369 feat_id: u16,
371 },
372 SneakAttack3D6 {
374 feat_id: u16,
376 },
377 SneakAttack4D6 {
379 feat_id: u16,
381 },
382 SneakAttack5D6 {
384 feat_id: u16,
386 },
387 SneakAttack6D6 {
389 feat_id: u16,
391 },
392 UncannyDodge1 {
394 feat_id: u16,
396 },
397 UncannyDodge2 {
399 feat_id: u16,
401 },
402 Toughness {
404 feat_id: u16,
406 },
407 ImprovedToughness {
409 feat_id: u16,
411 },
412 MasterToughness {
414 feat_id: u16,
416 },
417 Conditioning {
419 feat_id: u16,
421 },
422 ImprovedConditioning {
424 feat_id: u16,
426 },
427 MasterConditioning {
429 feat_id: u16,
431 },
432 ImplantLevel1 {
434 feat_id: u16,
436 },
437 ImplantLevel2 {
439 feat_id: u16,
441 },
442 ImplantLevel3 {
444 feat_id: u16,
446 },
447 DroidUpgrade1 {
449 feat_id: u16,
451 },
452 DroidUpgrade2 {
454 feat_id: u16,
456 },
457 DroidUpgrade3 {
459 feat_id: u16,
461 },
462 BlasterIntegration {
465 feat_id: u16,
467 },
468 LogicUpgradeCombat {
470 feat_id: u16,
472 },
473 LogicUpgradeTactician {
475 feat_id: u16,
477 },
478 Dueling {
480 feat_id: u16,
482 },
483 AdvancedDueling {
485 feat_id: u16,
487 },
488 MasterDueling {
490 feat_id: u16,
492 },
493 JediSense {
496 feat_id: u16,
498 },
499 KnightSense {
501 feat_id: u16,
503 },
504 MasterSense {
506 feat_id: u16,
508 },
509 GuardStance {
512 feat_id: u16,
514 },
515 AdvancedGuardStance {
517 feat_id: u16,
519 },
520 MasterGuardStance {
522 feat_id: u16,
524 },
525 SkillFocusAwareness {
529 feat_id: u16,
531 },
532 SkillFocusTreatInjury {
535 feat_id: u16,
537 },
538 SkillFocusStealth {
540 feat_id: u16,
542 },
543 Cautious {
546 feat_id: u16,
548 },
549 Perceptive {
552 feat_id: u16,
554 },
555 Empathy {
557 feat_id: u16,
559 },
560 GearHead {
562 feat_id: u16,
564 },
565 WookieEndurance {
567 feat_id: u16,
569 },
570 ScoundrelsLuck {
572 feat_id: u16,
574 },
575 Unknown {
580 feat_id: u16,
582 feat_label: Option<String>,
586 },
587}
588
589impl DecodedFeat {
590 pub fn feat_id(&self) -> u16 {
592 match self {
593 Self::ArmourProfLight { feat_id }
594 | Self::ArmourProfMedium { feat_id }
595 | Self::ArmourProfHeavy { feat_id }
596 | Self::WeaponProfBlaster { feat_id }
597 | Self::WeaponProfBlasterRifle { feat_id }
598 | Self::WeaponProfHeavyWeapons { feat_id }
599 | Self::WeaponProfMeleeWeapons { feat_id }
600 | Self::WeaponProfLightsaber { feat_id }
601 | Self::WeaponProfGrenade { feat_id }
602 | Self::WeaponProfSimpleWeapons { feat_id }
603 | Self::ProficiencyAll { feat_id }
604 | Self::WeaponFocusBlaster { feat_id }
605 | Self::WeaponFocusBlasterRifle { feat_id }
606 | Self::WeaponFocusGrenade { feat_id }
607 | Self::WeaponFocusHeavyWeapons { feat_id }
608 | Self::WeaponFocusLightsaber { feat_id }
609 | Self::WeaponFocusMeleeWeapons { feat_id }
610 | Self::WeaponFocusSimpleWeapons { feat_id }
611 | Self::WeaponSpecBlaster { feat_id }
612 | Self::WeaponSpecBlasterRifle { feat_id }
613 | Self::WeaponSpecGrenade { feat_id }
614 | Self::WeaponSpecHeavyWeapons { feat_id }
615 | Self::WeaponSpecLightsaber { feat_id }
616 | Self::WeaponSpecMeleeWeapons { feat_id }
617 | Self::WeaponSpecSimpleWeapons { feat_id }
618 | Self::PowerAttack { feat_id }
619 | Self::ImprovedPowerAttack { feat_id }
620 | Self::MasterPowerAttack { feat_id }
621 | Self::PowerBlast { feat_id }
622 | Self::ImprovedPowerBlast { feat_id }
623 | Self::MasterPowerBlast { feat_id }
624 | Self::CriticalStrike { feat_id }
625 | Self::ImprovedCriticalStrike { feat_id }
626 | Self::MasterCriticalStrike { feat_id }
627 | Self::Flurry { feat_id }
628 | Self::ImprovedFlurry { feat_id }
629 | Self::SniperShot { feat_id }
630 | Self::ImprovedSniperShot { feat_id }
631 | Self::MasterSniperShot { feat_id }
632 | Self::RapidShot { feat_id }
633 | Self::ImprovedRapidShot { feat_id }
634 | Self::MultiShot { feat_id }
635 | Self::WhirlwindAttack { feat_id }
636 | Self::TwoWeaponFighting { feat_id }
637 | Self::TwoWeaponAdvanced { feat_id }
638 | Self::TwoWeaponMastery { feat_id }
639 | Self::JediDefense { feat_id }
640 | Self::AdvancedJediDefense { feat_id }
641 | Self::MasterJediDefense { feat_id }
642 | Self::ForceFocus { feat_id }
643 | Self::ForceFocusAdvanced { feat_id }
644 | Self::ForceFocusMastery { feat_id }
645 | Self::ForceFocusAlter { feat_id }
646 | Self::ForceFocusControl { feat_id }
647 | Self::ForceJump { feat_id }
648 | Self::ForceJumpAdvanced { feat_id }
649 | Self::ForceJumpMastery { feat_id }
650 | Self::ForceImmunityFear { feat_id }
651 | Self::ForceImmunityStun { feat_id }
652 | Self::ForceImmunityParalysis { feat_id }
653 | Self::BattleMeditation { feat_id }
654 | Self::ForceCamoflage { feat_id }
655 | Self::SneakAttack1D6 { feat_id }
656 | Self::SneakAttack2D6 { feat_id }
657 | Self::SneakAttack3D6 { feat_id }
658 | Self::SneakAttack4D6 { feat_id }
659 | Self::SneakAttack5D6 { feat_id }
660 | Self::SneakAttack6D6 { feat_id }
661 | Self::UncannyDodge1 { feat_id }
662 | Self::UncannyDodge2 { feat_id }
663 | Self::Toughness { feat_id }
664 | Self::ImprovedToughness { feat_id }
665 | Self::MasterToughness { feat_id }
666 | Self::Conditioning { feat_id }
667 | Self::ImprovedConditioning { feat_id }
668 | Self::MasterConditioning { feat_id }
669 | Self::ImplantLevel1 { feat_id }
670 | Self::ImplantLevel2 { feat_id }
671 | Self::ImplantLevel3 { feat_id }
672 | Self::DroidUpgrade1 { feat_id }
673 | Self::DroidUpgrade2 { feat_id }
674 | Self::DroidUpgrade3 { feat_id }
675 | Self::BlasterIntegration { feat_id }
676 | Self::LogicUpgradeCombat { feat_id }
677 | Self::LogicUpgradeTactician { feat_id }
678 | Self::Dueling { feat_id }
679 | Self::AdvancedDueling { feat_id }
680 | Self::MasterDueling { feat_id }
681 | Self::JediSense { feat_id }
682 | Self::KnightSense { feat_id }
683 | Self::MasterSense { feat_id }
684 | Self::GuardStance { feat_id }
685 | Self::AdvancedGuardStance { feat_id }
686 | Self::MasterGuardStance { feat_id }
687 | Self::SkillFocusAwareness { feat_id }
688 | Self::SkillFocusTreatInjury { feat_id }
689 | Self::SkillFocusStealth { feat_id }
690 | Self::Cautious { feat_id }
691 | Self::Perceptive { feat_id }
692 | Self::Empathy { feat_id }
693 | Self::GearHead { feat_id }
694 | Self::WookieEndurance { feat_id }
695 | Self::ScoundrelsLuck { feat_id }
696 | Self::Unknown { feat_id, .. } => *feat_id,
697 }
698 }
699}
700
701fn dispatch_feat(feat_id: u16, feat_table: Option<&TwoDa>) -> DecodedFeat {
711 let label = feat_table.and_then(|table| {
712 let row = usize::from(feat_id);
713 if row >= table.rows.len() {
714 return None;
715 }
716 table.cell(row, "label").map(str::to_string)
717 });
718 match label.as_deref() {
719 Some("ARMOUR_PROF_LIGHT") => DecodedFeat::ArmourProfLight { feat_id },
721 Some("ARMOUR_PROF_MEDIUM") => DecodedFeat::ArmourProfMedium { feat_id },
722 Some("ARMOUR_PROF_HEAVY") => DecodedFeat::ArmourProfHeavy { feat_id },
723 Some("WEAPON_PROF_BLASTER") => DecodedFeat::WeaponProfBlaster { feat_id },
726 Some("WEAPON_PROF_BLASTER_RIFLE") => DecodedFeat::WeaponProfBlasterRifle { feat_id },
727 Some("WEAPON_PROF_HEAVY_WEAPONS") => DecodedFeat::WeaponProfHeavyWeapons { feat_id },
728 Some("WEAPON_PROF_MELEE_WEAPONS") => DecodedFeat::WeaponProfMeleeWeapons { feat_id },
729 Some("WEAPON_PROF_LIGHTSABER") => DecodedFeat::WeaponProfLightsaber { feat_id },
730 Some("XXXX_WEAPON_PROF_GRENADE") => DecodedFeat::WeaponProfGrenade { feat_id },
731 Some("XXXX_WEAPON_PROF_SIMPLE_WEAPONS") => DecodedFeat::WeaponProfSimpleWeapons { feat_id },
732 Some("PROFICIENCY_ALL") => DecodedFeat::ProficiencyAll { feat_id },
734 Some("WEAPON_FOCUS_BLASTER") => DecodedFeat::WeaponFocusBlaster { feat_id },
736 Some("WEAPON_FOCUS_BLASTER_RIFLE") => DecodedFeat::WeaponFocusBlasterRifle { feat_id },
737 Some("XXXX_WEAPON_FOCUS_GRENADE") => DecodedFeat::WeaponFocusGrenade { feat_id },
738 Some("WEAPON_FOCUS_HEAVY_WEAPONS") => DecodedFeat::WeaponFocusHeavyWeapons { feat_id },
739 Some("WEAPON_FOCUS_LIGHTSABER") => DecodedFeat::WeaponFocusLightsaber { feat_id },
740 Some("WEAPON_FOCUS_MELEE_WEAPONS") => DecodedFeat::WeaponFocusMeleeWeapons { feat_id },
741 Some("XXXX_WEAPON_FOCUS_SIMPLE_WEAPONS") => {
742 DecodedFeat::WeaponFocusSimpleWeapons { feat_id }
743 }
744 Some("WEAPON_SPEC_BLASTER") => DecodedFeat::WeaponSpecBlaster { feat_id },
746 Some("WEAPON_SPEC_BLASTER_RIFLE") => DecodedFeat::WeaponSpecBlasterRifle { feat_id },
747 Some("XXXX_WEAPON_SPEC_GRENADE") => DecodedFeat::WeaponSpecGrenade { feat_id },
748 Some("WEAPON_SPEC_HEAVY_WEAPONS") => DecodedFeat::WeaponSpecHeavyWeapons { feat_id },
749 Some("WEAPON_SPEC_LIGHTSABER") => DecodedFeat::WeaponSpecLightsaber { feat_id },
750 Some("WEAPON_SPEC_MELEE_WEAPONS") => DecodedFeat::WeaponSpecMeleeWeapons { feat_id },
751 Some("XXXX_WEAPON_SPEC_SIMPLE_WEAPONS") => DecodedFeat::WeaponSpecSimpleWeapons { feat_id },
752 Some("POWER_ATTACK") => DecodedFeat::PowerAttack { feat_id },
754 Some("IMPROVED_POWER_ATTACK") => DecodedFeat::ImprovedPowerAttack { feat_id },
755 Some("MASTER_POWER_ATTACK") => DecodedFeat::MasterPowerAttack { feat_id },
756 Some("POWER_BLAST") => DecodedFeat::PowerBlast { feat_id },
758 Some("IMPROVED_POWER_BLAST") => DecodedFeat::ImprovedPowerBlast { feat_id },
759 Some("MASTER_POWER_BLAST") => DecodedFeat::MasterPowerBlast { feat_id },
760 Some("CRITICAL_STRIKE") => DecodedFeat::CriticalStrike { feat_id },
762 Some("IMPROVED_CRITICAL_STRIKE") => DecodedFeat::ImprovedCriticalStrike { feat_id },
763 Some("MASTER_CRITICAL_STRIKE") => DecodedFeat::MasterCriticalStrike { feat_id },
764 Some("FLURRY") => DecodedFeat::Flurry { feat_id },
766 Some("IMPROVED_FLURRY") => DecodedFeat::ImprovedFlurry { feat_id },
767 Some("SNIPER_SHOT") => DecodedFeat::SniperShot { feat_id },
769 Some("IMPROVED_SNIPER_SHOT") => DecodedFeat::ImprovedSniperShot { feat_id },
770 Some("MASTER_SNIPER_SHOT") => DecodedFeat::MasterSniperShot { feat_id },
771 Some("RAPID_SHOT") => DecodedFeat::RapidShot { feat_id },
773 Some("IMPROVED_RAPID_SHOT") => DecodedFeat::ImprovedRapidShot { feat_id },
774 Some("MULTI_SHOT") => DecodedFeat::MultiShot { feat_id },
776 Some("WHIRLWIND_ATTACK") => DecodedFeat::WhirlwindAttack { feat_id },
777 Some("TWO_WEAPON_FIGHTING") => DecodedFeat::TwoWeaponFighting { feat_id },
779 Some("TWO_WEAPON_ADVANCED") => DecodedFeat::TwoWeaponAdvanced { feat_id },
780 Some("TWO_WEAPON_MASTERY") => DecodedFeat::TwoWeaponMastery { feat_id },
781 Some("JEDI_DEFENSE") => DecodedFeat::JediDefense { feat_id },
783 Some("ADVANCED_JEDI_DEFENSE") => DecodedFeat::AdvancedJediDefense { feat_id },
784 Some("MASTER_JEDI_DEFENSE") => DecodedFeat::MasterJediDefense { feat_id },
785 Some("FORCE_FOCUS") => DecodedFeat::ForceFocus { feat_id },
787 Some("FORCE_FOCUS_ADVANCED") => DecodedFeat::ForceFocusAdvanced { feat_id },
788 Some("FORCE_FOCUS_MASTERY") => DecodedFeat::ForceFocusMastery { feat_id },
789 Some("XXXX_FORCE_FOCUS_ALTER") => DecodedFeat::ForceFocusAlter { feat_id },
790 Some("XXXX_FORCE_FOCUS_CONTROL") => DecodedFeat::ForceFocusControl { feat_id },
791 Some("FORCE_JUMP") => DecodedFeat::ForceJump { feat_id },
793 Some("FORCE_JUMP_ADVANCED") => DecodedFeat::ForceJumpAdvanced { feat_id },
794 Some("FORCE_JUMP_MASTERY") => DecodedFeat::ForceJumpMastery { feat_id },
795 Some("FORCE_IMMUNITY_FEAR") => DecodedFeat::ForceImmunityFear { feat_id },
797 Some("FORCE_IMMUNITY_STUN") => DecodedFeat::ForceImmunityStun { feat_id },
798 Some("FORCE_IMMUNITY_PARALYSIS") => DecodedFeat::ForceImmunityParalysis { feat_id },
799 Some("BATTLE_MEDITATION") => DecodedFeat::BattleMeditation { feat_id },
801 Some("FORCE_CAMOFLAGE") => DecodedFeat::ForceCamoflage { feat_id },
803 Some("SNEAK_ATTACK_1D6") => DecodedFeat::SneakAttack1D6 { feat_id },
807 Some("SNEAK_ATTACK_2D6") => DecodedFeat::SneakAttack2D6 { feat_id },
808 Some("SNEAK_ATTACK_3D6") => DecodedFeat::SneakAttack3D6 { feat_id },
809 Some("SNEAK_ATTACK_4D6") => DecodedFeat::SneakAttack4D6 { feat_id },
810 Some("SNEAK_ATTACK_5D6") => DecodedFeat::SneakAttack5D6 { feat_id },
811 Some("SNEAK_ATTACK_6D6") => DecodedFeat::SneakAttack6D6 { feat_id },
812 Some("UNCANNY_DODGE_1") => DecodedFeat::UncannyDodge1 { feat_id },
814 Some("UNCANNY_DODGE_2") => DecodedFeat::UncannyDodge2 { feat_id },
815 Some("TOUGHNESS") => DecodedFeat::Toughness { feat_id },
817 Some("IMPROVED_TOUGHNESS") => DecodedFeat::ImprovedToughness { feat_id },
818 Some("MASTER_TOUGHNESS") => DecodedFeat::MasterToughness { feat_id },
819 Some("CONDITIONING") => DecodedFeat::Conditioning { feat_id },
821 Some("IMPROVED_CONDITIONING") => DecodedFeat::ImprovedConditioning { feat_id },
822 Some("MASTER_CONDITIONING") => DecodedFeat::MasterConditioning { feat_id },
823 Some("IMPLANT_LEVEL_1") => DecodedFeat::ImplantLevel1 { feat_id },
825 Some("IMPLANT_LEVEL_2") => DecodedFeat::ImplantLevel2 { feat_id },
826 Some("IMPLANT_LEVEL_3") => DecodedFeat::ImplantLevel3 { feat_id },
827 Some("DROID_UPGRADE_1") => DecodedFeat::DroidUpgrade1 { feat_id },
829 Some("DROID_UPGRADE_2") => DecodedFeat::DroidUpgrade2 { feat_id },
830 Some("DROID_UPGRADE_3") => DecodedFeat::DroidUpgrade3 { feat_id },
831 Some("BLASTER_INTEGRATION") => DecodedFeat::BlasterIntegration { feat_id },
832 Some("LOGIC_UPGRADE_COMBAT") => DecodedFeat::LogicUpgradeCombat { feat_id },
833 Some("LOGIC_UPGRADE_TACTICIAN") => DecodedFeat::LogicUpgradeTactician { feat_id },
834 Some("DUELING") => DecodedFeat::Dueling { feat_id },
836 Some("ADVANCED_DUELING") => DecodedFeat::AdvancedDueling { feat_id },
837 Some("MASTER_DUELING") => DecodedFeat::MasterDueling { feat_id },
838 Some("JEDI_SENSE") => DecodedFeat::JediSense { feat_id },
840 Some("KNIGHT_SENSE") => DecodedFeat::KnightSense { feat_id },
841 Some("MASTER_SENSE") => DecodedFeat::MasterSense { feat_id },
842 Some("XXXX_GUARD_STANCE") => DecodedFeat::GuardStance { feat_id },
844 Some("XXXX_ADVANCED_GUARD_STANCE") => DecodedFeat::AdvancedGuardStance { feat_id },
845 Some("XXXX_MASTER_GUARD_STANCE") => DecodedFeat::MasterGuardStance { feat_id },
846 Some("XXXX_SKILL_FOCUS_AWARENESS") => DecodedFeat::SkillFocusAwareness { feat_id },
848 Some("XXXX_SKILL_FOCUS_TREAT_INJURY") => DecodedFeat::SkillFocusTreatInjury { feat_id },
849 Some("XXXX_SKILL_FOCUS_STEALTH") => DecodedFeat::SkillFocusStealth { feat_id },
850 Some("CAUTIOUS") => DecodedFeat::Cautious { feat_id },
853 Some("XXXX_PERCEPTIVE") => DecodedFeat::Perceptive { feat_id },
854 Some("EMPATHY") => DecodedFeat::Empathy { feat_id },
855 Some("GEAR_HEAD") => DecodedFeat::GearHead { feat_id },
856 Some("WOOKIE_ENDURANCE") => DecodedFeat::WookieEndurance { feat_id },
857 Some("SCOUNDRELS_LUCK") => DecodedFeat::ScoundrelsLuck { feat_id },
858 _ => DecodedFeat::Unknown {
862 feat_id,
863 feat_label: label,
864 },
865 }
866}
867
868#[derive(Debug, Clone)]
879pub struct UtcProjection<'a> {
880 utc: &'a Utc,
881}
882
883#[derive(Debug)]
895pub struct UtcResolved<'a> {
896 utc: &'a Utc,
897 race_info: Option<RaceInfo>,
898 appearance_info: Option<AppearanceInfo>,
899 portrait_info: Option<PortraitInfo>,
900 soundset_info: Option<SoundsetInfo>,
901 decoded_classes: Vec<DecodedClass>,
902 decoded_special_abilities: Vec<DecodedSpecialAbility>,
903 decoded_feats: Vec<DecodedFeat>,
904}
905
906#[derive(Debug, Clone, PartialEq, Eq)]
925pub enum DecodedClass {
926 Soldier {
928 class_id: i32,
930 level: i16,
933 powers: Vec<u16>,
935 },
936 Scout {
938 class_id: i32,
940 level: i16,
942 powers: Vec<u16>,
944 },
945 Scoundrel {
947 class_id: i32,
949 level: i16,
951 powers: Vec<u16>,
953 },
954 JediGuardian {
956 class_id: i32,
958 level: i16,
960 powers: Vec<u16>,
962 },
963 JediConsular {
965 class_id: i32,
967 level: i16,
969 powers: Vec<u16>,
971 },
972 JediSentinel {
974 class_id: i32,
976 level: i16,
978 powers: Vec<u16>,
980 },
981 CombatDroid {
983 class_id: i32,
985 level: i16,
987 powers: Vec<u16>,
989 },
990 ExpertDroid {
992 class_id: i32,
994 level: i16,
996 powers: Vec<u16>,
998 },
999 Minion {
1001 class_id: i32,
1003 level: i16,
1005 powers: Vec<u16>,
1007 },
1008 Unknown {
1013 class_id: i32,
1015 class_label: Option<String>,
1019 level: i16,
1021 powers: Vec<u16>,
1023 },
1024}
1025
1026impl DecodedClass {
1027 pub fn class_id(&self) -> i32 {
1030 match self {
1031 Self::Soldier { class_id, .. }
1032 | Self::Scout { class_id, .. }
1033 | Self::Scoundrel { class_id, .. }
1034 | Self::JediGuardian { class_id, .. }
1035 | Self::JediConsular { class_id, .. }
1036 | Self::JediSentinel { class_id, .. }
1037 | Self::CombatDroid { class_id, .. }
1038 | Self::ExpertDroid { class_id, .. }
1039 | Self::Minion { class_id, .. }
1040 | Self::Unknown { class_id, .. } => *class_id,
1041 }
1042 }
1043
1044 pub fn level(&self) -> i16 {
1046 match self {
1047 Self::Soldier { level, .. }
1048 | Self::Scout { level, .. }
1049 | Self::Scoundrel { level, .. }
1050 | Self::JediGuardian { level, .. }
1051 | Self::JediConsular { level, .. }
1052 | Self::JediSentinel { level, .. }
1053 | Self::CombatDroid { level, .. }
1054 | Self::ExpertDroid { level, .. }
1055 | Self::Minion { level, .. }
1056 | Self::Unknown { level, .. } => *level,
1057 }
1058 }
1059
1060 pub fn powers(&self) -> &[u16] {
1063 match self {
1064 Self::Soldier { powers, .. }
1065 | Self::Scout { powers, .. }
1066 | Self::Scoundrel { powers, .. }
1067 | Self::JediGuardian { powers, .. }
1068 | Self::JediConsular { powers, .. }
1069 | Self::JediSentinel { powers, .. }
1070 | Self::CombatDroid { powers, .. }
1071 | Self::ExpertDroid { powers, .. }
1072 | Self::Minion { powers, .. }
1073 | Self::Unknown { powers, .. } => powers,
1074 }
1075 }
1076}
1077
1078#[derive(Debug, Clone, Default)]
1082struct RaceInfo {
1083 label: Option<String>,
1084}
1085
1086#[derive(Debug, Clone, Default)]
1088struct AppearanceInfo {
1089 label: Option<String>,
1090}
1091
1092#[derive(Debug, Clone, Default)]
1094struct PortraitInfo {
1095 label: Option<String>,
1096}
1097
1098#[derive(Debug, Clone, Default)]
1100struct SoundsetInfo {
1101 label: Option<String>,
1102}
1103
1104impl RaceInfo {
1105 fn from_row(table: &TwoDa, race_id: u8) -> Option<Self> {
1106 let row = usize::from(race_id);
1107 if row >= table.rows.len() {
1108 return None;
1109 }
1110 Some(Self {
1111 label: table.cell(row, "label").map(str::to_string),
1112 })
1113 }
1114}
1115
1116impl AppearanceInfo {
1117 fn from_row(table: &TwoDa, appearance_id: u16) -> Option<Self> {
1118 let row = usize::from(appearance_id);
1119 if row >= table.rows.len() {
1120 return None;
1121 }
1122 Some(Self {
1123 label: table.cell(row, "label").map(str::to_string),
1124 })
1125 }
1126}
1127
1128impl PortraitInfo {
1129 fn from_row(table: &TwoDa, portrait_id: u16) -> Option<Self> {
1130 let row = usize::from(portrait_id);
1131 if row >= table.rows.len() {
1132 return None;
1133 }
1134 Some(Self {
1135 label: table.cell(row, "label").map(str::to_string),
1136 })
1137 }
1138}
1139
1140impl SoundsetInfo {
1141 fn from_row(table: &TwoDa, soundset_id: u16) -> Option<Self> {
1142 let row = usize::from(soundset_id);
1143 if row >= table.rows.len() {
1144 return None;
1145 }
1146 Some(Self {
1147 label: table.cell(row, "label").map(str::to_string),
1148 })
1149 }
1150}
1151
1152#[derive(Debug, Clone, PartialEq, Eq)]
1171pub enum DecodedSpecialAbility {
1172 BodyFuel {
1175 spell_id: u16,
1177 flags: u8,
1179 caster_level: u8,
1181 },
1182 Rage {
1184 spell_id: u16,
1186 flags: u8,
1188 caster_level: u8,
1190 },
1191 MonsterSlamAttack {
1194 spell_id: u16,
1196 flags: u8,
1198 caster_level: u8,
1200 },
1201 Unknown {
1206 spell_id: u16,
1208 spell_label: Option<String>,
1212 flags: u8,
1214 caster_level: u8,
1216 },
1217}
1218
1219impl DecodedSpecialAbility {
1220 pub fn spell_id(&self) -> u16 {
1222 match self {
1223 Self::BodyFuel { spell_id, .. }
1224 | Self::Rage { spell_id, .. }
1225 | Self::MonsterSlamAttack { spell_id, .. }
1226 | Self::Unknown { spell_id, .. } => *spell_id,
1227 }
1228 }
1229
1230 pub fn flags(&self) -> u8 {
1232 match self {
1233 Self::BodyFuel { flags, .. }
1234 | Self::Rage { flags, .. }
1235 | Self::MonsterSlamAttack { flags, .. }
1236 | Self::Unknown { flags, .. } => *flags,
1237 }
1238 }
1239
1240 pub fn caster_level(&self) -> u8 {
1242 match self {
1243 Self::BodyFuel { caster_level, .. }
1244 | Self::Rage { caster_level, .. }
1245 | Self::MonsterSlamAttack { caster_level, .. }
1246 | Self::Unknown { caster_level, .. } => *caster_level,
1247 }
1248 }
1249}
1250
1251fn dispatch_special_ability(
1256 ability: &UtcSpecialAbility,
1257 spells_table: Option<&TwoDa>,
1258) -> DecodedSpecialAbility {
1259 let label = spells_table.and_then(|table| {
1260 let row = usize::from(ability.spell_id);
1261 if row >= table.rows.len() {
1262 return None;
1263 }
1264 table.cell(row, "label").map(str::to_string)
1265 });
1266 let spell_id = ability.spell_id;
1267 let flags = ability.spell_flags;
1268 let caster_level = ability.spell_caster_level;
1269 match label.as_deref() {
1270 Some("SPECIAL_ABILITY_BODY_FUEL") => DecodedSpecialAbility::BodyFuel {
1271 spell_id,
1272 flags,
1273 caster_level,
1274 },
1275 Some("SPECIAL_ABILITY_RAGE") => DecodedSpecialAbility::Rage {
1276 spell_id,
1277 flags,
1278 caster_level,
1279 },
1280 Some("MONSTER_ABILITY_SLAM_ATTACK") => DecodedSpecialAbility::MonsterSlamAttack {
1281 spell_id,
1282 flags,
1283 caster_level,
1284 },
1285 _ => DecodedSpecialAbility::Unknown {
1286 spell_id,
1287 spell_label: label,
1288 flags,
1289 caster_level,
1290 },
1291 }
1292}
1293
1294fn dispatch_class(class: &UtcClass, classes_table: Option<&TwoDa>) -> DecodedClass {
1304 let label = classes_table.and_then(|table| {
1305 let row = usize::try_from(class.class_id).ok()?;
1306 if row >= table.rows.len() {
1307 return None;
1308 }
1309 table.cell(row, "label").map(str::to_string)
1310 });
1311 let class_id = class.class_id;
1312 let level = class.class_level;
1313 let powers = class.powers.clone();
1314 match label.as_deref() {
1315 Some("Soldier") => DecodedClass::Soldier {
1316 class_id,
1317 level,
1318 powers,
1319 },
1320 Some("Scout") => DecodedClass::Scout {
1321 class_id,
1322 level,
1323 powers,
1324 },
1325 Some("Scoundrel") => DecodedClass::Scoundrel {
1326 class_id,
1327 level,
1328 powers,
1329 },
1330 Some("JediGuardian") => DecodedClass::JediGuardian {
1331 class_id,
1332 level,
1333 powers,
1334 },
1335 Some("JediConsular") => DecodedClass::JediConsular {
1336 class_id,
1337 level,
1338 powers,
1339 },
1340 Some("JediSentinel") => DecodedClass::JediSentinel {
1341 class_id,
1342 level,
1343 powers,
1344 },
1345 Some("CombatDroid") => DecodedClass::CombatDroid {
1346 class_id,
1347 level,
1348 powers,
1349 },
1350 Some("ExpertDroid") => DecodedClass::ExpertDroid {
1351 class_id,
1352 level,
1353 powers,
1354 },
1355 Some("Minion") => DecodedClass::Minion {
1356 class_id,
1357 level,
1358 powers,
1359 },
1360 _ => DecodedClass::Unknown {
1365 class_id,
1366 class_label: label,
1367 level,
1368 powers,
1369 },
1370 }
1371}
1372
1373impl<'a> UtcProjection<'a> {
1374 pub fn as_utc(&self) -> &'a Utc {
1376 self.utc
1377 }
1378
1379 pub fn resolve(&self, cache: &mut impl TwoDaSource) -> UtcResolved<'a> {
1391 let race_info = cache
1392 .twoda(tables::RACIALTYPES)
1393 .and_then(|table| RaceInfo::from_row(table, self.utc.race_id));
1394 let appearance_info = cache
1395 .twoda(tables::APPEARANCE)
1396 .and_then(|table| AppearanceInfo::from_row(table, self.utc.appearance_id));
1397 let portrait_info = cache
1398 .twoda(tables::PORTRAITS)
1399 .and_then(|table| PortraitInfo::from_row(table, self.utc.portrait_id));
1400 let soundset_info = cache
1401 .twoda("soundset")
1402 .and_then(|table| SoundsetInfo::from_row(table, self.utc.soundset_id));
1403 let decoded_classes = {
1404 let classes_table = cache.twoda(tables::CLASSES);
1405 self.utc
1406 .classes
1407 .iter()
1408 .map(|class| dispatch_class(class, classes_table))
1409 .collect()
1410 };
1411 let decoded_special_abilities = {
1412 let spells_table = cache.twoda("spells");
1413 self.utc
1414 .special_abilities
1415 .iter()
1416 .map(|ability| dispatch_special_ability(ability, spells_table))
1417 .collect()
1418 };
1419 let decoded_feats = {
1420 let feat_table = cache.twoda(tables::FEAT);
1421 self.utc
1422 .feats
1423 .iter()
1424 .map(|feat_id| dispatch_feat(*feat_id, feat_table))
1425 .collect()
1426 };
1427 UtcResolved {
1428 utc: self.utc,
1429 race_info,
1430 appearance_info,
1431 portrait_info,
1432 soundset_info,
1433 decoded_classes,
1434 decoded_special_abilities,
1435 decoded_feats,
1436 }
1437 }
1438}
1439
1440impl<'a> UtcResolved<'a> {
1441 pub fn as_utc(&self) -> &'a Utc {
1443 self.utc
1444 }
1445
1446 pub fn race_label(&self) -> Option<&str> {
1452 self.race_info.as_ref()?.label.as_deref()
1453 }
1454
1455 pub fn appearance_label(&self) -> Option<&str> {
1462 self.appearance_info.as_ref()?.label.as_deref()
1463 }
1464
1465 pub fn portrait_label(&self) -> Option<&str> {
1472 self.portrait_info.as_ref()?.label.as_deref()
1473 }
1474
1475 pub fn soundset_label(&self) -> Option<&str> {
1482 self.soundset_info.as_ref()?.label.as_deref()
1483 }
1484
1485 pub fn alignment(&self) -> u8 {
1493 self.utc.alignment.min(100)
1494 }
1495
1496 pub fn classes(&self) -> &[DecodedClass] {
1505 &self.decoded_classes
1506 }
1507
1508 pub fn total_level(&self) -> i32 {
1515 self.decoded_classes
1516 .iter()
1517 .map(|class| i32::from(class.level()))
1518 .sum()
1519 }
1520
1521 pub fn has_class(&self, filter: ClassKindFilter) -> bool {
1528 self.decoded_classes
1529 .iter()
1530 .any(|class| filter.matches(class))
1531 }
1532
1533 pub fn is_force_user(&self) -> bool {
1538 self.has_class(ClassKindFilter::AnyJedi)
1539 }
1540
1541 pub fn is_droid(&self) -> bool {
1545 self.has_class(ClassKindFilter::AnyDroid)
1546 }
1547
1548 pub fn feats(&self) -> &[DecodedFeat] {
1558 &self.decoded_feats
1559 }
1560
1561 pub fn has_feat(&self, filter: FeatKindFilter) -> bool {
1568 self.decoded_feats.iter().any(|feat| filter.matches(feat))
1569 }
1570
1571 pub fn special_abilities(&self) -> &[DecodedSpecialAbility] {
1582 &self.decoded_special_abilities
1583 }
1584
1585 pub fn equipment(&self) -> &[UtcEquipmentItem] {
1599 &self.utc.equipment
1600 }
1601
1602 pub fn inventory(&self) -> &[UtcInventoryItem] {
1612 &self.utc.inventory
1613 }
1614}
1615
1616#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash)]
1628pub enum ClassKindFilter {
1629 Soldier,
1631 Scout,
1633 Scoundrel,
1635 JediGuardian,
1637 JediConsular,
1639 JediSentinel,
1641 CombatDroid,
1643 ExpertDroid,
1645 Minion,
1647 AnyJedi,
1649 AnyDroid,
1651}
1652
1653impl ClassKindFilter {
1654 fn matches(self, class: &DecodedClass) -> bool {
1655 match self {
1656 Self::Soldier => matches!(class, DecodedClass::Soldier { .. }),
1657 Self::Scout => matches!(class, DecodedClass::Scout { .. }),
1658 Self::Scoundrel => matches!(class, DecodedClass::Scoundrel { .. }),
1659 Self::JediGuardian => matches!(class, DecodedClass::JediGuardian { .. }),
1660 Self::JediConsular => matches!(class, DecodedClass::JediConsular { .. }),
1661 Self::JediSentinel => matches!(class, DecodedClass::JediSentinel { .. }),
1662 Self::CombatDroid => matches!(class, DecodedClass::CombatDroid { .. }),
1663 Self::ExpertDroid => matches!(class, DecodedClass::ExpertDroid { .. }),
1664 Self::Minion => matches!(class, DecodedClass::Minion { .. }),
1665 Self::AnyJedi => matches!(
1666 class,
1667 DecodedClass::JediGuardian { .. }
1668 | DecodedClass::JediConsular { .. }
1669 | DecodedClass::JediSentinel { .. }
1670 ),
1671 Self::AnyDroid => matches!(
1672 class,
1673 DecodedClass::CombatDroid { .. } | DecodedClass::ExpertDroid { .. }
1674 ),
1675 }
1676 }
1677}
1678
1679#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash)]
1691pub enum FeatKindFilter {
1692 AnyArmourProficiency,
1694 AnyWeaponProficiency,
1697 AnyWeaponFocus,
1699 AnyWeaponSpecialization,
1701 AnyPowerAttack,
1703 AnyPowerBlast,
1705 AnyCriticalStrike,
1708 AnyFlurry,
1710 AnySniperShot,
1712 AnyRapidShot,
1714 AnyTwoWeapon,
1716 AnyJediDefense,
1718 AnyForceFocus,
1720 AnyForceJump,
1722 AnyForceImmunity,
1724 AnySneakAttack,
1726 AnyUncannyDodge,
1728 AnyToughness,
1730 AnyConditioning,
1732 AnyImplant,
1734 AnyDroidUpgrade,
1736 AnyDueling,
1738 AnySense,
1740 AnyGuardStance,
1743 AnySkillFocus,
1746}
1747
1748impl FeatKindFilter {
1749 fn matches(self, feat: &DecodedFeat) -> bool {
1750 match self {
1751 Self::AnyArmourProficiency => matches!(
1752 feat,
1753 DecodedFeat::ArmourProfLight { .. }
1754 | DecodedFeat::ArmourProfMedium { .. }
1755 | DecodedFeat::ArmourProfHeavy { .. }
1756 ),
1757 Self::AnyWeaponProficiency => matches!(
1758 feat,
1759 DecodedFeat::WeaponProfBlaster { .. }
1760 | DecodedFeat::WeaponProfBlasterRifle { .. }
1761 | DecodedFeat::WeaponProfHeavyWeapons { .. }
1762 | DecodedFeat::WeaponProfMeleeWeapons { .. }
1763 | DecodedFeat::WeaponProfLightsaber { .. }
1764 | DecodedFeat::WeaponProfGrenade { .. }
1765 | DecodedFeat::WeaponProfSimpleWeapons { .. }
1766 | DecodedFeat::ProficiencyAll { .. }
1767 ),
1768 Self::AnyWeaponFocus => matches!(
1769 feat,
1770 DecodedFeat::WeaponFocusBlaster { .. }
1771 | DecodedFeat::WeaponFocusBlasterRifle { .. }
1772 | DecodedFeat::WeaponFocusGrenade { .. }
1773 | DecodedFeat::WeaponFocusHeavyWeapons { .. }
1774 | DecodedFeat::WeaponFocusLightsaber { .. }
1775 | DecodedFeat::WeaponFocusMeleeWeapons { .. }
1776 | DecodedFeat::WeaponFocusSimpleWeapons { .. }
1777 ),
1778 Self::AnyWeaponSpecialization => matches!(
1779 feat,
1780 DecodedFeat::WeaponSpecBlaster { .. }
1781 | DecodedFeat::WeaponSpecBlasterRifle { .. }
1782 | DecodedFeat::WeaponSpecGrenade { .. }
1783 | DecodedFeat::WeaponSpecHeavyWeapons { .. }
1784 | DecodedFeat::WeaponSpecLightsaber { .. }
1785 | DecodedFeat::WeaponSpecMeleeWeapons { .. }
1786 | DecodedFeat::WeaponSpecSimpleWeapons { .. }
1787 ),
1788 Self::AnyPowerAttack => matches!(
1789 feat,
1790 DecodedFeat::PowerAttack { .. }
1791 | DecodedFeat::ImprovedPowerAttack { .. }
1792 | DecodedFeat::MasterPowerAttack { .. }
1793 ),
1794 Self::AnyPowerBlast => matches!(
1795 feat,
1796 DecodedFeat::PowerBlast { .. }
1797 | DecodedFeat::ImprovedPowerBlast { .. }
1798 | DecodedFeat::MasterPowerBlast { .. }
1799 ),
1800 Self::AnyCriticalStrike => matches!(
1801 feat,
1802 DecodedFeat::CriticalStrike { .. }
1803 | DecodedFeat::ImprovedCriticalStrike { .. }
1804 | DecodedFeat::MasterCriticalStrike { .. }
1805 ),
1806 Self::AnyFlurry => {
1807 matches!(
1808 feat,
1809 DecodedFeat::Flurry { .. } | DecodedFeat::ImprovedFlurry { .. }
1810 )
1811 }
1812 Self::AnySniperShot => matches!(
1813 feat,
1814 DecodedFeat::SniperShot { .. }
1815 | DecodedFeat::ImprovedSniperShot { .. }
1816 | DecodedFeat::MasterSniperShot { .. }
1817 ),
1818 Self::AnyRapidShot => matches!(
1819 feat,
1820 DecodedFeat::RapidShot { .. } | DecodedFeat::ImprovedRapidShot { .. }
1821 ),
1822 Self::AnyTwoWeapon => matches!(
1823 feat,
1824 DecodedFeat::TwoWeaponFighting { .. }
1825 | DecodedFeat::TwoWeaponAdvanced { .. }
1826 | DecodedFeat::TwoWeaponMastery { .. }
1827 ),
1828 Self::AnyJediDefense => matches!(
1829 feat,
1830 DecodedFeat::JediDefense { .. }
1831 | DecodedFeat::AdvancedJediDefense { .. }
1832 | DecodedFeat::MasterJediDefense { .. }
1833 ),
1834 Self::AnyForceFocus => matches!(
1835 feat,
1836 DecodedFeat::ForceFocus { .. }
1837 | DecodedFeat::ForceFocusAdvanced { .. }
1838 | DecodedFeat::ForceFocusMastery { .. }
1839 | DecodedFeat::ForceFocusAlter { .. }
1840 | DecodedFeat::ForceFocusControl { .. }
1841 ),
1842 Self::AnyForceJump => matches!(
1843 feat,
1844 DecodedFeat::ForceJump { .. }
1845 | DecodedFeat::ForceJumpAdvanced { .. }
1846 | DecodedFeat::ForceJumpMastery { .. }
1847 ),
1848 Self::AnyForceImmunity => matches!(
1849 feat,
1850 DecodedFeat::ForceImmunityFear { .. }
1851 | DecodedFeat::ForceImmunityStun { .. }
1852 | DecodedFeat::ForceImmunityParalysis { .. }
1853 ),
1854 Self::AnySneakAttack => matches!(
1855 feat,
1856 DecodedFeat::SneakAttack1D6 { .. }
1857 | DecodedFeat::SneakAttack2D6 { .. }
1858 | DecodedFeat::SneakAttack3D6 { .. }
1859 | DecodedFeat::SneakAttack4D6 { .. }
1860 | DecodedFeat::SneakAttack5D6 { .. }
1861 | DecodedFeat::SneakAttack6D6 { .. }
1862 ),
1863 Self::AnyUncannyDodge => matches!(
1864 feat,
1865 DecodedFeat::UncannyDodge1 { .. } | DecodedFeat::UncannyDodge2 { .. }
1866 ),
1867 Self::AnyToughness => matches!(
1868 feat,
1869 DecodedFeat::Toughness { .. }
1870 | DecodedFeat::ImprovedToughness { .. }
1871 | DecodedFeat::MasterToughness { .. }
1872 ),
1873 Self::AnyConditioning => matches!(
1874 feat,
1875 DecodedFeat::Conditioning { .. }
1876 | DecodedFeat::ImprovedConditioning { .. }
1877 | DecodedFeat::MasterConditioning { .. }
1878 ),
1879 Self::AnyImplant => matches!(
1880 feat,
1881 DecodedFeat::ImplantLevel1 { .. }
1882 | DecodedFeat::ImplantLevel2 { .. }
1883 | DecodedFeat::ImplantLevel3 { .. }
1884 ),
1885 Self::AnyDroidUpgrade => matches!(
1886 feat,
1887 DecodedFeat::DroidUpgrade1 { .. }
1888 | DecodedFeat::DroidUpgrade2 { .. }
1889 | DecodedFeat::DroidUpgrade3 { .. }
1890 ),
1891 Self::AnyDueling => matches!(
1892 feat,
1893 DecodedFeat::Dueling { .. }
1894 | DecodedFeat::AdvancedDueling { .. }
1895 | DecodedFeat::MasterDueling { .. }
1896 ),
1897 Self::AnySense => matches!(
1898 feat,
1899 DecodedFeat::JediSense { .. }
1900 | DecodedFeat::KnightSense { .. }
1901 | DecodedFeat::MasterSense { .. }
1902 ),
1903 Self::AnyGuardStance => matches!(
1904 feat,
1905 DecodedFeat::GuardStance { .. }
1906 | DecodedFeat::AdvancedGuardStance { .. }
1907 | DecodedFeat::MasterGuardStance { .. }
1908 ),
1909 Self::AnySkillFocus => matches!(
1910 feat,
1911 DecodedFeat::SkillFocusAwareness { .. }
1912 | DecodedFeat::SkillFocusTreatInjury { .. }
1913 | DecodedFeat::SkillFocusStealth { .. }
1914 ),
1915 }
1916 }
1917}
1918
1919impl Utc {
1920 pub fn project(&self) -> UtcProjection<'_> {
1929 UtcProjection { utc: self }
1930 }
1931
1932 pub fn resolve(&self, cache: &mut impl TwoDaSource) -> UtcResolved<'_> {
1940 self.project().resolve(cache)
1941 }
1942}
1943
1944#[cfg(test)]
1945mod tests {
1946 use super::*;
1947 use crate::decoded::test_tables::TestTables;
1948 use rakata_formats::twoda::TwoDaRow;
1949
1950 fn label_only_2da(rows: &[(usize, &str)]) -> TwoDa {
1951 let max_row = rows.iter().map(|(idx, _)| *idx).max().unwrap_or(0);
1952 let mut table_rows = Vec::with_capacity(max_row + 1);
1953 for row_index in 0..=max_row {
1954 let label = rows
1955 .iter()
1956 .find(|(idx, _)| *idx == row_index)
1957 .map(|(_, label)| (*label).to_string())
1958 .unwrap_or_default();
1959 table_rows.push(TwoDaRow {
1960 label: row_index.to_string(),
1961 cells: vec![label],
1962 });
1963 }
1964 TwoDa {
1965 headers: vec!["label".to_string()],
1966 rows: table_rows,
1967 }
1968 }
1969
1970 fn add_2da_entry(tables: &mut TestTables, name: &str, table: &TwoDa) {
1971 tables.insert(name, table);
1972 }
1973
1974 #[test]
1975 fn project_returns_projection_wrapping_source() {
1976 let utc = Utc::default();
1977 let projection = utc.project();
1978 assert!(std::ptr::eq(projection.as_utc(), &utc));
1979 }
1980
1981 #[test]
1982 fn resolve_returns_a_view_wrapping_the_source() {
1983 let utc = Utc::default();
1984 let mut tables = TestTables::new();
1985
1986 let resolved = utc.resolve(&mut tables);
1987 assert!(std::ptr::eq(resolved.as_utc(), &utc));
1988 }
1989
1990 #[test]
1991 fn sugar_resolve_matches_project_then_resolve() {
1992 let utc = Utc::default();
1993 let mut sugar_tables = TestTables::new();
1994 let mut explicit_tables = TestTables::new();
1995
1996 let sugar = utc.resolve(&mut sugar_tables);
1997 let explicit = utc.project().resolve(&mut explicit_tables);
1998
1999 assert!(std::ptr::eq(sugar.as_utc(), explicit.as_utc()));
2000 }
2001
2002 #[test]
2003 fn one_projection_feeds_independent_resolutions() {
2004 let utc = Utc::default();
2005 let projection = utc.project();
2006
2007 let mut tables_a = TestTables::new();
2008 let mut tables_b = TestTables::new();
2009
2010 let snap_a = projection.resolve(&mut tables_a);
2011 let snap_b = projection.resolve(&mut tables_b);
2012
2013 assert!(std::ptr::eq(snap_a.as_utc(), &utc));
2014 assert!(std::ptr::eq(snap_b.as_utc(), &utc));
2015 }
2016
2017 #[test]
2018 fn race_label_resolves_against_racialtypes_2da() {
2019 let utc = Utc {
2020 race_id: 6,
2021 ..Utc::default()
2022 };
2023 let racialtypes = label_only_2da(&[(6, "Human")]);
2024 let mut tables = TestTables::new();
2025 add_2da_entry(&mut tables, "racialtypes", &racialtypes);
2026
2027 let resolved = utc.resolve(&mut tables);
2028 assert_eq!(resolved.race_label(), Some("Human"));
2029 }
2030
2031 #[test]
2032 fn race_label_returns_none_when_race_id_is_out_of_range() {
2033 let utc = Utc {
2035 race_id: 99,
2036 ..Utc::default()
2037 };
2038 let racialtypes = label_only_2da(&[(0, "Human"), (1, "Droid")]);
2039 let mut tables = TestTables::new();
2040 add_2da_entry(&mut tables, "racialtypes", &racialtypes);
2041
2042 let resolved = utc.resolve(&mut tables);
2043 assert!(resolved.race_label().is_none());
2044 }
2045
2046 #[test]
2047 fn race_label_returns_none_when_racialtypes_unavailable() {
2048 let utc = Utc {
2049 race_id: 6,
2050 ..Utc::default()
2051 };
2052 let mut tables = TestTables::new();
2054
2055 let resolved = utc.resolve(&mut tables);
2056 assert!(resolved.race_label().is_none());
2057 }
2058
2059 #[test]
2060 fn appearance_portrait_soundset_labels_resolve_via_their_2das() {
2061 let utc = Utc {
2062 appearance_id: 12,
2063 portrait_id: 34,
2064 soundset_id: 56,
2065 ..Utc::default()
2066 };
2067 let appearance = label_only_2da(&[(12, "Male_Caucasian_Tough")]);
2068 let portraits = label_only_2da(&[(34, "po_pmhc01")]);
2069 let soundset = label_only_2da(&[(56, "n_default")]);
2070 let mut tables = TestTables::new();
2071 add_2da_entry(&mut tables, "appearance", &appearance);
2072 add_2da_entry(&mut tables, "portraits", &portraits);
2073 add_2da_entry(&mut tables, "soundset", &soundset);
2074
2075 let resolved = utc.resolve(&mut tables);
2076 assert_eq!(resolved.appearance_label(), Some("Male_Caucasian_Tough"));
2077 assert_eq!(resolved.portrait_label(), Some("po_pmhc01"));
2078 assert_eq!(resolved.soundset_label(), Some("n_default"));
2079 }
2080
2081 #[test]
2082 fn alignment_returns_raw_value_when_in_range() {
2083 let utc = Utc {
2084 alignment: 50,
2085 ..Utc::default()
2086 };
2087 let mut tables = TestTables::new();
2088
2089 let resolved = utc.resolve(&mut tables);
2090 assert_eq!(resolved.alignment(), 50);
2091 }
2092
2093 #[test]
2094 fn alignment_clamps_at_one_hundred_to_match_engine_load_behavior() {
2095 let utc = Utc {
2099 alignment: 200,
2100 ..Utc::default()
2101 };
2102 let mut tables = TestTables::new();
2103
2104 let resolved = utc.resolve(&mut tables);
2105 assert_eq!(resolved.alignment(), 100);
2106 }
2107
2108 fn class_entry(class_id: i32, level: i16, powers: Vec<u16>) -> UtcClass {
2111 UtcClass {
2112 class_id,
2113 class_level: level,
2114 powers,
2115 }
2116 }
2117
2118 #[test]
2119 fn classes_dispatch_every_vanilla_label_to_its_typed_variant() {
2120 let utc = Utc {
2123 classes: vec![
2124 class_entry(0, 1, vec![]),
2125 class_entry(1, 1, vec![]),
2126 class_entry(2, 1, vec![]),
2127 class_entry(3, 1, vec![]),
2128 class_entry(4, 1, vec![]),
2129 class_entry(5, 1, vec![]),
2130 class_entry(6, 1, vec![]),
2131 class_entry(7, 1, vec![]),
2132 class_entry(8, 1, vec![]),
2133 ],
2134 ..Utc::default()
2135 };
2136 let classes = label_only_2da(&[
2137 (0, "Soldier"),
2138 (1, "Scout"),
2139 (2, "Scoundrel"),
2140 (3, "JediGuardian"),
2141 (4, "JediConsular"),
2142 (5, "JediSentinel"),
2143 (6, "CombatDroid"),
2144 (7, "ExpertDroid"),
2145 (8, "Minion"),
2146 ]);
2147 let mut tables = TestTables::new();
2148 add_2da_entry(&mut tables, "classes", &classes);
2149
2150 let resolved = utc.resolve(&mut tables);
2151 let kinds: Vec<&str> = resolved
2152 .classes()
2153 .iter()
2154 .map(|class| match class {
2155 DecodedClass::Soldier { .. } => "Soldier",
2156 DecodedClass::Scout { .. } => "Scout",
2157 DecodedClass::Scoundrel { .. } => "Scoundrel",
2158 DecodedClass::JediGuardian { .. } => "JediGuardian",
2159 DecodedClass::JediConsular { .. } => "JediConsular",
2160 DecodedClass::JediSentinel { .. } => "JediSentinel",
2161 DecodedClass::CombatDroid { .. } => "CombatDroid",
2162 DecodedClass::ExpertDroid { .. } => "ExpertDroid",
2163 DecodedClass::Minion { .. } => "Minion",
2164 DecodedClass::Unknown { .. } => "<unknown>",
2165 })
2166 .collect();
2167 assert_eq!(
2168 kinds,
2169 vec![
2170 "Soldier",
2171 "Scout",
2172 "Scoundrel",
2173 "JediGuardian",
2174 "JediConsular",
2175 "JediSentinel",
2176 "CombatDroid",
2177 "ExpertDroid",
2178 "Minion",
2179 ]
2180 );
2181 }
2182
2183 #[test]
2184 fn classes_dispatch_by_label_so_mod_renumbered_class_still_routes() {
2185 let utc = Utc {
2189 classes: vec![class_entry(42, 5, vec![])],
2190 ..Utc::default()
2191 };
2192 let classes = label_only_2da(&[(42, "JediGuardian")]);
2193 let mut tables = TestTables::new();
2194 add_2da_entry(&mut tables, "classes", &classes);
2195
2196 let resolved = utc.resolve(&mut tables);
2197 match &resolved.classes()[0] {
2198 DecodedClass::JediGuardian {
2199 class_id, level, ..
2200 } => {
2201 assert_eq!(*class_id, 42);
2204 assert_eq!(*level, 5);
2205 }
2206 other => panic!("expected JediGuardian, got {other:?}"),
2207 }
2208 }
2209
2210 #[test]
2211 fn unknown_class_carries_resolved_label_when_2da_row_exists() {
2212 let utc = Utc {
2215 classes: vec![class_entry(9, 3, vec![])],
2216 ..Utc::default()
2217 };
2218 let classes = label_only_2da(&[(9, "DarkJediMaster")]);
2219 let mut tables = TestTables::new();
2220 add_2da_entry(&mut tables, "classes", &classes);
2221
2222 let resolved = utc.resolve(&mut tables);
2223 match &resolved.classes()[0] {
2224 DecodedClass::Unknown {
2225 class_id,
2226 class_label,
2227 level,
2228 ..
2229 } => {
2230 assert_eq!(*class_id, 9);
2231 assert_eq!(class_label.as_deref(), Some("DarkJediMaster"));
2232 assert_eq!(*level, 3);
2233 }
2234 other => panic!("expected Unknown, got {other:?}"),
2235 }
2236 }
2237
2238 #[test]
2239 fn unknown_class_carries_no_label_when_class_id_is_out_of_range() {
2240 let utc = Utc {
2243 classes: vec![class_entry(99, 1, vec![])],
2244 ..Utc::default()
2245 };
2246 let classes = label_only_2da(&[(0, "Soldier")]);
2247 let mut tables = TestTables::new();
2248 add_2da_entry(&mut tables, "classes", &classes);
2249
2250 let resolved = utc.resolve(&mut tables);
2251 match &resolved.classes()[0] {
2252 DecodedClass::Unknown {
2253 class_id,
2254 class_label,
2255 ..
2256 } => {
2257 assert_eq!(*class_id, 99);
2258 assert!(class_label.is_none());
2259 }
2260 other => panic!("expected Unknown, got {other:?}"),
2261 }
2262 }
2263
2264 #[test]
2265 fn unknown_class_carries_no_label_when_classes_2da_unavailable() {
2266 let utc = Utc {
2269 classes: vec![class_entry(3, 5, vec![])],
2270 ..Utc::default()
2271 };
2272 let mut tables = TestTables::new();
2273
2274 let resolved = utc.resolve(&mut tables);
2275 match &resolved.classes()[0] {
2276 DecodedClass::Unknown {
2277 class_id,
2278 class_label,
2279 level,
2280 ..
2281 } => {
2282 assert_eq!(*class_id, 3);
2283 assert!(class_label.is_none());
2284 assert_eq!(*level, 5);
2285 }
2286 other => panic!("expected Unknown, got {other:?}"),
2287 }
2288 }
2289
2290 #[test]
2291 fn class_powers_preserve_source_order_per_entry() {
2292 let utc = Utc {
2293 classes: vec![class_entry(4, 6, vec![100, 200, 50, 200])],
2294 ..Utc::default()
2295 };
2296 let classes = label_only_2da(&[(4, "JediConsular")]);
2297 let mut tables = TestTables::new();
2298 add_2da_entry(&mut tables, "classes", &classes);
2299
2300 let resolved = utc.resolve(&mut tables);
2301 assert_eq!(resolved.classes()[0].powers(), &[100, 200, 50, 200]);
2304 }
2305
2306 #[test]
2307 fn total_level_sums_across_typed_and_unknown_entries() {
2308 let utc = Utc {
2312 classes: vec![
2313 class_entry(3, 19, vec![]),
2314 class_entry(5, 3, vec![]),
2315 class_entry(99, 4, vec![]),
2316 ],
2317 ..Utc::default()
2318 };
2319 let classes = label_only_2da(&[(0, ""), (3, "JediGuardian"), (5, "JediSentinel")]);
2320 let mut tables = TestTables::new();
2321 add_2da_entry(&mut tables, "classes", &classes);
2322
2323 let resolved = utc.resolve(&mut tables);
2324 assert_eq!(resolved.total_level(), 26);
2325 }
2326
2327 #[test]
2328 fn one_projection_yields_diverging_class_kinds_per_scope() {
2329 let utc = Utc {
2334 classes: vec![class_entry(3, 10, vec![])],
2335 ..Utc::default()
2336 };
2337
2338 let vanilla_classes = label_only_2da(&[(3, "JediGuardian")]);
2339 let mut vanilla_tables = TestTables::new();
2340 add_2da_entry(&mut vanilla_tables, "classes", &vanilla_classes);
2341
2342 let mod_classes = label_only_2da(&[(3, "MandalorianWarrior")]);
2343 let mut mod_tables = TestTables::new();
2344 add_2da_entry(&mut mod_tables, "classes", &mod_classes);
2345
2346 let projection = utc.project();
2347
2348 let vanilla = projection.resolve(&mut vanilla_tables);
2349 let modded = projection.resolve(&mut mod_tables);
2350
2351 assert!(matches!(
2352 vanilla.classes()[0],
2353 DecodedClass::JediGuardian { .. }
2354 ));
2355 match &modded.classes()[0] {
2356 DecodedClass::Unknown { class_label, .. } => {
2357 assert_eq!(class_label.as_deref(), Some("MandalorianWarrior"));
2358 }
2359 other => panic!("expected Unknown, got {other:?}"),
2360 }
2361 }
2362
2363 #[test]
2366 fn equipment_and_inventory_passthrough_source_slices() {
2367 use rakata_core::ResRef;
2368
2369 let equip_resref = ResRef::new("g_w_blstrpstl001").expect("valid resref");
2370 let inv_resref = ResRef::new("g_i_medeqpmnt01").expect("valid resref");
2371 let utc = Utc {
2372 equipment: vec![UtcEquipmentItem::new(2, equip_resref)],
2373 inventory: vec![UtcInventoryItem::new(0, inv_resref)],
2374 ..Utc::default()
2375 };
2376 let mut tables = TestTables::new();
2377
2378 let resolved = utc.resolve(&mut tables);
2379 assert_eq!(resolved.equipment().len(), 1);
2380 assert_eq!(resolved.equipment()[0].slot_id, 2);
2381 assert_eq!(resolved.equipment()[0].resref, equip_resref);
2382 assert_eq!(resolved.inventory().len(), 1);
2383 assert_eq!(resolved.inventory()[0].entry_id, 0);
2384 assert_eq!(resolved.inventory()[0].resref, inv_resref);
2385 }
2386
2387 fn ability_entry(spell_id: u16, flags: u8, caster_level: u8) -> UtcSpecialAbility {
2390 UtcSpecialAbility {
2391 spell_id,
2392 spell_flags: flags,
2393 spell_caster_level: caster_level,
2394 }
2395 }
2396
2397 fn vanilla_spells_2da() -> TwoDa {
2398 label_only_2da(&[
2401 (52, "SPECIAL_ABILITY_BODY_FUEL"),
2402 (63, "SPECIAL_ABILITY_RAGE"),
2403 (83, "MONSTER_ABILITY_SLAM_ATTACK"),
2404 ])
2405 }
2406
2407 #[test]
2408 fn special_abilities_dispatch_three_vanilla_labels_to_typed_variants() {
2409 let utc = Utc {
2410 special_abilities: vec![
2411 ability_entry(52, 0, 1),
2412 ability_entry(63, 0, 1),
2413 ability_entry(83, 0, 1),
2414 ],
2415 ..Utc::default()
2416 };
2417 let mut tables = TestTables::new();
2418 add_2da_entry(&mut tables, "spells", &vanilla_spells_2da());
2419
2420 let resolved = utc.resolve(&mut tables);
2421 assert!(matches!(
2422 resolved.special_abilities()[0],
2423 DecodedSpecialAbility::BodyFuel { .. }
2424 ));
2425 assert!(matches!(
2426 resolved.special_abilities()[1],
2427 DecodedSpecialAbility::Rage { .. }
2428 ));
2429 assert!(matches!(
2430 resolved.special_abilities()[2],
2431 DecodedSpecialAbility::MonsterSlamAttack { .. }
2432 ));
2433 }
2434
2435 #[test]
2436 fn special_abilities_preserve_stacked_duplicates() {
2437 let utc = Utc {
2440 special_abilities: (0..5).map(|_| ability_entry(52, 0, 1)).collect(),
2441 ..Utc::default()
2442 };
2443 let mut tables = TestTables::new();
2444 add_2da_entry(&mut tables, "spells", &vanilla_spells_2da());
2445
2446 let resolved = utc.resolve(&mut tables);
2447 assert_eq!(resolved.special_abilities().len(), 5);
2448 for entry in resolved.special_abilities() {
2449 assert!(matches!(entry, DecodedSpecialAbility::BodyFuel { .. }));
2450 }
2451 }
2452
2453 #[test]
2454 fn out_of_range_spell_id_surfaces_as_unknown_with_no_label() {
2455 let utc = Utc {
2459 special_abilities: vec![ability_entry(299, 1, 1)],
2460 ..Utc::default()
2461 };
2462 let mut tables = TestTables::new();
2463 add_2da_entry(&mut tables, "spells", &vanilla_spells_2da());
2464
2465 let resolved = utc.resolve(&mut tables);
2466 match &resolved.special_abilities()[0] {
2467 DecodedSpecialAbility::Unknown {
2468 spell_id,
2469 spell_label,
2470 flags,
2471 caster_level,
2472 } => {
2473 assert_eq!(*spell_id, 299);
2474 assert!(spell_label.is_none());
2475 assert_eq!(*flags, 1);
2476 assert_eq!(*caster_level, 1);
2477 }
2478 other => panic!("expected Unknown, got {other:?}"),
2479 }
2480 }
2481
2482 #[test]
2483 fn empty_special_ability_list_decodes_to_empty_slice() {
2484 let utc = Utc::default();
2485 let mut tables = TestTables::new();
2486
2487 let resolved = utc.resolve(&mut tables);
2488 assert!(resolved.special_abilities().is_empty());
2489 }
2490
2491 fn utc_with_classes(class_ids: &[i32]) -> Utc {
2494 Utc {
2495 classes: class_ids
2496 .iter()
2497 .map(|id| class_entry(*id, 1, vec![]))
2498 .collect(),
2499 ..Utc::default()
2500 }
2501 }
2502
2503 fn vanilla_classes_2da() -> TwoDa {
2504 label_only_2da(&[
2505 (0, "Soldier"),
2506 (1, "Scout"),
2507 (2, "Scoundrel"),
2508 (3, "JediGuardian"),
2509 (4, "JediConsular"),
2510 (5, "JediSentinel"),
2511 (6, "CombatDroid"),
2512 (7, "ExpertDroid"),
2513 (8, "Minion"),
2514 ])
2515 }
2516
2517 fn vanilla_class_cache(tables: &mut TestTables) {
2518 let classes = vanilla_classes_2da();
2519 add_2da_entry(tables, "classes", &classes);
2520 }
2521
2522 #[test]
2523 fn is_force_user_matches_any_jedi_class() {
2524 for jedi_id in [3, 4, 5] {
2525 let utc = utc_with_classes(&[jedi_id]);
2526 let mut tables = TestTables::new();
2527 vanilla_class_cache(&mut tables);
2528
2529 let resolved = utc.resolve(&mut tables);
2530 assert!(
2531 resolved.is_force_user(),
2532 "class_id {jedi_id} should be a force user"
2533 );
2534 }
2535 }
2536
2537 #[test]
2538 fn is_force_user_false_for_non_jedi_classes() {
2539 for non_jedi_id in [0, 1, 2, 6, 7, 8] {
2540 let utc = utc_with_classes(&[non_jedi_id]);
2541 let mut tables = TestTables::new();
2542 vanilla_class_cache(&mut tables);
2543
2544 let resolved = utc.resolve(&mut tables);
2545 assert!(
2546 !resolved.is_force_user(),
2547 "class_id {non_jedi_id} should not be a force user"
2548 );
2549 }
2550 }
2551
2552 #[test]
2553 fn is_droid_matches_combat_and_expert_droid() {
2554 for droid_id in [6, 7] {
2555 let utc = utc_with_classes(&[droid_id]);
2556 let mut tables = TestTables::new();
2557 vanilla_class_cache(&mut tables);
2558
2559 let resolved = utc.resolve(&mut tables);
2560 assert!(resolved.is_droid(), "class_id {droid_id} should be a droid");
2561 }
2562 }
2563
2564 #[test]
2565 fn is_droid_false_for_organic_classes() {
2566 for organic_id in [0, 1, 2, 3, 4, 5, 8] {
2567 let utc = utc_with_classes(&[organic_id]);
2568 let mut tables = TestTables::new();
2569 vanilla_class_cache(&mut tables);
2570
2571 let resolved = utc.resolve(&mut tables);
2572 assert!(
2573 !resolved.is_droid(),
2574 "class_id {organic_id} should not be a droid"
2575 );
2576 }
2577 }
2578
2579 #[test]
2580 fn has_class_matches_specific_typed_variants() {
2581 let utc = utc_with_classes(&[0, 3]);
2582 let mut tables = TestTables::new();
2583 vanilla_class_cache(&mut tables);
2584
2585 let resolved = utc.resolve(&mut tables);
2586 assert!(resolved.has_class(ClassKindFilter::Soldier));
2587 assert!(resolved.has_class(ClassKindFilter::JediGuardian));
2588 assert!(!resolved.has_class(ClassKindFilter::Scout));
2589 assert!(!resolved.has_class(ClassKindFilter::Minion));
2590 }
2591
2592 #[test]
2593 fn has_class_never_matches_unknown_entries() {
2594 let utc = utc_with_classes(&[99]);
2597 let mut tables = TestTables::new();
2598 vanilla_class_cache(&mut tables);
2599
2600 let resolved = utc.resolve(&mut tables);
2601 assert!(!resolved.has_class(ClassKindFilter::Soldier));
2602 assert!(!resolved.has_class(ClassKindFilter::AnyJedi));
2603 assert!(!resolved.has_class(ClassKindFilter::AnyDroid));
2604 assert!(!resolved.is_force_user());
2605 assert!(!resolved.is_droid());
2606 }
2607
2608 #[test]
2609 fn empty_class_list_helpers_all_return_false() {
2610 let utc = Utc::default();
2611 let mut tables = TestTables::new();
2612
2613 let resolved = utc.resolve(&mut tables);
2614 assert!(!resolved.is_force_user());
2615 assert!(!resolved.is_droid());
2616 assert!(!resolved.has_class(ClassKindFilter::JediGuardian));
2617 }
2618
2619 #[test]
2620 fn empty_class_list_decodes_to_empty_classes_slice() {
2621 let utc = Utc::default();
2622 let mut tables = TestTables::new();
2623
2624 let resolved = utc.resolve(&mut tables);
2625 assert!(resolved.classes().is_empty());
2626 assert_eq!(resolved.total_level(), 0);
2627 }
2628
2629 #[test]
2630 fn one_projection_yields_diverging_race_labels_per_scope() {
2631 let utc = Utc {
2636 race_id: 6,
2637 ..Utc::default()
2638 };
2639
2640 let vanilla_races = label_only_2da(&[(6, "Human")]);
2641 let mut vanilla_tables = TestTables::new();
2642 add_2da_entry(&mut vanilla_tables, "racialtypes", &vanilla_races);
2643
2644 let mod_races = label_only_2da(&[(6, "Mandalorian")]);
2645 let mut mod_tables = TestTables::new();
2646 add_2da_entry(&mut mod_tables, "racialtypes", &mod_races);
2647
2648 let projection = utc.project();
2649
2650 let vanilla = projection.resolve(&mut vanilla_tables);
2651 let modded = projection.resolve(&mut mod_tables);
2652
2653 assert_eq!(vanilla.race_label(), Some("Human"));
2654 assert_eq!(modded.race_label(), Some("Mandalorian"));
2655 }
2656
2657 fn weapon_feats_2da() -> TwoDa {
2660 label_only_2da(&[
2661 (4, "ARMOUR_PROF_HEAVY"),
2662 (5, "ARMOUR_PROF_LIGHT"),
2663 (6, "ARMOUR_PROF_MEDIUM"),
2664 (32, "WEAPON_FOCUS_BLASTER"),
2665 (33, "WEAPON_FOCUS_BLASTER_RIFLE"),
2666 (34, "XXXX_WEAPON_FOCUS_GRENADE"),
2667 (35, "WEAPON_FOCUS_HEAVY_WEAPONS"),
2668 (36, "WEAPON_FOCUS_LIGHTSABER"),
2669 (37, "WEAPON_FOCUS_MELEE_WEAPONS"),
2670 (38, "XXXX_WEAPON_FOCUS_SIMPLE_WEAPONS"),
2671 (39, "WEAPON_PROF_BLASTER"),
2672 (40, "WEAPON_PROF_BLASTER_RIFLE"),
2673 (41, "XXXX_WEAPON_PROF_GRENADE"),
2674 (42, "WEAPON_PROF_HEAVY_WEAPONS"),
2675 (43, "WEAPON_PROF_LIGHTSABER"),
2676 (44, "WEAPON_PROF_MELEE_WEAPONS"),
2677 (45, "XXXX_WEAPON_PROF_SIMPLE_WEAPONS"),
2678 (46, "WEAPON_SPEC_BLASTER"),
2679 (47, "WEAPON_SPEC_BLASTER_RIFLE"),
2680 (48, "XXXX_WEAPON_SPEC_GRENADE"),
2681 (49, "WEAPON_SPEC_HEAVY_WEAPONS"),
2682 (50, "WEAPON_SPEC_LIGHTSABER"),
2683 (51, "WEAPON_SPEC_MELEE_WEAPONS"),
2684 (52, "XXXX_WEAPON_SPEC_SIMPLE_WEAPONS"),
2685 (93, "PROFICIENCY_ALL"),
2686 ])
2687 }
2688
2689 fn feat_kind_label(feat: &DecodedFeat) -> &'static str {
2690 match feat {
2691 DecodedFeat::ArmourProfLight { .. } => "ArmourProfLight",
2692 DecodedFeat::ArmourProfMedium { .. } => "ArmourProfMedium",
2693 DecodedFeat::ArmourProfHeavy { .. } => "ArmourProfHeavy",
2694 DecodedFeat::WeaponProfBlaster { .. } => "WeaponProfBlaster",
2695 DecodedFeat::WeaponProfBlasterRifle { .. } => "WeaponProfBlasterRifle",
2696 DecodedFeat::WeaponProfHeavyWeapons { .. } => "WeaponProfHeavyWeapons",
2697 DecodedFeat::WeaponProfMeleeWeapons { .. } => "WeaponProfMeleeWeapons",
2698 DecodedFeat::WeaponProfLightsaber { .. } => "WeaponProfLightsaber",
2699 DecodedFeat::WeaponProfGrenade { .. } => "WeaponProfGrenade",
2700 DecodedFeat::WeaponProfSimpleWeapons { .. } => "WeaponProfSimpleWeapons",
2701 DecodedFeat::ProficiencyAll { .. } => "ProficiencyAll",
2702 DecodedFeat::WeaponFocusBlaster { .. } => "WeaponFocusBlaster",
2703 DecodedFeat::WeaponFocusBlasterRifle { .. } => "WeaponFocusBlasterRifle",
2704 DecodedFeat::WeaponFocusGrenade { .. } => "WeaponFocusGrenade",
2705 DecodedFeat::WeaponFocusHeavyWeapons { .. } => "WeaponFocusHeavyWeapons",
2706 DecodedFeat::WeaponFocusLightsaber { .. } => "WeaponFocusLightsaber",
2707 DecodedFeat::WeaponFocusMeleeWeapons { .. } => "WeaponFocusMeleeWeapons",
2708 DecodedFeat::WeaponFocusSimpleWeapons { .. } => "WeaponFocusSimpleWeapons",
2709 DecodedFeat::WeaponSpecBlaster { .. } => "WeaponSpecBlaster",
2710 DecodedFeat::WeaponSpecBlasterRifle { .. } => "WeaponSpecBlasterRifle",
2711 DecodedFeat::WeaponSpecGrenade { .. } => "WeaponSpecGrenade",
2712 DecodedFeat::WeaponSpecHeavyWeapons { .. } => "WeaponSpecHeavyWeapons",
2713 DecodedFeat::WeaponSpecLightsaber { .. } => "WeaponSpecLightsaber",
2714 DecodedFeat::WeaponSpecMeleeWeapons { .. } => "WeaponSpecMeleeWeapons",
2715 DecodedFeat::WeaponSpecSimpleWeapons { .. } => "WeaponSpecSimpleWeapons",
2716 DecodedFeat::PowerAttack { .. } => "PowerAttack",
2717 DecodedFeat::ImprovedPowerAttack { .. } => "ImprovedPowerAttack",
2718 DecodedFeat::MasterPowerAttack { .. } => "MasterPowerAttack",
2719 DecodedFeat::PowerBlast { .. } => "PowerBlast",
2720 DecodedFeat::ImprovedPowerBlast { .. } => "ImprovedPowerBlast",
2721 DecodedFeat::MasterPowerBlast { .. } => "MasterPowerBlast",
2722 DecodedFeat::CriticalStrike { .. } => "CriticalStrike",
2723 DecodedFeat::ImprovedCriticalStrike { .. } => "ImprovedCriticalStrike",
2724 DecodedFeat::MasterCriticalStrike { .. } => "MasterCriticalStrike",
2725 DecodedFeat::Flurry { .. } => "Flurry",
2726 DecodedFeat::ImprovedFlurry { .. } => "ImprovedFlurry",
2727 DecodedFeat::SniperShot { .. } => "SniperShot",
2728 DecodedFeat::ImprovedSniperShot { .. } => "ImprovedSniperShot",
2729 DecodedFeat::MasterSniperShot { .. } => "MasterSniperShot",
2730 DecodedFeat::RapidShot { .. } => "RapidShot",
2731 DecodedFeat::ImprovedRapidShot { .. } => "ImprovedRapidShot",
2732 DecodedFeat::MultiShot { .. } => "MultiShot",
2733 DecodedFeat::WhirlwindAttack { .. } => "WhirlwindAttack",
2734 DecodedFeat::TwoWeaponFighting { .. } => "TwoWeaponFighting",
2735 DecodedFeat::TwoWeaponAdvanced { .. } => "TwoWeaponAdvanced",
2736 DecodedFeat::TwoWeaponMastery { .. } => "TwoWeaponMastery",
2737 DecodedFeat::JediDefense { .. } => "JediDefense",
2738 DecodedFeat::AdvancedJediDefense { .. } => "AdvancedJediDefense",
2739 DecodedFeat::MasterJediDefense { .. } => "MasterJediDefense",
2740 DecodedFeat::ForceFocus { .. } => "ForceFocus",
2741 DecodedFeat::ForceFocusAdvanced { .. } => "ForceFocusAdvanced",
2742 DecodedFeat::ForceFocusMastery { .. } => "ForceFocusMastery",
2743 DecodedFeat::ForceFocusAlter { .. } => "ForceFocusAlter",
2744 DecodedFeat::ForceFocusControl { .. } => "ForceFocusControl",
2745 DecodedFeat::ForceJump { .. } => "ForceJump",
2746 DecodedFeat::ForceJumpAdvanced { .. } => "ForceJumpAdvanced",
2747 DecodedFeat::ForceJumpMastery { .. } => "ForceJumpMastery",
2748 DecodedFeat::ForceImmunityFear { .. } => "ForceImmunityFear",
2749 DecodedFeat::ForceImmunityStun { .. } => "ForceImmunityStun",
2750 DecodedFeat::ForceImmunityParalysis { .. } => "ForceImmunityParalysis",
2751 DecodedFeat::BattleMeditation { .. } => "BattleMeditation",
2752 DecodedFeat::ForceCamoflage { .. } => "ForceCamoflage",
2753 DecodedFeat::SneakAttack1D6 { .. } => "SneakAttack1D6",
2754 DecodedFeat::SneakAttack2D6 { .. } => "SneakAttack2D6",
2755 DecodedFeat::SneakAttack3D6 { .. } => "SneakAttack3D6",
2756 DecodedFeat::SneakAttack4D6 { .. } => "SneakAttack4D6",
2757 DecodedFeat::SneakAttack5D6 { .. } => "SneakAttack5D6",
2758 DecodedFeat::SneakAttack6D6 { .. } => "SneakAttack6D6",
2759 DecodedFeat::UncannyDodge1 { .. } => "UncannyDodge1",
2760 DecodedFeat::UncannyDodge2 { .. } => "UncannyDodge2",
2761 DecodedFeat::Toughness { .. } => "Toughness",
2762 DecodedFeat::ImprovedToughness { .. } => "ImprovedToughness",
2763 DecodedFeat::MasterToughness { .. } => "MasterToughness",
2764 DecodedFeat::Conditioning { .. } => "Conditioning",
2765 DecodedFeat::ImprovedConditioning { .. } => "ImprovedConditioning",
2766 DecodedFeat::MasterConditioning { .. } => "MasterConditioning",
2767 DecodedFeat::ImplantLevel1 { .. } => "ImplantLevel1",
2768 DecodedFeat::ImplantLevel2 { .. } => "ImplantLevel2",
2769 DecodedFeat::ImplantLevel3 { .. } => "ImplantLevel3",
2770 DecodedFeat::DroidUpgrade1 { .. } => "DroidUpgrade1",
2771 DecodedFeat::DroidUpgrade2 { .. } => "DroidUpgrade2",
2772 DecodedFeat::DroidUpgrade3 { .. } => "DroidUpgrade3",
2773 DecodedFeat::BlasterIntegration { .. } => "BlasterIntegration",
2774 DecodedFeat::LogicUpgradeCombat { .. } => "LogicUpgradeCombat",
2775 DecodedFeat::LogicUpgradeTactician { .. } => "LogicUpgradeTactician",
2776 DecodedFeat::Dueling { .. } => "Dueling",
2777 DecodedFeat::AdvancedDueling { .. } => "AdvancedDueling",
2778 DecodedFeat::MasterDueling { .. } => "MasterDueling",
2779 DecodedFeat::JediSense { .. } => "JediSense",
2780 DecodedFeat::KnightSense { .. } => "KnightSense",
2781 DecodedFeat::MasterSense { .. } => "MasterSense",
2782 DecodedFeat::GuardStance { .. } => "GuardStance",
2783 DecodedFeat::AdvancedGuardStance { .. } => "AdvancedGuardStance",
2784 DecodedFeat::MasterGuardStance { .. } => "MasterGuardStance",
2785 DecodedFeat::SkillFocusAwareness { .. } => "SkillFocusAwareness",
2786 DecodedFeat::SkillFocusTreatInjury { .. } => "SkillFocusTreatInjury",
2787 DecodedFeat::SkillFocusStealth { .. } => "SkillFocusStealth",
2788 DecodedFeat::Cautious { .. } => "Cautious",
2789 DecodedFeat::Perceptive { .. } => "Perceptive",
2790 DecodedFeat::Empathy { .. } => "Empathy",
2791 DecodedFeat::GearHead { .. } => "GearHead",
2792 DecodedFeat::WookieEndurance { .. } => "WookieEndurance",
2793 DecodedFeat::ScoundrelsLuck { .. } => "ScoundrelsLuck",
2794 DecodedFeat::Unknown { .. } => "<unknown>",
2795 }
2796 }
2797
2798 #[test]
2799 fn feats_dispatch_armour_proficiency_labels_to_typed_variants() {
2800 let utc = Utc {
2801 feats: vec![4, 5, 6],
2802 ..Utc::default()
2803 };
2804 let mut tables = TestTables::new();
2805 add_2da_entry(&mut tables, "feat", &weapon_feats_2da());
2806
2807 let resolved = utc.resolve(&mut tables);
2808 let kinds: Vec<&str> = resolved.feats().iter().map(feat_kind_label).collect();
2809 assert_eq!(
2810 kinds,
2811 vec!["ArmourProfHeavy", "ArmourProfLight", "ArmourProfMedium"]
2812 );
2813 }
2814
2815 #[test]
2816 fn feats_dispatch_weapon_proficiency_labels_to_typed_variants() {
2817 let utc = Utc {
2820 feats: vec![39, 40, 41, 42, 43, 44, 45, 93],
2821 ..Utc::default()
2822 };
2823 let mut tables = TestTables::new();
2824 add_2da_entry(&mut tables, "feat", &weapon_feats_2da());
2825
2826 let resolved = utc.resolve(&mut tables);
2827 let kinds: Vec<&str> = resolved.feats().iter().map(feat_kind_label).collect();
2828 assert_eq!(
2829 kinds,
2830 vec![
2831 "WeaponProfBlaster",
2832 "WeaponProfBlasterRifle",
2833 "WeaponProfGrenade",
2834 "WeaponProfHeavyWeapons",
2835 "WeaponProfLightsaber",
2836 "WeaponProfMeleeWeapons",
2837 "WeaponProfSimpleWeapons",
2838 "ProficiencyAll",
2839 ]
2840 );
2841 }
2842
2843 #[test]
2844 fn feats_dispatch_weapon_focus_labels_to_typed_variants() {
2845 let utc = Utc {
2846 feats: vec![32, 33, 34, 35, 36, 37, 38],
2847 ..Utc::default()
2848 };
2849 let mut tables = TestTables::new();
2850 add_2da_entry(&mut tables, "feat", &weapon_feats_2da());
2851
2852 let resolved = utc.resolve(&mut tables);
2853 let kinds: Vec<&str> = resolved.feats().iter().map(feat_kind_label).collect();
2854 assert_eq!(
2855 kinds,
2856 vec![
2857 "WeaponFocusBlaster",
2858 "WeaponFocusBlasterRifle",
2859 "WeaponFocusGrenade",
2860 "WeaponFocusHeavyWeapons",
2861 "WeaponFocusLightsaber",
2862 "WeaponFocusMeleeWeapons",
2863 "WeaponFocusSimpleWeapons",
2864 ]
2865 );
2866 }
2867
2868 fn combat_feats_2da() -> TwoDa {
2869 label_only_2da(&[
2870 (3, "TWO_WEAPON_FIGHTING"),
2871 (8, "CRITICAL_STRIKE"),
2872 (9, "TWO_WEAPON_ADVANCED"),
2873 (11, "FLURRY"),
2874 (17, "IMPROVED_POWER_ATTACK"),
2875 (18, "IMPROVED_POWER_BLAST"),
2876 (19, "IMPROVED_CRITICAL_STRIKE"),
2877 (20, "IMPROVED_SNIPER_SHOT"),
2878 (26, "MULTI_SHOT"),
2879 (28, "POWER_ATTACK"),
2880 (29, "POWER_BLAST"),
2881 (30, "RAPID_SHOT"),
2882 (31, "SNIPER_SHOT"),
2883 (53, "WHIRLWIND_ATTACK"),
2884 (77, "MASTER_SNIPER_SHOT"),
2885 (81, "MASTER_CRITICAL_STRIKE"),
2886 (82, "MASTER_POWER_BLAST"),
2887 (83, "MASTER_POWER_ATTACK"),
2888 (85, "TWO_WEAPON_MASTERY"),
2889 (91, "IMPROVED_FLURRY"),
2890 (92, "IMPROVED_RAPID_SHOT"),
2891 ])
2892 }
2893
2894 fn long_tail_feats_2da() -> TwoDa {
2895 label_only_2da(&[
2896 (2, "XXXX_ADVANCED_GUARD_STANCE"),
2897 (7, "CAUTIOUS"),
2898 (10, "EMPATHY"),
2899 (12, "GEAR_HEAD"),
2900 (25, "XXXX_MASTER_GUARD_STANCE"),
2901 (27, "XXXX_PERCEPTIVE"),
2902 (54, "XXXX_GUARD_STANCE"),
2903 (71, "XXXX_SKILL_FOCUS_STEALTH"),
2904 (72, "XXXX_SKILL_FOCUS_AWARENESS"),
2905 (76, "XXXX_SKILL_FOCUS_TREAT_INJURY"),
2906 (78, "DROID_UPGRADE_1"),
2907 (79, "DROID_UPGRADE_2"),
2908 (80, "DROID_UPGRADE_3"),
2909 (95, "WOOKIE_ENDURANCE"),
2910 (96, "BLASTER_INTEGRATION"),
2911 (104, "SCOUNDRELS_LUCK"),
2912 (107, "JEDI_SENSE"),
2913 (108, "KNIGHT_SENSE"),
2914 (109, "MASTER_SENSE"),
2915 (110, "LOGIC_UPGRADE_COMBAT"),
2916 (111, "LOGIC_UPGRADE_TACTICIAN"),
2917 (113, "DUELING"),
2918 (114, "ADVANCED_DUELING"),
2919 (115, "MASTER_DUELING"),
2920 ])
2921 }
2922
2923 #[test]
2924 fn feats_dispatch_long_tail_labels_to_typed_variants() {
2925 let utc = Utc {
2929 feats: vec![
2930 2, 7, 10, 12, 25, 27, 54, 71, 72, 76, 78, 79, 80, 95, 96, 104, 107, 108, 109, 110,
2931 111, 113, 114, 115,
2932 ],
2933 ..Utc::default()
2934 };
2935 let mut tables = TestTables::new();
2936 add_2da_entry(&mut tables, "feat", &long_tail_feats_2da());
2937
2938 let resolved = utc.resolve(&mut tables);
2939 let kinds: Vec<&str> = resolved.feats().iter().map(feat_kind_label).collect();
2940 assert_eq!(
2941 kinds,
2942 vec![
2943 "AdvancedGuardStance",
2944 "Cautious",
2945 "Empathy",
2946 "GearHead",
2947 "MasterGuardStance",
2948 "Perceptive",
2949 "GuardStance",
2950 "SkillFocusStealth",
2951 "SkillFocusAwareness",
2952 "SkillFocusTreatInjury",
2953 "DroidUpgrade1",
2954 "DroidUpgrade2",
2955 "DroidUpgrade3",
2956 "WookieEndurance",
2957 "BlasterIntegration",
2958 "ScoundrelsLuck",
2959 "JediSense",
2960 "KnightSense",
2961 "MasterSense",
2962 "LogicUpgradeCombat",
2963 "LogicUpgradeTactician",
2964 "Dueling",
2965 "AdvancedDueling",
2966 "MasterDueling",
2967 ]
2968 );
2969 }
2970
2971 fn survival_feats_2da() -> TwoDa {
2972 label_only_2da(&[
2973 (13, "CONDITIONING"),
2974 (14, "IMPLANT_LEVEL_1"),
2975 (15, "IMPLANT_LEVEL_2"),
2976 (16, "IMPLANT_LEVEL_3"),
2977 (21, "IMPROVED_CONDITIONING"),
2978 (22, "MASTER_CONDITIONING"),
2979 (56, "UNCANNY_DODGE_1"),
2980 (57, "UNCANNY_DODGE_2"),
2981 (60, "SNEAK_ATTACK_1D6"),
2982 (61, "SNEAK_ATTACK_2D6"),
2983 (62, "SNEAK_ATTACK_3D6"),
2984 (63, "SNEAK_ATTACK_4D6"),
2985 (64, "SNEAK_ATTACK_5D6"),
2986 (65, "SNEAK_ATTACK_6D6"),
2987 (84, "TOUGHNESS"),
2988 (123, "IMPROVED_TOUGHNESS"),
2989 (124, "MASTER_TOUGHNESS"),
2990 ])
2991 }
2992
2993 #[test]
2994 fn feats_dispatch_survival_labels_to_typed_variants() {
2995 let utc = Utc {
2996 feats: vec![
2997 13, 14, 15, 16, 21, 22, 56, 57, 60, 61, 62, 63, 64, 65, 84, 123, 124,
2998 ],
2999 ..Utc::default()
3000 };
3001 let mut tables = TestTables::new();
3002 add_2da_entry(&mut tables, "feat", &survival_feats_2da());
3003
3004 let resolved = utc.resolve(&mut tables);
3005 let kinds: Vec<&str> = resolved.feats().iter().map(feat_kind_label).collect();
3006 assert_eq!(
3007 kinds,
3008 vec![
3009 "Conditioning",
3010 "ImplantLevel1",
3011 "ImplantLevel2",
3012 "ImplantLevel3",
3013 "ImprovedConditioning",
3014 "MasterConditioning",
3015 "UncannyDodge1",
3016 "UncannyDodge2",
3017 "SneakAttack1D6",
3018 "SneakAttack2D6",
3019 "SneakAttack3D6",
3020 "SneakAttack4D6",
3021 "SneakAttack5D6",
3022 "SneakAttack6D6",
3023 "Toughness",
3024 "ImprovedToughness",
3025 "MasterToughness",
3026 ]
3027 );
3028 }
3029
3030 fn force_feats_2da() -> TwoDa {
3031 label_only_2da(&[
3032 (1, "ADVANCED_JEDI_DEFENSE"),
3033 (24, "MASTER_JEDI_DEFENSE"),
3034 (55, "JEDI_DEFENSE"),
3035 (86, "XXXX_FORCE_FOCUS_ALTER"),
3036 (87, "XXXX_FORCE_FOCUS_CONTROL"),
3037 (88, "FORCE_FOCUS"),
3038 (89, "FORCE_FOCUS_ADVANCED"),
3039 (90, "FORCE_FOCUS_MASTERY"),
3040 (94, "BATTLE_MEDITATION"),
3041 (97, "FORCE_CAMOFLAGE"),
3042 (98, "FORCE_IMMUNITY_FEAR"),
3043 (99, "FORCE_IMMUNITY_STUN"),
3044 (100, "FORCE_IMMUNITY_PARALYSIS"),
3045 (101, "FORCE_JUMP"),
3046 (102, "FORCE_JUMP_ADVANCED"),
3047 (103, "FORCE_JUMP_MASTERY"),
3048 ])
3049 }
3050
3051 #[test]
3052 fn feats_dispatch_force_jedi_labels_to_typed_variants() {
3053 let utc = Utc {
3054 feats: vec![
3055 1, 24, 55, 86, 87, 88, 89, 90, 94, 97, 98, 99, 100, 101, 102, 103,
3056 ],
3057 ..Utc::default()
3058 };
3059 let mut tables = TestTables::new();
3060 add_2da_entry(&mut tables, "feat", &force_feats_2da());
3061
3062 let resolved = utc.resolve(&mut tables);
3063 let kinds: Vec<&str> = resolved.feats().iter().map(feat_kind_label).collect();
3064 assert_eq!(
3065 kinds,
3066 vec![
3067 "AdvancedJediDefense",
3068 "MasterJediDefense",
3069 "JediDefense",
3070 "ForceFocusAlter",
3071 "ForceFocusControl",
3072 "ForceFocus",
3073 "ForceFocusAdvanced",
3074 "ForceFocusMastery",
3075 "BattleMeditation",
3076 "ForceCamoflage",
3077 "ForceImmunityFear",
3078 "ForceImmunityStun",
3079 "ForceImmunityParalysis",
3080 "ForceJump",
3081 "ForceJumpAdvanced",
3082 "ForceJumpMastery",
3083 ]
3084 );
3085 }
3086
3087 #[test]
3088 fn feats_dispatch_combat_ladders_to_typed_variants() {
3089 let utc = Utc {
3092 feats: vec![
3093 3, 8, 9, 11, 17, 18, 19, 20, 26, 28, 29, 30, 31, 53, 77, 81, 82, 83, 85, 91, 92,
3094 ],
3095 ..Utc::default()
3096 };
3097 let mut tables = TestTables::new();
3098 add_2da_entry(&mut tables, "feat", &combat_feats_2da());
3099
3100 let resolved = utc.resolve(&mut tables);
3101 let kinds: Vec<&str> = resolved.feats().iter().map(feat_kind_label).collect();
3102 assert_eq!(
3103 kinds,
3104 vec![
3105 "TwoWeaponFighting",
3106 "CriticalStrike",
3107 "TwoWeaponAdvanced",
3108 "Flurry",
3109 "ImprovedPowerAttack",
3110 "ImprovedPowerBlast",
3111 "ImprovedCriticalStrike",
3112 "ImprovedSniperShot",
3113 "MultiShot",
3114 "PowerAttack",
3115 "PowerBlast",
3116 "RapidShot",
3117 "SniperShot",
3118 "WhirlwindAttack",
3119 "MasterSniperShot",
3120 "MasterCriticalStrike",
3121 "MasterPowerBlast",
3122 "MasterPowerAttack",
3123 "TwoWeaponMastery",
3124 "ImprovedFlurry",
3125 "ImprovedRapidShot",
3126 ]
3127 );
3128 }
3129
3130 #[test]
3131 fn feats_dispatch_weapon_specialization_labels_to_typed_variants() {
3132 let utc = Utc {
3133 feats: vec![46, 47, 48, 49, 50, 51, 52],
3134 ..Utc::default()
3135 };
3136 let mut tables = TestTables::new();
3137 add_2da_entry(&mut tables, "feat", &weapon_feats_2da());
3138
3139 let resolved = utc.resolve(&mut tables);
3140 let kinds: Vec<&str> = resolved.feats().iter().map(feat_kind_label).collect();
3141 assert_eq!(
3142 kinds,
3143 vec![
3144 "WeaponSpecBlaster",
3145 "WeaponSpecBlasterRifle",
3146 "WeaponSpecGrenade",
3147 "WeaponSpecHeavyWeapons",
3148 "WeaponSpecLightsaber",
3149 "WeaponSpecMeleeWeapons",
3150 "WeaponSpecSimpleWeapons",
3151 ]
3152 );
3153 }
3154
3155 #[test]
3156 fn feats_dispatch_by_label_so_mod_renumbered_feat_still_routes() {
3157 let utc = Utc {
3162 feats: vec![500],
3163 ..Utc::default()
3164 };
3165 let feat = label_only_2da(&[(500, "ARMOUR_PROF_LIGHT")]);
3166 let mut tables = TestTables::new();
3167 add_2da_entry(&mut tables, "feat", &feat);
3168
3169 let resolved = utc.resolve(&mut tables);
3170 match &resolved.feats()[0] {
3171 DecodedFeat::ArmourProfLight { feat_id } => {
3172 assert_eq!(*feat_id, 500);
3175 }
3176 other => panic!("expected ArmourProfLight, got {other:?}"),
3177 }
3178 }
3179
3180 #[test]
3181 fn feat_with_unknown_label_surfaces_as_unknown_carrying_resolved_label() {
3182 let utc = Utc {
3185 feats: vec![5000],
3186 ..Utc::default()
3187 };
3188 let feat = label_only_2da(&[(5000, "MOD_MYSTERY_FEAT")]);
3189 let mut tables = TestTables::new();
3190 add_2da_entry(&mut tables, "feat", &feat);
3191
3192 let resolved = utc.resolve(&mut tables);
3193 match &resolved.feats()[0] {
3194 DecodedFeat::Unknown {
3195 feat_id,
3196 feat_label,
3197 } => {
3198 assert_eq!(*feat_id, 5000);
3199 assert_eq!(feat_label.as_deref(), Some("MOD_MYSTERY_FEAT"));
3200 }
3201 other => panic!("expected Unknown, got {other:?}"),
3202 }
3203 }
3204
3205 #[test]
3206 fn feat_decodes_to_unknown_with_no_label_when_id_out_of_range() {
3207 let utc = Utc {
3210 feats: vec![99],
3211 ..Utc::default()
3212 };
3213 let feat = label_only_2da(&[(0, ""), (1, "ADVANCED_JEDI_DEFENSE")]);
3214 let mut tables = TestTables::new();
3215 add_2da_entry(&mut tables, "feat", &feat);
3216
3217 let resolved = utc.resolve(&mut tables);
3218 match &resolved.feats()[0] {
3219 DecodedFeat::Unknown {
3220 feat_id,
3221 feat_label,
3222 } => {
3223 assert_eq!(*feat_id, 99);
3224 assert!(feat_label.is_none());
3225 }
3226 other => panic!("expected Unknown, got {other:?}"),
3227 }
3228 }
3229
3230 #[test]
3231 fn feat_decodes_to_unknown_with_no_label_when_feat_2da_unavailable() {
3232 let utc = Utc {
3236 feats: vec![5, 93],
3237 ..Utc::default()
3238 };
3239 let mut tables = TestTables::new();
3240
3241 let resolved = utc.resolve(&mut tables);
3242 for entry in resolved.feats() {
3243 match entry {
3244 DecodedFeat::Unknown { feat_label, .. } => assert!(feat_label.is_none()),
3245 other => panic!("expected Unknown without label, got {other:?}"),
3246 }
3247 }
3248 }
3249
3250 #[test]
3251 fn empty_feat_list_decodes_to_empty_slice() {
3252 let utc = Utc::default();
3253 let mut tables = TestTables::new();
3254
3255 let resolved = utc.resolve(&mut tables);
3256 assert!(resolved.feats().is_empty());
3257 }
3258
3259 #[test]
3260 fn feat_id_accessor_returns_raw_id_regardless_of_variant() {
3261 let unknown = DecodedFeat::Unknown {
3262 feat_id: 84,
3263 feat_label: Some("TOUGHNESS".to_string()),
3264 };
3265 assert_eq!(unknown.feat_id(), 84);
3266 let typed = DecodedFeat::ArmourProfLight { feat_id: 5 };
3267 assert_eq!(typed.feat_id(), 5);
3268 }
3269
3270 #[test]
3271 fn feats_preserve_stacked_duplicates_as_typed_variant_slots() {
3272 let utc = Utc {
3275 feats: vec![5, 5, 5],
3276 ..Utc::default()
3277 };
3278 let mut tables = TestTables::new();
3279 add_2da_entry(&mut tables, "feat", &weapon_feats_2da());
3280
3281 let resolved = utc.resolve(&mut tables);
3282 assert_eq!(resolved.feats().len(), 3);
3283 for entry in resolved.feats() {
3284 assert!(matches!(entry, DecodedFeat::ArmourProfLight { feat_id: 5 }));
3285 }
3286 }
3287
3288 #[test]
3289 fn one_projection_yields_diverging_feat_dispatch_per_scope() {
3290 let utc = Utc {
3295 feats: vec![5],
3296 ..Utc::default()
3297 };
3298
3299 let mut vanilla_tables = TestTables::new();
3300 add_2da_entry(&mut vanilla_tables, "feat", &weapon_feats_2da());
3301
3302 let mod_feat = label_only_2da(&[(5, "MOD_FIBER_ARMOUR")]);
3303 let mut mod_tables = TestTables::new();
3304 add_2da_entry(&mut mod_tables, "feat", &mod_feat);
3305
3306 let projection = utc.project();
3307
3308 let vanilla = projection.resolve(&mut vanilla_tables);
3309 let modded = projection.resolve(&mut mod_tables);
3310
3311 assert!(matches!(
3312 vanilla.feats()[0],
3313 DecodedFeat::ArmourProfLight { feat_id: 5 }
3314 ));
3315 match &modded.feats()[0] {
3316 DecodedFeat::Unknown {
3317 feat_id,
3318 feat_label,
3319 } => {
3320 assert_eq!(*feat_id, 5);
3321 assert_eq!(feat_label.as_deref(), Some("MOD_FIBER_ARMOUR"));
3322 }
3323 other => panic!("expected Unknown, got {other:?}"),
3324 }
3325 }
3326
3327 #[test]
3330 fn has_feat_matches_each_family_filter() {
3331 let mut tables = TestTables::new();
3334 let combined = label_only_2da(&[
3337 (5, "ARMOUR_PROF_LIGHT"),
3338 (28, "POWER_ATTACK"),
3339 (39, "WEAPON_PROF_BLASTER"),
3340 (32, "WEAPON_FOCUS_BLASTER"),
3341 (46, "WEAPON_SPEC_BLASTER"),
3342 (29, "POWER_BLAST"),
3343 (8, "CRITICAL_STRIKE"),
3344 (11, "FLURRY"),
3345 (31, "SNIPER_SHOT"),
3346 (30, "RAPID_SHOT"),
3347 (3, "TWO_WEAPON_FIGHTING"),
3348 (55, "JEDI_DEFENSE"),
3349 (88, "FORCE_FOCUS"),
3350 (101, "FORCE_JUMP"),
3351 (98, "FORCE_IMMUNITY_FEAR"),
3352 (60, "SNEAK_ATTACK_1D6"),
3353 (56, "UNCANNY_DODGE_1"),
3354 (84, "TOUGHNESS"),
3355 (13, "CONDITIONING"),
3356 (14, "IMPLANT_LEVEL_1"),
3357 (78, "DROID_UPGRADE_1"),
3358 (113, "DUELING"),
3359 (107, "JEDI_SENSE"),
3360 (54, "XXXX_GUARD_STANCE"),
3361 (72, "XXXX_SKILL_FOCUS_AWARENESS"),
3362 ]);
3363 add_2da_entry(&mut tables, "feat", &combined);
3364
3365 let utc = Utc {
3366 feats: vec![
3367 5, 28, 39, 32, 46, 29, 8, 11, 31, 30, 3, 55, 88, 101, 98, 60, 56, 84, 13, 14, 78,
3368 113, 107, 54, 72,
3369 ],
3370 ..Utc::default()
3371 };
3372 let resolved = utc.resolve(&mut tables);
3373
3374 let cases = [
3375 (FeatKindFilter::AnyArmourProficiency, true),
3376 (FeatKindFilter::AnyWeaponProficiency, true),
3377 (FeatKindFilter::AnyWeaponFocus, true),
3378 (FeatKindFilter::AnyWeaponSpecialization, true),
3379 (FeatKindFilter::AnyPowerAttack, true),
3380 (FeatKindFilter::AnyPowerBlast, true),
3381 (FeatKindFilter::AnyCriticalStrike, true),
3382 (FeatKindFilter::AnyFlurry, true),
3383 (FeatKindFilter::AnySniperShot, true),
3384 (FeatKindFilter::AnyRapidShot, true),
3385 (FeatKindFilter::AnyTwoWeapon, true),
3386 (FeatKindFilter::AnyJediDefense, true),
3387 (FeatKindFilter::AnyForceFocus, true),
3388 (FeatKindFilter::AnyForceJump, true),
3389 (FeatKindFilter::AnyForceImmunity, true),
3390 (FeatKindFilter::AnySneakAttack, true),
3391 (FeatKindFilter::AnyUncannyDodge, true),
3392 (FeatKindFilter::AnyToughness, true),
3393 (FeatKindFilter::AnyConditioning, true),
3394 (FeatKindFilter::AnyImplant, true),
3395 (FeatKindFilter::AnyDroidUpgrade, true),
3396 (FeatKindFilter::AnyDueling, true),
3397 (FeatKindFilter::AnySense, true),
3398 (FeatKindFilter::AnyGuardStance, true),
3399 (FeatKindFilter::AnySkillFocus, true),
3400 ];
3401 for (filter, expected) in cases {
3402 assert_eq!(
3403 resolved.has_feat(filter),
3404 expected,
3405 "filter {filter:?} expected {expected}"
3406 );
3407 }
3408 }
3409
3410 #[test]
3411 fn has_feat_returns_false_for_empty_feat_list() {
3412 let utc = Utc::default();
3413 let mut tables = TestTables::new();
3414 let resolved = utc.resolve(&mut tables);
3415 for filter in [
3416 FeatKindFilter::AnyArmourProficiency,
3417 FeatKindFilter::AnyPowerAttack,
3418 FeatKindFilter::AnyForceJump,
3419 FeatKindFilter::AnySneakAttack,
3420 ] {
3421 assert!(!resolved.has_feat(filter));
3422 }
3423 }
3424
3425 #[test]
3426 fn has_feat_never_matches_unknown_entries() {
3427 let utc = Utc {
3431 feats: vec![5000],
3432 ..Utc::default()
3433 };
3434 let feat = label_only_2da(&[(5000, "MOD_POWER_ATTACK_REWORK")]);
3435 let mut tables = TestTables::new();
3436 add_2da_entry(&mut tables, "feat", &feat);
3437 let resolved = utc.resolve(&mut tables);
3438
3439 assert!(matches!(resolved.feats()[0], DecodedFeat::Unknown { .. }));
3440 assert!(!resolved.has_feat(FeatKindFilter::AnyPowerAttack));
3441 assert!(!resolved.has_feat(FeatKindFilter::AnyArmourProficiency));
3442 }
3443
3444 #[test]
3445 fn has_feat_distinguishes_specific_ladder_from_neighbour() {
3446 let utc = Utc {
3450 feats: vec![28],
3451 ..Utc::default()
3452 };
3453 let feat = label_only_2da(&[(28, "POWER_ATTACK")]);
3454 let mut tables = TestTables::new();
3455 add_2da_entry(&mut tables, "feat", &feat);
3456 let resolved = utc.resolve(&mut tables);
3457
3458 assert!(resolved.has_feat(FeatKindFilter::AnyPowerAttack));
3459 assert!(!resolved.has_feat(FeatKindFilter::AnyPowerBlast));
3460 assert!(!resolved.has_feat(FeatKindFilter::AnyCriticalStrike));
3461 assert!(!resolved.has_feat(FeatKindFilter::AnyForceJump));
3462 }
3463}