Skip to main content

manabrew_engine/ability/effects/
condition.rs

1//! SpellAbility condition gating.
2//!
3use forge_foundation::ZoneType;
4
5use crate::game::GameState;
6use crate::ids::{CardId, PlayerId};
7use crate::parsing::compare::{compare_expr, CompareExpr};
8use crate::spellability::SpellAbility;
9
10use super::helpers::matches_valid_cards_for_sa;
11
12/// Check if a conditional gate on this SA is satisfied.
13pub(super) fn check_condition(game: &GameState, sa: &SpellAbility) -> bool {
14    let activator = sa.activating_player;
15
16    // Player-state gates (SpellAbilityCondition.areMet L263–L269).
17    if sa.ir.condition_hellbent && !crate::player::has_hellbent(game, activator) {
18        return false;
19    }
20    if sa.ir.condition_threshold && !crate::player::has_threshold(game, activator) {
21        return false;
22    }
23    if sa.ir.condition_metalcraft && !crate::player::has_metalcraft(game, activator) {
24        return false;
25    }
26    if sa.ir.condition_delirium && !crate::player::has_delirium(game, activator) {
27        return false;
28    }
29    if sa.ir.condition_revolt && !crate::player::has_revolt(game, activator) {
30        return false;
31    }
32    if sa.ir.condition_desert && !crate::player::has_desert(game, activator) {
33        return false;
34    }
35    if sa.ir.condition_blessing && !crate::player::has_blessing(game, activator) {
36        return false;
37    }
38
39    // Kicked-related flags (L271–L280).
40    if sa.ir.condition_kicked && !sa.kicked {
41        return false;
42    }
43    if sa.ir.condition_optional_paid && !sa.optional_generic_cost_paid {
44        return false;
45    }
46    if sa.ir.condition_optional_not_paid && sa.optional_generic_cost_paid {
47        return false;
48    }
49
50    // Turn-owner gates (L300–L311).
51    if let Some(raw) = sa.ir.condition_player_turn.as_deref() {
52        let expect_self_turn = !raw.eq_ignore_ascii_case("False");
53        let is_self_turn = game.turn.active_player == activator;
54        if expect_self_turn != is_self_turn {
55            return false;
56        }
57    }
58    if sa.ir.condition_opponent_turn && game.turn.active_player == activator {
59        return false;
60    }
61
62    // Hand-size gate: `ConditionCardsInHand$ N` or `ConditionCardsInHand$ GE3`.
63    if let Some(raw) = sa.ir.condition_cards_in_hand.as_deref() {
64        let size = game
65            .cards_in_zone(forge_foundation::ZoneType::Hand, activator)
66            .len() as i32;
67        if let Ok(n) = raw.parse::<i32>() {
68            if size != n {
69                return false;
70            }
71        } else if !compare_expr(size, raw) {
72            return false;
73        }
74    }
75
76    // Phase gate: `ConditionPhases$ End Of Turn,Upkeep` (comma-separated).
77    if let Some(phases) = sa.ir.condition_phases.as_deref() {
78        let current = game.turn.phase;
79        let ok = phases
80            .split(',')
81            .map(str::trim)
82            .filter_map(forge_foundation::PhaseType::from_script_name)
83            .any(|p| p == current);
84        if !ok {
85            return false;
86        }
87    }
88
89    // Life-compare gate: `ConditionLifeCompare$ GE20`.
90    if let Some(cmp) = sa.ir.condition_life_compare.as_deref() {
91        if !compare_expr(game.player(activator).life, cmp) {
92            return false;
93        }
94    }
95
96    // Check Condition$ Kicked (most common pattern: simple kicked gate)
97    if let Some(cond) = sa.ir.condition.as_deref() {
98        if cond == "Kicked" {
99            return sa.kicked;
100        }
101    }
102    // Check ConditionCheckSVar$ Kicked (SVar-based kicked gate)
103    if let Some(cond) = sa.ir.condition_check_svar.as_deref() {
104        if cond == "Kicked" || cond == "X:Kicked" {
105            return sa.kicked;
106        }
107        let compare = sa.ir.condition_svar_compare.as_deref().unwrap_or("GE1");
108        let Some(source_id) = sa.source else {
109            return false;
110        };
111        let Some(expr) = game.card(source_id).get_s_var(cond) else {
112            return false;
113        };
114
115        let value = if let Some(valid_filter) = expr.strip_prefix("Imprinted$Valid ") {
116            let imprinted = game.card(source_id).imprinted_cards.clone();
117            if valid_filter.eq_ignore_ascii_case("Card.sharesNameWith Remembered") {
118                let remembered_names: std::collections::HashSet<String> = game
119                    .card(source_id)
120                    .remembered_cards
121                    .iter()
122                    .map(|&cid| game.card(cid).card_name.clone())
123                    .collect();
124                imprinted
125                    .into_iter()
126                    .filter(|&cid| remembered_names.contains(&game.card(cid).card_name))
127                    .count() as i32
128            } else {
129                imprinted
130                    .into_iter()
131                    .filter(|&cid| {
132                        matches_valid_cards_for_sa(game, sa, game.card(cid), None, valid_filter)
133                    })
134                    .count() as i32
135            }
136        } else {
137            crate::svar::resolve_svar_expression(expr, game, source_id, sa.activating_player, sa)
138        };
139        return compare_with_svar_threshold(value, compare, game, source_id, sa);
140    }
141    true
142}
143
144/// Like `compare_expr` but resolves an SVar reference when the threshold isn't
145/// a literal integer. Beza's condition uses
146/// `ConditionSVarCompare$ GTYLands` where `YLands` is another SVar on the host
147/// — the bare comparator parser would fall through to "permissive true" and
148/// silently make every conditional sub-ability fire.
149fn compare_with_svar_threshold(
150    value: i32,
151    compare: &str,
152    game: &GameState,
153    source_id: CardId,
154    sa: &SpellAbility,
155) -> bool {
156    if let Some(parsed) = CompareExpr::parse(compare) {
157        return parsed.evaluate(value);
158    }
159    let Some((op_str, rhs_name)) = split_compare_prefix(compare) else {
160        return true;
161    };
162    let Some(rhs_expr) = game.card(source_id).get_s_var(rhs_name) else {
163        return true;
164    };
165    let rhs_value =
166        crate::svar::resolve_svar_expression(rhs_expr, game, source_id, sa.activating_player, sa);
167    compare_expr(value, &format!("{op_str}{rhs_value}"))
168}
169
170fn split_compare_prefix(expr: &str) -> Option<(&'static str, &str)> {
171    for prefix in ["GE", "GT", "LE", "LT", "NE", "EQ"] {
172        if let Some(rest) = expr.strip_prefix(prefix) {
173            return Some((prefix, rest));
174        }
175    }
176    None
177}
178
179/// Check ConditionPresent$ / ConditionZone$ / ConditionCompare$ against game state.
180/// Returns true if the condition is met (or if no condition params exist).
181///
182/// When `ConditionDefined$` is present, check the defined cards instead of
183/// scanning a zone
184pub(super) fn check_condition_present(
185    game: &GameState,
186    sa: &SpellAbility,
187    player: PlayerId,
188    source_id: CardId,
189) -> bool {
190    let condition = match sa.ir.condition_present.as_deref() {
191        Some(c) => c,
192        None => return true, // No condition — always passes
193    };
194
195    // Parse condition alternatives (comma-separated)
196    let alternatives: Vec<&str> = condition.split(',').map(|s| s.trim()).collect();
197
198    // ── ConditionDefined$ — check specific defined cards, not a zone ──
199    if let Some(cond_defined) = sa.ir.condition_defined.as_ref() {
200        let defined_cards: Vec<CardId> = match cond_defined.refs.first() {
201            Some(crate::ability::ability_ir::DefinedRef::Targeted) => {
202                sa.target_chosen.target_card.into_iter().collect()
203            }
204            Some(crate::ability::ability_ir::DefinedRef::SelfCard) => {
205                sa.source.into_iter().collect()
206            }
207            Some(crate::ability::ability_ir::DefinedRef::Remembered) => sa
208                .source
209                .map(|sid| game.card(sid).remembered_cards.clone())
210                .unwrap_or_default(),
211            Some(other) => match other.as_legacy_str() {
212                "Targeted" => sa.target_chosen.target_card.into_iter().collect(),
213                "Self" => sa.source.into_iter().collect(),
214                "Remembered" => sa
215                    .source
216                    .map(|sid| game.card(sid).remembered_cards.clone())
217                    .unwrap_or_default(),
218                _ => Vec::new(),
219            },
220            None => Vec::new(),
221        };
222
223        // ConditionDefined$ cards are explicitly defined — don't exclude self.
224        // Self-exclusion only makes sense for the zone-scan path below.
225        let count = defined_cards
226            .iter()
227            .filter(|&&cid| {
228                matches_condition_filter_no_self_exclude(
229                    game,
230                    cid,
231                    source_id,
232                    player,
233                    &alternatives,
234                )
235            })
236            .count() as i32;
237
238        return if let Some(compare) = sa.ir.condition_compare.as_deref() {
239            compare_expr(count, compare)
240        } else {
241            count > 0
242        };
243    }
244
245    let zone = sa.ir.condition_zone.unwrap_or(ZoneType::Battlefield);
246
247    let cards: Vec<CardId> = game
248        .players
249        .iter()
250        .flat_map(|p| game.cards_in_zone(zone, p.id).iter().copied())
251        .collect();
252    let count = cards
253        .iter()
254        .filter(|&&cid| {
255            matches_condition_filter_no_self_exclude(game, cid, source_id, player, &alternatives)
256        })
257        .count() as i32;
258
259    // Check ConditionCompare$ (e.g. "GE2", "EQ0")
260    if let Some(compare) = sa.ir.condition_compare.as_deref() {
261        compare_expr(count, compare)
262    } else {
263        count > 0
264    }
265}
266
267/// Check if a card matches a condition filter expression.
268/// Handles type matching + qualifier checks (YouCtrl, OppCtrl, ChosenCtrl, etc.).
269/// Like matches_condition_filter but without self-exclusion.
270/// Used by ConditionDefined$ where the defined cards are explicitly specified.
271fn matches_condition_filter_no_self_exclude(
272    game: &GameState,
273    cid: CardId,
274    source_id: CardId,
275    player: PlayerId,
276    alternatives: &[&str],
277) -> bool {
278    let card = game.card(cid);
279    let source = game.card(source_id);
280    alternatives
281        .iter()
282        .any(|alt| crate::card::valid_filter::matches_valid(alt, Some(card), None, source, player))
283}
284
285#[cfg(test)]
286mod tests {
287    use super::*;
288    use crate::card::Card;
289    use crate::game::GameState;
290    use crate::ids::{CardId, PlayerId};
291    use crate::spellability::SpellAbility;
292    use forge_foundation::{CardTypeLine, ColorSet, ManaCost};
293
294    #[test]
295    fn condition_present_targeted_nonbasic_matches_destroyed_land() {
296        let player = PlayerId(0);
297        let mut game = GameState::new(&["P1"], 20);
298        let spell_source = game.create_card(Card::new(
299            CardId(0),
300            "Choking Sands".to_string(),
301            player,
302            CardTypeLine::parse("Sorcery"),
303            ManaCost::parse("1 B B"),
304            ColorSet::BLACK,
305            None,
306            None,
307            vec![],
308            vec![],
309        ));
310        let target = game.create_card(Card::new(
311            CardId(0),
312            "Cliffgate".to_string(),
313            player,
314            CardTypeLine::parse("Land - Gate"),
315            ManaCost::parse(""),
316            ColorSet::COLORLESS,
317            None,
318            None,
319            vec![],
320            vec![],
321        ));
322
323        let mut sa = SpellAbility::new_simple(
324            Some(spell_source),
325            player,
326            "DB$ DealDamage | ConditionDefined$ Targeted | ConditionPresent$ Land.Basic | ConditionCompare$ EQ0",
327        );
328        sa.target_chosen.target_card = Some(target);
329
330        assert!(check_condition_present(&game, &sa, player, spell_source));
331    }
332}