Skip to main content

forge_foundation/
phase.rs

1use serde::{Deserialize, Serialize};
2
3/// Turn phases/steps. Mirrors Java `PhaseType`.
4#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash, Serialize, Deserialize)]
5pub enum PhaseType {
6    Untap,
7    Upkeep,
8    Draw,
9    Main1,
10    CombatBegin,
11    CombatDeclareAttackers,
12    CombatDeclareBlockers,
13    CombatFirstStrikeDamage,
14    CombatDamage,
15    CombatEnd,
16    Main2,
17    EndOfTurn,
18    Cleanup,
19}
20
21impl PhaseType {
22    /// The full turn sequence in order.
23    pub const TURN_ORDER: [PhaseType; 13] = [
24        PhaseType::Untap,
25        PhaseType::Upkeep,
26        PhaseType::Draw,
27        PhaseType::Main1,
28        PhaseType::CombatBegin,
29        PhaseType::CombatDeclareAttackers,
30        PhaseType::CombatDeclareBlockers,
31        PhaseType::CombatFirstStrikeDamage,
32        PhaseType::CombatDamage,
33        PhaseType::CombatEnd,
34        PhaseType::Main2,
35        PhaseType::EndOfTurn,
36        PhaseType::Cleanup,
37    ];
38
39    /// Phase groups for grouping related steps.
40    pub const BEGINNING_PHASE: [PhaseType; 3] =
41        [PhaseType::Untap, PhaseType::Upkeep, PhaseType::Draw];
42
43    pub const COMBAT_PHASE: [PhaseType; 6] = [
44        PhaseType::CombatBegin,
45        PhaseType::CombatDeclareAttackers,
46        PhaseType::CombatDeclareBlockers,
47        PhaseType::CombatFirstStrikeDamage,
48        PhaseType::CombatDamage,
49        PhaseType::CombatEnd,
50    ];
51
52    pub fn is_main(self) -> bool {
53        matches!(self, PhaseType::Main1 | PhaseType::Main2)
54    }
55
56    pub fn is_combat(self) -> bool {
57        matches!(
58            self,
59            PhaseType::CombatBegin
60                | PhaseType::CombatDeclareAttackers
61                | PhaseType::CombatDeclareBlockers
62                | PhaseType::CombatFirstStrikeDamage
63                | PhaseType::CombatDamage
64                | PhaseType::CombatEnd
65        )
66    }
67
68    /// Index in the turn order (0-12).
69    pub fn index(self) -> usize {
70        Self::TURN_ORDER.iter().position(|&p| p == self).unwrap()
71    }
72
73    /// Get the next phase in the turn sequence. Wraps from Cleanup -> Untap.
74    pub fn next(self) -> PhaseType {
75        let idx = self.index();
76        Self::TURN_ORDER[(idx + 1) % Self::TURN_ORDER.len()]
77    }
78
79    pub fn is_before(self, other: PhaseType) -> bool {
80        self.index() < other.index()
81    }
82
83    pub fn is_after(self, other: PhaseType) -> bool {
84        self.index() > other.index()
85    }
86
87    /// Parse a frontend step string (e.g. "main1", "declare_attackers") to PhaseType.
88    pub fn from_step_string(s: &str) -> Option<Self> {
89        match s {
90            "untap" => Some(PhaseType::Untap),
91            "upkeep" => Some(PhaseType::Upkeep),
92            "draw" => Some(PhaseType::Draw),
93            "main1" => Some(PhaseType::Main1),
94            "begin_combat" => Some(PhaseType::CombatBegin),
95            "declare_attackers" => Some(PhaseType::CombatDeclareAttackers),
96            "declare_blockers" => Some(PhaseType::CombatDeclareBlockers),
97            "first_strike_damage" => Some(PhaseType::CombatFirstStrikeDamage),
98            "combat_damage" => Some(PhaseType::CombatDamage),
99            "end_combat" => Some(PhaseType::CombatEnd),
100            "main2" => Some(PhaseType::Main2),
101            "end" => Some(PhaseType::EndOfTurn),
102            "cleanup" => Some(PhaseType::Cleanup),
103            _ => None,
104        }
105    }
106
107    /// Script-compatible name used in card definition files.
108    pub fn script_name(self) -> &'static str {
109        match self {
110            PhaseType::Untap => "Untap",
111            PhaseType::Upkeep => "Upkeep",
112            PhaseType::Draw => "Draw",
113            PhaseType::Main1 => "Main1",
114            PhaseType::CombatBegin => "BeginCombat",
115            PhaseType::CombatDeclareAttackers => "Declare Attackers",
116            PhaseType::CombatDeclareBlockers => "Declare Blockers",
117            PhaseType::CombatFirstStrikeDamage => "First Strike Damage",
118            PhaseType::CombatDamage => "Combat Damage",
119            PhaseType::CombatEnd => "EndCombat",
120            PhaseType::Main2 => "Main2",
121            PhaseType::EndOfTurn => "End of Turn",
122            PhaseType::Cleanup => "Cleanup",
123        }
124    }
125
126    pub fn from_script_name(s: &str) -> Option<Self> {
127        let s = s.trim();
128        if s.eq_ignore_ascii_case("EndStep") || s.eq_ignore_ascii_case("EndOfTurnStep") {
129            return Some(PhaseType::EndOfTurn);
130        }
131        for &phase in &Self::TURN_ORDER {
132            if phase.script_name().eq_ignore_ascii_case(s)
133                || format!("{:?}", phase).eq_ignore_ascii_case(s)
134            {
135                return Some(phase);
136            }
137        }
138        // "Main" matches both main phases — return Main1 as default
139        if s.eq_ignore_ascii_case("Main") {
140            return Some(PhaseType::Main1);
141        }
142        None
143    }
144
145    pub fn parse_range(values: &str) -> Vec<Self> {
146        let mut result: Vec<Self> = Vec::new();
147        let mut push = |phase: PhaseType, result: &mut Vec<Self>| {
148            if !result.contains(&phase) {
149                result.push(phase);
150            }
151        };
152        for s in values.split(',') {
153            let s = s.trim();
154            if let Some(idx) = s.find("->") {
155                let from = Self::from_script_name(&s[..idx]);
156                let to_str = s[idx + 2..].trim();
157                let to = if to_str.is_empty() {
158                    Some(PhaseType::Cleanup)
159                } else {
160                    Self::from_script_name(to_str)
161                };
162                if let (Some(from), Some(to)) = (from, to) {
163                    let mut in_range = false;
164                    for &phase in &Self::TURN_ORDER {
165                        if phase == from {
166                            in_range = true;
167                        }
168                        if in_range {
169                            push(phase, &mut result);
170                        }
171                        if phase == to {
172                            break;
173                        }
174                    }
175                }
176            } else if s.eq_ignore_ascii_case("Main") {
177                push(PhaseType::Main1, &mut result);
178                push(PhaseType::Main2, &mut result);
179            } else if let Some(phase) = Self::from_script_name(s) {
180                push(phase, &mut result);
181            }
182        }
183        result
184    }
185}
186
187impl std::fmt::Display for PhaseType {
188    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
189        write!(f, "{}", self.script_name())
190    }
191}
192
193#[cfg(test)]
194mod tests {
195    use super::*;
196
197    #[test]
198    fn turn_order() {
199        assert_eq!(PhaseType::Untap.next(), PhaseType::Upkeep);
200        assert_eq!(PhaseType::Cleanup.next(), PhaseType::Untap);
201    }
202
203    #[test]
204    fn is_before_after() {
205        assert!(PhaseType::Untap.is_before(PhaseType::Draw));
206        assert!(PhaseType::Main2.is_after(PhaseType::Main1));
207    }
208
209    #[test]
210    fn script_names() {
211        assert_eq!(
212            PhaseType::from_script_name("BeginCombat"),
213            Some(PhaseType::CombatBegin)
214        );
215        assert_eq!(
216            PhaseType::from_script_name("End of Turn"),
217            Some(PhaseType::EndOfTurn)
218        );
219    }
220}